限界上下文与架构

  • 识别限界上下文:可以作为逻辑架构与物理架构的参考模型
  • 上下文映射:直观地体现了系统架构的通信模型

    架构范围

    限界上下文不仅仅作用于领域层和应用层,它是架构设计而非仅仅是领域设计的关键因素。

  • 限界上下文体现的是一个垂直的架构边界,主要针对后端架构层次的垂直切分。

例如,订单上下文的内部就包含了应用层、领域层和基础设施层,每一层的模块都是面向业务进行划分,甚至可能是一一对应的。

  • 对资源的规划与设计也属于限界上下文的设计范围

例如,如何设计数据表、如何规划消息队列的主题。在进行这一系列的技术选型和决策时,依据的其实是该限界上下文的业务场景与质量属性,这些架构活动自然就属于该限界上下文的范畴。我们还需要决定框架的版本,这些框架并不属于系统的代码库,但需要考虑它们与限界上下文代码模型的集成、构建与部署。

通信边界

限界上下文之间是否为进程边界隔离,直接影响架构设计。此为限界上下文的通信边界,以进程为单位分为进程内与进程间两种边界。
进程内与进程间在如下方面存在迥然不同的处理方式:

  • 通信
  • 消息的序列化
  • 资源管理
  • 事务与一致性处理
  • 部署

通信边界的不同还影响了系统对各个组件(服务)的重用方式与共享方式。

进程内的通信边界

代码模型都运行在同一个进程中,可以通过实例化的方式重用领域模型或其他层次的对象。
限界上下文的代码模型(Code Model)仍然存在两种级别的设计方式:

  • 命名空间级别:通过命名空间进行界定,所有的限界上下文其实都处于同一个模块(Module)中,编译后生成一个 Jar 包。
  • 模块级别:在命名空间上是逻辑分离的,不同限界上下文属于同一个项目的不同模块,编译后生成各自的 Jar 包。这里所谓的“模块”,在 Java 代码中也可以创建为 Jigsaw 的 module。

仅仅存在编译期的差异,后者的解耦会更加彻底,倘若限界上下文的划分足够合理,也能提高它们对变化的应对能力。例如,当限界上下文 A 的业务场景发生变更时,我们可以只修改和重编译限界上下文 A 对应的 Jar 包,其余 Jar 包并不会受到影响。
仍需重视代码模型的边界划分,因为这种边界隔离有助于整个系统代码结构变得更加清晰。
越容易重用,就越容易产生耦合。编写代码时,我们需要谨守这条无形的逻辑边界,时刻注意不要逾界,并确定限界上下文各自对外公开的接口,避免它们之间产生过多的依赖。
防腐层(ACL)就成了抵御外部限界上下文变化的最佳场所。一旦系统架构需要将限界上下文调整为进程间的通信边界,这种“各自为政”的设计与实现能够更好地适应这种演进:
在项目上下文中定义通知服务的接口 NotificationService,并由 NotificationClient 去实现这个接口,它们扮演的就是防腐层的作用。
在未来需要将通知上下文分离为进程间的通信边界,这种变动将只会影响到防腐层的实现,作为 NotificationService 服务的调用者,并不会受到这一变化的影响。

进程间的通信边界

限界上下文是以进程为边界,一个限界上下文就不能直接调用另一个限界上下文的方法,而是要通过分布式的通信方式
限界上下文需要访问的外部资源:

  • 数据库共享架构
  • 零共享架构

    数据库共享架构

    零共享

    将两个限界上下文共享的外部资源彻底斩断后,就成为了零共享架构
    image.png
    这是一种限界上下文彻底独立的架构风格,它保证了边界内的服务、基础设施乃至于存储资源、中间件等其他外部资源的完整性与独立性,最终形成自治的微服务。
    这种架构的表现形式为:每个限界上下文都有自己的代码库、数据存储以及开发团队,每个限界上下文选择的技术栈和语言平台也可以不同,限界上下文之间仅仅通过限定的通信协议和数据格式进行通信。
    彻底分离的限界上下文变得小而专,复杂度:
    通信的健壮性
    跨限界上下文去访问数据库
    保证数据的一致性
    限界上下文独立部署,运维与监控的复杂度也随之而剧增

    通信边界对架构的影响

    这两个边界讲的有点混乱
    逻辑边界、物理边界
    整个系统的架构划分为多个不同的视图,其中最主要的视图就是逻辑视图物理视图,这是我们看待系统的两种不同视角。前者关注代码结构、层次以及职责的分配,后者关注部署、运行以及资源的分配,这两种视图都需要考虑限界上下文以及它们之间的协作关系。

  • 两个限界上下文的代码模型属于同一个物理边界,就是部署和运行在同一个进程中的好哥俩儿,调用方式变得直接,协作关系较为简单,我们只需要在实现时尽可能维护好逻辑边界即可。

  • 对于跨进程边界进行协作的限界上下文,我建议为其绘制上下文映射,并通过六边形架构来确定二者之间的通信端口与通信协议。上游限界上下文公开的接口可以是基于 HTTP 的 REST 服务,也可以通过 RPC 访问远程对象,又或者利用消息中间件传递消息。

    三位一体

    将单个限界上下文代码模型的边界视为物理边界,则可以认为一个限界上下文就是一个微服务。
    六边形架构的六边形边界实则也是物理边界。
    一个限界上下文就是一个六边形,限界上下文之间的通信通过六边形的端口进行;
    一个微服务就是一个六边形,微服务之间的协作就是限界上下文之间的协作。

  • 一个限界上下文就是一个六边形,限界上下文之间的通信通过六边形的端口进行;

  • 一个微服务就是一个六边形,微服务之间的协作就是限界上下文之间的协作。

image.png

限界上下文部署和运行

库间单和要货单需要拆分:

  • 每个微服务是如何独立部署和运行的?如果我们从运维角度去思考微服务,就可以直观地理解所谓的“零共享架构”到底是什么含义。如果我们在规划系统的部署视图时,发现微服务之间在某些资源存在共用或纠缠不清的情况,就说明微服务的边界存在不合理之处,换言之,也就是之前识别限界上下文存在不妥。
  • 微服务之间是如何协作的?这个问题牵涉到通信机制的决策、同步或异步协作的选择、上游与下游服务的确定。我们可以结合上下文映射六边形架构来思考这些问题。上下文映射帮助我们确定这种协作模式,并在确定了上下游关系后,通过六边形架构来定义端口。

将六边形架构与限界上下文结合起来,即通过端口确定限界上下文之间的协作关系,绘制上下文映射。如果采用客户方—供应商开发模式,则各个限界上下文六边形的端口就是上游(Upstream,简称 U)或下游(Downstream,简称 D)。由于这些限界上下文都是独立部署的微服务,因此,它们的上游端口应实现为 OHS 模式(下图以绿色端口表示),下游端口应实现为 ACL 模式(下图以蓝色端口表示):
image.png
每个微服务规划自己的分层架构,进而确定微服务内的领域建模方式。
微服务的协作也有三种机制,分别为命令、查询和事件:

  • 命令:是一个动作,是一个要求其他服务完成某些操作的请求,它会改变系统的状态,命令会要求响应。
  • 查询:是一个请求,查看是否发生了什么事。重要的是,查询操作没有副作用,它们不会改变系统的状态。
  • 事件:既是事实又是触发器,用通知的方式向外部表明发生了某些事。

发出命令或查询请求的为下游服务,而服务的定义则处于上游。如上图所示,我以菱形端口代表“命令”,矩形端口代表“查询”,这样就能直观地通过上下文映射以及六边形的端口清晰地表达微服务的服务定义以及服务之间的协作方式。
消息发送放在哪里?端口?dependency?
如果微服务的协作采用事件机制,则上下文映射的模式为发布/订阅事件模式。这时,限界上下文之间的关系有所不同,我们需要识别在这个流程中发生的关键事件。传递关键事件的就是六边形的端口,具体实现为消息队列,适配器则负责发布事件。于是,系统的整体架构就演变为以事件驱动架构(Event-Driven Architecture,EDA)风格构建的微服务系统。
image.png
Hexagonal:六边形

代码模型

DDD风格的代码模型

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

更加符合领域驱动设计特色的模型:
- application
- domain
- interfaces
- repositories
- mq
- acl
- …
- gateways
- ohs
- persistence
- mq
- acl
- …
ohs 和 acl 不言自明,充分说明了它们在架构中发挥的作用。倘若我们在团队中明确传递这一设计知识,不仅可以让团队成员更加充分地理解“开放主机服务”与“防腐层”的意义,也更有利于保证限界上下文在整个架构中的独立性。诸如 ohs 与 acl 的命名,也可以认为是代码模型中的一种“统一语言”吧。
架构的设计需要“恰如其分”,在不同的微服务中,各自的领域逻辑复杂程度亦不尽相同,故而不必严格遵循领域驱动设计的规范:

进程间通信的代码模型

如果限界上下文的边界是进程间通信,则意味着每个限界上下文就是一个单独的部署单元,此即微服务的意义。
在微服务的边界范围内,仍需建立分层架构。由于微服务的粒度较小,它的代码模型一般采用命名空间级别的方式,整个微服务的代码模型生成一个 JAR 包即可。
ordercontext
- gateways
- controllers
- OrderController
- persistence
- client
- NotificationClient
- application
- OrderAppService
- interfaces
- client
- NotificationService
- domain
- repositories
notificationcontext
- controllers
- NotificationController
- application
- NotificationAppService
- interfaces
- domain
- gateways
- JavaMailSender
image.png
领域建模模式,包括事务脚本(Transaction Script)、表模块(Table Module)或领域模型(Domain Model)。
image.png
代码模型需要考虑 Order Context 与 Notification Context 之间的跨进程协作,设计的目标是:

  • 确保彼此之间的解耦合,可以引入上下文映射的开放主机服务模式与防腐层模式-
  • 避免遵奉者模式,即避免重用上游上下文的领域模型。

Order Context 中定义了调用 Notification Context 上下文服务的客户端 NotificationClient 与对应的抽象接口 NotificationService。这两个类型合起来恰好就是针对 Notification Context 的防腐层。
Notification Context 定义了 NotificationController,相当于是该限界上下文的开放主机服务。
Order Context 与 Notification Context 属于两个不同的微服务,因此在 Order Context 微服务中 gateways/client 的 NotificationClient 会发起对 NotificationController 的调用,这种协作方式如下图所示:
image.png
由于限界上下文之间采用进程间通信,因此在 Notification Context 中,提供开放主机服务是必须的。倘若 NotificationController 以 RESTful 服务实现,则在 Order Context 发起对 RESTful 服务的调用属于基础设施的内容,因而必须定义 NotificationService 接口来隔离这种实现机制,使其符合整洁架构思想。
接口隔离原则(ISP):任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。
以及对枚举的直接引用。

进程内通信的代码结构

关键要考虑两个处于相同进程中的限界上下文彼此之间该如何协作。针对各种设计因素的考量:

  • 简单:在下游限界上下文的领域层直接实例化上游限界上下文的领域类。
  • 解耦:在下游限界上下文的领域层通过上游限界上下文的接口和依赖注入进行调用。
  • 迁移:在下游限界上下文中定义一个防腐层,而非直接调用。
  • 清晰:要保证领域层代码的纯粹性,应该避免在当前限界上下文中依赖不属于自己的代码模型。

在 interface 中定义自己的服务接口,然后在 gateway/client 中提供一个适配器,在实现该接口的同时,调用上游限界上下文的服务,无论这个服务是领域服务还是应用服务,甚至也可以是领域层的领域对象。
下订单:
image.png
NotificationClient 直接通过实例化的方式调用了 Notification Context 应用层的 NotificationAppService。这是在 Order Context 中,唯一与 Notification Context 产生了依赖的地方。
即使限界上下文采用进程内通信,也仅仅是封装在防腐层中发起调用的实现有所不同。无论是进程间通信,还是进程内通信,我们设计的代码模型其实是一致的,并不受通信边界的影响。

  • 通信边界的划分是物理意义,代码模型的划分是逻辑意义,二者互相并不影响。
  • 为保证系统从单体架构向微服务架构迁移,应保证代码结构不受架构风格变化的影响。

无论限界上下文的边界为进程间通信还是进程内通信,上下文的命名空间都应该为 practiceddd.ecommerce.{contextname},
com.youzan.retail.supplychain.{ContextName}
- praticeddd
-ecommerce
- ordercontext
- application
- interfaces
- domain
- repositories
- gateways
- productcontext
- application
- interfaces
- domain
- repositories
- gateways
- ……
基于这样的设计前提,如果两个或多个限界上下文还存在共同代码,只能说明一点:那就是我们之前识别的限界上下文有问题
“共享内核”模式就是用来解决此类问题的一种方法:一旦提炼或发现了这个隐藏的限界上下文,就应该将它单列出来,与其他限界上下文享受相同的待遇,即处于代码模型的相同层次,然后再通过 interfaces 与 gateways/client 下的相关类配合完成限界上下文之间的协作即可。
biz-shared