领域驱动设计06-上下文映射

1、理解上下文映射

领域驱动设计通过上下文映射(Context Map) 来讨论限界上下文之间的协作问题。

限界上下文的一个核心价值,就是利用边界来约束不同上下文的领域模型,以保证模型的一致性。然而,每个限界上下文都不是独立存在的,多数时候,都需要多个限界上下文通力协作,才能完成一个完整的用例场景。

两个限界上下文之间的关系是有方向的,领域驱动设计使用两个专门的术语来表述它们:“上游(Upstream)”和“下游(Downstream)”,在上下文映射图中,以 U 代表上游,D 代表下游,理解它们之间的关系,正如理解该术语隐喻的河流,自然是上游产生的变化会影响到下游,反之则不然。故而从上游到下游的关系方向,代表了影响产生的作用力,影响作用力的方向与程序员惯常理解的依赖方向恰恰相反,上游影响了下游,意味着下游依赖于上游。

下文映射模式分为了两大类:团队协作模式与通信集成模式。前者对应的其实是团队合作的工作边界,后者则从应用边界的角度分析了限界上下文之间应该如何进行通信才能提升设计质量。

2、上下文映射的团队协作模式

如果我们将限界上下文理解为是对工作边界的控制,则上下文之间的协作实则就是团队之间的协作,高效的团队协作应遵循“各司其职、权责分明”的原则。

映射到领域驱动设计的术语,就是要在满足合理分配职责的前提下,谨慎地确保每个限界上下文的粒度

领域驱动设计根据团队协作的方式与紧密程度,定义了五种团队协作模式。

2.1 合作关系

在软件领域,这种关系是不好的,合作意味着依赖,意味着耦合。

比如:

ReportEngine 与 EntityEngine 之间存在双向依赖,二者又与 DataEngine 之间产生了循环依赖。若这三个限界上下文被构建为三个 JAR 包,这种依赖会导致它们在编译时谁也离不开谁。如果是微服务,则任何一个服务出现故障,其他服务都不可用。

限界上下文的“合作关系”其实是一种“反模式”,罪魁祸首是因为职责分配的不当,是一种设计层面的“特性依恋(Feature envy)”坏味道。解决的办法通常有三种:

  • 既然限界上下文存在如此紧密的合作关系,就说明当初拆分的理由较为牵强,与其让它们因为分开而“难分难舍”,不如干脆让它们合在一起。
  • 将产生特性依赖的职责分配到正确的位置,尽力减少一个方向的多余依赖。
  • 识别产生双向依赖或循环依赖的原因,然后将它们从各个限界上下文中剥离出来,并为其建立单独的限界上下文,这就是所谓的“共享内核(Shared Kernel)”。

比如: Entity Engine、DataEngine、Report Design 对Repot Engine只是需要访问其元数据信息,并不是真的依赖Report Engine的核心功能。则,拆分出元数据限界上下文才是最好的方法:

新引入的 Metadata 成为了其余限界上下文的上游,却解除了 DataEngine 对 ReportEngine 的依赖,同样解除了 EntityEngine 以及 ReportDesigner 对 ReportEngine 的依赖。

2.2 共享内核(Shared Kernel)

上面的抽取元数据限界上下文就是“共享内核”的设计了。从设计层面看,共享内核是解除不必要依赖实现重用的重要手段。当我们发现了属于共享内核的限界上下文后,需要确定它的团队归属。

名字叫做“内核”从技术层面听起来比较高大上,但并不意味着他属于业务的核心领域(Core Domain),相反,在大多数情况下,共享内核属于子领域(SubDomain)

共享内核往往被用来解决合作关系引入的问题。

共享内核是通过上下文映射识别出来的,通过它可以改进设计质量,弥补之前识别限界上下文的不足。与其说它是上下文映射的一种模式,不如说它是帮助我们识别隐藏限界上下文的模式,主要的驱动力就是“避免重复”,即 DRY(Don’t Repeat Yourself)原则的体现。

当然,这种重用是需要付出代价的。Eric Evans 指出:“共享内核不能像其他设计部分那样自由更改,在做决定时需要与另一个团队协商。”至于修改产生的影响有多大,需要视该限界上下文与其他限界上下文之间的集成关系。尤其是大多数共享内核可能是多个限界上下文共同的上游,每次修改都可能牵一发而动全身。因此在对共享内核进行修改时,需要充分评估这种修改可能带来的影响。

2.3 客户方-供应方开发(Customer-Supplier Development)

正常情况下,这是团队合作中最为常见的合作模式,体现的是上游(供应方)与下游(客户方)的合作关系。这种合作需要两个团队共同协商:

  • 下游团队对上游团队提出的领域需求
  • 上游团队提供的服务采用什么样的协议与调用方式
  • 下游团队针对上游服务的测试策略
  • 上游团队给下游团队承诺的交付日期
  • 当上游服务的协议或调用方式发生变更时,该如何控制变更

比如一个通知上下文,作为上游服务的开发团队,需要考虑各种信息通知的领域需求。从通知类型看,可以是邮件、短信、微信推送和站内信息推送等多种方式。从通知格式看,可能是纯文本、HTML 或微信文章。从通知内容看,可以是固定内容,也可能需要提供通知模板,由调用者提供数据填充到模板中的正确位置。

设计该服务时,我们既要考虑这些通知服务实现的多样化,又要考虑服务调用的简单与一致性。至于发送的通知内容,则需要上游团队事先定义通知上下文的领域模型。该领域模型既要覆盖所有的业务场景,又要保证模型的稳定性,同时还必须注意维持通知上下文的职责边界

譬如说,我们在通知上下文中定义了 Message 与 Template 领域对象,后者内部封装了一个HashMap<String, String>类型的属性。Map 的 key 对应模板中的变量,value 则为实际填充的值。建模时,我们明确了通知上下文的职责,它仅负责模板内容正确地填充,并不负责对值的解析。这就是上游定义的契约,它清晰地勾勒了上下文之间协作的边界。倘若下游团队在填充通知模板的值时,还需要根据自己的业务规则进行运算,就应该在调用通知服务之前,首先在自己的限界上下文中进行计算,然后再将计算后的值作为模板的 value 传入。

2.4 遵奉者(Conformist)

一个正常的客户方-供应方开发模式,是上游团队满足下游团队提出的领域需求;但当需求的控制权发生了逆转,由上游团队来决定是响应还是拒绝下游团队提出的请求时,所谓的“遵奉者”模式就产生了。从这个角度来看,我们可以将遵奉者模式视为一种“反模式”。糟糕的是在现实的团队合作中,这种情形可谓频频发生,尤其是当两个团队分属于不同的管理者时,牵涉到的因素就不仅仅是与技术有关了。所以说领域驱动设计提出的“限界上下文”实践,影响的不仅仅是设计决策与技术实现,还与企业文化、组织结构直接有关。许多企业推行领域驱动设计之所以不够成功,除了团队成员不具备领域驱动设计的能力之外,还要归咎于企业文化和组织结构层面。例如,企业的组织结构人为地制造了领域专家与开发团队的壁垒,又比如两个限界上下文因为利益倾轧而导致协作障碍,而团队领导的求稳心态,也可能导致领域驱动设计“制造”的变化屡屡碰壁,无法将这种良性的“变化”顺利地传递下去。

遵奉者还有一层意思是下游限界上下文对上游限界上下文模型的追随。当我们选择对上游限界上下文的模型进行“追随”时,就意味着:

  • 可以直接重用上游上下文的模型(好的)
  • 减少了两个限界上下文之间模型的转换成本(好的)
  • 使得下游限界上下文对上游产生了模型上的强依赖(坏的)

做出遵奉模型决策的前提是需要明确这两个上下文的统一语言是否存在一致性,因为限界上下文的边界本身就是为了维护这种一致性而存在的。理想状态下,即使是上下游关系的两个限界上下文都应该使用自己专属的领域模型,因为原则上不同限界上下文对统一语言的观察视角多少会出现分歧,但模型转换的成本确实会令你左右为难。设计总是如此,没有绝对好的解决方案,只能依据具体的业务场景权衡利弊得失,以求得到相对好(而不是最好)的方案。

2.5 分离方式(Separate Ways)

分离方式的合作模式就是指两个限界上下文之间没有哪怕一丁点儿的丝毫关系。这种“无关系”仍然是一种关系,而且是一种最好的关系。这意味着我们无需考虑它们之间的集成与依赖,它们可以独立变化而互相不产生影响。

举例子:支付上下文和商品上下文无关系, 前者关注的是每笔交易的金额,后者关注的是商品的价格,商品价格影响的是订单上下文,支付上下文会作为订单上下文的上游,被订单上下文调用,但这种调用传递的是每条订单的总金额,支付上下文并不关心每笔订单究竟包含了哪些商品。

3、上下文映射的通信集成模式

3.1 防腐层(Anticorruption Layer)

防腐层通过引入一个中间层,隔绝限界上下文之间的耦合,这个间接的防腐层还可以扮演“适配器”的角色、“调停者”的角色、“外观”的角色。

防腐层往往属于下游限界上下文,用以隔绝上游限界上下文可能发生的变化。因为不管是遵奉者模式,还是客户方-供应方模式,下游团队终究可能面临不可掌控的上游变化。在防腐层中定义一个映射上游限界上下文的服务接口,就可以将掌控权控制在下游团队中,即使上游发生了变化,影响的也仅仅是防腐层中的单一变化点,只要防腐层的接口不变,下游限界上下文的其他实现就不会受到影响。

引入防腐层后,之前产生的多处依赖转为对防腐层的依赖,再由防腐层指向上游上下文,形成单一依赖。上游变更时,影响的仅仅是防腐层,下游上下文自身并未受到影响。

3.2 开放主机服务(Open Host Service)

如果说防腐层是下游限界上下文对抗上游变化的利器,那么开放主机服务就是上游服务用来吸引更多下游调用者的诱饵。设计开放主机服务,就是定义公开服务的协议,包括通信的方式、传递消息的格式(协议),如CSE/Restful/RPC等。同时,也可视为是一种承诺,保证开放的服务不会轻易做出变化。

绘制上下文映射图时,我们往往用 ACL 缩写来代表防腐层,用 OHS 缩写代表开放主机服务。

3.3 发布/订阅事件

即使是确定了发布语言规范的开放主机服务,仍然会导致两个上下文之间存在耦合关系,下游限界上下文必须知道上游服务的 ABC(Address、Binding 与 Contract),对于不同的分布式实现,还需要在下游定义类似服务桩的客户端。例如,在基于 Spring Cloud 的微服务架构中,虽然通过引入 Euraka 实现了对服务的注册与发现,降低了对 Address、Binding 的依赖,但仍然需要在下游限界上下文定义 Feign 客户端,你可以将这个 Feign 客户端理解为是真实服务的一个代理(Proxy)。基于代理模式,我们要求代理与被代理的真实服务(Subject)保持相同的接口,这就意味着,一旦服务的接口发生变化,就需要修改客户端代码。

布/订阅事件的方式可以在解耦合方面走得更远。一个限界上下文作为事件的发布方,另外的多个限界上下文作为事件的订阅方,二者的协作通过经由消息中间件进行传递的事件消息来完成。当确定了消息中间件后,发布方与订阅方唯一存在的耦合点就是事件,准确地说,是事件持有的数据。

发布/订阅事件模式是松耦合的,但它有特定的适用范围,通常用于异步非实时的业务场景。当然,它的非阻塞特性也使得整个架构具有更强的响应能力,因而常用于业务相对复杂却没有同步要求的命令(Command)场景。这种协作模式往往用于事件驱动架构或者 CQRS(Command Query Responsibility Segregation,命令查询职责分离)架构模式中。