领域驱动设计(DDD)理论与方法
DDD 由来与优势
软件架构设计的真正目的是解决软件复杂度带来的问题,软件复杂度由来主要由三方面:高并发场景下的对软件高性能要求、业务场景对软件高可用要求、持续变化的业务以及业务扩张和增加需求对软件扩展性的要求,除此外,对低成本、安全、软件规模也一定程度上增加了软件设计的复杂度。
在解决每个复杂度维度上,分别有各自的应对解决方案:
在高性能方面,可以通过单机和集群两个维度提升系统性能:在单机方面通过多进程、多线程等技术解决单机高并发,在集群方面通过任务拆解、分解,将任务调度到每个 Worker 节点执行,从而进一步提升系统整体高并发与吞吐量
在高可用方面,可以通过冗余服务或机器节点方式来确保整体应用的高可用,常见的有计算和存储高可用(如 Mysql 主从集群),而高可用的状态决策是非常关键的一点,即是否由一个状态转移到另一个状态,常见决策方式有独裁、协商(如 redis 哨兵、主备切换)、民主(如 Paxos、Raft 等分布式一致性算法)
在扩展性方面,由于软件唯一不变就是变化这个基本定理存在,正确的预测变化、完美封装变化,本身就是一件复杂事情;在这点上,才有把变化封装起来,与稳定的不变内容进行隔离(分层、提升软件内聚性,降低耦合度),不变层通过接口去适配多变的情况(如文件系统的 VFS 适配多种文件格式,同时对用户提供标准化的操作接口场景)
在低成本、安全、规模方面,技术革命创新、功能和架构安全、软件规模量变引发质变(微服务就是典型例子),这些因素也增加了软件的复杂度。
然而我们往往忽略了在软件开发阶段,因为需求的理解不到位等导致的开发返工以及因为原班开发人员离职带来的交接文档的缺失,导致新人接手项目的高上手成本、高沟通成本和高维护成本。是否存在有效的方法能够在开发人员开始编码之前能解决这一问题呢?
领域驱动设计(Domain Driven Desing,简称 DDD)就是在可扩展性方面将复杂多变的业务排除在稳定不变的内核业务之外,从而在多变的环境中找到不变的部分,达到以不变应万变的目标。不仅如此,DDD 还能够以更优于静态文档的方式提供开发人员,业务人员所需要的业务知识,因为 DDD 在设计之初就要求业务人员特别是领域专家与开发人员通过双方统一的语言一起来协作设计系统,最终交付给业务人员能够“读”得懂的,符合其期望的产品,尽管这一愿望很激动人心,但实施过程并不顺利,但无论如何我们也应该坚持这一目标,就像 DDD 作者总结的那样,会给整个团队甚至公司带来的益处远超过因为学习 DDD 带来的成本,这些优势归结起来如是:
• 使领域专家和开发者在一起密切合作,这样开发出来的软件能够准确地传达业务规则。“准确传达业务规则”的意思是说,此时的软件就像是领域专家所开发出来的一样。
• 可以帮助业务人员自我提高。没有任何一个领域专家或者管理者敢说他对业务已经了如指掌了,业务知识也需要一个长期的学习过程。在 DDD 中,每个人都在学习,同时每个人又是知识的贡献者。
• 关键在于对知识的集中,因为这样可以确保软件知识并不只是掌握在少数人手中。
• 在领域专家、开发者和软件本身之间不存在“翻译”,意思是当大家都使用相同的语言进行交流时,每人都能听懂他人所说。
• 设计就是代码,代码就是设计。设计是关于软件如何工作的,最好的编码设计来自于多次试验,这得益于敏捷的发现过程。
• DDD 同时提供了战略设计和战术设计两种方式。战略设计帮助我们理解哪些投入是最重要的;哪些既有软件资产是可以重新拿来使用的;哪些人应该被加到团队中?战术设计则帮助我们创建 DDD 模型中各个部件。
就像其他高回报率的投入一样,DDD 需要我们在时间和精力上都有所投入。但是,考虑到我们在开发软件的过程中经常遇到的各种问题和挑战,这样的投入是值得的。
领域驱动设计分层架构
分层架构模式被认为是所有架构的始祖。在自然界中,大到宇宙天体,小到原子的运行模式。在人类社会中,大到国际组织,小到团体的组织模式,都采用层次结构。在软件架构中,从早期的网络七层协议,操作系统的内核架构到广泛使用的 MVC 软件开发架构,都在使用分层架构,可见分层模式经久不衰。 分层架构的好处是显而易见的,首先,由于层间松散的耦合关系,使得我们可以专注于本层的设计,而不必关心其他层的设计,也不必担心自己的设计会影响其它层,对提高软件质量大有裨益。其次,分层架构使得程序结构清晰,升级和维护都变得十分容易,更改某层的具体实现代码,只要本层的接口保持稳定,其他层可以不必修改。即使本层的接口发生变化,也只影响相邻的上层,修改工作量小且错误可以控制,不会带来意外的风险。 分层架构的一个重要原则是每层只能与位于其下方的层发生耦合。分层架构可以简单分为两种,即严格分层架构和松散分层架构。在严格分层架构中,某层只能与位于其直接下方的层发生耦合,而在松散分层架构中,则允许某层与它的任意下方层发生耦合。
下图 A 中所示为一个典型的 DDD 系统所采用的传统分层架构,其中核心域只位于架构中的其中一层,其上为用户界面层(User Interface)和应用层(Application Layer),其下是基础设施层(Infrastructure Layer)。
User Interface 为用户界面层(或表示层),负责向用户显示信息和解释命令,业务逻辑仅仅是有关输入参数验证等的验证等,不同于领域层的业务逻辑。这里指的用户可以是另一个计算机系统,不一定是使用用户界面的人。
Application 为应用层,定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其它系统的应用层进行交互的必要渠道。应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作。 当需要创建新的聚合时,应用服务应该使用工厂或聚合的构造函数来实例化对象,然后采用资源库对其进行持久化。应用服务还可以调用领域服务来完成和领域相关的任务操作,但此时的操作应该是无状态的。当领域模型用于发布领域事件时,应用层可以将订阅方注册到任意数量的事件上,这样的好处是可以对事件进行存储和转发。同时,领域模型只需要关注自己的核心逻辑;领域事件发布器也可以保持轻量化,而不用依赖于消息机制的基础设施。
Domain 为领域层(或模型层),负责表达业务概念,业务状态信息以及业务规则。尽管保存业务状态的技术细节是由基础设施层实现的,但是反映业务情况的状态是由本层控制并且使用的。领域层是业务软件的核心,领域模型位于这一层。
Infrastructure 层为基础设施层,向其他层提供通用的技术能力:为应用层传递消息,为领域层提供持久化机制,为用户界面层绘制屏幕组件等,其中消息包含了消息中间件所发的消息、基本的电子邮件(SMTP)或者文本消息(SMS)等
但是分层架构也不是没有缺点,除了引入分层导致层之间交互带来的性能开销之外,也存在一些开发上的问题,比如:
1)容易导致分层划分不当,比如领域逻辑大量渗透到应用服务层,导致领域模型变成贫血模型,尽管有些最佳实践和其他解决方案能够解决上述问题,但也增大了新方案带来的学习成本
2)容易违反分层架构原则,比如在 DDD 设计中,如果领域层使用了基础设施层的实体对象持久化机制,那么基础设施层将反向引用领域层的业务对象,违反了分层架构原则。即使领域层使用基础设施层的持久化库接口 Repository,领域模型仍然存在外部依赖,无法保证业务逻辑的顺利执行,也给测试开发带来了困难,理想中的领域模型应该是纯业务、基于内存运行的业务逻辑封装。
对第一个问题,可以通过 DCI 来解决,详见下文 #DDD 与 DCI 部分。对第二个问题可以通过依赖倒置原则(Dependency Inversion Principle,DIP)来解决,详见下文 #DDD 与六边形架构部分
DDD 与 DCI
面向对象建模面临的一个棘手问题是数据边界和行为边界往往不一致。遵循模块化的思想,我们通过类将行为和其紧密耦合的数据封装在一起。但是在复杂的业务场景下,行为往往跨越多个领域对象,这样的行为如果放在某一个对象中必然会导致别的对象需要向该对象暴漏其内部状态。所以面向对象发展的后来,领域建模出现两种派别之争,一种倾向于将跨越多个领域对象的行为建模在领域服务中。如果这种做法使用过度,则会导致领域对象变成只提供一堆 get 方法的哑对象,这种建模结果被称之为贫血模型。而另一派则坚定地认为方法应该属于领域对象,所以所有的业务行为仍然被放在领域对象中,这样导致领域对象随着支持的业务场景变多而变成上帝类,而且类内部方法的抽象层次很难一致。另外由于行为边界很难恰当,导致对象之间数据访问关系也比较复杂,这种建模结果被称之为充血模型。
比如以字处理器中拼音检查为例,拼音检查这个行为功能放在哪里?是 dictionary 还是一个全局的拼音检查器呢?无论放在哪个对象内部,都显得和这个对象内聚性不高,由此带来多个调用拼音检查行为对象之间的协作耦合,在 DDD 中,可以使用领服务来实现;在 SOA 看来,拼音检查属于一种规则,可由规则引擎实现,通过服务整合流程和规则。
再比如银行转账,如果将转账的业务逻辑这个程序算法整合到账户 Account 数据模型中,因为转账涉及到其他账户和 Money 数据对象,那么就将因为行为操作带来的耦合带到当前账户对象中了;当然,如果程序算法可以精化细分,那么我们把它切分到几个部分,封装成几个对象的方法,但是这些方法都是无法表达算法逻辑高内聚性的琐碎小方法。
DCI 架构则不同于 DDD 这种有些折扣的处理方法,而是思路复位,重新考虑架构。DCI 架构是 James O. Coplien 和 Trygve Reenskaug 在 2009 年发表的论文《Architecture: A New Vision of Object-Oriented Programming》(以下简称 DCI Architecture)中引入,从 OO 思想根源来深入解剖 DCI 对传统面向对象的颠覆。DCI 从对象数据 object Data, 对象之间的协作 the Collaborations between objects, 和表达需求用例中操作者角色之间的交互这三个出发点来考虑,认为数据模型 data model, 角色模型 role model 和协作交互模型 collaboration model(算法属于协作交互模型) 应该是程序语言核心关心点,应该从语言层面来关注,DCI 各部分组成及含义如下:
Data:描述系统有哪些领域概念及其之间的关系,该层专注于领域对象的确立和这些对象的生命周期管理及关系,让程序员站在对象的角度思考系统
Context:Context 通过无状态绑定 role 来完成业务逻辑,提供理解软件业务流程的切入点和主线
Interaction: 主要体现在对 role 的建模,role 是每个 context 中复杂的业务逻辑的真正执行者。role 所做的是对行为进行建模,它连接了 context 和领域对象。由于系统的行为是复杂且多变的,role 使得系统将稳定的领域模型层和多变的系统行为层进行了分离,由 role 专注于对系统行为进行建模。
DCI 目前广泛被看作是对 DDD 的一种发展和补充,用在基于面向对象的领域建模上。显式的对 role 进行建模,解决了面向对象建模中的充血模型和贫血模型之争。DCI 通过显式的用 role 对行为进行建模,同时让 role 在 context 中可以和对应的领域对象进行绑定(cast),从而既解决了数据边界和行为边界不一致的问题,也解决了领域对象中数据和行为高内聚低耦合的问题。跟 DDD 将对象和行为静态结合不同,DCI 结合上下文将 role 代表的行为和对象动态结合。
DDD 与六边形架构
依赖倒置原则由 Robert C. Martin 提出,正式定义为:
根据该定义,DDD 分层架构中的低层组件应该依赖于高层组件提供的接口,即无论高层还是低层都依赖于抽象,我们将原来位于底层的基础设施层调整到最高层,然后依赖下面各层提供的抽象接口,那么就解决了之前基础设施层反向依赖领域层的问题,同时原来的领域层位于最底层,不再包含持久化等,关于可测试性问题也迎刃而解,调整后的层次结构如上图 B 所示。
上面只是采用依赖倒置原则实现的一种架构表现,因为依赖倒置原则要求所有层次都依赖抽象,这就意味着层次可以“平等相处”,已经失去存在的必要,这时的层次架构如同在依赖倒置原则的“重力效应”下瞬间坍缩一样,形成下图所示的一种具有对称性特征的架构风格,即六边形架构:
六边形架构也叫端口和适配器架构,是 Alistair Cockburn 于 2005 年在其博客中提出。他对该架构的一句话定义是:应用应能平等地被用户、其他程序、自动化测试或脚本驱动,也可以独立于其最终的运行时设备和数据库进行开发和测试。该架构由端口和适配器组成,所谓端口是应用的入口和出口,在许多语言中,它以接口的形式存在。例如以取消订单为例,“发送订单取消通知”可以被认为是一个出口端口,订单取消的业务逻辑决定了何时调用该端口,订单信息决定了端口的输入,而端口为预订流程屏蔽了通知发送方式的实现细节。
而适配器分为两种,主适配器(别名 Driving Adapter)代表用户如何使用应用,从技术上来说,它们接收用户输入,调用端口并返回输出。Rest API 是目前最常见的应用使用方式,以取消订单为例,该适配器实现 Rest API 的 Endpoint,并调用入口端口 CancelOrderService。同一个端口可能被多种适配器调用,例如 CancelOrderService 也可能会被实现消息协议的 Driving Adapter 调用以便异步取消订单。
次适配器(别名 Driven Adapter)实现应用的出口端口,向外部工具执行操作,例如
向 MySQL 执行 SQL,存储订单
使用 Elasticsearch 的 API 搜索产品
使用邮件/短信发送订单取消通知
若将其可视化,Driving Adapter 和 Driven Adapter 基于端口围绕着应用和域形成左右结构:
其中值得注意的是,中间核心区的业务逻辑是由 Application 和 Domain 组成,即由分层架构中的应用层和领域层合并而来。图例中的 Controller、Listener 和 Template 是北向适配器(Driving Adapter),JPARepository、EventPublisher、DomainService 是南向适配器(Driven Adapter),业务逻辑核心区中的不同 Application Service 组成不同的端口。如果将六边形架构和传统的分层架构做个对比,那就是:
领域驱动设计要素
在 DDD 中,软件的核心是其为客户解决领域相关的问题的能力。这里的领域,就是指软件系统要解决的实际问题相关的东西的集合。例如:为一个电子商务公司开发一个电商系统,我们就需要围绕这个盈利模式的运营方式、业务规则,比如如何进货,如何促销,如何物流等等了解这个电子商务公司的盈利模式,所有和业务相关的东西都属于领域。领域分为问题域和解决方案域两部分。
为了分解问题域的复杂度,问题域又会被拆解为多个子域,每个子域都要明确待解决的业务问题和业务流程,以及通过解决业务问题为企业带来了什么样的业务价值(这个是因,业务流程和要解决的业务问题是果)。
在清晰的定义子域后,我们就可以建立通用语言来提取该子域的领域知识,并基于通用语言为解决问题建立领域模型。领域模型是关于某个特定业务领域的软件模型。通常,领域模型通过对象模型来实现,这些对象同时包含了数据和行为,并且表达了准确的业务含义。
一个领域模型会存在于一个限界上下文中。限界上下文在 DDD 中用来定义模型的适用范围、模型的用途、以及在何处保持一致,限界上下文会让团队明确模型的职责边界是什么。同时,通用语言被限定在限界上下文中;限界上下文提供了一个语义边界,在每个限界上下文内通用语言的每个词汇必须和领域概念一一对应。理想条件下,子域和限界上下文是一一对应。但是子域划分的粒度,遗留系统的现状,语言的歧义,团队结构等子域和限界上下文对应可能是 1:N 或者 N:N 的。通过限界上下文间的映射,上下文中的多个模型会协作以满足系统需求。我们也可以了解在不同上下文中的同名词汇是否存在关系,存在什么样的关系。对通用语言而言,子域解释了通用语言和现实世界业务活动的关系;限界上下文提供了一个语义边界,来保持通用语言和领域概念的一一对应关系;上下文映射则提供了不同限界上下中的通用语言的转换关系。
DDD 领域驱动设计通常会包含战略设计和战术设计两部分:
战略设计:重业务建模,以业务视角,拆分领域,通过事件风暴(从发散到收敛过程),梳理业务并构建领域模型,这块过程会涉及业务人员、产品人员、架构师等多方参与
战术设计:重落地实现,以构建的领域模型,建立了领域模型的边界与上下文,也就确认了微服务的边界,这个过程会涉及架构师、技术人员参与
下面的图展示了 DDD 设计开发的一般步骤和涉及到的战略设计和战术设计相关的概念与要素:
原型与 4 色原型建模法
在接手一个新的业务需求,首先要明确业务边界和要解决的问题,然后通过一种方法将业务活动涉及到的各种要素进行分解,抽象出原型,然后描述各原型之间的协作方式,通过 UML 类图的形式可视化呈现出来。最后跟业务人员和领域专家一起协作完善,保证最后开发的系统能体现业务方的需求。
原型一词来自于希腊语 archetypo (arcetupo), 意味着 "original pattern." (原始模式)。 比如英雄这个原型在美国或在中国看上去可能不一样,但是英雄就是英雄,我们还是能够很快地总结出英雄的一些特点。 因为原型是人类组织、总结和概括客观世界的基本概念,我们完全有理由在软件开发领域应用这些概念。
OO 软件系统是对业务领域(business domain)的思考抽象并进行管理操作,注意业务领域这个概念,我们相信应该能在业务领域中发现原型,或者在软件系统;或者这些系统模型中。我们称这种原型类型为业务原型(business archetype)
一个业务原型应该是一个在业务领域或商业软件系统持续发生并且普遍存在的最初级的事物。参与方 Party 是一个业务原型例子,一个 Party 代表可标识的、可定位的单元或单位,这些单位有正常的状态。通常一个 Party 用来表示某个人或某个组织。所有商业系统都或多或少有 Party 概念。
原型之间是相互交互的,Party, Product 和 Order 是每个虚拟商业系统的基本概念,在这个商业系统中,你可以卖产品或服务。我们将这些原型之间的协作看成是业务原型模式(business archetype patterns):它是在业务领域或商业软件系统持续发生并且普遍存在业务原型之间的协作。
四色原型是诞生于 90 年代,被广泛使用的一种系统分析方法,在域建模阶段,将 4 种不同的业务原型按照不同的颜色标出。这 4 种原型分别为:
时刻-时段原型(Moment-Interval Archetype,简称 MI):一段时间内发生的业务,包括与业务相关的数据以及行为。数据及行为是可以追踪的,如卖东西是在某个时刻发生的,它有发生日期和时间等相关记录信息。时刻-时段原型使用粉红颜色表示。
角色原型(Role Archetype):任何一个系统都需要人或某个组织介入运行,例如论坛系统需要注册者角色发言;销售订单需要业务员角色制定等。角色模型使用黄色标识。
参与方-地点-物品原型(Part-Place-Thing Archetype,简称 PPT):表示参与扮演不同角色的参与方或地点或事物。Party 表示有自己正常的状态并且能够自主控制自己的一些行为,如人或组织, place, or thing 表示没有自主行为,如某个地方或某个事情。Part-Place-Thing 都可以成为角色,也就是通过扮演角色参与活动。如商品可以是售卖中的商品也可以是使用中的商品,可以在不同场景扮演不同的角色。参与方-地点-物品原型使用绿色表示。
描述原型(Description Archetype,简称 DESC):Desc 是对 PPT 的抽象概念,它是 PPT 的特性的总结,每一个 PPT 都属于一个(种)Desc。比如每一台出厂的车辆都属于车辆种类的一辆,每一个具体的人都属于人种的一员。PPT 原型是对单个个体的抽象,比如一辆具体的轿车可以用车辆编号、车辆颜色、车辆型号等描述,一个人可以用身份证号码、身高、体重、喜好来描述。而对车辆种类的抽象描述为生产厂家、生产批号、适用颜色等等,当然轿车种类通常还可以划分为微型轿车、普通轿车、高级轿车等。同理对于人种包含欧罗巴人种(又称白色人种或高加索人种或欧亚人种)、蒙古人种(又称黄色人种或亚美人种)、尼格罗人种(又称黑色人种或赤道人种),对于人种的描述可以是肤色、地狱分布、主要使用语言等。描述原型用蓝色表示。
4 色原型是对物理世界的建模方式,如果以 PPT 为中心,那么以上概念可以总结为:什么东西(人或事物)在什么地方通过什么方式(角色)在什么时刻进行操作,类似于 5W2H 分析法。4 色原型的一个直接好处是帮助我们快速地梳理不同的业务原型,通过类图的方式绘画不同原型及其之间的关系,在实际建模过程中可以采取以下步骤分辨不同原型:
第一:它是不是依赖时间长瞬间或一段短时间存在的,是不是业务需求需要跟踪记录的对象?如果是,它就是 MI 原型。
第二:它是不是角色呢?如果是,就属于黄色 Role 原型。
第三:它是不是属于一种种类性质对象,或者代表一组可以反复使用的概念,如果是,它就是蓝色 Description 原型。
第四:它是某人或组织?或者是某个地方或者某个东西?那它就是绿色的 PPT 原型。
下文通过实际案例结合 5W2H 分析法和四色原型法来描述 DDD 建模过程。
DDD 建模案例
燃气抄表计费场景每月末,燃气公司制定抄表计划并批量生成抄表任务,抄表任务通过工单的形式下发到抄表人员到客户现场抄表,抄表完成之后给客户应收账单,客户可以现场缴费或者延后通过在线自助缴费。下面以此案例描述建模步骤。
1 描述业务场景
用 5W2H 进行分析:用户(WHO)在什么环境(WHERE)下遇到什么时机(WHEN)因为什么(WHY)产生什么目标(WHAT),继而通过什么方法(HOW)去达成目标。大部分场景不需要考虑 HOW MUCH。
通过本案例的分析,可以得到以下需求场景清单:
2 梳理业务流程
在得到场景清单后,我们把各个场景串联起来用业务流程的方式表达出来:
3 寻找时标性对象
时标性对象对应到时刻-时段原型,是我们关心的可追溯的业务事件。通过将业务流程分解到业务过程的最小粒度来寻找,然后绘制以下格式的表格:
接着将这些时标性对象(用红色标记)的关系描述出来,就得到了领域模型的骨干:
4 寻找时标对象周围的“人、地、物”
在“时标”对象周围的用绿色所表示的“人、地、物”概念,如下图所示:
5 抽象“角色”
在上图中插入用黄色所表示的“角色”概念,如下图所示:
6 补充“描述”信息
在上图中插入用蓝色所表示的“描述”概念,描述对象包括“人、地、物”和时标对象,如下图所示:
7 划分限界上下文
在本案例中,抄表缴费属于核心业务,将业务过程中的时标对象都划分到核心子域,但核心子域解决的问题有所不同,比如抄表计划和抄表任务指派属于抄表前期规划阶段,参与人员同其他阶段都不同,因此将其划分到计划上下文。同样,抄表记录属于抄表的实施阶段,划分到抄表上下文,缴费属于抄表后期阶段,划分到缴费上下文,如下图:
8 确定聚合及聚合根
在每个上下文中,对时标对象中的概念按照相近原则划分为不同的组,形成新的聚合,然后选出聚合根作为与外界交互的代表。比如抄表时标对象产出的核心数据是待缴费的应收账单,关联对象涉及到抄表员、客户和表具,而其他暂时无法分组的概念或属性独立为值对象,待需求需要的时候进一步处理。浅绿色代表聚合根,下图所示:
同理,继续寻找其他上下文中的聚合和聚合根,如缴费和支付:
注意上图中支付和缴费、抄表都涉及到账单聚合根,但是因为账单关注的点有所区别,比如抄表关心的是应收账单,缴费关心的是实收账单,支付账单关心的是无业务含义的交易流水和交易账号,不同关注点通过限界上下文隔离。
9 优化调整子域划分
通过聚合的过程我们发现,客户和表具在多个上下文中共用,是可以将其独立出来,作为共享上下文,划分到通用子域中。另外,在缴费环节,因为支付通常作为基础功能,支撑着缴费等支付相关业务,因此也划分到独立的支撑子域,调整后的子域限界上下文如图,其中虚线椭圆标识的客户、表具属于共享子域:
作者:闻乃松
原文链接:https://mp.weixin.qq.com/s?__biz=MzI3ODU3MzQ2Ng==&mid=2247484331&idx=1&sn=7da3e9a6f2e7acd5dd60eae574575dc9
如果觉得本文对你有帮助,可以转发关注支持一下
评论