應該在哪裡 `catch` 例外並輸出日誌、進行錯誤處理 - 以實務向整理呼叫階層的邊界與職責

· · 例外處理, 日誌, 錯誤處理, 設計, C# / .NET

1. 先講結論

  • 原則是不在深層中寬泛地 catchcatch 的場所集中在能定義失敗單位的邊界
  • 日誌以對 1 個失敗輸出 1 個主日誌為基本。各層對同一例外持續 Error 的話讀的人會困擾。
  • 最深層的職責是善後、局部回滾、例外的翻譯、必要時有限的 retry。如果要重新 throw,通常不在那裡輸出主日誌。
  • 畫面操作、HTTP 請求、1 件作業、1 件訊息處理這樣的處理邊界容易成為最自然的主日誌地點。
  • 預期內的失敗以該用例的單位結果化。不一定全部都要當作例外繼續向上拋。
  • AppDomain.UnhandledException、WPF 的 DispatcherUnhandledException、WinForms 的 ThreadException、ASP.NET Core 的例外處理器、主機的最終例外處理,與其說是復原點,不如說是最後的記錄地點
  • 由使用者取消或關機造成的 OperationCanceledException 通常不當作 Error 處理
  • 迷惘時依以下順序看。
    1. 在這個地方真的能判斷嗎
    2. 失敗的單位在這裡知道嗎
    3. 這裡能把狀態還原嗎、能重建嗎
    4. 在這裡記錄日誌的話,上層不會對同一例外也記錄日誌嗎

簡言之,基本是不在能 catch 的地方,而是在能負責判斷的地方接

2. catch、日誌、錯誤處理是不同的事

2.1. catch 這件事

catch 是把例外一度接下來,改變處理流程。 但是,那件事本身不是復原

例如,就算在下位方法接到例外,

  • 不知道該對使用者顯示什麼
  • 不知道那個失敗該停止整個畫面,還是只讓這次操作失敗就好
  • 不知道該不該繼續那個 request 或 job

的話,那個地方常常不是 catch 的適地

2.2. 輸出日誌這件事

日誌不只是「發生例外」這個事實,而是之後追查哪個工作失敗了的記錄。

所以好的日誌地點大致都有以下某項。

  • requestId / traceId
  • userId
  • orderId / fileId / batchId
  • 是第幾筆輸入
  • 是哪個畫面操作
  • 是哪個 queue、哪個訊息

深層 helper 或共用函式即使知道技術細節,常常不具備這個脈絡。 所以,知道技術細節的地方知道運營脈絡的地方常常不同。

2.3. 錯誤處理這件事

這裡說的錯誤處理例如以下。

  • 在畫面顯示錯誤訊息
  • HTTP 中回傳 4xx / 5xx
  • 只讓 1 件失敗,進到下一件
  • 重新初始化那個 subsystem
  • 終止行程交給重新啟動
  • 釋放資源安全地離開

也就是說,決定從呼叫端或使用者看到的失敗形式

2.4. 翻譯例外這件事

實務上,在 catch 和「處理」之間還有 1 個重要工作。 那就是翻譯

例如,

  • HttpRequestException
  • IOException
  • JsonException
  • DB 驅動程式特有的例外
  • vendor SDK 特有的例外

直接漏到 UI 或 Controller 的話,上層就會開始知道下位實作的方便。

所以在邊界面,

  • 「無法連線到付款服務」
  • 「CSV 格式損毀」
  • 「無法寫入儲存目的地」
  • 「裝置回應不正當」

這樣轉換成該層中有意義的失敗

這裡重要的是翻譯和日誌不同。 只是翻譯後往上拋的話,通常不輸出主日誌。

3. 先看的判斷表

先用下面這個表決定大方針,就容易整理。

地方 基本方針 主日誌 主要職責
helper / utility / private method 原則上不寬泛地 catch 不輸出 finally 善後、局部回滾、必要最小的脈絡追加
Repository / Gateway / SDK 封裝 只接具體例外 通常不輸出 例外翻譯、有限 retry、連線或控制碼的銷毀
Application Service / UseCase 把預期內失敗結果化 如果吞掉則在此視需要 定義失敗單位、部分失敗化、用例單位的判斷
UI / Controller / API / Job / Message 邊界 預期外例外的主要接收口 這裡容易成為主日誌 面向使用者的回應、HTTP 回應、下一件繼續、abort 判斷
未處理例外處理器 / 主機最終邊界 防止遺漏的最後防線 Critical 最終記錄、flush、dump、終止・重新啟動導線

畫成下圖的話大致是這樣。

flowchart TD
    A["發生例外"] --> B{"在這個地方能決定 retry / 結果化 / 繼續可否嗎?"}
    B -- "否" --> C["原則上不 catch 往上送"]
    B -- "是" --> D{"這裡是層的邊界嗎?"}
    D -- "否" --> E["只做局部 cleanup"]
    D -- "是" --> F["必要時翻譯成有意義的例外"]
    E --> G{"這裡能知道失敗單位和運營脈絡嗎?"}
    F --> G
    G -- "否" --> H["不輸出主日誌往上送"]
    G -- "是" --> I["輸出 1 次主日誌並決定回應"]
    I --> J["必要時終止 / 重新初始化 / 下一件繼續"]

這張圖的要點有 2 個。

  1. catch 的首要理由是復原或 cleanup,不是日誌
  2. 日誌的首要理由是湊齊運營脈絡,不是發現例外

4. 呼叫階層的哪裡做什麼

4.1. 最深的 helper / utility / private method

這裡原則上不寬泛地接是基本。

例如,字串轉換、解析、計算、內部格式化、共用 helper 這樣的地方,

  • 是哪個畫面操作
  • 是哪個 request
  • 這次只失敗就好嗎
  • 該關整個畫面嗎

是無法判斷的。

這一層可以做的主要是以下。

  • finally 中的 resource 釋放
  • 做到一半的局部狀態的回滾
  • 對例外訊息追加最小的脈絡
  • 替換為更合適的例外型別
  • 銷毀無法再使用的物件

相反想避免的是以下。

  • catch (Exception) 後回傳 null / false / 空陣列
  • 在這裡跳出 MessageBox
  • 在這裡輸出 Error 日誌後重新 throw
  • 明明還原不了卻「總之繼續」

特別危險的是把自己的狀態改寫到一半後失敗,卻繼續使用的模式。 這種情況,如果能當場還原就還原,還原不了就以銷毀為前提,兩者擇一。

4.2. 外部 I/O 邊界: Repository / Gateway / SDK 封裝

這裡是 catch 的理由明確的層。

因為這裡下層的實作方便會浮到表面。

  • DB 驅動例外
  • HTTP 通訊例外
  • 檔案 I/O 例外
  • COM / P/Invoke / vendor SDK 特有的例外
  • 解析函式庫或序列化器的例外

這一層做的事大致是以下 4 個。

  1. 接具體例外 不是寬泛的 Exception,而是接有意義的具體例外。

  2. 翻譯成有意義的失敗 讓上層不用直接知道下位的方便。

  3. 要局部 retry 的話在這裡做 但條件相當嚴格。
    • 已知是暫時性失敗
    • 有冪等性
    • 上限次數和等待方式已定
    • 失敗時的最終行為明確 只有這 4 個都齊全時。
  4. 丟棄損壞的連線或控制碼 「下次也用同一物件繼續」不如「重建連線」安全的情況很多。

這一層的日誌方針,以下面這樣思考就不容易搖擺。

  • 如果往上重新 throw,通常不輸出主日誌
  • 如果在這裡吞下例外轉為結果,當下輸出必要的日誌或指標
  • retry 中的各次嘗試以 Debug / Information / Warning 的範圍處理,只對最終失敗強烈記錄

這一層是翻譯的地方,通常不是最終判斷的地方

4.3. Application Service / UseCase

這裡是決定「這次的工作要如何失敗」的層。

例如,

  • 儲存處理
  • 訂單確定
  • CSV 匯入
  • 批次 1 件份的處理
  • 訊息 1 件份的反映

這類作為用例有整理單位的在這裡。

這一層能做的判斷包括以下。

  • validation 錯誤只這次失敗
  • NotFound 相當於 404
  • 業務規則違反等待使用者修正
  • CSV 的 1 行不正當以 Warning 繼續
  • 外部服務暫時故障讓處理整體失敗
  • 銷毀中間成果從頭再來

也就是,能決定失敗單位的地方。

這一層適合的例如以下。

  • 把預期內失敗做成 Result 或失敗 DTO 的形式
  • 彙總部分失敗
  • 決定允許幾件失敗繼續
  • 轉換成錯誤碼或面向使用者的訊息鍵

相反地,這一層不該做的是過度帶入 UI 顯示或 HTTP 回應本文的組裝。 這裡決定到作為用例的意義為止,最終的顯示方式交給邊界端,比較容易分離。

4.4. UI / HTTP / Job / Message 的邊界

這裡在很多應用程式中容易成為主日誌地點

例如以下。

  • WinForms / WPF 的「儲存」按鈕按 1 次
  • ASP.NET Core 的 HTTP request 1 次
  • worker 的訊息 1 件
  • 批次的輸入 1 件
  • 排程執行作業 1 次

這個地方知道以下。

  • 是什麼操作
  • 是誰的操作
  • 是第幾件
  • 是哪個 request / batch / message
  • 失敗的話要對使用者或呼叫端回傳什麼

所以,

  • 在這裡統一接預期外例外
  • 附上脈絡輸出 1 次主日誌
  • 轉換為錯誤對話方塊、HTTP 500、Problem Details、作業失敗、下一件繼續等

容易持有這樣的角色。

這一層重要的不是寬泛地接這件事本身,而是寬泛地接之後要回什麼被定義了

例如 batch 或 queue 中分成以下 2 階段比較容易整理。

  • 在 1 件邊界接 決定是否只讓 1 件失敗繼續往下
  • 父迴圈不要寬泛地壓住 父迴圈死掉的話靠向整個行程的重新啟動

「1 件一件失敗並繼續」和「父迴圈因預期外例外掉下來卻默默活著」完全不同。

4.5. 最後的未處理例外處理器

這裡是最後防線。 不是魔法的復原點。

代表性的有以下。

  • AppDomain.UnhandledException
  • WPF 的 Application.DispatcherUnhandledException
  • WinForms 的 Application.ThreadException
  • ASP.NET Core 的例外處理中介軟體或處理器
  • Generic Host / worker / BackgroundService 的最終例外處理

這一層的主要職責是以下。

  • 最終日誌
  • flush
  • dump 擷取導線
  • session 資訊或前一刻脈絡的保存
  • 終止代碼或重新啟動導線的整備

相反地,不要過度期待這裡比較好。

  • 到這裡的時點通常是上層的設計遺漏
  • 狀態可能已經損壞
  • 可能持有鎖中,重處理很危險
  • 外觀能繼續也不一定繼續就安全

.NET 相關的實務上要記住的注意點也有。

  • AppDomain.UnhandledException通知和記錄未處理例外的事件。之後加入太多復原處理很危險。
  • WPF 的 DispatcherUnhandledException 可以設 Handled = true 在外觀上繼續,但能否復原的判斷在先。
  • WinForms 的 ThreadException 也可能在該處應對後應用程式變成不明狀態
  • ASP.NET Core 的例外處理中介軟體需要放在能接受後續例外的管線早期階段
  • BackgroundService 的未處理例外在 .NET 6 以後會被記錄並預設停止主機。父迴圈全部壓住不如停止後搭上重新啟動策略來得安全的情況也有。

特別在桌面應用程式中,存在「接住未處理例外繼續」的路。 但是能繼續能繼續下去也安全是不同的。

4.6. 用 1 條呼叫階層來看

例如考慮以下流程。

flowchart LR
    A["UI / Controller / Job 邊界"] --> B["Application Service / UseCase"]
    B --> C["Domain / 業務邏輯"]
    C --> D["Repository / Gateway / SDK wrapper"]
    D --> E["DB / HTTP / File / Vendor SDK"]

這時角色大致如下分配。

儲存按鈕 → SaveOrderUseCasePaymentGateway → HTTP

  • PaymentGateway
    • 接通訊失敗或回應格式異常
    • 翻譯為「付款服務連線失敗」「付款服務回應不正當」
    • 要 retry 的話在這裡有條件地做
    • 要重新 throw 的話通常不輸出主日誌
  • SaveOrderUseCase
    • 把付款拒絕這類預期內失敗轉為結果
    • 當作「只這次訂單確定失敗」處理
    • 做成讓失敗結果容易回傳給 UI 或 API 的形式
  • UI 按鈕處理器 / Controller
    • 統一接預期外例外
    • 附上 orderIduserIdrequestId 輸出主日誌
    • 轉換為對話方塊顯示或 500 / 503 回應
  • 未處理例外處理器
    • 只記錄漏到那裡的
    • 做 dump 或最終 flush
    • 優先終止導線而不是復原

這樣分的話,形成技術細節在下面封閉、運營脈絡在上面附加、判斷在邊界進行的形式。

5. 分開預期內失敗和預期外例外

這個主題最重要的是不把全部都當作同樣「例外」處理

先這樣分比較容易整理。

失敗的種類 先處理的地方 典型的處理
validation 不備 UseCase / request 邊界 當作輸入錯誤回傳
NotFound / Conflict UseCase / Controller 404 / 409 或畫面訊息
使用者取消 / 關機 操作邊界 取消處理。通常不當作 Error
CSV 的 1 行不正當 1 行邊界 Warning 記錄並繼續下一件
暫時性 timeout 最終失敗 I/O 邊界~request 邊界 retry 後當作失敗回傳
NullReferenceException、前提崩潰 request / job 邊界 輸出主日誌並失敗回應
AccessViolationException、嚴重的 OutOfMemoryException、native 邊界破壞氣味 最終邊界 當作 Critical 偏向終止

預期內失敗是設計上能先決定的失敗。 預期外例外是之後狀態是否能信任可疑的失敗

光分開這 2 個,就能減少以下事故。

  • 每次把 NotFoundError
  • 把使用者取消當成故障
  • 真正危險的前提崩潰被「這次只是失敗」流掉

6. 日誌該在哪裡輸出幾次

日誌的設計中,比起 catch 的位置,誰輸出主日誌要先決定比較重要。

基本規則如下。

  1. 對 1 個失敗,主要的 Error / Critical 日誌是 1 次
  2. 下層必要時做翻譯和脈絡追加
  3. 上位的邊界附上失敗單位和運營脈絡輸出主日誌
  4. 當場吞掉的層才對吞掉的失敗負記錄責任
  5. 預期內失敗不每次都當 Error
  6. OperationCanceledException 從一般的故障日誌分開

日誌地點粗略做成表如下。

狀況 主要記錄的地方 等級的基準 補充
validation 錯誤 request / use case 邊界 Information 或沒有日誌 不是故障而是契約上的失敗
使用者取消 / shutdown 操作邊界 Debug / Information 通常不當 Error
retry 中的暫時失敗 持有 retry 的層 Debug / Warning 最終失敗前不要太吵
retry 用盡後失敗 request / job 邊界,或當場吞掉的層 Warning / Error 附上失敗單位記錄
只 1 行不正當繼續 item 邊界 Warning 附上 fileId, rowNumber
讓整個 request 失敗的預期外例外 request / UI / job 邊界 Error 附上 requestId, userId, entityId
行程終止級 未處理例外邊界 Critical flush, dump, 重新啟動導線

實務上以下重複日誌相當多。

  • Repository 用 Error
  • Service 對同一例外用 Error
  • Controller 又 Error
  • 最後的未處理例外處理器也 Critical

這樣 1 次故障會排好幾條同樣的堆疊追蹤。 讀的人想要的不是同樣 stack trace 的 4 條,而是1 條主日誌,必要時少數輔助日誌

換句話說日誌 1 次、脈絡必要就補足是基本。

7. 常見的 NG

7.1. 在深層 catch (Exception) 後回傳 null / false

這容易掉原因資訊。 而且呼叫端會變得無法區別「真的沒有資料」或「中途損壞」。

7.2. 每層 Error 日誌後重新 throw

最多的重複日誌原因。

  • 下層只翻譯
  • 上位邊界主日誌

這樣分工就能減少很多。

C# 中重新 throw 的話,為了不破壞堆疊追蹤,基本使用 throw;

7.3. 函式庫層或共用部件直接輸出 UI

共用部件跳 MessageBox 或直接決定 HTTP 回應本文的話,再利用性和職責分離都會崩壞。 下層要集中在回傳有意義的失敗為止比較安全。

7.4. 把 OperationCanceledException 當故障 Error 日誌

取消是控制流程的一部分。 每次都當 Error 的話真的故障會被埋沒。

7.5. 明明有外部副作用卻輕易 retry

郵件發送、扣款、裝置命令、檔案搬移這類同樣操作再做一次會出事的很多。 retry 只在暫時性失敗冪等性雙方都看得到時才做。

7.6. 在最後的未處理例外處理器想什麼都復原

這裡是最後的保險。 不是設計的中心位置。

復原策略放在前面的層,也就是 request / job / subsystem 的邊界比較安全。

8. 審查時的檢核表

例外處理的審查中,依序看以下比較容易整理。

  • 這個 catch為了判斷什麼存在能以 1 句話說出嗎
  • 在這個地方真的能決定 retry / 結果化 / 繼續可否 / 使用者回應嗎
  • 在這裡記錄日誌的話,上層不會對同一失敗也 Error
  • 把下位實作特有的例外在邊界翻譯成有意義的失敗了嗎
  • 中途損壞的狀態能在這裡還原嗎。還原不了的話有以銷毀為前提嗎
  • OperationCanceledException 從一般故障分開了嗎
  • 是 item 單位繼續、request 單位失敗、還是 process 終止,明確嗎
  • 對最後的未處理例外處理器期待的是記錄而非復原嗎
  • 日誌有乘載 requestId / userId / batchId / fileId / rowNumber 等失敗單位的脈絡嗎
  • 「預期內失敗」和「前提崩潰」沒有當作同樣處理嗎

這檢核表中特別有效的是每次把「這個 catch 決定什麼」說成話。 說不出來的 catch 通常不必要或位置太深。

9. 大致的使用區分

最後相當短地整理是以下表。

場面 catch 日誌 錯誤處理
helper / utility 原則不做 不做 不做
Repository / Gateway / SDK 封裝 只接具體例外 通常不輸出主日誌 翻譯、局部 retry、連線銷毀
UseCase / Application Service 接預期內失敗 吞掉的話視需要 結果化、部分失敗化
UI / Controller / request / item / job 邊界 寬泛地接預期外例外 主日誌 回應、訊息、繼續 / abort
未處理例外處理器 只漏到那裡的 Critical 最終記錄、終止導線

迷惘時先以下就夠。

  1. 深層不寬泛地抓
  2. 在邊界接
  3. 主日誌 1 次
  4. 吞掉的層負責
  5. 最後的未處理例外是記錄和終止導線

10. 總結

例外處理不是「因為到處都能 catch,所以到處都 catch」的話題。

看的順序大致以下就夠。

  1. 在這個地方真的能判斷嗎
  2. 這裡能知道失敗單位嗎
  3. 這裡能把狀態還原嗎、能重建嗎
  4. 在這裡記錄日誌會不會重複
  5. 這裡是復原點還是最後的記錄地點

依此順序看,呼叫階層的整理會相當容易。

特別重要的是以下 3 個。

  • 深層主要做翻譯和 cleanup
  • 邊界主要做判斷和主日誌
  • 最後的未處理例外處理器主要做記錄和終止導線

換句話說, 例外在邊界接,附加脈絡,只在能復原的地方處理是基本。

這個決定後,程式碼審查和故障調查都會相當不容易搖擺。

11. 參考資料

12. 相關文章

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽