ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C# try/catch 优雅设计:从异常分类到结构化日志

C# try/catch 优雅设计:从异常分类到结构化日志 上周评审新同事提交的代码看到他在一处外部接口调用附近的 try/catch 处理我本来想照惯例在评论区提示两句“这里最好还是细化一下异常分支”结果看完他的 catch 块我把打好的字删了。那个 catch 用 when 过滤了异常码、把日志上下文带全、还顺手把重试和用户提示分开了整段代码干净得像教科书又在教科书之外多了不少实战感。这件事让我想认真聊一聊 try/catch。很多人觉得 try/catch 是“最常见的语法糖”是个人都会写但我在日常评审和排查故障时见过太多烂摊子吞掉异常的、丢失堆栈的、把业务逻辑塞进 catch 的、日志只打了一句 ex.Message 就再无下文的。一个工程师的异常处理功底往往在系统出故障时才会被放大检视而优雅的 try/catch 写法和粗糙的写法之间差的不是语法掌握程度而是对“异常也是系统状态的一部分”这件事的理解深度。这篇内容是基于 C# / .NET 技术栈来展开的热搜里也挂着一句“C# 的 try catch ex.Message”说明这个话题的共鸣度确实高但里面的设计思路拿到 Java、Go 甚至前端 TypeScript 里也能平移大部分。适合刚接触异常处理的新人也适合想从“能用”进阶到“好用”的工程师。1. 为什么同样是 try catch有人写出的是烂摊子有人写出的是防护网1.1 先看三种常见的反面写法我在评审中遇到最多的 try/catch是下面这幅样子try { // 一堆业务逻辑 } catch (Exception ex) { // 什么都不做 }空 catch 是比不写还要糟糕的做法。不写 catch异常至少还能向上抛出、被全局兜底接住、被日志系统记录写了空 catch等于告诉系统“这个错误我知道了但我决定假装它没发生”业务状态从此可能处于一个未知的中间状态。比如库存扣减失败后被吞掉用户订单看似生成了库存实际没扣对账时才发现对不上排查成本直接拉满。第二种常见写法是 catch 住Exception再用throw ex抛出去try { // 调用外部服务 } catch (Exception ex) { Log(ex); throw ex; }这段代码最致命的问题在于throw ex会重置异常的堆栈原本能定位到真正抛错的那一行被这一行覆盖成了当前 catch 块的位置。线上排查问题本身就不容易需要的信息被自己亲手抹掉几乎是给自己挖坑。第三种写法则更隐蔽catch 里塞了一堆业务逻辑。catch (Exception ex) { _logger.LogError(ex, xxx失败); if (condition) { // 回滚一份数据 } if (other) { // 发一封邮件 MailHelper.Send(xxx); } _unitOfWork.Rollback(); return FailResp(); }把日志、补偿、通知、回滚全堆在 catch 里会让方法瞬间膨胀到几十行。表面上看起来很“周全”实际上每加一种动作都在增加 catch 块的耦合度和出错的概率。比如发通知的代码本身抛了个异常新异常会覆盖原始异常旧问题还没解决新问题先出来了。1.2 异常是真正常态化的一部分不是“意外”这些年我逐渐意识到一个核心观念异常处理的方向是把它当作业务流程里必然会发生的一种分支而不是代码运行过程中“不该出现的意外”。网络会抖动第三方接口会超时数据库会锁表用户会传入脏数据。这些在真实生产环境里不是“万一”而是“一定会”。既然一定会发生那么代码里就应该给这些情况留出明确通道。try/catch本质上是在声明一条备用路径而不是在“补救一个错误”。所以一个写 try/catch 很优雅的工程师通常不是运气好、遇不到异常而是把异常处理当作一套独立于主流程的分支设计。每个 catch 块承担什么职责、向谁汇报、接下来执行什么都清清楚楚没有多余的包揽也没有不该有的沉默。我之前带过的一个团队里有个习惯写 catch 之前先问自己两个问题。第一这段代码失败了我能做什么第二我做什么之后系统是变得更安全了还是只是在自我安慰这两个问题能过滤掉大量无意义的 catch。2. 拆解“优雅”的底层逻辑异常分类、异常过滤与全局兜底的分层设计2.1 先把异常分类再决定怎么捕获优雅的 try/catch 不是对每一段代码都套上保护而是在动手之前先给异常分个类。我个人的分类习惯是三类业务异常、外部依赖异常、程序缺陷异常。业务异常指的是余额不足、订单状态不允许、参数非法这类情况。这类异常在多数团队中更适合用自定义异常类型来表示而不是用 int 错误码再在 catch 里做一堆 if/else 转译。外部依赖异常指的是 HTTP 调用超时、数据库连接失败、Redis 不可用等。这类异常通常要考虑重试、熔断和降级catch 住之后的行为要跟业务异常区分开。程序缺陷异常则是空引用、数组越界、类型转换错误这类 bug 类的异常。这类情况基本不应该在靠近业务的地方做过多处理而是应该让异常抛出去落到全局兜底并且日志一定要完整记录方便定位修复。把三类混在一起用一个catch (Exception ex)全接收是代码变烂的开始。2.2 用特定的异常捕获而不是 Exception 一把抓基于上面的分类写代码的时候就应该养成一个习惯尽量用最具体的异常类型来写 catch 子句。try { var order await _orderRepository.GetByIdAsync(orderId); } catch (TimeoutException) { // 处理数据库连接超时 } catch (InvalidOperationException) { // 处理订单状态非法 }这比catch (Exception ex)然后在方法体里判断异常类型要清晰得多。特定的 catch 子句让代码的意图直接暴露在语法层面而且不会拦截那些你不打算处理的异常类型。理想状态下catch (Exception)只应该出现在全局兜底或最顶层出口而不应该遍布在业务代码的各个角落。2.3 C# 6 的 when 过滤器让 catch 直接带条件新同事那个让我删掉评论的代码核心就是用了异常过滤器。C# 6 开始支持的when关键字可以在 catch 子句后面加一个布尔条件只有条件为 true 时才进入这个 catchtry { var paymentResult await _paymentClient.AuthorizeAsync(order); } catch (OrderPaymentException ex) when (ex.ErrorCode PAY_TIMEOUT) { // 超时场景允许重试 return RetryResult.From(ex); } catch (OrderPaymentException ex) when (ex.ErrorCode PAY_DECLINED) { // 银行拒绝场景直接告知用户不重试 return DeclineResult.From(ex); }这里的设计很巧妙同一个异常类型根据错误码的不同走向完全不同的处理分支而不用在 catch 内部写 if/else。异常过滤器还有一个容易被忽略的好性质when 条件为 false 时异常不会被这个 catch 捕获会继续向上传播而且堆栈完全不会被动过。这比“catch 住再判断、不满足再 throw”的方式更干净也不会重新污染调用栈。我实测下来这个特性特别适合处理外部服务返回的各种错误码。尤其在做支付、物流这类需要对接大量渠道商的系统时一个服务可能返回几十种错误码用 when 把每个场景精确拆开比在 catch 里写一长串 switch 清晰一个量级。异常过滤器还有一个非常实际的应用场景是日志记录如果希望某个 catch 记录日志但又不想让这个 catch 改变异常传播路径可以这样写catch (Exception ex) when (LogAndReturnFalse(ex)) { // 永远不会执行到这里 }LogAndReturnFalse返回 false所以这个 catch 块本身不会拦截异常只会“路过”时做一次日志记录。这个技巧我最初觉得有点 hack但真正排查难缠问题的时候它能精确地在异常路径上“埋点”又不改变异常行为。2.4 全局兜底业务代码不该到处 try catch对于 Web API 项目来说很多业务代码里的 try/catch 其实是可以省掉的。让异常自由抛出然后在全局统一出口处理反而更利于维护。我用 ASP.NET Core 的一个自定义中间件做全局兜底整体思路是业务异常返回可读的错误信息其他异常记录完整日志、给客户端一个不泄露内部细节的通用响应。public class ExceptionHandlingMiddleware { private readonly RequestDelegate _next; private readonly ILoggerExceptionHandlingMiddleware _logger; public ExceptionHandlingMiddleware(RequestDelegate next, ILoggerExceptionHandlingMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (OrderException ex) { context.Response.StatusCode 400; context.Response.ContentType application/json; await context.Response.WriteAsJsonAsync(new { errorCode ex.ErrorCode, message ex.Message }); } catch (Exception ex) { _logger.LogError(ex, 未处理异常请求路径{Path}, context.Request.Path); context.Response.StatusCode 500; await context.Response.WriteAsJsonAsync(new { errorCode INTERNAL_ERROR, message 服务器开小差了 }); } } }用这个中间件之后Controller 层可以完全不用自己写 try/catch。代码的主流程变短了真正需要关注的业务逻辑不会被异常处理打断。哪些异常需要业务层自己消化哪些异常应该抛到全局在分层设计上就划分清楚了。这比我以前见过的“每个 Action 里都包一层 try/catch、然后返回统一结构”的做法要清爽得多也避免了同一个异常在不同 Action 里被重复处理。2.5 自定义异常是优雅 try/catch 的基石要让上面的全局兜底和 when 过滤器真正发挥作用自定义异常类型是绕不开的。写一个能携带业务上下文的异常并不复杂public class OrderException : Exception { public string OrderNo { get; } public string ErrorCode { get; } public OrderException(string errorCode, string message, string orderNo, Exception innerException null) : base(message, innerException) { ErrorCode errorCode; OrderNo orderNo; } }这里的设计要点是把调用方需要做出的决策信息比如错误码、关联的单号直接作为异常对象的属性暴露出来。这样 catch 的地方不需要再去查数据库、不需要从 message 字符串里解析参数直接通过属性就能拿到写起 when 条件也顺手很多。这里有一个需要提醒的坑不要在自定义异常里塞太多与决策无关的字段也不要让异常携带那些可能包含敏感信息的对象。异常会随着调用栈传播、被日志系统记录塞一个包含用户手机号、身份证号的实体进去等于把这些信息写进了日志。3. C# 中真正决定 try catch 水准的细节异步异常、throw 与 using/using 边界3.1 throw vs throw ex堆栈就是命案现场前面提到过throw ex会重置堆栈这里展开说。异常对象里的 StackTrace 是定位问题的第一手证据throw ex会把当前异常的堆栈替换为throw ex所在的位置原始调用链信息就丢了。catch (Exception ex) { // 错误做法重置堆栈 throw ex; }catch (Exception ex) { // 正确做法保留原始堆栈 throw; }一字之差排查成本天差地别。我在团队里定的规矩是除非有明确理由否则一律不允许throw ex。如果确实需要包装异常应该把原始异常作为 InnerException 传进去catch (Exception ex) { // 转译把底层异常包装成领域异常并保留内部异常 throw new OrderException(PAY_TIMEOUT, 支付调用超时, orderNo, ex); }这样既能向上层提供领域化的异常信息又不丢失底层的堆栈和原因。日志系统在输出异常时会把 InnerException 的堆栈一并打印出来问题链路仍然是完整的。3.2 异步代码中的异常处理不要用 async voidC# 的异步异常处理有几个非常容易踩的深坑它们通常在压测和生产故障时才暴露。第一个坑是async void。async void方法的异常无法被调用方捕获一旦抛出会直接传播到线程池或 SyncContext 的顶层轻则导致日志上下文丢失重则直接带崩进程。事件处理器确实被迫只能用async void但其他地方一律应该用async Task这样异常才能通过 await 正常传播// 错误异常无法被调用方捕获 public async void DoSomethingAsync() { await _client.PostAsync(...); } // 正确异常可以被 await 捕获 public async Task DoSomethingAsync() { await _client.PostAsync(...); }第二个坑是Task.WhenAll。很多人以为 catch 住的是AggregateException实际上用await Task.WhenAll(...)时抛出的异常只会是第一个异常任务准确说是先出现异常的那个任务对应的异常并不会自动聚合。如果你真的需要拿到全部失败信息需要通过Task.WhenAll返回的任务对象的Exception属性来访问AggregateException或者手动遍历任务列表逐个查看状态。try { var results await Task.WhenAll(tasks); } catch (Exception ex) { // 这里拿到的是第一个抛出的异常而不是 AggregateException _logger.LogError(ex, 批量任务执行失败); }如果需要保留所有子任务的异常可以这样var allTask Task.WhenAll(tasks); try { await allTask; } catch { var aggregate allTask.Exception; // AggregateException包含所有子异常 foreach (var innerException in aggregate.InnerExceptions) { _logger.LogError(innerException, 子任务失败); } throw; // 仍然重新抛出 }第三个坑是关于线程池异常。在Task.Run或Parallel.ForEach里产生的异常如果用的是异步方式最终会落到Task的异常状态上等待被观察。但如果用的是Parallel且没有正确处理未观察异常在某些 .NET 版本里可能引发进程崩溃。规则很简单要么在并发体内部自行 catch 并记录要么保证外层一定能捕获到完整异常。不要“放一半”。3.3 ExceptionDispatchInfo跨线程保存并重新抛出异常有时候你需要在 catch 到一个异常之后把异常留到稍后某个时机再重新抛出而且希望尽量不要破坏原始堆栈。直接保存异常对象再throw会破坏堆栈用ExceptionDispatchInfo是专门干这件事的ExceptionDispatchInfo capturedException null; try { throw new InvalidOperationException(有些问题); } catch (Exception ex) { capturedException ExceptionDispatchInfo.Capture(ex); } // 后在某个合适的时机 capturedException?.Throw();Throw()会恢复原始的堆栈信息异常看起来就像是在原位置直接抛出的一样。这在实现“延迟上报”“部分失败重试”这类逻辑时很实用也是高手在写通用框架时常用的手法。3.4 using 与 finally资源释放不该用 try/catch 来兜异常处理里有一类情况跟 catch 无关但经常被人混淆处理资源释放。IDisposable的资源数据库连接、文件流、HttpClient、CancellationTokenSource 等应该优先用using来管理而不是在 finally 里手动判断再加一堆 try/catch。// 优雅作用域结束自动释放 using (var conn new SqlConnection(connectionString)) { // 使用连接 }C# 8 之后的 using 声明则更简洁using var conn new SqlConnection(connectionString);不过 using 声明要特别注意作用域变化它会在当前代码块结束时释放而不是在语句结束时释放。如果你在方法中间使用 using 声明资源会被保留到方法末尾可能比预期晚释放很久。对于长生命周期的方法还是用括号形式的 using 更安全。finally 里有个常见的坑finally 块中不应该写可能引发新异常的代码。比如在 finally 里做StreamWriter.Dispose()如果释放过程出错新异常会覆盖原始异常。如果必须做需要给 finally 里的操作再做一层防护。4. 从 ex.Message 到结构化日志让异常记录保留现场信息4.1 为什么只记录 ex.Message 远远不够“C# 的 try catch ex.Message”能成为热搜词恰恰说明了很多人把异常处理简化成了“记录一句话”。我在排查线上问题的时候最怕看到日志里只有一句错误调用支付接口失败没有堆栈、没有请求参数、没有订单号、没有耗时、没有上游报文。一条没有上下文的异常日志等于告诉排查者“这里出事了但你自己去猜吧”。ex.Message只能提供异常本身的概要描述。完整的做法是记录异常的完整ToString()输出它包含堆栈和内部异常在此基础上再把当前业务上下文订单号、用户标识、请求路径、关键参数、耗时一并记下来。4.2 用结构化日志模板保留上下文.NET 生态里的ILogger本身就支持结构化日志不需要额外引入重型框架就能做到。关键是使用消息模板和命名占位符catch (TimeoutException ex) { _logger.LogWarning(ex, 支付调用超时订单号{OrderNo}当前重试次数{RetryCount}上游耗时{ElapsedMs}ms, orderNo, retryCount, elapsedMs); }这样输出到 Serilog、NLog 这类日志平台时OrderNo、RetryCount、ElapsedMs会成为独立字段可以搜索、聚合、过滤而不是被拼到一段人肉可读的字符串里。生产环境日志量上去之后结构化字段的价值会成倍放大一条异常消息里多一个可检索的订单号排障效率就能差出不少。4.3 把业务上下文放到自定义异常属性里除了在日志模板中补充上下文异常对象本身也应该尽量携带业务上下文。前面自定义的OrderException里加了OrderNo和ErrorCode属性捕获方和日志记录方都能直接使用。这是让 try/catch 变得“优雅”的重要一步异常不再只是“失败信号”它还自带诊断信息。在 catch 里记录日志时就可以把异常属性和结构化日志结合起来catch (OrderException ex) { _logger.LogWarning(ex, 订单处理失败订单号{OrderNo}错误码{ErrorCode}, ex.OrderNo, ex.ErrorCode); }这比把订单号拼到 Message 字符串里再让运维去日志里人肉翻要可靠得多。Message 只负责“给人看的一句话”机器可读的诊断信息应该走属性。4.4 日志级别不是所有异常都要 Error还有一个经常被忽略的细节日志级别。很多团队习惯所有异常统一LogError这会导致 Error 日志里灌满了可预期的业务校验失败真正的系统故障被淹没。我自己的分法是这样Trace/Debug调试期细节生产环境默认关闭。Information正常业务阶段如支付回调到达、定时任务执行完成。Warning可预期的异常或降级路径如调用第三方超时重试后恢复、缓存未命中、并发冲突。Error当前请求失败但系统核心进程仍存活比如数据库连接中断、未处理的 API 异常。Critical进程级故障、核心链路不可用需要立即告警。同一个调用第三方失败的场景如果重试后成功了我记Warning如果重试多次仍然失败、直接影响到用户订单才记Error。这样看日志时能快速分出优先级不至于每天被几百条“垃圾 Error”轰炸到对告警麻木。4.5 记录日志时避免两个副作用第一个副作用是日志代码本身抛异常。如果你在 catch 里做日志时又抛了异常原始异常会被覆盖。所以日志调用要尽量简单避免复杂拼接和资源操作。第二个副作用是敏感信息外泄。不要把手机号、身份证号、密码、密钥、完整报文写进日志。这些年我在审计日志系统时清理过不少把支付明文报文写进日志的案例教训都非常深刻。日志字段需要“够用就好”缺失的信息可以通过关联 ID 去其他系统查。5. 代码评审中识别优质 try catch 的五个观察点5.1 一页评审清单这几年我做代码评审遇到 try/catch 相关的代码时会在心里走一遍相对固定的观察清单。这里直接整理出来也方便你自己 review 代码时对照观察点差的写法好的写法捕获粒度catch (Exception ex)到处都是优先捕获具体异常类型仅全局兜底捕获 Exceptioncatch 分支条件catch 内用 if/else 判断来源用when在 catch 子句上做精确过滤异常传播throw ex堆栈被重置throw;保留堆栈包装异常时传 InnerExceptioncatch 块职责日志、业务回滚、通知全部堆在 catch 里catch 只做日志、转换、补偿职责单一上下文信息只记ex.Message找不到订单号异常属性 结构化日志携带完整上下文边界处理不区分业务异常和系统异常吞掉不可恢复的异常业务异常转译系统异常抛全局可恢复的异常才局部处理有了这个清单再去看同事的代码对方有没有认真设计异常处理几页代码就能看出来。5.2 评审时我会追问的几个“为什么”看 try/catch 代码时我不只看语法还会在评审意见里追问几个问题这个 catch 为空真的可以安全吞掉吗有没有可能把业务状态置于未知位置这个 catch 里调用外部服务做补偿外部服务失败了怎么办是不是又需要一个兜底这里记录的日志字段线上排查时能不能仅凭这些字段定位问题如果把所有 catch 都删掉交给全局兜底系统表现会不会更好这个自定义异常后续扩展新的错误码时会不会需要改动多个调用方这几个问题问下来大部分看似“能用”的异常处理都会被过滤掉。写 try/catch 的门槛很低但把它写得像设计过的代码需要的是对业务边界、调用链路、运维可观测性的综合理解。5.3 一个容易忽略的细节异常的性能开销最后补一个偏底层的经验。异常创建和抛出的开销比正常流程大不少尤其是new Exception()时收集堆栈信息那一步。有些人喜欢用异常控制普通流程比如用 try/catch 做字符串解析判断、用异常做循环退出标志。这在数据量大或调用频率高的路径上会造成可测量的性能损耗。优雅的 try/catch 还有一个隐含原则用异常处理异常而不是用异常处理正常控制流。可预期的分支比如参数校验失败、库存是否充足应该用条件判断或结果对象来表达只有真正非预期的失败才交给异常机制。注意这个边界异常处理才是健康的。我在实际项目里见过不少工程师刚学会自定义异常和异常过滤器之后就到处“炫技”把原本简单的 if/else 全改成 try/catch when结果代码反而更绕。后来大家慢慢形成共识try/catch 的价值不在于它有多高级的语法而在于它帮系统守住了哪条边界、留住了哪些证据、让调用方接下来能做哪个正确的决策。一个写得好 try/catch 的工程师本质上是在用代码告诉后来的人这条路断了但我知道接下来该往哪里走。
返回列表