如何在DDD中处理异常

图片

作为最近的DotNext 2018大会的一部分,BoF开展了域驱动设计。 它讨论了处理例外的问题,这引起了激烈的辩论,但由于它不是主要主题,因此没有得到详细的讨论。

另外,研究大量资源,从堆栈溢出问题到付费架构课程结束,您都可以发现IT社区对异常及其使用方式持模棱两可的态度。

最常提到的是,使用异常很容易构建具有goto运算符语义的执行线程,这会对代码的可读性产生不利影响。

对于创建自己的异常类型还是使用.NET中提供的标准异常,存在不同的意见。

有人对异常进行验证,而其他人则使用Result monad 。 的确,使用Result可以使您通过方法签名了解是否可以成功执行。 但是,在命令式语言(包括C#)中,广泛使用Result会导致可读性差的代码被语言结构覆盖,因此很难辨认出原始脚本。

在本文中,我将讨论我们团队采用的做法(总之-我们使用所有方法,但都不是教条)。

我们将讨论基于ASP.NET MVC + WebAPI构建的企业应用程序。 该应用程序基于洋葱体系结构 ,与数据库和消息代理进行通信。 它使用结构化日志记录到ELK堆栈,并使用Grafana配置监视。

我们将从三个角度研究异常处理:

  1. 一般例外规则
  2. 异常,错误和洋葱架构
  3. Web应用程序的特殊情况

一般例外规则


  1. 异常和错误不是同一回事。 对于例外,我们使用例外,对于错误-结果。
  2. 例外仅适用于例外情况,根据定义,例外情况不能太多。 因此,异常越少越好。
  3. 异常处理应尽可能细化。 正如里希特(Richter)在他的巨著中所写。
  4. 如果错误应以其原始形式传递给用户-使用结果。
  5. 异常不应以原始形式离开系统的边界。 这不是用户友好的,并且为攻击者提供了一种方法来进一步探索系统中可能存在的缺陷。
  6. 如果抛出的异常是由我们的应用程序处理的,则我们不使用异常,而是使用Result。 goto运算符将隐藏对异常的实现,并且情况更糟,处理代码与异常抛出代码之间的距离越远。 结果明确声明了错误的可能性,并且仅允许其“线性”处理。

异常,错误和洋葱架构


在以下各节中,我们将考虑以下层的引发/处理异常/错误的责任和规则:

  • 应用主机
  • 基础设施
  • 申请服务
  • 领域核心

应用主机


负责什么

  • 组成root ,自定义整个应用程序的操作。
  • 与外界交互的边界是用户,其他服务,计划的启动。

由于这些职责非常复杂,因此有必要限制自己。 我们将剩下的责任交给内部层。

如何处理结果错误

广播到外部,转换为适当的格式(例如,在http响应中)。

结果如何产生

没办法 该层不包含逻辑,因此无处产生错误。

如何处理异常

  1. 隐藏细节并转换为适合发送到外界的格式
  2. 登录。

如何引发异常

绝对不可能,这一层是最外部的,并且不包含逻辑-没有人向其抛出异常。

基础设施


负责什么

  1. 端口的适配器 ,或者只是实现域接口的适配器 ,从而提供对基础结构的访问-第三方服务,数据库,活动目录等。此层应尽可能愚蠢,并包含尽可能少的逻辑。
  2. 如有必要,它可以充当反腐败层

如何处理结果错误

我不知道在Result monad上运行的数据库提供程序和其他服务。 但是,某些服务在返回码上运行。 在这种情况下,我们会将其转换为端口所需的结果格式。

结果如何产生

通常,此层不包含逻辑,这意味着它不会产生错误。 但是,如果用作反腐败层,则可能有多种选择。 例如,解析来自旧服务的异常,并将那些简单验证消息的异常转换为Result。

如何处理异常

通常,如果有必要,它将进一步确保细节。 如果正在实施的端口允许在合同中返回Result,则基础结构会将那些可以处理的异常类型转换为Result。

例如,当代理不可用时,项目中使用的消息代理在尝试发送消息时会引发异常。 应用服务层已为这种情况做好了准备,并能够通过重试策略,断路器或手动数据回滚来处理它。

在这种情况下,Application Services层声明发生错误时返回Result的合同。 基础结构层实现此端口,将异常从代理转换为结果。 自然,它仅转换特定类型的异常,而不是全部转换。

使用这种方法,我们有两个优点:

  1. 明确声明合同错误的可能性。
  2. 当应用程序服务知道如何处理错误但不知道异常类型时,我们摆脱了这种情况,因为它是从特定的消息代理中抽象出来的。 在基础System.Exception上构建catch块意味着捕获所有类型的异常,而不仅仅是捕获Application Service可以处理的异常。

如何引发异常

取决于系统的细节。

例如,当请求不存在的数据时,Single和First LINQ语句将引发InvalidOperationException。 但是,.NET中到处都使用这种类型的异常,这使得无法对其进行精细处理。

我们团队中的做法是创建一个自定义的ItemNotFoundException,如果未找到请求的数据(根据业务规则也不应该这样),则将其从基础结构层抛出。

如果找不到请求的数据并且允许这样做,则应在端口合同中明确声明。 例如,使用Maybe monad

申请服务


负责什么

  1. 验证输入数据。
  2. 服务的编排和协调-事务的开始和结束,分布式脚本的实现等。
  3. 通过端口将域对象和外部数据下载到基础架构,然后在Domain Core中调用命令。

如何处理结果错误

来自域核心的错误将转换为外部世界,而不会发生变化。 基础结构中的错误可以通过重试,断路器策略处理或广播到外部。

结果如何产生

可以将验证实施为结果。

可以生成操作部分成功的通知。 例如,向用户发送消息,例如“您的订单已成功下达,但在验证收货地址时发生了错误。 专家会尽快与您联系以澄清交货细节。”

如何处理异常

假设应用程序能够处理的基础结构异常已由基础结构层转换为结果,那么它根本不会处理它。

如何引发异常

一般来说,没有办法。 但是,本文的最后一部分介绍了一些边界选项。

领域核心


负责什么

业务逻辑的实施,系统的“核心”及其存在的主要含义。

如何处理结果错误

由于该层是内部的,并且错误仅可能来自同一域中的对象,因此处理可以简化为业务规则,也可以简化为原始形式的错误向上转换。

结果如何产生

如果您违反了封装在Domain Core中但未在Application Services级别进行输入数据验证的业务规则。 通常,在此层中,最常使用Result。

如何处理异常

没办法 基础架构异常已由基础架构层处理,由于应用程序服务层,数据已经结构化,完整且经过验证。 因此,所有可能出现的例外都是真正的例外。

如何引发异常

通常,一般规则在这里起作用:例外越少-越好。

但是,您是否曾经遇到过编写代码并了解在某些条件下它可以做糟糕的事情的情况? 例如,两次注销资金或破坏数据,以至于我们以后不再收集骨头。

通常,我们正在谈论执行对于对象的当前状态不可接受的命令。

当然,在此状态下,UI上的相应按钮应该不可见。 在这种状态下,我们不应从总线上收到命令。 只要外层和系统正常执行其功能,这一切都是正确的。 但是在Domain Core中,我们不应该知道外部层的存在,而应该相信它们的正确性,因此我们必须保护系统的不变性。

可以将某些检查放在验证级别的应用程序服务中。 但这可能会变成防御性编程 ,在极端情况下会导致以下问题:

  1. 封装被削弱,因为某些不变量必须在外层进行验证。
  2. 对主题领域的了解“流入”了外层;两层都可以重复进行检查。
  3. 与验证域对象无法在其当前状态下执行命令相比,从外部层验证命令执行可能更为复杂且可靠性较低。

同样,如果我们将这样的检查放在验证层中,那么我们必须告诉用户错误的原因。 鉴于我们正在谈论的是在当前条件下根本无法执行的操作,因此存在处于以下两种情况之一的风险:

  • 我们给普通用户一条消息,他根本听不懂,并且无论如何都会去支持,就像消息“发生意外错误”一样。
  • 我们以一种相当容易理解的方式告诉了小人,为什么他不能执行他想执行的操作,而他可以寻找其他解决方法。

但是回到文章的主题。 所有迹象表明,正在讨论的情况是例外的。 它永远都不会发生,但是如果这样做,那将是不好的。

在这种情况下,抛出异常,承诺必要的细节,向用户返回一般形式的“操作不可行”错误,为此类错误设置监视并期望我们永远不会看到它们,这是最合乎逻辑的。

在这种情况下使用什么类型的异常? 从逻辑上讲,这应该是单独的异常类型,以便我们可以将其与其他异常区分开来,以免被外层的异常处理意外捕获。 我们也不需要层次结构或许多例外,本质是一样的-发生了不可接受的事情。 在我们的项目中,我们为此创建一个CorruptedInvariantException类型,并在适当的情况下使用它。

Web应用程序的特殊情况


Web应用程序与其他应用程序(桌面,守护程序和Windows服务等)之间的显着区别是,它们以短期操作(处理HTTP请求)的形式与外界进行交互,此后该应用程序立即“忘记”了发生的事情。

同样,在处理请求之后,始终会生成响应。 如果我们的代码执行的操作未返回数据,则平台仍将返回包含状态代码的响应。 如果操作被异常中止,则平台仍将返回包含相应状态码的响应。

为了实现此行为,Web平台中的请求处理以管道的形式构建。 首先,顺序处理请求(请求),然后准备响应。

我们可以使用中间件,操作过滤器,http处理程序或ISAPI过滤器(取决于平台),并在任何阶段集成到此管道中。 在请求处理的任何阶段,我们都可以中断处理,管道将继续形成响应。

通常,我们不再在管道体系结构中实现应用程序的业务部分,而是编写按顺序执行操作的代码。 而且,采用这种方法,当我们中断请求的执行并立即进行响应的形成时,实施该方案会有些困难。

您问所有这些与异常处理有什么关系?

事实是,本文前面各部分中描述的用于处理异常的规则并不适合这种情况。

异常很难使用,因为它是goto语义。

Result的广泛使用导致我们在应用程序的所有层上拖动它(Result),并且在形成响应时,我们需要以某种方式解析Result以便了解要返回的状态码。 还建议将解析代码归纳并推送到Middleware或ActionFilter中,这将成为一个单独的冒险。 也就是说,结果并不比异常好多少。

在这种情况下该怎么办?

不要建立绝对。 我们制定规则是为了我们自己的利益,而不是有害。

如果由于无法继续操作而要中止操作,则抛出异常将不会具有goto语义。 我们将执行定向到出口,而不是另一个业务代码块。

如果中断的原因对于确定所需的状态代码很重要,则可以使用自定义异常类型。

之前,我们提到了我们使用的两种自定义类型:ItemNotFoundException(转换为404)和CorruptedInvariant(转换为500)。

如果您检查用户的权限(因为他们不属于角色模型或声明),则可以创建自定义的ForbiddenException(状态码403)。

最后是验证。 在用户修改其请求之前,我们仍然无能为力,这种语义由代码422描述 。 因此,我们中断了操作并将请求直接发送到出口。 也可以使用异常来完成。 例如, FluentValidation库已经具有内置的异常类型该异常类型将所有必要的详细信息传递给客户端,以向用户清楚地显示请求的问题。

仅此而已。 您如何处理例外情况?

Source: https://habr.com/ru/post/zh-CN431804/


All Articles