iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 18

Day 18:錯誤契約與使用者可見訊息

  • 分享至 

  • xImage
  •  

Day 18:錯誤契約與使用者可見訊息

一次呼叫失敗分兩種。一種是業務規則正常擋下來的,訂單沒有選客戶、狀態不允許這樣轉,這段訊息是寫給使用者看的;另一種是程式沒有預期到的例外,訊息裡可能有伺服器的檔案路徑,那是寫給開發者看的。兩種回到呼叫端長得一模一樣,都是 JSON-RPC payload 裡的 error 欄位。分不出來就只能一律顯示「請求失敗」,該讓使用者知道的那一半也跟著沒了。要分得出來,這個欄位本身得有契約。

本篇說明:

  1. 業務規則擋下一個動作時,框架用什麼形式讓呼叫端知道
  2. 錯誤碼怎麼分配,哪些落在 HTTP 狀態碼那一邊
  3. 哪些例外的訊息可以原樣送出,這條白名單是按什麼判的
  4. 呼叫端怎麼依錯誤碼把它翻回一個例外,兩端的對映為什麼收成了一份

一、例外當作業務流程的中斷訊息

一張訂單存到一半被規則擋下來。擋它的那段程式可能在三個不同的地方:定義層的規則求值器、BO 覆寫的檢查步驟、或框架自己的前置驗證。三個位置離派發那一層都隔了好幾層方法呼叫。

用回傳值傳這個中斷訊息,中間每一層都要接一次、判一次、再往上傳一次。漏掉其中任何一層,這個訊息就停在那裡:存檔照常走完,呼叫端什麼都沒收到,而漏接的那一層在原始碼裡看起來完全正常。

例外的預設方向相反,沒有人接就一路往上,要讓它消失,得有人特地寫一個 catch 把它吞掉。框架因此選擇丟例外,而這個決定在擴充點的簽章上看得到:DoBeforeSave(SaveContext)void,它從一開始就沒有留一條把訊息送回去的路。

UserMessageException 的定位是業務流程的中斷訊息,不是真正的程式錯誤。控制流被中止是因為這件事做不下去,而那段訊息本來就要原樣送到使用者面前。

同一個型別接住三種來源:

哪一種檢查 誰丟出來 訊息從哪裡來
定義檔宣告的規則 表單規則求值器 FormRuleMessage 屬性
BO 覆寫的檢查 應用自己的程式碼 程式碼裡的字串
框架自己的前置驗證 框架 框架的字串

第一種是規則求值器(FormExpressionCalculator)驗證規則那一段的最後一行,整個宣告式規則機制的出口就是它:

if (!_evaluator.Evaluate<bool>(rule.Condition, NarrowVariables(rule.Condition, variables), timeZoneId))
    throw new UserMessageException(rule.Message);

三種來源丟的是同一個型別,所以往上走的每一層都不必知道這個中斷訊息是宣告出來的還是寫出來的。派發那一層看到的永遠是同一個東西。

擋下來的那一刻還沒有 transaction 可以 rollback,這是 Day 14 那條邊界的另一面。規則跑在寫入那一步的外面,中止一次還沒開始的寫入不需要任何補償動作。

二、錯誤碼與 HTTP 語意的分界

昨天在呼叫端看到的是兩個不同的 catch,前天在伺服端看到的是同一條分界:失敗落在請求受理之前還是之後,決定它走 HTTP 狀態碼還是走 error 欄位。同一個決定還有第三種說法,就是錯誤碼本身。

錯誤碼有兩個來源,一批是 JSON-RPC 2.0 的標準碼,其餘取自規格留給伺服端自訂的那一段。實際會回到呼叫端的是這幾個:

目前由誰產生 落點
ParseError -32700 傳輸層讀不出 JSON 受理之前,HTTP 400
InvalidRequest -32600 傳輸層:媒體型別、空 body、缺 method、API 金鑰不過、Bearer 格式或 Guid 格式不對 受理之前,HTTP 415 / 400 / 401
InternalError -32000 執行器:不在登錄表上的例外。傳輸層:執行器之外的未預期例外 受理之後,HTTP 200;傳輸層那一種 HTTP 500
CompanyNotEntered -32002 執行器:CompanyNotEnteredException 受理之後,HTTP 200
CompanyAccessDenied -32003 執行器:CompanyAccessDeniedException 受理之後,HTTP 200
PermissionDenied -32004 執行器:ForbiddenException 受理之後,HTTP 200
ReplayRejected -32005 執行器:ReplayRejectedException 受理之後,HTTP 200
UserMessage -32099 執行器:給使用者看的中斷訊息(白名單) 受理之後,HTTP 200

受理之後那幾個碼分成三種用途。-32000 是真正的例外,程式或基礎設施壞了,訊息不該離開伺服器。-32099 反過來,它裝的就是要顯示給使用者的那句話。中間那幾個具名碼兩者都不是,它們存在是為了讓呼叫端接到之後做不同的事:退回選公司、把畫面降級、別再重試。訊息未必適合直接顯示,「這個 session 還沒有進公司」對使用者沒有意義,對呼叫端才有。

一次失敗走到哪一個碼,看的是丟出來的例外型別,不是那是什麼失敗。找不到 action 丟的是 MissingMethodException,沒有登錄,於是回 -32000;令牌不存在或已過期丟的是 UnauthorizedAccessException,它登錄在 -32099。

UserMessage 取 -32099 是刻意的,它排在自訂區段的尾端,跟具體的分類碼隔開一段距離。中間那段空號留給未來的分類,一般業務訊息與有明確語意的失敗因此不會混在同一段連號裡。

傳輸層驗得了憑證的形狀,驗不了它的有效性

同樣是「沒有登入」,實測回來的是兩種答案:

# 沒有 Authorization 標頭
HTTP 401  {"error":{"code":-32600,"message":"Missing Authorization header."}}

# 格式正確但不存在的令牌
HTTP 200  {"error":{"code":-32099,"message":"Session key not found or expired."}}

傳輸層檢查的是有沒有標頭、是不是 Bearer 開頭、後面那一段能不能 parse 成 Guid。這三件在請求本身上就看得完,不必問任何一層。令牌還有沒有效要問 session,而 session 在後面那一層,所以一個格式正確的無效令牌在協定上算已經受理,失敗回到 error 欄位裡。

維運上的後果是,閘道統計到的 401 只包含連格式都不對的那一批,令牌過期造成的失敗全部埋在 HTTP 200 裡面。

錯誤碼合併,是為了關掉一條列舉通道

CompanyAccessDenied 這個碼對應三種原因:公司不存在、公司被停用、這個使用者沒有被授權。三種在伺服端都丟同一個例外、帶同一段訊息,實測拿一個不存在的公司代號去打,回的是 Company access denied.

合併要同時做在兩個地方才有意義。碼合併了而訊息文字分歧,呼叫端照樣可以用訊息把有效的公司代號一個一個試出來。這條要求落在那個例外型別的建構子訊息參數上:每一種原因都要用同一段文字。它是一條約定,不是編譯器擋得住的東西。

這個手法在錯誤碼上目前只用在這一處。其他錯誤碼都是照失敗的種類分的,分得越細對呼叫端越有用;只有在錯誤碼本身會洩漏「這個識別碼存不存在」的時候,分細才變成一條可以被反覆探測的通道。

三、哪些訊息可以原樣送到使用者面前

例外訊息裡什麼都可能有。找不到定義檔的那一個裝的是完整檔案路徑,資料庫驅動程式那一個可能帶著語句片段。這些送到呼叫端手上,等於把伺服器的內部結構免費告訴對方。

框架的判定機制是一份登錄表(JsonRpcErrorContract),執行器(JsonRpcExecutor)捕捉到例外之後,做的就是拿它的型別去查那張表:

internal static (JsonRpcErrorCode code, string message) MapException(Exception ex)
{
    if (JsonRpcErrorContract.TryGetCode(ex, out var code))
        return (code, ex.Message);
    return (JsonRpcErrorCode.InternalError,
        SysInfo.IsDebugMode ? ex.Message : "Internal server error");
}

登錄成 -32099 的那幾列就是那份白名單,七個型別,分兩批:

型別 現況
UserMessageException 新程式碼的正解,型別本身就是「這句話要給使用者看」的宣告
UnauthorizedAccessException 過渡期保留
ArgumentException 家族 過渡期保留
InvalidOperationException 過渡期保留
NotSupportedException 過渡期保留
FormatException 過渡期保留
JsonRpcException 過渡期保留

那六個型別留在名單上是遷移路徑,會隨業務程式改用專屬型別而逐步移除。

例外型別白名單的代價是它判的是誰丟的,不是丟了什麼。這件事有輕重兩種後果,先看輕的。ParseMethod 切不出兩段時丟的是 FormatException,於是一個協定層的格式錯誤走了 -32099,而那個碼的契約說這段訊息可以直接顯示給使用者。實測送出一個沒有句點的 method,回來的是 HTTP 200 加上 -32099Invalid method format: Ping。這句話給使用者看沒有意義,但也沒有洩漏什麼。

另一種貴得多:InvalidOperationException 散落在框架與應用的各個角落,任何一個沒被預期到的實例都會把它的訊息原樣送出去。這正是那份清單被標記為過渡的理由。

遮蔽是整段換掉,不是把敏感的部分挑掉

不在登錄表上的例外一律換成一句固定文字。同一個請求在兩種設定下的差別:

# 偵錯旗標開著
{"code":-32000,"message":"The file /Users/…/Define/FormSchema/NoSuchProg.FormSchema.xml does not exist."}

# 偵錯旗標關掉
{"code":-32000,"message":"Internal server error"}

第一種把伺服器的檔案系統路徑送到了呼叫端手上。遮蔽之所以是整段換掉而不是過濾,是因為過濾得先知道哪一段敏感,而那要看每一個例外自己。換掉整段不需要知道任何事。

執行器與傳輸層看的不是同一個開關。執行器看的是 SysInfo.IsDebugMode,它來自 SystemSettings 這份定義檔;傳輸層那一段看的是宿主的環境名稱。兩個都要對,訊息才不會外洩。把定義檔裡那個旗標留成開啟就部署出去,宿主設成正式環境也擋不住。

偵錯分支只帶訊息,不帶 stack trace 或任何更廣的傾印,那類內容一旦進了 API 回應,外洩的就不只是一行路徑。

這個決定另有一筆代價:執行器在這裡把例外處理掉了、沒有往上拋,所以更上層也沒有機會回報它,開發者在正式環境拿到的就只有那一句話。伺服端這一側另有一條記錄的路,會把例外的型別名與那段原始訊息寫成一列,但那條路預設不開,關著的時候那一次失敗連型別名都不會留下來。

四、呼叫端把錯誤欄位翻回一個例外

昨天說過 FinalizeResponse 是把錯誤欄位翻回例外的地方,也說過它在解碼之前就先判讀錯誤。位置已經清楚,剩下的是它翻成什麼。

整條往返是這樣:

BO               throw new UserMessageException("Please select a customer for the order.")
執行器           例外型別 → 錯誤碼      (-32099, "Please select …")
JSON-RPC payload {"error":{"code":-32099,"message":"Please select …"}}
呼叫端           錯誤碼 → 例外型別      throw new UserMessageException("Please select …")
畫面             catch (UserMessageException ex) → 顯示 ex.Message

呼叫端(ApiConnector)查的是同一份登錄表,方向相反:

if (JsonRpcErrorContract.TryRebuild(response.Error.Code, response.Error.Message, out var rebuilt))
    throw rebuilt;
throw new InvalidOperationException($"API error: {response.Error.Code} - {response.Error.Message}");

呼叫端因此不必認得任何一個具體的例外型別,那份登錄表替它認。

重建不是還原。-32099 那一條是多對一:伺服端丟的可能是白名單上七個型別裡的任何一個,回到呼叫端一律是 UserMessageException。型別資訊送回來只剩一個整數,能還原的就是這個整數認得的那幾種。這是刻意的取捨,呼叫端要的是「這個訊息能不能直接給使用者看」,不是「伺服端當初丟的是哪個型別」。

新增一種錯誤因此是一處編輯,不是兩個組件各一處。分開寫在兩邊的話,伺服端多認一個碼而呼叫端沒跟上,那個碼會安靜落到通用分支,而編譯得過、測試也未必測得到。Day 15 那組回傳型別的命名慣例仍然是靠約定接起來的,這一組不是。

同行程呼叫走的也是完整一趟。近端那條路解析的是同一個執行器,例外照樣被接住、變成一個碼、再由 FinalizeResponse 重建,所以拿到的一樣是一個新的例外實例,原本的 stack trace 與內層例外都不在了。昨天說兩條路要讓呼叫端看不出差別,這是那個目標的另一筆帳單:差別確實看不出來,代價是近端那條也交出了它本來免費就有的東西。

回到 Northwind

拿一張沒有客戶的訂單去存,回來的是 HTTP 200 加上 -32099Please select a customer for the order.。那段文字不在任何一行程式碼裡,它是案例訂單那份 FormSchemaOrder.FormSchema.xml)裡的一個屬性值:

<FormRule RuleId="customer_required" Condition="customer_rowid != Guid.Empty"
          Message="Please select a customer for the order." />

從定義檔的一個屬性,經過求值器丟出的例外、一個整數錯誤碼、呼叫端重建出來的例外,到畫面上那一行字,中間沒有任何一段應用程式碼碰過它。Day 8 談宣告式規則時只講到求值那一半,這裡是它的另一半:規則的訊息本身也是定義的一部分,一路走到使用者眼前都沒有換過手。

OrderBO 在寫入前那一步自己接手的那幾個檢查,用的也是同一個型別。至少要有一列明細、狀態不允許這樣轉、確認之後明細鎖定,三句丟的都是 UserMessageException,所以定義檔宣告的規則與程式碼寫的檢查到了呼叫端分不出來。分不出來是對的,呼叫端要的資訊是「這是給使用者看的訊息」,不是「它是宣告的還是寫的」。

上面那個 -32003 在案例裡走得到:登入之後就會呼叫進公司那一支,而它對不存在的公司與沒被授權的公司回的是同一句話。

-32000 那兩種面貌在案例裡看得到一種:它的 SystemSettings.xml 把偵錯旗標開著,所以找不到定義檔的失敗回的是完整路徑。把那個旗標關掉重打同一個請求,回的就是那句固定文字。同一份程式碼、同一個請求、兩種答案,差別在一份定義檔的一行。

小結

一份錯誤契約回答的是:這次失敗要讓對方知道多少。太少,呼叫端只能顯示「請求失敗」,使用者不知道該改什麼;太多,伺服器的內部結構就順著錯誤訊息流出去。

  • 中斷訊息用例外傳遞,因為它要跨越好幾層,而回傳值只跨得了一層
  • 失敗落在受理的哪一邊決定它走 HTTP 狀態碼還是走 error 欄位,而傳輸層驗得了憑證的形狀、驗不了它的有效性
  • 訊息能不能原樣送出由例外型別白名單決定,代價是它判的是誰丟的、不是丟了什麼
  • 呼叫端依碼重建例外,而兩端查的是同一份登錄表,新增一種錯誤是一處編輯

最該帶走的是第三點。「可以顯示給使用者」判的不是這段文字寫了什麼,是它由誰丟出來。一段訊息裡有沒有路徑、有沒有語句片段,程式在執行期看不出來;型別看得出來,編譯期就定下來,數得出來也改得動。

這一章的每一篇問的都是同一件事:這個東西擺在哪裡。一個值屬於 session 的哪一段、一個擴充點在 transaction 的哪一邊、一個型別在合約的哪一層、一次失敗落在受理之前還是之後、一份狀態屬於一個使用者還是整個應用、一段訊息由哪一個型別丟出來。位置決定語意,而位置不寫在程式碼裡,它散在幾份文件、幾段註解和幾個人的記憶裡。一份契約要做的事,就是把它寫下來。

明天起換一個方向:同一套框架要讓不同客戶長得不一樣,而不一樣的地方可以落在哪裡。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 17:Connector 與呼叫端的職責收斂
下一篇
Day 19:客製層的範圍與疊加粒度
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言