业务问题与 Exception.Data 的引入

我们经常面临这样的业务挑战:一个复杂且冗长的业务流程,例如销售订单的生效和库存处理,其中任何环节都可能因为各种原因中断并抛出异常。当异常发生时,仅仅抛出一个通用的错误信息是远远不够的。用户需要的是具体、有上下文的错误提示,比如”单据 XXX 的第 3 行,商品 A 库存不足,需要 5 个,现在只有 1 个。”

传统的异常处理方式,如在底层服务抛出简单的”库存不足”异常,然后由上层业务逻辑捕获并包装成新的、包含更多上下文信息的异常(比如 OrderProcessingException 内部包含 InsufficientStockException),会导致所谓的”洋葱式”异常。这种层层嵌套的异常链虽然能保留因果关系,但:

  1. 臃肿且难以解析:调试和日志分析时,需要一层层剥开异常链,获取真正的业务信息。
  2. 职责混淆:如果让底层服务添加所有上下文信息,会使其职责不单一;如果上层代码总是捕获并包装,又增加了大量重复的 try-catch-rethrow 代码。
  3. 丢失原始异常类型:包装后的异常可能丢失了原始系统或领域异常的类型信息,导致后续的错误分类和处理变得复杂。

为了解决这些问题,.NET 的 Exception.Data 属性应运而生。它是一个 IDictionary<object, object> 类型的属性,允许你在异常抛出时,附加任意的、非结构化的上下文数据到原始异常对象上,而无需创建新的异常或进行异常包装。

Exception.Data 设计的优点

  1. 避免”洋葱式”异常:这是最核心的优点。它允许异常在传播过程中,由知道更多上下文信息的上层代码直接向其 Data 字典中添加额外信息,而不需要捕获、创建新异常、再重新抛出。这大大简化了异常处理链,使异常对象保持简洁。
  2. 保留原始异常类型:无论多少层代码向 Data 中添加信息,异常对象的类型始终是最初抛出的那个(例如 InvalidOperationExceptionOutOfMemoryException),这对于基于异常类型进行错误分类、监控和报警至关重要。
  3. 高度灵活:Data 属性是一个键值对字典,可以存储任何类型的对象。这使得它能够适应各种不可预测的、需要附加的上下文信息,例如单据 ID、行号、商品编码、用户 ID 等。
  4. 简化异常传递:异常在方法调用栈中向上传播时,Data 字典中的信息会自动跟随,上层代码可以轻松访问这些附加数据,用于生成详细的错误报告或日志。

Exception.Data 设计的缺点与挑战

  1. 缺乏类型安全性:IDictionary<object, object> 的设计意味着你可以在其中放入任何类型的数据,且编译器无法在编译时检查键或值的类型。这在大型团队或复杂项目中可能导致键冲突、值类型错误、数据结构不一致等问题。
  2. 可读性和可维护性下降:如果 Data 中存储了大量或结构复杂的非标准化数据,调试时需要手动检查字典内容,增加了理解异常上下文的难度。
  3. 不适用于强业务语义:尽管灵活,但 Data 无法像自定义异常属性那样提供强业务语义。例如,一个 InsufficientStockException 应该直接有 RequestedQuantityAvailableQuantity 这样的属性,而不是将它们作为键值对放入 Data 中。
  4. 序列化和反序列化问题:如果异常需要在进程间传输,Data 中的任意对象可能导致序列化问题,特别是当其中包含非可序列化对象时。

与其他 .NET 异常处理模式的比较

  • 自定义异常类型:对于预期的、具有特定业务含义的错误,应优先定义自定义异常类型(例如 InsufficientStockException),并在其中包含强类型的业务属性。Data 适用于那些不值得或无法用自定义属性表示的临时性、动态性上下文信息。
  • InnerException(异常链)InnerException 用于表达异常的因果关系,即”这个异常是因为那个异常引起的”。Data 关注的是上下文信息,而非直接的因果关系。两者是互补的,而不是替代关系。
  • Result 模式/结构化错误对象:在函数预期会失败的情况下(例如数据校验、业务规则检查),许多现代设计倾向于使用 Result<T, E>Either 这样的返回类型,而不是抛出异常。这种方式比 Exception.Data 提供更强的类型安全和结构化错误信息。对于可预期的、需要明确处理的业务错误,Result 模式往往比异常更好。

其他大型 ERP 产品及编程语言的实践

大型 ERP 产品(SAP, Oracle EBS, MS Dynamics 365):

  • SAP (ABAP):通常使用自定义的异常类(cx_root 的子类),这些类可以定义自己的属性来承载业务上下文。此外,SAP 有强大的消息管理机制,通过消息号和消息变量来构建动态、国际化的错误消息。
  • Oracle EBS:同样依赖于预定义的消息字典(Message Dictionary)和消息替换变量(tokens)来提供上下文相关的错误信息。
  • Microsoft Dynamics 365 (X++):X++ 语言有自己的异常处理机制,虽然与 .NET CLR 有互操作性,但在业务层面更倾向于通过明确的错误消息和日志来记录上下文。

ERP 系统倾向于更结构化、国际化和可配置的错误消息和上下文管理,通常通过自定义异常属性、消息字典或专门的错误对象来承载上下文,而非通用的、非类型安全的字典。

其他编程语言:

  • Java:Throwable 类有 getCause() 方法用于实现异常链。Apache Commons Lang 提供了 ContextedException,它允许你添加任意键值对的上下文信息,与 Exception.Data 的理念非常相似。
  • Python:Python 的异常是对象,开发者可以直接给异常对象添加自定义属性来携带额外数据。Python 也支持 __cause____context__ 用于异常链。
  • Go:Go 语言通过多返回值(value, error)来处理错误。当需要传递上下文时,常见的做法是使用 fmt.Errorf("%w", err) 进行错误包装,通过字符串或自定义结构体添加上下文信息。
  • Rust:Rust 使用 Result<T, E> 枚举来表示成功或失败。常用的 anyhowthiserror 等 Crate 提供了强大的功能:anyhow::Error 允许动态添加上下文信息,而 thiserror 则允许你定义带有结构化字段的自定义错误枚举,强制类型安全。

架构师的视角与建议

何时使用 Exception.Data

  • 非结构化、动态的附加信息:当你有一些临时性的、难以预先定义为强类型属性的上下文信息,但又需要在异常传递过程中携带时,Data 是一个快捷方便的选择。
  • 避免过度包装:当你确实想避免”洋葱式”异常,同时又想在不改变原始异常类型的情况下,由上层代码丰富错误信息时。
  • 诊断和调试:Data 可以用于在开发和测试阶段,向异常中注入诊断信息,方便快速定位问题。

何时避免或谨慎使用 Exception.Data

  • 强业务语义的错误:对于像”库存不足”、”价格错误”、”用户未授权”等具有明确业务含义的错误,强烈建议定义自定义异常类型,并为其添加强类型的属性。
  • 需要结构化处理的数据:如果附加的上下文信息需要被下游代码进行解析、过滤或自动化处理,那么 Data 的非结构化特性将带来挑战。
  • 跨服务/进程边界传输:如果异常需要通过序列化在服务之间传输,通常更推荐定义清晰的错误 DTO 来作为服务契约的一部分。

最佳实践建议:

  1. 约定优先:如果决定使用 Exception.Data,请在团队内部建立严格的键命名约定(例如,使用 ModuleName.PropertyName),并明确每种异常可能在 Data 中包含哪些键以及它们的值类型。
  2. 日志先行:在捕获异常并决定向 Data 添加信息后,立即将其添加到日志中。在最终处理异常时,确保 Data 中的所有重要信息都被提取并记录下来。
  3. 少量精炼:Data 应该包含解决问题所需的最少且最关键的上下文信息。避免将大量不相关的数据倾倒进去。
  4. 结合使用:Exception.Data 并非万能药,它应与自定义异常、InnerExceptionResult 模式结合使用,形成一个全面且健壮的异常处理策略。

Exception.Data 是 .NET 框架提供的一个灵活的工具,它填补了自定义异常和异常链之间的空白,允许在不改变异常类型和不增加层级的情况下附加任意上下文。然而,它的非类型化特性也要求我们在设计和使用时保持高度的纪律性和约定,才能真正发挥其价值,而不是成为”技术债”的源头。


<
Previous Post
UI界面设计:继承 vs 组合 的架构思考
>
Next Post
探索 Nim 中的 sequtils 与箭头语法 —— 立即计算与惰性计算的那些事