Skip to content

30 限界上下文(下):限界上下文之间如何集成?

你好,我是钟敬。

上节课我们进一步深入学习了上下文映射,并且开始根据限界上下文进行架构设计,主要谈的是单体架构。

在某些场合里,采用单体架构比较适合。不过,我们现在开发的是一个基于云原生的 SaaS 应用。在云原生的情况下,一般不会采用单体架构,微服务才是最佳实践。那么微服务应该怎么设计呢?

这节课,我们会继续学习微服务的设计。之后,会讨论限界上下文之间的集成。所谓限界上下文的集成,就是通过在代码中实现限界上下文的映射,完成跨限界上下文的业务功能。

微服务的设计

我们先了解一下微服务的设计方法,再进一步讨论为什么要根据限界上下文设计微服务。

微服务的设计方法

设计微服务,我们可以先假定每个限界上下文对应一个微服务。然后,再综合考虑多方面的因素,决定是否需要进一步细分。下面是几种常见的情况。

第一,不同的可伸缩性要求。如果一个上下文里有些部分,需要随着使用情况,动态部署到更多的容器,比如说“双十一”促销的时候。而另外的部分性能要求比较稳定,不需要动态伸缩。那么,如果不同部分都混在一个微服务中,那么当扩展到更多容器的时候,成本就会比较高了。这时候,我们可以考虑根据可伸缩性的不同,划分成两个微服务。

第二,不同的安全性要求。比如说,有些功能要接入互联网,有些部分在内网用,需要部署在防火墙的不同位置。这时候,也需要划分成不同的微服务。

第三,技术异构性。比如说,有些部分需要用 Java 开发,有些部分需要用 node.js 开发,这时候,也要分成不同的微服务了。

就我们目前的例子而言,假设没有上述因素的影响,那么直接按限界上下文来划分就可以了。我画了一张架构图,供你参考。

这个图在形式上看起来和单体架构很像,不同之处在于衍型。父组件用 <> 衍型来说明这是一个分布式应用。各个子组件用 <> 来说明这些是微服务。这两个衍型也是自定义的。事实上,你也可以自己定义衍型,满足团队或项目的需求。

为什么要根据限界上下文设计微服务

说到这,我们回过头来思考一下为什么要根据限界上下文来设计微服务。

微服务的划分,可以从 功能性需求非功能性需求 两方面考虑。

从功能性方面考虑,微服务的划分应该有利于保证系统概念的一致性,更容易灵活扩展功能,而这些又要求开发团队顺畅的沟通协作。

根据限界上下文来划分模型,既考虑到了传统模块化思维中对业务概念的松耦合、高内聚的要求,又考虑到团队的认知负载和认知边界。这样,一方面解决了团队协作和概念一致性问题。另一方面,每个限界上下文又是一个业务概念内聚的边界。在这个边界内部,就更容易建立可维护、易扩展的模型。

从另一个角度来说,合理的微服务划分,应该是对于多数需求变更,只需改动一个或少量的微服务。而划分不合理的话,对于多数业务需求,都要修改多个微服务。有人把这种现象叫做“分布式单体”,这其实也是因为模型划分不合理,没有找到内聚的业务边界。而限界上下文可以解决这个问题。

限界上下文为微服务的划分奠定了基础。然后,就可以再考虑性能、安全、可用性等非功能性需求,看是不是需要进一步划分。有时候,其实也可以考虑把几个限界上下文合并到一个微服务里。极端情况下,所有上下文合并到一个服务,就又变成了单体。

这样做的话,至少保证微服务和限界上下文的划分不会产生“交错”的情况。也就是一个微服务包含了一个上下文的一部分,但是又包含了另一个上下文的另一部分,这就很容易出现“分布式单体”了。

限界上下文间的集成

现在我们已经讨论完了微服务设计,下面继续进行限界上下文集成的设计。

不同的集成策略

在概念上的同一种映射关系,在实现上可以有不同的策略。我们可以先想一想,从“基础信息管理”上下文把员工信息映射到“工时管理”上下文的逻辑。

假设已经按上下文划分了微服务,这时候“工时管理”中的某个功能,需要获得“基础信息管理”中的 员工 数据,那么,至少可以有两种策略供我们选择。

一种是 数据同步 策略。也就是说,在“工时管理”服务对应的数据库里,建立员工表,但只包含工时管理需要的字段。然后,当“基础信息管理”中的员工信息发生了新增、修改或删除的时候,以某种方式把数据同步到“工时管理”数据库。这样,工时管理就可以通过访问本地数据库获得员工信息了。

另一种是 API调用 策略。在“工时管理”的数据库里不需要建立员工表,而是每次需要员工信息的时候,都调用“基础信息管理”提供的 API 来获取数据。

再来考虑一下“项目管理上下文”的 项目 信息映射到“工时管理”中的 工时项 的情况。

这时候,工时管理数据库中有一个工时项表。“项目管理”在新增、修改和删除项目时,“工时管理”中的工时项表可能会发生相应的变化。我们以在“项目管理”中新增项目为例,来看看实现策略有哪些。

一种是 同步调用。“项目管理”新增一个项目,“项目管理”服务就会调用“工时管理”服务中的一个“新增工时项”接口,“工时管理”服务就会在自己的工时项表里增加一条记录,然后把成功信息返回给调用方。“项目管理”服务会等待这个成功信息,收到以后,才会继续处理其他逻辑。

另一种是 异步调用。“项目管理”新增项目以后,会向消息中间件发送一个“项目已增加”的事件,然后不用等待,继续进行其他处理。而“工时管理”服务中会订阅“项目已增加”事件。当监听到这个事件发生的时候,就会在自己的工时项表里增加一条记录。这种方式也叫做“事件驱动”架构。

前面说的“工时管理”服务调用“基础信息管理”服务的 API, 来获取员工信息的策略,通常也是同步调用。

注意, 不论采用哪种策略,概念层面的映射关系都是一样的,只是实现层面不同。不同的实现策略,可以根据性能、时效性、技术复杂度、数据可靠性等多个维度进行权衡。

防腐层

下面我们聊一个比较重要的问题,就是当两个上下文发生概念映射的时候,进行数据转换的逻辑应该写在哪里。

就以“工时管理”服务调用“基础信息管理”服务的 API, 获取员工信息为例吧。我们先谈单体的情况,然后再过渡到微服务。

先看看单体架构下,上下文调用的设计类图。

从图里可以看到,工时管理(effortmng)和基础信息管理(basicinfomng)是这个单体系统根目录下的两个包,分别代表两个限界上下文。假设 EffortItemService 里有一个 aFunction() 方法,在执行过程中,需要获得员工信息。这时,aFunction() 可以调用 EmpRepository 中的 findById() 方法。我们一步一步来说明。

上图的 EffortItemService 和 EmpRepository 之间是实线箭头,表示单向关联关系,也可以叫单向导航关系。之所以是实线,是因为 EmpRepository 的实例是 EffortItemService 的一个属性,一般通过依赖注入机制来注入。而 EffortItemService 和 Emp 之间则是虚线箭头,表示依赖关系,这是因为 Emp 并不是 EffortItemService 的属性,只是被使用了。其他的虚线箭头同理。

下面再强调一下两个上下文间的概念区别。

同一个系统里,有两个 Emp 类,但是属于不同的上下文。“基础信息管理”里面的 Emp 带有工作经验和技能,并且属性会更多;而“工时管理”里面的 Emp 没有工作经验和技能信息,而且只有少数几个要用到的属性。那么这两个Emp 转换发生在哪里呢?

转换就发生在员工仓库,也就是 EmpRepository 的实现里。

首先,EmpRepository 并不直接访问数据库,而是访问另一个上下文的 Service。不过,对于 EffortItemService 来说,它并不需要知道仓库是怎么实现的。它只知道,要调用仓库取一个实体,所以,仍然命名为 Repository。但是,如果在语义上不是简单的增删改查的话,就不应该用 Repository 的名字了,可以起别的名字,比如说 XxxService 之类。

再留意一下,仓库的实现类 EmpRepositoryImpl 在 gateway 包里面,而不是像之前那样在 persistence(持久化)包里面。这是因为,在实现层面,就要考虑不同技术了。如果访问数据库,就算是持久化;如果访问别的 Service,通常叫 gateway,也有人喜欢叫做 proxy。

EmpRositoryImpl 对 findById() 的实现过程分两步。

第一步,调用 “基础信息管理”里的EmpService 中的 findEmpById(),得到 EmpDto。

第二步,调用自身的 mapToEmp() 方法,把来自于“基础信息管理”上下文的 EmpDto 转化成“工时管理”上下文中的 Emp,然后返回给 EffortItemService。

我们看到,在“工时管理”上下文,只有 EmpRositoryImpl 能够看到其他上下文的Service 和 DTO。也就是说,仓库的实现封装了对其他上下文的调用。如果将来,“基础信息管理”的 API 和DTO 改变了,那么只需要改EmpRositoryImpl 内部的逻辑就可以了,“工时管理”的其他部分都不需要修改。EmpRositoryImpl 就充当了 防腐层 的作用。

DDD 中, 防腐层 也是一种用于上下文映射的模式。指的是两个上下文之间的转换逻辑,这个逻辑可以屏蔽两个上下文的差异,从而使两个上下文可以相对独立地演进。我们目前采用的方法是让适配器充当防腐层。

下面,假设我们要把单体拆成微服务,那么同样的功能,就变成下面的样子。

这个图和之前的单体架构相比发生了几个变化。

第一,原来在一个组件里的两个包,变成了两个表示微服务的组件。

第二,原来的本地调用变成了远程调用。

注意一下新增的两个符号。

组件上的小正方形,在 UML 里叫做端口(port)。端口上的小圆圈代表组件对外提供的接口,也叫“供给接口”。端口上的小半圆代表对其它接口的调用,也叫“需求接口”。两者连在一起,表示一个完整的调用关系。一个端口上可以提供若干个接口,也可以调用若干个接口。

接口可以是本地的或者远程的(例如 Resful API)。我这里用一个 <> 衍型表示远程 API。

第三,原来EmpRepositoryIml 直接调用 EmpService,现在则要经过 EmpController ,也就是适配器层。这是因为,在远程调用的情况下需要适配器来进行技术适配,而本地调用时则不需要。

第四,原来EmpRepositoryImpl 直接使用“基础信息管理”中的 EmpDto,现在要自己定义一个了。假如远程通信使用的数据格式是JSON,那么 EmpRepositoryImpl 在接收数据时,就会通过某种机制把 JSON 转成 EmpDto。

尽管有这四点改变,但是有一样没有变,就是从“基础信息管理”上下文获取员工数据的逻辑,仍然是封装在仓库(EmpRepository),或者说防腐层里。也就是说,如果按照这里说的防腐层的机制来做,在把单体拆成微服务的时候,理论上只需要改防腐层的逻辑就可以了。

这样,你是不是更深刻地体会到了防腐层的作用?防腐层隔离了上下文之间的变化,可以使两个上下文各自独立演化,逻辑更加内聚,也更容易测试。

什么是战略设计和战术设计

最后,我们再说一下什么是战略设计和战术设计。我发现这个问题也是众说纷纭。其实在 《领域驱动设计》原书里只提了战略设计,包括限界上下文、精炼、大型结构三部分。但没有提战术设计这个词,或许这就是后来概念混乱的原因吧。

不过按一般人的思维,有了战略,自然要有“战术”。所以后来有的书就提出了“战术设计”这个词。大体上,不是战略的部分就属于战术了。

首先,战略设计和战术设计中的“设计”应该指广义的设计,而不是和“分析”相对的软件设计。

所谓战术设计,就是细粒度的建模,包括实体、值对象、关联、模块、聚合等等。

而战略设计,解决的是当系统变得很大很复杂的时候,怎样从宏观上把握系统的总体结构,应对系统的规模和复杂性的问题。我们的课程中讲的限界上下文,就是一种在宏观上分而治之的手段。

总结

好,这节课的内容讲完了,我们来总结一下。

设计微服务,可以先假定一个限界上下文就对应一个微服务。然后,再综合考虑可伸缩性、安全性、技术异构性等因素,看是否要把限界上下文进一步拆分或合并。

根据限界上下文设计微服务有两个优点,一个是便于开发团队的协作,另一个是避免开发出“分布式单体”。

我们还讨论了上下文间的集成问题。限界上下文之间可以有多种集成策略。在一个维度上,可以分成 数据同步API调用 两种;在另一个维度上,可以分成 同步调用异步调用。异步调用,常常采用“事件驱动”的架构。

我们也介绍了在集成中常用的 防腐层 模式。防腐层能够隔离两个限界上下文的变化,使两个上下文各自独立地演进。理论上,当单体架构拆分成微服务的时候,只需要修改防腐层就可以了。我们例子中的 防腐层 是在适配器中实现的。

我们还顺便复习了设计图的画法,学习了 UML 中组件的端口和接口如何表示。

思考题

最后给你留两道思考题。

1.软件开发中,一般都比较注重可重用性。那么,如果两个限界上下文中有类似或相同的部分,你觉得是否应该抽取成可重用的模块呢?

2.除了在仓库里以外,你觉得防腐层还有没有其他的实现方式以及其他的实现位置?

好,今天的课程结束了,有什么问题欢迎在评论区留言。下节课,我们讲解 CQRS 模式,用于处理查询需求,敬请期待。