大概一个月前,在做 2018 年领域驱动设计 大会预告 的时候,上一届大会的主题演讲者肖然提出这样的担忧:工具和方法似乎没有很好地解决“落地难”的挑战
所以,推动领域驱动设计实践的方向是否应该从介绍方法转变为介绍如何累积经验?
看了这篇文章后,我放弃了之前准备的话题《CQRS 和 EventSourcing,从入门到放弃》,因为可能你一年都不会遇到一个需要使用这两种方法才能解决的复杂项目。
如何快速获取经验?无非就是多练,但是练了要讨论和总结,我遇到过这样的对话,我将它称为”两小儿辩 DDD“:
A: 我觉得你这里不该使用实体,应该使用值对象
B: 我觉得你这个接口不是领域服务,它其实是应用服务,你这样做不 DDD
A: 你的实体不应该调用 Repository,你这样做也不 DDD
B:(看着我) 你来评评理,我们谁说的对
我:俺也不知道,这取决于…
这样的复盘方式效果欠佳,我建议不妨从 DDD 中跳出,找一种方法互为参照和检验,比如”端口和适配器架构“
套用流行的提问方式:当我们在说架构时,我们在说什么?在本文中我们不是在讨论微服务架构,也不是讨论基础设施架构,这里的架构指:
一个例子是三层架构,展现层负责接收用户指令、渲染视图;业务逻辑层负责处理”业务逻辑”;数据层负责和数据库打交道,保存和读取数据。
三层(或多层)架构仍然是目前最普遍的架构,但它也有缺点:
因此,在实际落地时,业务逻辑容易泄漏到展示层中,导致当应用需要一种新的使用方式时 (例如开放 API),原有的业务逻辑层可能不能快速重用,同样的问题也发生在数据层和业务逻辑层之间。
那么有没有替代的方案?AlistairCockburn 是敏捷运动的早期推动者之一,他于 2005 年在其博客中提出了端口和适配器架构,他对该架构的一句话定义是:
“应用应能 平等 地被用户、其他程序、自动化测试或脚本 驱动 ,也可以 独立 于其最终的运行时设备和数据库进行 开发和测试 ”
原文为“Allowanapplicationto equallybedriven byusers,programs,automatedtestorbatchscripts,andtobedevelopedandtestedin isolation fromitseventualrun-timedevicesanddatabases.”
该架构由端口和适配器组成,所谓 端口 是应用的入口和出口,在许多语言中,它以接口的形式存在。例如以取消订单为例,“发送订单取消通知”可以被认为是一个出口端口,订单取消的业务逻辑决定了何时调用该端口,订单信息决定了端口的输入,而端口为预订流程屏蔽了通知发送方式的实现细节。
而适配器分为两种,主适配器(别名 DrivingAdapter )代表用户如何使用应用,从技术上来说,它们接收用户输入,调用端口并返回输出。RestAPI 是目前最常见的应用使用方式,以取消订单为例,该适配器实现 RestAPI 的 Endpoint,并调用入口端口 CancelOrderService。同一个端口可能被多种适配器调用,例如 CancelOrderService 也可能会被实现消息协议的 DrivingAdapter 调用以便异步取消订单。
次适配器(别名 DrivenAdapter )实现应用的出口端口,向外部工具执行操作,例如
若将其可视化,DrivingAdapter 和 DrivenAdapter 基于端口围绕着应用形成左右结构,有别于传统的分层形象,形成一个六边形,因此也会称作六边形架构
如果到此我已经成功地把你讲晕了,请不要担心,我们接下来通过一个案例体验一下这个架构。
DDD 邮轮,有咨询公司的报告显示,在接下来的几年内,邮轮游作为国人出游形式的比例会大幅上升,在这样一个大背景下,DDDCruise,一家中国的邮轮公司,正在研发新一代的预订系统,尝试在线邮轮预订。
目前计划中有两个触点应用:
在这两个触点背后,是这次的主角,预订引擎 1.0,计划以一个单体应用起步,为触点应用提供 API,实现邮轮搜索、邮轮预订。邮轮有多个数据来源,一部分来自一个遗留的预订系统,一部分来自业务部门的 Excel 表格,存放在 AWSS3 对象存储中。最后还有一个小型的 HeadlessCMS 为市场人员提供邮轮描述,吸引眼球。
现在让我们代入端口和适配器:
那么我们接下来在这个架构的基础上,进行概要设计,组件很自然地分为了三个部分:
a.CompositeCruiseSource,它不直接与数据源打交道,但它负责合并多个数据源并根据规则去除重复的 Cruise
b.CachingCruiseSource,它也不直接与数据源打交道,负责缓存 Cruise
从架构角度来看,这些组件很简单。请注意,简单(Simple)并不代表着容易(Easy),简单说的是只做一件事(或一种事),而容易是指做一件事的难度,例如如果使用 SpringMVC 实现 DrivingAdapter,利用注解寥寥几行代码就可以实现。由于这些组件要么实现业务逻辑,要么实现对某种技术的适配,符合单一职责原则,你可以更有效地将变更控制在某一个范围内,更有信心地应对变化。
应对变化的另一个有效手段是自动化测试,测试金字塔是最常被提及的测试策略,它建议自动化测试集应该由大量单元测试作为基础,它们编写容易、运行速度快,应该只包含少量的用 UI 驱动的测试,由于需要处理测试数据冲突、外部依赖准备,它们编写困难、运行速度也较慢。但中层的 service/ 集成测试的测试目标是什么,它们和单元测试有什么区别呢?
如果你也有此困惑,不妨按照端口和适配器架构来重新解读,金字塔应该包含大量的 DrivingAdatper 测试、业务逻辑测试、DrivenAdapter 测试。
需要注意的是以上测试都是在技术上检测组件是否符合预期,可以考虑适当加入 E2ETest 来验证这些组件集成起来可用,业务上符合预期,一般覆盖关键功能的 HappyPath 场景即可。
端口和适配器架构可能还能给与我们一些灵感,实施增量开发,不妨看一下这个用户故事分解的例子:
由于旅行社代售的邮轮都来自于 Excel 表格,只要确定了表格字段含义,我们就可以开始集成,我们选择这张卡来搭建脚手架:
接下来在 InMemoryCruiseSearch 中实现筛选:
引入 LegacyBookingCruiseSource 和 CompositeCruiseSource
最后,可以引入一张技术卡:
到这里,我们不妨小结一下:
由于概念简单、易于掌握,端口和适配器架构很适合作为 DDD 的入门辅导工具,而领域驱动设计的诸多方法也能够补充端口和适配器架构的空白,形成合力。
通用语言是领域驱动设计的核心精髓,它建议各方(无论是领域专家和还是开发人员)对于同一件事都使用相同的词汇。这可以防止各方在沟通领域问题、制定解决方案时不会由于不同的专业背景产生误解,最终促进了识别正确的问题,采用正确的解决方案。甚至有激进的观点认为“领域模型就是通用语言本身”。
端口和适配器虽然不能直接帮助我们找到领域模型或通用语言,但它有助于我们从通用语言中快速剔除技术概念:凡是用于实现适配器的技术细节都应该被排除。让我们回到 DDDCruise 的例子:
领域驱动设计于 2004 年横空出世,一年后端口和适配器被提出,在战术设计层面,我们可以发现诸多相似点,互为呼应。以架构为例,DDD 原著中提出的架构很有意思:乍看之下,以为是传统的分层架构,但却强调了 Infrastructure 对各层的实现。
端口和适配器的优势是突出了分层不是重点,技术实现隔离才是关键,让你不再纠结是否允许组件跨层调用。而 DDD 原著架构的优势是用 Application 和 Domain 进一步澄清了业务逻辑这个模糊的概念。不妨合二为一:
让我们回到 DDDCruise,细化 Cruise 的领域模型: CruiseSearch
(应用服务),但实际的筛选逻辑会交给 Cruise
(实体)及其值对象 Itinerary
, Leg
实现,你甚至可以引入 DDD 书中提到的规格模式,进一步强化单一职责,将筛选条件与领域模型筛选方法的映射工作从 InMemoryCruiseSearch
中剥离,使其完全只负责步骤协调
实施战略设计时候,有一个重要的实践是限界上下文的识别,当存在多个限界上下文的时候,很有可能需要集成,防腐层是常见的集成手段。来看这个示意:ServiceA 是左侧限界上下文暴露出来的接口,通过适配器调用右侧限界上下文的接口。
这是不是很眼熟?这不正是端口和 DrivenAdapter 吗?你可以认为它们是一种特化的防腐层。那么当一个单体应用中有多个限界上下文时,它们之间也应该用端口隔离,用适配器集成。如果你使用微服务来隔离限界上下文,端口和适配器架构则适用于其中每个服务。
回到 DDDCruise,还记得我们需要集成 HeadlessCMS 吗,由于在当前阶段,我们工作在单体应用中,CruiseSearch 的 API 需要返回包含邮轮描述的信息。
一种方案是将这些描述信息加入到领域模型中,由于已有的两个数据源都无法提供这些信息,我们又引入了 ContentfulCruiseSource
及另一个出口端口 CruiseContentEnricher
及其 DrivenAdapter 以便填充这些信息。但这个方案不够理想:
在限界上下文概念指导下的另一种方案,引入 CruiseContentEnricher
既作为入口端口、同时也作为出口端口,保持邮轮搜索上下文不被干扰,这个方案的好处是,假设邮轮搜索引擎进行微服务改造,很有可能将描述信息填充的职责分离到单独的服务中去,这时,只需要再提供一个输入、输出不含描述信息的 DrivingAdapter 就可以了。
我们介绍了端口和适配器架构,它简单易掌握,和领域驱动设计又合拍,希望它能帮助你快速积累 DDD 经验!
周宇刚,拥有 10 年的 JAVA EE 开发经验,在 ThoughtWorks 担任高级咨询师。在加入 ThoughtWorks 之前,在一家国内领先的航旅企业担任架构师,专注于持续交付实践和大型企业应用架构治理。
https://insights.thoughtworks.cn/port-and-adapter-architecture/