應該在哪裡 `catch` 例外並輸出日誌、進行錯誤處理 - 以實務向整理呼叫階層的邊界與職責
· 小村 豪 · 例外處理, 日誌, 錯誤處理, 設計, C# / .NET
1. 先講結論
- 原則是不在深層中寬泛地
catch。catch的場所集中在能定義失敗單位的邊界。 - 日誌以對 1 個失敗輸出 1 個主日誌為基本。各層對同一例外持續
Error的話讀的人會困擾。 - 最深層的職責是善後、局部回滾、例外的翻譯、必要時有限的 retry。如果要重新 throw,通常不在那裡輸出主日誌。
- 畫面操作、HTTP 請求、1 件作業、1 件訊息處理這樣的處理邊界容易成為最自然的主日誌地點。
- 預期內的失敗以該用例的單位結果化。不一定全部都要當作例外繼續向上拋。
AppDomain.UnhandledException、WPF 的DispatcherUnhandledException、WinForms 的ThreadException、ASP.NET Core 的例外處理器、主機的最終例外處理,與其說是復原點,不如說是最後的記錄地點。- 由使用者取消或關機造成的
OperationCanceledException通常不當作 Error 處理。 - 迷惘時依以下順序看。
- 在這個地方真的能判斷嗎
- 失敗的單位在這裡知道嗎
- 這裡能把狀態還原嗎、能重建嗎
- 在這裡記錄日誌的話,上層不會對同一例外也記錄日誌嗎
簡言之,基本是不在能 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 個重要工作。
那就是翻譯。
例如,
HttpRequestExceptionIOExceptionJsonException- 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 個。
catch的首要理由是復原或 cleanup,不是日誌- 日誌的首要理由是湊齊運營脈絡,不是發現例外
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 個。
-
接具體例外 不是寬泛的
Exception,而是接有意義的具體例外。 -
翻譯成有意義的失敗 讓上層不用直接知道下位的方便。
- 要局部 retry 的話在這裡做
但條件相當嚴格。
- 已知是暫時性失敗
- 有冪等性
- 上限次數和等待方式已定
- 失敗時的最終行為明確 只有這 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"]
這時角色大致如下分配。
儲存按鈕 → SaveOrderUseCase → PaymentGateway → HTTP
PaymentGateway- 接通訊失敗或回應格式異常
- 翻譯為「付款服務連線失敗」「付款服務回應不正當」
- 要 retry 的話在這裡有條件地做
- 要重新 throw 的話通常不輸出主日誌
SaveOrderUseCase- 把付款拒絕這類預期內失敗轉為結果
- 當作「只這次訂單確定失敗」處理
- 做成讓失敗結果容易回傳給 UI 或 API 的形式
- UI 按鈕處理器 / Controller
- 統一接預期外例外
- 附上
orderId、userId、requestId輸出主日誌 - 轉換為對話方塊顯示或 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 個,就能減少以下事故。
- 每次把
NotFound當Error - 把使用者取消當成故障
- 真正危險的前提崩潰被「這次只是失敗」流掉
6. 日誌該在哪裡輸出幾次
日誌的設計中,比起 catch 的位置,誰輸出主日誌要先決定比較重要。
基本規則如下。
- 對 1 個失敗,主要的
Error/Critical日誌是 1 次 - 下層必要時做翻譯和脈絡追加
- 上位的邊界附上失敗單位和運營脈絡輸出主日誌
- 當場吞掉的層才對吞掉的失敗負記錄責任
- 預期內失敗不每次都當
Error 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 次
- 吞掉的層負責
- 最後的未處理例外是記錄和終止導線
10. 總結
例外處理不是「因為到處都能 catch,所以到處都 catch」的話題。
看的順序大致以下就夠。
- 在這個地方真的能判斷嗎
- 這裡能知道失敗單位嗎
- 這裡能把狀態還原嗎、能重建嗎
- 在這裡記錄日誌會不會重複
- 這裡是復原點還是最後的記錄地點
依此順序看,呼叫階層的整理會相當容易。
特別重要的是以下 3 個。
- 深層主要做翻譯和 cleanup
- 邊界主要做判斷和主日誌
- 最後的未處理例外處理器主要做記錄和終止導線
換句話說, 例外在邊界接,附加脈絡,只在能復原的地方處理是基本。
這個決定後,程式碼審查和故障調查都會相當不容易搖擺。
11. 參考資料
- .NET: 例外的最佳實踐
- .NET: System.AppDomain.UnhandledException 事件
- WPF: Application.DispatcherUnhandledException 事件
- Windows Forms: Application.ThreadException 事件
- 處理 ASP.NET Core 的錯誤
- ASP.NET Core 的中介軟體
- 使用 BackgroundService 的 Windows 服務
12. 相關文章
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
發生非預期例外時的 checklist - 要讓應用結束還是繼續,先看的判斷表
本文以 C# / .NET 與 Windows 應用為前提,把非預期例外發生後該結束還是繼續的判斷拆成失敗單位、共用狀態、外部副作用、原生邊界四個觀察點,並提供判斷表與典型情境,協助讀者在 catch 之前先判斷是否還能信任應用狀態。
Windows 應用程式因程式錯誤的例外掉下也要確實留下日誌 - 不賭 in-process 的設計與 WER / 最終日誌 / 監視程序的最佳實踐
整理 Windows 應用程式因預期外例外或程式錯誤掉下時,如何同時保留通常時序日誌、最終當機標記與 WER LocalDumps 等多層證跡的最佳實踐。並說明在 .NET、WinForms、WPF、native C++ 等各框架上不該過度信任 in-process 處理器...
Windows 應用程式開發中遵守最低限度安全性的檢核表
用檢核表形式整理 WPF / WinForms / WinUI / C++ / C# 等 Windows 應用程式發佈前最低限不想漏的安全性要點。涵蓋避免不必要的管理員權限、EXE 與更新物簽章加時間戳、改用 DPAPI 與 Credential Locker、保留 HTT...
哪些應該用單元測試驗證,哪些該留給整合測試 - 切界線的方法與實務判斷表
本文以「想消弭哪種不確定性」為主軸,整理單元測試與整合測試該各自承擔什麼。從純邏輯、格式、接線、環境、時間五個切面歸納成判斷表,並列出 Repository 全 mock、Controller 連框架一起驗等常見誤區,幫讀者在實務上不再為界線猶豫。
Windows 上為什麼應先用事件等待而不是計時器等待 - 避免以約 15.6ms 粒度做輪詢
本文聚焦於 Windows 上短時間 timed wait 為何不可靠,並說明在工作抵達、I/O 完成或停止請求等場景應改採 event 驅動。讀者可學會以 system clock 粒度與排程延遲為線索,挑選 event、semaphore、WaitOnAddress 或...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。