异常处理中,catch 与日志应该放在哪里

· · 异常处理, 日志, 错误处理, 设计, C# / .NET

目录

  1. 先说结论
  2. catch、日志、错误处理是三件不同的事
    • 2.1. 进行 catch
    • 2.2. 记录日志
    • 2.3. 进行错误处理
    • 2.4. 对异常进行翻译
  3. 先看这张判断表
  4. 在调用层级的哪个位置做什么
    • 4.1. 最深层的 helper / utility / private method
    • 4.2. 外部 I/O 边界:Repository / Gateway / SDK 封装
    • 4.3. Application Service / UseCase
    • 4.4. UI / HTTP / Job / Message 边界
    • 4.5. 最后的未处理异常处理程序
    • 4.6. 用一条调用链来看
  5. 区分预期内的失败与预期外的异常
  6. 日志应该在哪里、记录几次
  7. 常见的 NG 做法
  8. 审查时的检查清单
  9. 简单的使用区分
  10. 总结
  11. 参考资料
  12. 相关文章

1. 先说结论

  • 原则是:不要在深层大范围 catchcatch 的位置应靠近能够定义失败单位的 边界
  • 日志的基本原则是:针对一次失败,只记录一条主日志。如果每一层都对同一个异常持续记录 Error,会让阅读的人感到困惑。
  • 最深层的职责是 收尾、局部回滚、异常翻译,必要时进行有限的 retry。如果要重新抛出,通常不在这里记录主日志。
  • 界面操作、HTTP 请求、一个作业、一条消息处理这类 处理边界,最容易成为最自然的主日志记录点。
  • 预期内的失败应该在对应用例(use case)的单位上进行 结果化处理。不必把所有失败都作为异常一路往上抛。
  • AppDomain.UnhandledException、WPF 的 DispatcherUnhandledException、WinForms 的 ThreadException、ASP.NET Core 的异常处理程序,以及宿主的最终异常处理,与其说是恢复点,更应该看作最后的记录点
  • 因用户取消或关闭而产生的 OperationCanceledException,通常 不作为 Error 处理
  • 拿不准的时候,按以下顺序判断:
    1. 在这个位置真的能做出判断吗
    2. 在这里能明确失败的单位吗
    3. 在这里能恢复状态,或重新构建状态吗
    4. 如果在这里记录日志,上层是否会对同一个异常再记录一次

总而言之,基本原则是:不是”哪里能 catch 就在哪里 catch”,而是在能够负责判断的地方接收异常

2. catch、日志、错误处理是三件不同的事

2.1. 进行 catch

catch 是先接收异常,再改变处理流程。但这本身并不等于 恢复

例如,即使在下层方法中接收了异常,

  • 不知道该向用户展示什么
  • 不知道这个失败应该让整个界面停止,还是只让本次操作失败即可
  • 不知道该 request 或 job 是否可以继续

如果是这种情况,这个位置往往并不适合 catch

2.2. 记录日志

日志不仅是记录”发生了异常”这个事实,更是为了日后能够追溯 哪项工作失败了 的记录。

因此,好的日志记录点通常具备以下要素中的某一个或几个。

  • requestId / traceId
  • userId
  • orderId / fileId / batchId
  • 第几条输入
  • 是哪次界面操作
  • 是哪个队列、哪条消息

较深的 helper 或公共函数,即便掌握技术细节,往往也不具备这些上下文。 因此,掌握技术细节的位置掌握运维上下文的位置,往往并不是同一个地方。

2.3. 进行错误处理

这里所说的错误处理,指的是以下这类操作。

  • 在界面上显示错误消息
  • 在 HTTP 中返回 4xx / 5xx
  • 将这一条视为失败,继续处理下一条
  • 重新初始化该 subsystem
  • 终止进程,交由重启机制处理
  • 释放资源并安全退出

也就是说,这是在 决定调用方或用户所看到的失败形态

2.4. 对异常进行翻译

在实务中,catch 与”处理”之间,还有一项重要的工作,那就是 翻译

例如,

  • 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 响应、继续处理下一条、是否中止的判断
未处理异常处理程序 / 宿主最终边界 防止漏记的最后一道防线 Critical 最终记录、flush、dump、退出与重启通道

画成图,大致是这样。

发生异常能否在此处决定 retry / 结果化 / 是否继续?原则上不 catch,向上传递这里是层的边界吗?仅进行局部 cleanup必要时翻译为有意义的异常在这里能明确失败单位和运维上下文吗?不记录主日志,向上层传递记录一次主日志并决定响应必要时终止 / 重新初始化 / 继续下一条

这张图的重点有两个。

  1. catch 的首要理由是恢复或 cleanup,而不是记录日志
  2. 记录日志的首要理由是运维上下文已经齐备,而不是发现了异常

4. 在调用层级的哪个位置做什么

4.1. 最深层的 helper / utility / private method

这里的基本原则是 不大范围接收异常

例如字符串转换、解析、计算、内部格式整理、公共 helper 这类地方,

  • 不知道是哪次界面操作
  • 不知道是哪个 request
  • 不知道这次失败是否只影响本次操作
  • 不知道是否应该关闭整个界面

是无法判断的。

在这一层可以做的,主要是以下这些。

  • 通过 finally 释放资源
  • 回滚中途已经破坏的局部状态
  • 为异常消息补充最小限度的上下文
  • 替换为更合适的异常类型
  • 销毁已经无法再复用的对象

反过来,应该避免以下这类写法。

  • catch (Exception) 后返回 null / false / 空数组
  • 在这里弹出 MessageBox
  • 在这里先记录 Error 日志,再重新抛出
  • 明明无法恢复原状,却”先继续再说”

尤其危险的是这种模式:中途已经改写了自己的状态却失败了,却仍然继续使用下去。 这种情况下,应当在当场就把状态恢复原状,或者,如果无法恢复,就以销毁该对象为前提处理,二者选其一。

4.2. 外部 I/O 边界:Repository / Gateway / SDK 封装

这是一个 catch 的理由很明确的层。

因为在这里,下层实现的具体细节会暴露出来。

  • DB 驱动异常
  • HTTP 通信异常
  • 文件 I/O 异常
  • COM / P/Invoke / vendor SDK 特有的异常
  • 解析库或序列化器的异常

在这一层要做的事情,大致有 4 项。

  1. 接收具体的异常类型 不是接收宽泛的 Exception,而是接收有明确含义的具体异常。

  2. 翻译为有意义的失败 让上层不需要直接了解下层的实现细节。

  3. 如果要做局部 retry,就在这里做 不过,条件要设定得严格一些:
    • 已确认是临时性失败
    • 具备幂等性
    • 已确定重试上限次数与等待方式
    • 最终失败时的行为已经明确 只有这 4 个条件都满足时才可以进行 retry。
  4. 舍弃已损坏的连接或句柄 相比”继续用同一个对象接着用”,”重新创建连接”往往更安全。

这一层的日志方针,按以下思路思考会比较不容易动摇。

  • 如果要向上重新抛出,通常不记录主日志
  • 如果在这里把异常吞掉并转换为结果,那么此时应记录必要的日志或指标
  • retry 过程中的每次尝试应控制在 Debug / Information / Warning 的范围内,只对最终失败做较强的记录

这一层是 进行翻译的地方,通常不是 做出最终判断的地方

4.3. Application Service / UseCase

这是决定”这次工作该以何种方式失败”的层。

例如,

  • 保存处理
  • 订单确认
  • CSV 导入
  • 批处理中的一条
  • 一条消息的处理

这类 作为用例、具有整体性的单位,就存在于这一层。

在这一层,可以做出以下判断。

  • validation 错误只让本次失败
  • NotFound 相当于 404
  • 违反业务规则则等待用户修正
  • CSV 中一行不合规则以 Warning 记录并继续
  • 外部服务临时故障则让整个处理失败
  • 舍弃中途成果,从头重新开始

也就是说,这里是能够决定 失败单位 的地方。

这一层适合承担以下工作。

  • 将预期内的失败转换为 Result 或失败 DTO 的形式
  • 汇总部分失败
  • 决定允许多少条失败后仍继续处理
  • 转换为错误代码或面向用户的消息 key

反过来,这一层不应该做的是,过多地引入 UI 展示或 HTTP 响应主体的组装逻辑。 在这一层,只需决定 作为用例的含义,把最终的展现方式交给边界层处理,这样更容易做到职责分离。

4.4. UI / HTTP / Job / Message 边界

在很多应用中,这里往往会成为 主日志记录点

例如以下这些单位。

  • WinForms / WPF 中一次”保存”按钮点击
  • ASP.NET Core 中一个 HTTP request
  • worker 处理的一条消息
  • 批处理中的一条输入
  • 一次定时执行的作业

这个位置了解以下信息。

  • 是什么操作
  • 是谁的操作
  • 是第几条
  • 是哪个 request / batch / message
  • 失败时应向用户或调用方返回什么

因此,

  • 在这里统一接收预期外的异常
  • 带上下文记录一次主日志
  • 转换为错误对话框、HTTP 500、Problem Details、作业失败、继续处理下一条等

这样的角色,容易落在这一层。

在这一层,重要的不是 大范围接收本身,而是 大范围接收之后,返回什么已经被明确定义

例如在 batch 或 queue 中,分成两个阶段来考虑,会更清晰。

  • 在单条边界处接收 决定是否只让这一条失败,继续处理下一条
  • 父循环不要大范围吞掉异常 如果父循环崩溃,就倾向于让整个进程重启

“逐条失败并继续”和”父循环因预期外异常崩溃却悄悄地继续存活”,这两者完全不同。

4.5. 最后的未处理异常处理程序

这里是 最后的堡垒,不是什么神奇的恢复点。

具有代表性的大致有以下这些。

  • AppDomain.UnhandledException
  • WPF 的 Application.DispatcherUnhandledException
  • WinForms 的 Application.ThreadException
  • ASP.NET Core 的异常处理中间件或处理程序
  • Generic Host / worker / BackgroundService 的最终异常处理

这一层的主要职责,大致也就这些。

  • 最终日志记录
  • flush
  • dump 采集通道
  • 保存会话信息或紧邻之前的上下文
  • 整备退出代码或重启通道

反过来,也有一些不该对这一层抱有过高期待的地方。

  • 一旦走到这里,往往说明上层设计存在遗漏
  • 状态可能已经被破坏
  • 有可能仍持有锁,进行繁重处理很危险
  • 即便表面上还能继续运行,也不代表继续运行是安全的

关于 .NET 相关的实务注意事项,还有以下几点值得掌握。

  • AppDomain.UnhandledException 是用于 通知和记录未处理异常 的事件。在其中加入过多恢复处理是危险的。
  • WPF 的 DispatcherUnhandledException 虽然可以通过设置 Handled = true 让程序表面上继续运行,但 是否真的可以恢复 才是首先要判断的事情。
  • WinForms 的 ThreadException 在处理之后,应用也有可能进入 未知状态
  • ASP.NET Core 的异常处理中间件,需要放在 管道较靠前的位置,以便能够接收到后续的异常。
  • BackgroundService 的未处理异常,在 .NET 6 及以后版本中会 被记录,并默认导致宿主停止。相比在父循环中把所有异常都吞掉,让宿主停止并交给重启策略处理,有时会更安全。

尤其是在桌面应用中,确实存在”捕获未处理异常后继续运行”的做法。 不过,能够继续运行可以放心继续运行,是两件不同的事。

4.6. 用一条调用链来看

举个例子,考虑以下这样的流程。

UI / Controller / Job 边界Application Service / UseCaseDomain / 业务逻辑Repository / Gateway / SDK wrapperDB / HTTP / File / Vendor SDK

此时,角色大致会这样划分。

保存按钮 → SaveOrderUseCasePaymentGateway → HTTP

  • PaymentGateway
    • 接收通信失败或响应格式异常
    • 翻译为”支付服务连接失败”“支付服务响应异常”
    • 如果要 retry,就在这里附带条件进行
    • 如果要重新抛出,通常不记录主日志
  • SaveOrderUseCase
    • 将支付被拒等预期内的失败,转换为结果
    • 视为”仅本次订单确认失败”
    • 把失败结果整理成便于返回给 UI 或 API 的形式
  • UI 按钮处理程序 / Controller
    • 统一接收预期外的异常
    • 附带 orderIduserIdrequestId 记录主日志
    • 转换为对话框展示或 500 / 503 响应
  • 未处理异常处理程序
    • 只记录漏到这里的异常
    • 执行 dump 或最终 flush
    • 优先考虑退出通道,而不是恢复

采用这种划分方式,就会形成 技术细节在下层闭环,运维上下文在上层补充,判断在边界处进行 的格局。

5. 区分预期内的失败与预期外的异常

在这个主题上,最重要的是 不要把所有情况都当作同一种”异常”来处理

先尝试这样划分。

失败类型 首先处理的位置 典型处理方式
validation 不合规 UseCase / request 边界 作为输入错误返回
NotFound / Conflict UseCase / Controller 404 / 409 或界面消息
用户取消 / 关闭 操作边界 作为取消处理,通常不视为 Error
CSV 中一行不合规 单行边界 Warning 记录并继续
临时 timeout 最终导致失败 I/O 边界 ~ request 边界 retry 后仍失败则返回失败
NullReferenceException,前提被破坏 request / job 边界 记录主日志并返回失败响应
AccessViolationException、严重的 OutOfMemoryException、疑似 native 边界被破坏 最终边界 作为 Critical 处理,倾向于终止

预期内的失败是 可以在设计阶段就预先确定的失败。 预期外的异常则是 之后是否还能信任状态,值得怀疑的失败

只要把这两者区分开,就能减少以下这类事故。

  • NotFound 每次都当作 Error
  • 把用户取消当作故障处理
  • 把真正危险的前提破坏问题,用”仅本次失败”轻描淡写地放过

6. 日志应该在哪里、记录几次

在日志设计中,比 catch 的位置更重要的是先决定 由谁来记录主日志

基本规则有 6 条。

  1. 针对一次失败,主要的 Error / Critical 日志只记录一次
  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 附带失败单位记录
仅一行不合规仍继续 item 边界 Warning 附带 fileId、rowNumber
导致整个 request 失败的预期外异常 request / UI / job 边界 Error 附带 requestId、userId、entityId
进程级终止 未处理异常边界 Critical flush、dump、重启通道

在实务中相当常见的,是以下这类重复日志。

  • Repository 记录 Error
  • Service 对同一个异常再记录 Error
  • Controller 又记录一次 Error
  • 最后的未处理异常处理程序还记录了 Critical

这样一来,一次故障就会排列出好几份相同的堆栈跟踪。 对阅读的人来说,需要的不是 4 份相同的 stack trace,而是 一条主日志,加上必要时少量的辅助日志

换句话说,基本原则是 日志记录一次,上下文按需补充

7. 常见的 NG 做法

7.1. 在深层 catch (Exception) 后返回 null / false

这样容易丢失原因信息。 而且,调用方将无法区分”确实没有数据”还是”中途出现了故障”。

7.2. 每一层都记录 Error 日志后再重新抛出

这是最常见的重复日志的原因。

  • 下层只做翻译
  • 上层边界记录主日志

采用这种分工,可以大幅减少重复。

在 C# 中如果要重新抛出,基本原则是使用 throw;,以避免破坏堆栈跟踪。

7.3. 库层或公共组件直接输出 UI

如果公共组件直接弹出 MessageBox,或者直接决定 HTTP 响应正文,会破坏可复用性和职责分离。 下层最好只做到 返回有意义的失败 为止,这样更安全。

7.4. 把 OperationCanceledException 作为故障记录 Error 日志

取消是控制流程的一部分。 如果每次都记为 Error,真正的故障就会被掩盖。

7.5. 明明有外部副作用,却轻易进行 retry

发送邮件、扣款、设备指令、文件移动等操作,如果重复执行同一操作,很容易出事故。 只有在同时确认了 临时性失败幂等性 时,才应该进行 retry。

7.6. 试图在最后的未处理异常处理程序中恢复一切

这里是最后的保险,不应该被放在设计的核心位置。

恢复策略最好放在更靠前的层,也就是 request / job / subsystem 的边界处,这样更安全。

8. 审查时的检查清单

在审查异常处理时,按以下顺序检查,会比较不容易漏掉问题。

  • 能否用一句话说清楚这个 catch为了判断什么 而存在的
  • 在这个位置,是否真的能够决定 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 最终记录、退出通道

拿不准的时候,先记住以下这 5 点就足够了。

  1. 深层不要大范围捕获
  2. 在边界处接收
  3. 主日志只记录一次
  4. 吞掉异常的那一层承担责任
  5. 最后的未处理异常只负责记录和退出通道

10. 总结

异常处理并不是”哪里都能 catch,所以就到处 catch“这样的事情。

检查的顺序,大致按以下这样就足够了。

  1. 在这个位置真的能做出判断吗
  2. 在这里能明确失败单位吗
  3. 在这里能恢复状态,还是能重新构建状态
  4. 在这里记录日志会不会重复
  5. 这里是恢复点,还是最后的记录点

按这个顺序检查,梳理调用层级会变得相当容易。

尤其重要的是以下这 3 点。

  • 深层主要负责翻译和 cleanup
  • 边界主要负责判断和记录主日志
  • 最后的未处理异常处理程序主要负责记录和退出通道

换句话说,基本原则是:在边界处接收异常,附加上下文,并只在能够恢复的地方进行处理

一旦确立了这个原则,无论是代码审查还是故障调查,都会变得相当稳定,不容易出现偏差。

11. 参考资料

12. 相关文章

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

应该在哪一层 catch 异常?
原则是不要在深层大范围 catch,而应该把捕获点靠近能够定义失败单位的边界。例如一次界面操作、一个 HTTP 请求、一个作业、一条消息这类处理边界,是比较自然的接收点。基本原则不是「哪里能 catch 就在哪里 catch」,而是在能够负责判断 retry、结果化、是否继续的地方进行捕获。在较深的 helper 或 utility 中,只应做 finally 中的收尾、局部回滚,以及异常的翻译,不宜更多。
异常日志应该在每一层都记录吗?
基本原则是:针对一次失败,主要的 Error / Critical 日志只记录一次。如果 Repository 记录 Error,Service 对同一个异常再记录 Error,Controller 又记录一次 Error,那么一次故障就会出现好几份相同的堆栈跟踪,让阅读的人感到困惑。下层应只做翻译和补充上下文,而在具备 requestId、userId 等运维上下文的上层边界记录主日志。只有把异常吞掉并转换为结果的那一层,才需要承担该次失败的记录责任。
预期内的失败和预期外的异常应该如何区分?
预期内的失败是可以在设计阶段就预先确定的失败,例如校验不通过或 NotFound,可以在用例(use case)单位上转换为结果,不必每次都记录 Error 日志。因用户取消而产生的 OperationCanceledException,通常也不当作 Error 处理。另一方面,像 NullReferenceException 这类前提被破坏的情况,应在 request / job 边界记录主日志并返回失败响应;AccessViolationException 或严重的 OutOfMemoryException 应作为 Critical 处理,并倾向于终止程序。只需区分这两类,就能减少真正危险的失败被掩盖的事故。
未处理异常处理程序中应该做什么?
AppDomain.UnhandledException、WPF 的 DispatcherUnhandledException、WinForms 的 ThreadException 等,并不是恢复点,而是最后的记录点。它们的主要职责是最终日志记录、flush、dump 采集通道,以及退出代码或重启通道的整备。一旦泄漏到这一层,状态就可能已经损坏,即便表面上还能继续运行,也不代表可以安全地继续下去。恢复策略最好放在更靠前的 request 或 job 边界上处理。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表