张逸说

出口成张,逸派胡言

0%

领域驱动设计中的架构要素

多数时候,领域驱动设计的分层架构并不能清晰表达各模块之间的依赖关系,以及这些模块在分层架构中所处的位置。因为我倾向于将Uncle Bob的Clean Architecture与DDD的分层架构整合起来,如下图所示:

在这个架构图中,基础设施层处于最外部,然后是应用层,最核心的是领域层。基础设施中的模块,我都称之为gateway。根据依赖方向,如果是被调用的方向,即由外至内的调用方向,就是北向,称之为北向网关。如果当前限界上下文是通过该网关调用外部资源或者别的限界上下文,即由内至外的调用方向,则是南向网关。例如图中的OrderController,会被别人调用,因而属于北向网关。注意,倘若OrderController通过RESTful方式暴露API,即为REST服务,也就是基于资源的服务。我们不能将它与DDD的应用服务混为一谈。

南向网关要特殊一些,它是打通应用层或领域层与外部资源(数据库、消息队列、第三方服务)的通道。根据整洁架构的设计原则,我们不能让内层依赖外层,以保证内层的纯粹性与稳定性。为了解除应用层或领域层与它的耦合,南向网关往往需要提供接口。这就说明,基础设施层的南向网关都是具体实现,内层对南向网关的调用则通过接口和依赖注入。至于它们的接口,就应该放在领域层或者应用层。例如,数据库的持久化属于南向网关,但它们的抽象Repository就属于领域层。

通过上图,可以帮助我们明确各个模块和各层之间的职责。下图则基于这样的内外层架构清晰地表达了限界上下文(Bounded Context,以下简称BC)之间的协作关系,即DDD中的Context Map:

Context Map中有两个常用的模式OHS(开放主机服务)与ACL(防腐层)。显然,OHS就对应前面提到的北向网关,ACL就对应着南向网关。

为了遵循整洁架构原则,就需要为ACL提供一个抽象。例如订单要调用商家BC的服务,就需要在订单BC中定义一个被调用服务的接口,然后在ACL中,通过具体框架提供的跨进程调用方式,去真正发起对商家BC服务的调用。所以,我通常将代表ACL的模块命名为Client。通过Client可以防止上游BC发生变化时对下游BC产生直接影响。一旦变化发生,我们仅需要修改南向网关中的client实现。如下图所示:

这张图体现了有ACL和无ACL的区别。

下图体现了BC对领域概念的控制,它是控制领域概念一致性的边界。在DDD中,最好的方式是不去跨BC重用一个相同的领域概念:

假设我们的BC都是微服务,就是零共享架构,数据库是独立的。那么,各自BC关心的Product属性应该放在各自数据库中,它们的ID要保持一致。

现在基于这些认识来讨论两个问题:

  • 一个BC如何发起对另一个BC的调用
  • 调用时,是否会产生所谓的“领域模型”耦合

例如在订单BC中,如果在获得订单信息的同时,还需要获得订单中商品的信息以及该商品所属商家的信息,那么该谁发起对商家BC和商品BC的调用?

首先,我们在订单BC中定义自己的模型,该模型除了Order之外,还包含了商家与商品的信息,但这些信息是Read Model,是不需要在订单BC中持久化的。这就遵循了“BC是控制领域概念一致性的边界”这一原则。由于商家与商品在订单BC中并没有持久化的需求,因此当修改发生时,并不会因此而产生数据的不一致,更不会产生领域模型的耦合。这些领域模型都各自被定义在自己的BC中,没有重用。

其次,该谁来发起商家和商品BC的调用呢?通过第一张图与第二张图的讨论,我们需要在订单BC中定义商家BC和商品BC对应服务的接口(即前面提到的Client的接口),然后在领域层的相关对象(通常是领域服务),发起对这些接口的调用。框架会通过IoC框架注入Client实现,以满足对外部服务的调用。调用后,会在订单BC将返回的结果转换为自己BC的模型对象。如果需要组装最后的DTO,则可以在领域服务之上再包装一个应用服务,完成整个完整用例的逻辑。这样,就可以让Controller只调用应用服务,减少Controller对领域层的理解,从而遵循“最小知识”法则。

基于这样的设计思想,DDD的代码模型就可以定义为:

以下是对代码结构的说明:

  • application:对应了领域驱动设计的应用层,主要内容为该限界上下文中所有的应用服务。
  • interfaces:对gateways中除persistence之外的抽象,包括访问除数据库之外其他外部资源的抽象接口,以及对第三方服务或其他限界上下文服务的抽象接口。从分层架构的角度讲,interfaces应该属于应用层,但在实践时,往往会遭遇领域层需要访问这些抽象接口的情形,单独分离出interfaces,非常有必要。
  • domain:对应了领域驱动设计的领域层,但是我将repositories单独分了出来,目的是为了更好地体现它在基础设施层扮演的与外部资源打交道的网关语义。
  • repositories:代表了领域驱动设计中战术设计阶段的资源库,皆为抽象类型。如果该限界上下文的资源库并不复杂,可以将repositories合并到domain中。
  • gateways:对应了领域驱动设计的基础设施层,命名为gateways,则是为了更好地体现网关的语义,其下可以视外部资源的集成需求划分不同的包。其中,controllers相对特殊,它属于对客户端提供接口的北向网关,等同于上下文映射中“开放主机服务(OHS)”的概念。如果为了凸显它的重要性,可以将controllers提升到与application、domain、gateways同等层次。我之所以将其放在gateways之下,还是想体现它的网关本质。persistence对应了repositories抽象,至于其余网关,对应的则是application/interfaces下的抽象,包括消息队列以及与其他限界上下文交互的客户端,例如通过http通信的客户端。其中,client包下的实现类与interfaces下的对应接口组合起来,等同于上下文映射中“防腐层(ACL)”的概念。

归根结底,在运用DDD进行架构设计,并通过BC映射到微服务设计时,要遵循两方面的设计原则。一个是普适性的架构与设计原则,例如整洁架构、分而治之思想、关注点分离、最小知识法则等。理解了这些原则,你就清楚该如何分配职责,如何解耦。另一个是DDD的设计原则,搞清楚每个层的职责,层之间的关系,BC之间的关系,领域模型是什么?在明白了这些设计原则的真谛时,当我们碰到DDD设计落地的问题时,不知道该如何处理时,都可以基于这些设计原则来做出符合当前场景的决策,而不要做个“寻章摘句老雕虫”,照搬书上的方法,只要书上未曾涉及到此问题,就无从应对了。