京东研发团队 - 领域驱动设计(DDD)实践

服务端思维

共 17702字,需浏览 36分钟

 ·

2021-10-12 10:22

点击上方“服务端思维”,选择“设为星标

回复”669“获取独家整理的精选资料集

回复”加群“加入全国服务端高端社群「后端圈」


作者 | 京东售技术
出品 | 物流IT圈

过去几年,通天塔一直处于快速的业务能力建设和架构完善的阶段,以应对不断增长的业务需求和容量、高可用等技术需求,现在通天塔平台已经能满足集团主站的大部分活动、频道搭建和运营能力,主流程的新需求越来越少,个性化需求和非标准化流程的数据源和服务接入的需求越来越多,有些甚至是京东零售体系外的,同时通天塔技术和产品也在积极主动寻求变化和创新,这些因素结合在一起驱动通天塔孵化出了一个以技术为导向的项目:通天塔积木,旨在构建一个基于完全开放的前端 SDK 和后端数据源&服务、高度灵活和强大的积木画布、能够快速移植和部署到任何第三方 IT 环境的活动搭建解决方案,这套方案的初衷和设计理念也契合了京东国际化赋能和 PaaS 化的战略。

目前通天塔积木已经取得阶段性成果,已开始赋能京东国内和国际站,但如何应对异常复杂的积木业务逻辑和不可预知的业务变化,构建业务和底层技术基础实施的完全解耦的系统,一直是我们面对的巨大挑战。也是时候从更高视角来看清问题和源头,思考 一种能应对和控制业务复杂度、具备强扩展性和弹性的解决方案 。纵观我们的目标,DDD 这个词不知不觉映入了我的眼帘。

2004 年著名建模专家 Eric Evans 发表了他最具影响力的书籍《Domain-Driven Design –Tackling Complexity in the Heart of Software》(领域驱动设计—软件核心复杂性应对之道),书中反复强调领域通用语言(Ubiquitous Language)的重要性,全面阐述了 DDD 战略设计到战术设计的方法论和实践。让软件研发所有参与者围绕着一个统一和一致的领域模型建模和设计,分析模型和设计模型不再割裂,并引出了以领域为核心的分层架构,有效地分离业务和技术复杂度,使得领域层的代码和领域模型保持高度一致。在战术上提供了诸多元模式帮助构建职责清晰、内聚和高维护性和可扩展性的代码 。

领域驱动设计不是新鲜的概念,至今已有十六年时间,一直来不曾大行其道,直到 IT 行业内掀起微服务的狂潮,技术界才重新审视和意识到领域驱动设计的价值。不能说微服务拯救了领域驱动设计,但确实是微服务,让领域驱动设计又重新焕发了青春。DDD 是一个非常庞大的建模和设计体系,这篇文章只在理论和概念上阐述 DDD 的价值、方法和架构,欢迎任何的问题指正和补充。

DDD 价值

应对复杂业务

引起软件系统复杂度的主要因素是需求,软件系统需求又可以分两个方面:业务需求和技术需求 。我们分析系统的复杂度时就可以从业务复杂度和技术复杂度这两个维度出发。

业务复杂度跟系统的业务需求规模和需求之间的关系层级有直接关系,需求的数量和关系的层级决定代码的规模和逻辑循环或递归的层级,系统的需求数量越大,需求之间的关系越复杂,系统的业务复杂度就越大。John Ousterhout 的著作《A Philosophy of Software Design》从认知的负担和开发工作量的角度来定义软件系统的复杂度,并给出了一个复杂度公式:

子模块的复杂度(cp)乘以该模块对应的开发时间权重值(tp),累加后得到系统的整体复杂度(C)。可以看到系统整体的复杂度并不简单等于所有子模块复杂度的累加,还要考虑该模块的开发维护所花费的时间在整体时间中的权重占比(tp),这个权重比就跟模块划分是否内聚、设计是否优雅有直接关系。

技术复杂度则来自于对软件系统运行的质量需求,包括安全、高性能、高并发、高可用和高扩展性。系统安全性要求对访问进行控制,无论是加密还是认证和授权,都需要为整个系统架构添加额外的间接层。不仅对访问的低延迟产生影响,还极大提升了系统代码复杂度;为了让后端系统能具备高扩展性和弹性,要求所有系统的设计必须是无状态的;为了提升用户端访问体验,后端需要增添离线任务对数据加工、异构、预热、预缓存,以实现用空间换时间,降低实时接口的逻辑复杂度来降低请求的延迟。然而最让开发者更抓狂的是这些技术需求彼此又是相互影响甚至相互矛盾,在一些复杂流程并要求高响应的业务场景,如下单、秒杀等,会将一个同步的访问请求拆分为多级步骤的异步请求,再通过引入消息中间件对这些请求进行整合和分散处理,这种分离一方面增加了系统架构的复杂性,另一方面也因为引入了更多的资源,使得系统的高可用面临挑战,并增加了维护数据一致性的难度。而且技术复杂度与业务复杂度并非孤立,二者复杂度因子混合在一起产生的负作用更让系统的复杂度变得不可预期,难以掌控,就好比氢气和氯气混合在一起遇到光亮发生爆炸一样。

DDD 的核心思想就是要避免业务逻辑的复杂度与技术实现的复杂度混淆在一起,确定业务逻辑与技术实现的边界,从而隔离各自的复杂度,业务逻辑并不关心技术是如何实现的。无论采用何种技术,只要业务需求不变,业务规则就不会变化。理想状态下,应该保证业务逻辑与技术实现是正交的。

DDD 通过分层架构与六边形架构确保业务逻辑与技术实现的隔离。

DDD 战略设计指导我们面对客户的业务需求,由领域专家与开发团队展开充分的交流,经过需求分析与知识提炼,获得清晰的问题域,在引入限界上下文和上下文映射对问题域进行合理的分解,识别出核心领域与子领域,并确定领域的边界以及它们之间的关系,从而把一个大的复杂系统问题拆分成多个细粒度、独立和内聚的业务子问题,从而很好地分解和控制业务复杂度,各个小组聚焦各自的子领域中。

在架构方面,通过分层架构来隔离关注点,将领域实现独立出来,利于领域模型的单一性与稳定性;

引入六边形架构清晰地界定领域与技术基础设施的边界;CQRS 模式则分离了查询场景和命令场景,针对不同场景选择使用同步或异步操作,提高架构的低延迟性与高并发能力。

分层架构

“分层架构”遵循了“关注点分离”原则,将属于业务逻辑的关注点放到领域层(Domain Layer)中,而将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中。同时,领域驱动设计又颇具创见的引入了应用层(Application Layer)。应用层扮演了双重角色。一方面它作为业务逻辑的外观(Facade),暴露了能够体现业务用例的应用服务接口;另一方面它又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。下图展现的就是一个典型的领域驱动设计分层架构。蓝色区域的内容与业务逻辑有关,灰色区域的内容与技术实现有关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency Injection)的方式将二者结合起来。

六边形架构

由 Cockburn 提出的六边形架构则以“内外分离”的方式,更加清晰地勾勒出业务逻辑与技术实现的边界,且将业务逻辑放在了架构的核心位置。这种架构模式改变了我们观察系统架构的视角。体现业务逻辑的应用层与领域层处于六边形架构的内核,并通过内部的六边形边界与基础设施的模块隔离开。当我们在进行软件开发时,只要恪守架构上的六边形边界,就不会让技术实现的复杂度污染到业务逻辑,保证了领域的整洁。边界还隔离了变化产生的影响。如果我们在领域层或应用层抽象了技术实现的接口,再通过依赖注入将控制的方向倒转,业务内核就会变得更加的稳定,不会因为技术选型或其他决策的变化而导致领域代码的修改。

快速响应业务变化

不确定性和变化是这个时代的主旋律,业务需要快速上线,并根据用户的反馈不停地调整和升级,有生命力的业务主动寻求变化,不变则亡是很多行业目前的共识,企业应对变化的响应力成了成败的关键。同时一个长期困扰软件研发的问题是,需求总是在变化,无论预先设计如何“精确”,总是发现下一个坑就在不远处。相信很多技术人员都有这样的经历,架构和响应能力越来越糟糕,也就是我们常说的架构腐化了,最后大家不得不接受重写。软件架构设计的另一个关键方面是让系统能够更快地响应外界业务的变化,并且使得系统能够持续演进。在遇到变化时不需要从头开始,保证实现成本得到有效控制。

DDD 的核心是从业务出发、面向业务变化构建软件架构,实质是保证面对业务变化时我们能够有足够快的响应能力。面向业务变化而架构就要求首先理解业务的核心问题,即有针对性地进行关注点分离来找到相对内聚的业务活动形成子问题域。让每个字问题的划分尽可能靠近变化的原点,子问题域内部是相对稳定的,未来的变化频率不会很高,是符合深模块特性的,而子问题边界是很容易变化的。DDD 最后在实现层面利用成熟的技术模式屏蔽掉技术细节的复杂度。

与微服务相得益彰

Martin Fowler 和 James Lewis 提出微服务时,提出了微服务的 9 大架构特质,指导组织围绕业务组建团队,把业务拆分为一个个业务上高度内聚、技术上松散耦合、运行在独立进程中的小型服务,微服务架构赋予了每个服务业务上的敏捷性和技术上的自主性,因此可以针对每个服务进行独立地迭代、更新、部署和弹性扩展,从而缩短需求交付周期并加速创新。

在面对复杂业务和快速变化需求时,DDD 从业务视角进行关注点分离和应对复杂度,让业务具备更高的响应力。DDD 战略设计阶段,引入限界上下文(Bounded Context)和上下文映射(Context Map)对问题域进行合理的分解,确定领域的边界以及它们之间的关系,维持模型的完整性。

限界上下文不仅限于对领域模型的控制,而在于分离关注点之后,使得整个上下文可以成为独立部署的设计单元,这就是“微服务”的概念,上下文映射的诸多模式则对应了微服务之间的协作。因此在战略设计阶段,微服务扩展了领域驱动设计的内容,反过来领域驱动设计又能够保证良好的微服务设计。

边界给了实现限界上下文内部的最大自由度。这也是战略设计在分治上起到的效用,我们可以在不同的限界上下文选择不同的架构模式和技术实现,这也正好映照了微服务的特点:在技术架构上,系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

ThoughtWorks 公司技术专家编写的《微服务设计》书中,专门有一章节“限界上下文”,充分说明微服务的落地需要 DDD 来辅助的,起码在建模阶段是需要借助 DDD 强大的战略模式来支撑的。微服务不是简单的指将服务尽可能的拆小,然后一个 RPC 框架搞定了,这太粗糙了,无法落地。

辅助中台战略落地

领域驱动设计让参与者基于统一语言沟通和协作,围绕一个统一和一致的领域模型工作,传统的分析模型和设计模型不再割裂;显式地把业务领域和设计放到了软件开发的核心,软件人员和业务人员合作来构建领域模型,使得软件的交付质量更高且维护成本更低;利用限界上下文来分解问题域,识别核心领域,有效分解和控制了业务的复杂度;

利用 DDD 提倡的分层、六边形等架构,分离了业务复杂度和技术复杂度,使得系统具备更强的扩展性和弹性;战术层面提供了元模型(聚合,实体,值对象,服务,工厂,仓储)帮助构建清晰、稳定,能快速响应变化和新需求能力的应用;

DDD 构建的应用能快速方便地切到微服务;领域驱动设计给企业应用带来的稳定性、灵活性、扩展性和应对变化的响应力对于建立灵活前台、稳固中台能带来巨大的帮助作用。

DDD 过程

领域驱动设计是一套面对复杂业务进行建模和设计的方法论和实践,建立了以领域为核心驱动力的设计体系。领域驱动设计分为 2 个主要过程:战略设计、战术设计 。

在战略设计阶段 ,面对纷繁复杂的业务需求,领域专家和研发团队进行紧密合作、充分沟通,进行事件风暴或场景驱动设计,分析需求并提炼知识,得到比较清晰的问题域,输出由领域专家和研发团队达成共识的统一语言(UL,Ubiquitous Language),基于统一语言对问题域进行分析和建模,识别业务边界,确定限界上下文,根据限界上下文划分独立的领域,建立限界上下文彼此之间的关系,接着引入系统上下文(System Context)确定系统的边界,并确定它的外部环境,包括与其集成的第三方系统与基础设施。利用 DDD 分层架构或六边形架构界定业务领域和技术实现的边界,让稳定的核心领域模型处于架构的最内部,避免技术实现和架构变动带来的影响。

接着进入战术设计阶段 ,一个大的业务问题被分解为多个限界上下文(问题域),团队视野和专注就可以聚焦到每一个内聚的限界上下文,进行战术设计。战术设计的重点是利用领域驱动设计的元模型对领域的复杂性进行分解和建模。

领域驱动设计强调和突出了领域模型的重要性,通过整个领域驱动设计过程,绑定领域模型和技术模型,以保证领域模型和技术模型在贯穿整个软件开发的生命周期中(需求分析、建模、架构、设计、编码、测试与持续重构)的强一致性。领域模型指导着软件设计以及技术编码实现,接着通过重构实践来挖掘隐式概念,完善统一语言和模型,运用设计模式改进设计与开发质量。以下是领域驱动设计的粗略过程:

战略设计

提炼问题域

回顾我们往日的分析和解决问题过程, 面对复杂问题,很多同学还没完全理解问题的全貌就已经在提出解决办法,这些解决办法只是针对问题的局部,经典图书《第五项修炼》把这种行为称为“反应式”的,碰到一个问题给出一个回应办法,而从这些问题整体来看这种方式会阻碍团队找出最佳解决方案。

DDD 作为一种建模和架构方法,最大的突破是着重明确了区分了问题域和解决方案域,对业务问题的认知不是技术人员最擅长的,很多研发在碰到需求时,脑子本能就闪现表、类、服务、架构,把解决方案当终极问题来追求,而 DDD 要求研发进行痛苦的蜕变,在业务分析和领域建模阶段忘记技术解决方案。同时 DDD 要求领域专家和技术人员坐在一起通力合作、密切沟通来分析和建模,领域专家对业务有着深刻的理解,技术人员擅长技术实现和架构设计,而领域专家和技术人员由于工种的差异导致交流产生障碍,开发人员满脑子是技术语言,领域专家脑子也都是业务概念,如果按照本能基于自己的专业背景进行沟通,效率太低了,即使有翻译的角色也会产生理解偏差, DDD 的一个核心原则是所有人员包括领域专家和技术的进行任何沟通都使用一种基于模型的通用语言(UL,Ubiquitous Language),在代码中也是这样。

DDD 帮助技术人员对需求进行本质思考和理解,关注点不在是聚焦在功能上,而是理解需求的真正意图和愿景,而非开发一个 feature,更深层次地理解隐含的愿景才能开发出真正地解决问题和创造价值的系统来。在提炼问题域过程中,领域专家和技术专家通过充分交流,进行需求分析和知识提炼,获得清晰的问题子域,识别出核心域、通用域、支撑域。通用域是开发该软件系统根本竞争力所在,也是领域建模的重心,建议分配最精锐的研发;

通用域 是指多个子域依赖的通用功能子域,比如权限、邮件、日志系统等;支撑域 是指系统中非核心域和通用域的业务域。

需求分析时从用例开始,列出达成业务目标需要的步骤,切忌跳转到解决方案上,识别出用于构建模型的知识,通过 UML 表示分析模型和业务模型,形成业务和技术人员达成共识的通用语言。

该阶段领域专家只专注于问题域而不是解决方案,业务和技术人员基于 UL 沟通,并且考虑投入产出比,团队只为核心业务进行领域驱动设计并创建 UL,订单系统为下单模块进行 DDD,订单监控模块用普通的事务脚本方式来即可,我们通天塔的活动模板和积木业务非常复杂和核心,非常适合使用 DDD 来建模和架构设计,而通天塔后端的 Man 系统是面向开发者进行后端和线上业务监控的,进行 DDD 就是小题大做。

识别限界上下文(Bounded Context)

Eric Evans 说:“对一个大型系统,领域模型的完全统一将是不可行的或者不划算的。”。DDD 的构建块不能盲目地应用在一个无限大的领域模型上,一个无限大的领域模型也无助于我们开发出优质的软件,限界上下文是分解领域模型的关键。限界上下文是一种“分而治之”的思维,也是一种高层的抽象机制,让人们对领域进行本质思考,简化问题和应对复杂性。

限界上下文如同细胞,细胞是上下文,细胞壁是边界,细胞内的信息负责对代谢和遗传进行调控,细胞壁对细胞起着支持和保护防御的作用,控制物质进出,让对细胞有用的物质不能出来,有害的物质也不能进入细胞。而领域驱动设计中的限界上下文保证领域模型的一致性和完整性,清晰边界的控制力保证了领域的安全和稳定。

如何识别限界上下文?

明确了系统的问题域和业务期望后,梳理出主要的业务流程,这些业务流程体现了各种参与者在这个过程中通过业务活动共同协作,最终完成具有业务价值的领域功能。业务流程结合了参与角色(Who)、业务活动(What)和业务价值(Why)。在业务流程的基础上,我们就可以抽象出不同的业务场景,这些业务场景又由多个业务活动组成,可以利用领域场景分析方法剖析场景,以帮助我们识别业务活动,例如采用用例对场景进行分析,此时,一个业务活动实则就是一个用例。业务流程是一个由多个用户角色参与的动态过程,而业务场景则是这些用户角色执行业务活动的静态上下文。

接下来,我们利用领域场景分析的用例分析方法剖析这些场景。通过参与者(Actor)来驱动对用例的识别,这些参与者恰好就是参与到场景业务活动的角色。根据用例描述出来的业务活动应该与统一语言一致,最好直接从统一语言中撷取。一旦准确地用统一语言描述出这些业务活动,我们就可以从语义相关性和功能相关性两个方面识别业务边界,进而提炼出初步的限界上下文。

从不同角度看待限界上下文,限界上下文会呈现出对不同对象的控制力。

  • 领域逻辑层面:限界上下文确定了领域模型的业务边界,维护了模型的完整性与一致性,从而降低系统的业务复杂度。
  • 团队合作层面:限界上下文确定了团队的工作边界,建立了团队之间的合作模式,提升了团队间的协作效率,“康威定律”告诉我们,系统设计(产品结构)等同组织形式,每个设计系统的组织,其产生的设计等同于组织之间的沟通结构,限界上下文指导产生的团队结构的工作模式是最高效的。
  • 技术架构层面:限界上下文确定了系统架构的应用边界,保证了系统层和上下文领域层各自的一致性,建立了上下文之间的集成方式。微服务中,限界上下文指导技术人员划分微服务的边界,通常一个限界上下文作为一个在独立进程中运行的微服务。

DDD 驱动我们把每一个限界上下文设计成一个个“自治”的单元,自治要满足四个特点:

  • 最小完备 是实现自治的基本条件,指的是自治单元履行的职责是根据业务价值的完整性和最小功能集进行设计的,这让自治单元无需求助其他自治单元获得信息,避免了不必要的依赖关系,同时也避免了不必要和不合适的职责添加到该自治单元上。
  • 自我履行意味着由自治单元自身决定要做什么。是否应该履行某职责,由限界上下文拥有的信息来决定。站在自治单元的角度去思考:“如果我拥有了这些信息,我究竟应该履行哪些职责?”这些职责属于当前上下文的活动范围,一旦超出,就该毫不犹豫地将不属于该范围的请求转交给别的上下文。自我履行其实意味着对知识的掌握,为避免风险,你要履行的职责一定是你掌握的知识范畴之内。
  • 稳定空间 指的是减少外界变化对限界上下文内部的影响。稳定空间符合开放封闭原则(OCP),即对修改是封闭的,对扩展是开放的,该原则其实体现了一个单元的封闭空间与开放空间。封闭空间体现为对细节的封装与隐藏,开放空间体现为对共性特征的抽象与统一,二者共同确保了整个空间的稳定。
  • 独立进化 指的是减少限界上下文的变化对外界的影响。用限界上下文的上下游关系来阐释,则稳定空间寓意下游限界上下文,无论上游怎么变,我自岿然不动。要做到独立进化,就必须保证对外公开接口的稳定性,因为这些接口被众多消费者依赖和调用,一旦发生变更,就会牵一发而动全身。一个独立进化的限界上下文,需要一个稳定、设计良好的接口设计,并在版本上考虑了兼容与演化。

最小完备是基础,只有赋予了限界上下文足够的信息,才能保证它的自我履行。稳定空间与独立进化则一个对内一个对外,是对变化的有效应对,而它们又是通过最小完备和自我履行来保障限界上下文受到变化的影响最小。

上下文映射

限界上下文仅是一种对领域问题域的静态划分,还缺少一个重要的关注点,即:限界上下文之间是如何协作的?当我们发现彼此协作存在问题时,说明限界上下文的划分出现了问题,也是识别限界上下文的一种验证方法。Eric Evans 将这种体现限界上下文协作方式的要素称之为“上下文映射(Context Map)”,并给出了 9 种上下文映射关系:

Open Host Service 相当于微服务之间的协作关系;防腐层(Anti-Corruption)是一种高度防御性的策略,结合门面(Facade)模式和适配器(Adapter)设计模式,将模型与其需要集成的其他模型隔离开来,以防止被频繁变更或不稳定的依赖模型污染和腐败。

架构设计

“DDD 不需要特殊的架构,只要是能将技术问题与业务问题分离的架构即可。” -- Eric Evans

传统的三层架构分而治之、降低耦合、提高复用,但存在弊端,业务逻辑在不同层泄露,导致替换某一层变得困难、难以对核心逻辑完整测试。领域驱动设计给出了 DDD 分层架构、六边形架构、整洁架构等分层架构,它们遵循“关注点分离”原则,旨在分离和隔离业务复杂度和技术复杂度,凸显了领域模型,保证了领域模型的稳定性和一致性。

DDD 分层架构

DDD 分层架构将属于业务逻辑的关注点放到领域层(Domain Layer)中,将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中,DDD 创新性地引入了应用层(Application Layer),应用层扮演了两重角色。一作为业务逻辑的门面(Facade),暴露了能够体现业务用例的应用服务接口,又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。下图展现的是一个典型的领域驱动设计分层架构。蓝色区域和业务逻辑相关,灰色区域与技术实现相关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency Injection)的方式将二者结合起来。

我们详细介绍 DDD 分层架构中每一层的用意和设计:

表现层(User Interface Layer):负责向用户显示信息和解释用户命令,完成前端界面逻辑应用层(Application Layer) 很薄的一层,负责展现层与领域层之间的协调,不包含任何的业务逻辑和业务规则,也不保留业务对象的状态,是对领域服务的编排和转发。应用层扮演了两重角色。一作为业务逻辑的门面(Facade),暴露了能够体现业务用例的应用服务接口,又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。一个 Application Service 代表一个 Use Case,一个 Use Case 代表了一个完整的业务场景,对于外部的客户来说,应用层是与客户协作的应用服务,接口代表是业务的含义。

我们知道 DDD 分层架构的主要目标是分离业务复杂度与技术复杂度,应用层扮演的就是这样的分界线。从设计模式的角度来理解,应用层的 Application Service 是一个 Facade,对外部客户,作为代表 Use Case 的整体应用,对架构内部,它负责整合领域层的领域逻辑与非业务相关的横切关注点。

应用中,存在与具体的业务逻辑无关,在整个系统中会被诸多服务调用的横切关注点实现,他们在职责上是内聚的,散布在所有代码层次中,包括异常处理、事务、监控、日志、认证和授权等。所以与横切关注点协作的服务应被定义为应用服务。

领域层(Domain Layer),是业务软件的核心所在,也是软件架构的核心,包含了业务所涉及的领域对象(实体、值对象)、领域服务,负责表达业务概念、业务状态信息以及业务规则,具体表现形式就是领域模型。领域驱动设计提倡富领域模型,将业务逻辑归属到领域对象上。基础设施层(Infrastructure Layer):基础层为各层提供通用的技术能力,包括:为应用层传递消息、提供 API 管理,为领域层提供数据库持久化机制等。它还能通过技术框架来支持各层之间的交互。

整洁架构(Clean Architecture)

整洁架构中,同心圆代表应用软件架构的不同部分,也是一种以领域模型为中心的架构,从里到外依次是 Entities、Use Cases、Interface Adapters、Frameworks and Drivers。整洁架构明确了各层的依赖关系,越往里,依赖越低,越抽象,外圆代码依赖只能指向内圆,内圆不知道外圆的任何事情。

六边形架构(Hexagonal Architecture)

又称为端口-适配器,六边形架构也是一种分层架构,不是从上下或左右分,而是从内部和外部来分。六边形架构在领域驱动设计和微服务架构设计中扮演了较重要的角色。六边形架构将系统分为内部(内部六边形)和外部,内部代表了应用的业务逻辑,外部代表应用的驱动逻辑、基础设施(诸如 REST,SOAP,NoSQL,SQL,Message Queue 等)或其他应用,UI 层、DB 层、和各种中间件层实际上是没有本质上区别的,都只是数据的输入和输出。内部通过端口和外部系统通信,端口代表了一定协议,以 API 呈现。

一个端口对应多个适配器,对应多个外部系统,对这一类外部系统的归纳,不同的外部系统需要使用不同的适配器,适配器负责对协议进行转换。六边形架构有一个明确的关注点,一开始就强调把重心放在业务逻辑上,外部的驱动逻辑或被驱动逻辑存在可变性、可替换性,依赖具体技术细节。而核心的业务领域相对稳定,体现应用的核心价值。六边形的六并没有实质意义,只是为了留足够的空间放置端口和适配器,一般端口数不会超过 4 个。适配器可以分为 2 类,“主”、“从”适配器,也可称为“驱动者”和“被驱动者”。

代码依赖只能使由外向内。对于驱动者适配器(也称主适配器,Driving Adapter),就是外部依赖内部的。但是对于被驱动者适配器(也称次适配器,Driven Adapter),实际是内部依赖外部,这时需要使用依赖倒置,由驱动者适配器将被驱动者适配器注入到应用内部,这时端口的定义在应用内部,但是实现是由适配器实现。

CQRS(命令与查询职责分离)

CQRS 使用分离的接口将数据查询操作(Queries)和数据修改操作(Commands)分离开来,这也意味着在查询和更新过程中使用的数据模型也是不一样的,这样读和写逻辑就隔离开来了。使用 CQRS 分离了读写职责之后,可以对数据进行读写分离操作来改进性能,可扩展性和安全。DDD 和 CQRS 结合,可以分别对读和写建模:

查询模型是一种非规范化数据模型,不反映领域行为,只用于数据查询和显示。命令模型执行领域行为,在领域行为执行完成后通知查询模型。如果查询模型和领域模型共享数据源,则可以省略这一步;如果没有共享数据源,可以借助于发布订阅的消息模式通知到查询模型,从而达到数据最终一致性。对于写少读多的共享类通用数据服务(如主数据类应用)可以采用读写分离架构模式。单数据中心写入数据,通过发布订阅模式将数据副本分发到多数据中心。通过查询模型微服务,实现多数据中心数据共享和查询。

通天塔从系统维度对数据库进行了读写分离,通天塔的 C 端应用和服务大部分是读场景,CMS 是多写应用,所以 CMS 的写走主库,读服务按照使用场景不同访问不同的从库,实时请求、同步数据到集市、数据中心等,这点也从数据库基础架构上保证了通天塔系统的低延时和稳定。

综述

六边形架构的内部六边形、DDD 分层架构的领域层和应用层、以及整洁架构 Use Cases 和 Entities 区域实现了核心业务逻辑。但是核心业务逻辑又由两部分来完成:应用层和领域层逻辑。领域层实现了最核心的业务领域部分的逻辑,对外提供领域模型内细粒度的领域服务,应用层依赖领域层业务逻辑,通过服务组合和编排通过 API 网关向前台应用提供粗粒度的服务。业务需求变幻莫测,但我们总能在这些变化找出一些规律,用户体验、操作交互、以及业务流程的变化,往往只会导致 UI 层和流程的变化,总体来说,不管前端和外部如何变化,核心领域逻辑基本不会大变。把握好这个规律,我们就知道如何设计应用层和领域层,如何进行逻辑划界了。架构模型正是通过分层方式来控制需求变化对系统的影响,确保从外向里受的影响逐步减小。面向用户端的展现层可以快速响应外部需求进行调整和发布,灵活多变;应用层通过服务组合和编排实现业务流程的快速适配上线,以满足不同的业务场景;领域层是经过抽象和提炼的业务原子单元,是非常稳定的。这些架构设计的好处是可以保证领域层的核心业务逻辑不会因为外部需求和流程的变动而调整,对于建立前台灵活、中台稳固的架构能力是很有好处的。下面是 Herberto Graca 的一张包含了六边形、整洁、CQRS 等架构的综合图,全面的说明了这些架构的设计要点和不同的出发点。

战术设计

战略设计为我们提供一种高层视角来审视我们的软件系统,而战术设计则将战略设计的成果具体化和细节化,它关注的是单个限界上下文内部技术层面的实施。DDD 给我们提供了一整套技术工具集,包括实体、值对象、领域服务和资源库等,如下:

行为饱满的领域对象

让我们先看几个概念:

  • 失血模型 :是仅包含属性的 getter/setter 方法的数据载体,没有行为和动作,业务逻辑由服务层完成。贫血模型:包括了属性、getter/setter 方法,和不依赖于持久化的原子领域逻辑,依赖于持久层的业务逻辑将会放到服务层中。
  • 充血模型:包含了属性、getter/setter 方法、大部分的业务逻辑,包括依赖于持久层的业务逻辑,所以使用充血模型的领域层是依赖于持久层,服务层是很薄的一层,仅仅封装事务和少量逻辑。
  • 胀血模型:取消了 Service 层,胀血模型就是把和业务逻辑不相关的其他应用逻辑(如授权、事务等)都放到领域模型中。

胀血模型是显而易见不可取的,这里不做过多讨论。失血模型是绝大数企业开发应用的模式,一些火热的 ORM 工具比如 Hibernate,Entity Framework 实际上助长了失血模型的扩散,而且传统三层架构中的服务层,承受了太多的职责,如事务管理、业务逻辑、权限检查等,这违反了单一职责原则和关注分离原则,并且产生了大量的依赖和循环依赖,当业务复杂度上升时,服务层所包含的代码将会非常庞大和复杂,直接导致了维护成本和测试成本的上升。同时也会导致业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由失血症引起的失忆症,它会导致系统变得愈发复杂和难以维护。

采用领域模型的开发方式,将数据和业务逻辑封装在一起,从服务层移动到领域将业务逻辑模型中,这样服务层可以只负责应用逻辑(事务、日志、认证、监控、编排等),领域模型可以专门负责其相关的业务逻辑,相关的业务分别内聚到不同的领域模型中,与现实领域的业务对象映射,一些很有可能重复的业务代码都会被集中到一处,降低重复代码,提升业务逻辑的复用、可测试性和维护性。贫血模型和充血模型都是满足数据+行为的,应该采用哪种模式,大家这是一个争论了旷日持久的问题,关注点还是在于领域模型是否要依赖持久层,我个人还是偏重于贫血模式,依赖持久层就意味着单元测试的展开要更加困难,而且领域对象的生命周期应该交给外部模型才更合理。

领域驱动设计元模型

实体(Entity) 实体是一种具有唯一身份标识的对象,具有持续的生命周期,除唯一标识其他属性是可变的。实体通过它的唯一标识被区分。例如实体订单 Order,标识为 oderId,通天塔的活动实体 Activity,标识为 activityId。

  • 值对象(Value Object)  当我们只关心一个模型元素的属性时,应把它归类为值对象。应该使这个模型元素能够表示出其属性的意义,并为它提供相关功能。建议将值对象设计成一个不变(Immutable)对象,这样就不需要担心并发带来的诸如同步、冲突等问题了,这既降低了编程的难度,又可以无需引入额外的同步锁影响程序的性能。也不要为它分配任何标识,这样应用也无需去管理值对象的生命周期。值对象通过比较其属性(equals)区分是否是相同值对象。应该尽量使用值对象来建模而不是实体对象。在领域驱动设计中,提倡尽量定义值对象来替代基本类型,因为基本类型无法体现统一语言中的领域概念。假设一个实体定义了许多属性,这些属性都是基本类型,就会导致与这些属性相关的领域行为都要放到实体中,导致实体的职责变得不够单一。引入值对象后情况就不同了,我们可以利用合理的职责分配,将这些职责(领域行为)按照内聚性分配到各个值对象中,这个领域模型就能变得协作良好。值对象可以与其所在的实体对象保存在同一张表中,值对象的每一个属性保存为一列;值对象也可以独立于其所在的实体对象保存在另一张表中,值对象获得委派主键,该主键对客户端是不可见的。

  • 聚合(Aggregate)  聚合中所包含的对象之间具有密不可分的联系,一个聚合中可以包含多个实体和值对象,因此聚合也被称为根实体。聚合是持久化的基本单位,它和资源库具有一一对应的关系。在聚合中,根是唯一允许外部对象保持对它的引用的元素,而边界内部的对象之间则可以互相引用。除根以外的其他 Entity 都有本地表示,但这些标识只有在聚合内部才需要加以区别,因为外部对象除了根 Entity 之外看不到其他对象。在一个聚合中直接引用另外一个聚合并不是 DDD 所鼓励的,但是我们可以通过 ID 的方式引用另外的聚合,聚合是一个事务的边界。如果一次业务操作涉及到了对多个聚合状态的更改,那么应该采用发布领域事件(参考下文)的方式通知相应的聚合。此时的数据一致性便从事务一致性变成了最终一致性(Eventual Consistency)。

  • 领域服务(Domain Service) 建模一个领域概念,把它放在实体上不合适,它放在值对象上也不合适,或者碰到跨聚合实例业务逻辑,没办法合理放到某个实体中的业务逻辑,领域服务就是应对这些情况的服务。如果勉强地把这些重要的领域功能归为 Entity 或 Value Object 的职责,那么不是歪曲了基于模型的对象的定义,就是人为地增加了一些无意义的对象;领域服务和上文中提到的应用服务是不同的,领域服务是领域模型的一部分,而应用服务不是。应用服务是领域服务的客户,它将领域模型变成对外界可用的软件系统。如果将太多的领域逻辑放在领域服务上,实体和值对象上的业务逻辑会越来越弱,将变成贫血对象。在分层架构中要区分什么时候应该定义领域服务,什么时候应该定义应用服务,一个根本的判断依据是看需要封装的职责是否与领域相关。

  • 资源库(Repository)  资源库用于保存和获取聚合对象,将实际的存储和查询技术封装起来,对外隐藏封装了数据访问机制。只为那些确实需要直接访问的聚合提供 Repository。让客户始终聚焦于模型,而将所有对象的存储和访问操作交给 Repository 来完成。资源库与 DAO 有些相似,但也存在显著区别,DAO 是比 Repository 更低的一层,同时 DAO 只是对数据库的一层很薄的封装,而资源库则更加具有领域特征,以“领域”为中心,所描述的是“领域语言”。另外,所有的实体都可以有相应的 DAO,但并不是所有的实体都有资源库,只有聚合才有相应的资源库。

  • 领域事件(Repository)  在 Eric 的《领域驱动设计》中并没有提到领域事件,领域事件是最近几年才加入 DDD 生态系统的。在传统的软件系统中,对数据一致性的处理都是通过事务完成的,其中包括本地事务和全局事务。DDD 的一个重要原则便是一次事务只能更新一个聚合实例,但存在一个业务流程涉及修改多个聚合的事务,怎么实现整个业务流程的数据一致性呢?在 DDD 中,领域事件便可以用于处理上述问题,此时最终一致性取代了事务一致性,通过领域事件的方式达到各个组件之间的数据一致性。既然是领域事件,他们便应该从领域模型中发布,一个领域事件是指一个在领域中“有意义”的事件。领域事件的最终接收者可以是本限界上下文中的组件,也可以是另一个限界上下文。再进一步发展,事件驱动架构可以演变成事件源(Event Sourcing),即对聚合的获取并不是通过加载数据库中的瞬时状态,而是通过重放发生在聚合生命周期中的所有领域事件完成。

  • 工厂(Factories) 当创建一个对象或创建整个聚合时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用 Factory 进行封装,应该将创建复杂对象的实例和聚合的职责转移到一个单独的对象,这个对象本身在领域模型中可能没有职责,但它仍是领域设计的一部分。

  • 模块(Modules) 可以从两种维度来观察模型,一是可以在 Module 中查看细节,而不会被整个模型淹没;二是观察 Module 之间的关系,而不考虑其内部细节。模块之间应该是低耦合的,而在模块内部则是高内聚的。模块并不仅仅是代码的划分,而且也是概念的划分。找到一种低耦合的概念组织方式,从而可以相互独立地理解和分析这些概念。对模型进行精化,直到可以根据高层领域概念对模型进行划分,同时相应的代码也不会产生耦合。

模型关系

对象概念

VO(View Object):视图对象,用于展示层,它的作用是把某个指定页面(或组件)的所有数据封装起来。DTO(Data Transfer Object)数据传输对象,分布式应用提供粗粒度的数据实体,也是一种数据传输协议,以减少分布式调用的次数,从而提高分布式调用的性能和降低网络负载,这里泛指用于展示层与服务层之间的数据传输对象。RPC 对外暴露的服务涉及对象 API 就是 DTO,如 JSF(京东 RPC 框架)、Dubbo。对比 VO:绝大多数应用场景下,VO 与 DTO 的属性值基本一致,但对于设计层面来说,概念上还是存在区别,DTO 代表服务层需要接收的数据和返回的数据,而 VO 代表展示层需要显示的数据。

DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体。DO 不是简单的 POJO,它具有领域业务逻辑。PO(Persistent Object):持久化对象。

对比 DO:DO 和 PO 在绝大部分情况下是一一对应的,但也存在区别,例如 DO 在某些场景下不需要进行显式的持久化,只驻留在静态内存。同样 PO 也可以没有对应的 DO,比如一对多表关系在领域模型层面不需要单独的领域对象。

下面是这些对象在系统架构中的分布:

Domain Primitive

Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有丰富行为和业务逻辑的 Value Object,DP 使用业务域中的原生语言,可以是业务域的最小组成部分、也可以构建复杂组合。Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。在项目中,散落在各个服务或工具类里面的代码,都可以抽出来放在 DP 里,成为 DP 自己的行为或属性。原则是:所有抽离出来的方法要做到无状态,比如原来是 static 的方法。如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP。因为 DP 也是一种 Object Value,本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。Domain Primitive 涉及三种手段:

让隐性的概念显性化(Make Implicit Concepts Explicit)通天塔活动类型就是一个简单的 int 类型,属于隐式概念,但活动类型包含了很多相关业务逻辑,比如类型名称,不同类型活动具有独特的 Icon,判断活动类型是否是判断等,我们把活动类型显性化,定义为一个 Value Object。

让隐性的上下文显性化(Make Implicit Context Explicit)当要实现一个功能或进行逻辑判断依赖多个概念时,可以把这些概念封装到一个独立地完整概念,也是一种 Object Value:

封装多对象行为(Encapsulate Multi-Object Behavior)常见推荐使用 Domain Primitive 的场景有:

有格式要求的 String:比如 Name,PhoneNumber,OrderNumber,ZipCode,Address 等。

限制的 Integer:比如 OrderId(>0),Percentage(0-100%),Quantity(>=0)等。

可枚举的 int:比如 Status(一般不用 Enum 因为反序列化问题)。

Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等。

复杂的数据结构:比如 Map等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为,如通天塔的活动 Map 类。

接口变得清晰可读,校验逻辑内聚,在接口边界外完成,无胶水代码,业务逻辑清晰可读,代码变得更容易测试,也更安全。

最后

DDD 不是一套框架,而是一种面向复杂问题的建模方法论和实践,所以在代码层面缺乏了足够的约束,导致 DDD 在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差。

而且 DDD 诸多实践在真正践行时面临很多挑战,

  • 首先是领域专家和技术人员在建模过程中要摒弃自己固有的专业背景和思维定式,专注于问题域,基于统一语言紧密沟通和协作,具有深度业务领域理解和洞察的领域专家和一个精通领域建模和架构设计的技术团队一样少见,都必须经过长时间学习和实践的。
  • 其次技术人员必须转变思维和架构习惯,软件系统最终交付的是业务价值,不是功能和技术方案,一切要以问题和业务为核心去建模和架构。

通天塔后端团队在高并发和高性能应用构建方面有着非常丰富的经验,但在 DDD 实践和享受到它的巨大价值层面我们还是刚起步,千里之行始于足下,我们正在迈出坚实一步,后续我们也会出 DDD 通天塔实践篇,讲述我们的经验和心得。

— 本文结束 —


● 漫谈设计模式在 Spring 框架中的良好实践

● 颠覆微服务认知:深入思考微服务的七个主流观点

● 人人都是 API 设计者

● 一文讲透微服务下如何保证事务的一致性

● 要黑盒测试微服务内部服务间调用,我该如何实现?



关注我,回复 「加群」 加入各种主题讨论群。



对「服务端思维」有期待,请在文末点个在看

喜欢这篇文章,欢迎转发、分享朋友圈


在看点这里
浏览 21
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报