在笔者学习 DDD 的过程中,大部分文章通常都是在谈 DDD 的概念,理论,诚然这些很重要,但 DDD 的读者大多还是习惯与传统开发的方式,而 DDD 的思想与传统开发模式大为不同,当大量的理论铺面而来的时候,难免觉得无从着力,本系列文章希望通过一个实际系统的 DDD 案例,让读者对 DDD 的落地有一定的认识,认识的同时也会产生新的疑问,带着这些疑问在回头去学习 DDD 的系统理论,相信能够对读者起到帮助。
DDD概览
此章节希望读者对DDD有一些基本概念,在本章中不会深入到具体概念的细节,在《实现领域驱动设计》一书中DDD每个概念背后都有一套详细的设计原则,后续文章中我们将结合编码的同时将一些概念与读者一起描述。
什么是领域驱动设计?
领域驱动设计目前被大量的提及,那么什么是领域驱动设计呢?笔者在刚开始接触时被这个问题纠结了很久,随着持续的学习,搜索大家对DDD的总结,发现DDD很难用一句话简单的描述清楚,让读者可以理解其含义。因此关于这个问题的解释我们就稍微繁琐一点,在领域驱动设计中,领域可以理解为业务,领域专家就是对业务很了解的人,比如你想要做一个在线车票的售票系统,那么平时我们看到的售票员可能就是领域专家,在比如你已经在一个业务上做了5年研发了,经历了各种需求的迭代,讨论,你懂得比新来的产品,业务还多,那么你有可能就是你们公司的领域专家。领域驱动设计的核心就是与领域专家一起通过领域建模的方式去设计的我们的软件程序。
- 那么领域如何驱动设计?或者说业务如何驱动软件设计?
单纯聊这个问题很奇怪,我们平时开发不都是业务驱动的吗?是的,但仔细的琢磨一下我们的开发过程,你会发现其中的问题。我们在和业务(领域)专家讨论时,我们是想着将需求如何映射到代码上,还是想着应该创建那些表,改那些表字段才能满足需求呢?我们在拿到一个产品原型,需求清单第一步是写代码还是创建数据表呢?大多数时候答案是后者,因此我们实际是将面向业务开发转换为了面向数据开发。
那么DDD如何解决这个问题呢,答案是领域模型,我个人认为领域模型的核心是通过模型承载和保存领域知识,并通过模型与代码的映射将这些领域知识保存在程序代码中。在传统的开发中,当业务被转换为一张张数据表时,丢失最多的就是领域知识。
DDD可以做什么
DDD主要分为两个部分,战略设计与战术设计,战略设计围绕微服务拆分,战术设计围绕微服务构建
DDD怎么做
- 领域专家与研发人员一起(研发人员可能就是领域专家),通过一系列的方式方法(DDD并没有明确说明用什么方法),划分出业务的边界,这个边界就是限界上下文,微服务可以以限界上下文指定微服务的拆分,但是微服务的拆分并不是说一定以限界上下文为边界,这里面还需要考虑其它因数,比如3个火枪手原则、两个披萨原则以及组织架构对微服务拆分的影响等。
- 研发人员通过领域模型,领域模型就是DDD中用于指定微服务实现的模型,保存领域知识,通过这种方式DDD通过领域模型围绕业务进⾏建模,并将模型与代码进⾏映射,业务调整影响代码的同时,代码也能直接的反映业务。
按照常规的编码⽅式,代码就不能直接反映业务了吗? 请参考贫血模型与充血模型
DDD领域模型
实体与值对象
- 实体的特征
- 唯一标识,对唯一性事物进行建模
- 包含了业务的关键行为,可以随着业务持续变化
- 修改时,因为有唯一标识,所以还是同一个实体
在上图中,订单就是一个实体,因为他有订单的唯一ID,通过它可以表示订单这个事务的唯一性,并且在订单的整个生命周期,随着业务订单也在不断的变化,创建订单到订单完成,订单状态在不断的变化,但是因为它们有唯一的订单ID,所以它们就是同一个实体。
- 值对象的特征
- 描述事物的某个特征,通常作为实体属性存在
- 创建后即不可变
- 修改时,用另一个值对象予以替换
在上图中,订单商品就是一个值对象,因为在订单语境下,商品就是订单的一个特征,同时订单中的商品在订单创建的那一刻就会被”快照”下来,如果商品的发生变化,比如价格从100元涨价到10000元,订单中的商品也不会同步去修改。
在此种业务语境下,订单商品就符合对值对象的描述,那么如果卖家修改订单中商品的价格怎么办呢,在DDD中通过覆盖的方式进行修改,而不是只修改一个价格属性。
除了订单商品外,收获地址也是一个值对象,那么收获地址可以是一个实体吗? 答案是可以的,当业务在收获地址管理的上下文语境里的时候,收获地址就是一个实体。
更多对实体特征的描述,可以参考《实现领域驱动设计》一书
领域服务
领域服务可以帮助我们分担实体的功能,承接部分业务逻辑,做一些实体不变处理的业务流程,它不是必须的。
在上图中,描述的是一个创建消息的领域服务,因为消息的实体中有用户的值对象,但是用户的信息通常在另一个限界上下文,也就是另一个微服务中,因此需要通过一些facade接口获取,如果把这些接口的调用防在领域实体
中就会导致实体过于臃肿,且也不必保持其独立性,因为它需要被类似于Spring这样的框架进行管理,依赖注入一些接口,因此通过领域服务进行辅助是一种很好的方式。
聚合
将实体和值对象在一致性边界之内组成聚合,使用聚合划分限界上下文(微服务)内部的边界,聚合根做为一种特殊的实体,用于管理聚合内部的实体与值对象,并将自身暴露给外部进行引用。
比如在上图中描述的是一个订单聚合,在这个聚合中,它里面有两个实体,一个是订单一个是退货退款协议,显然退货退款协议应该依托于订单,但是它也符合实体的特征,因此被定义为实体。在此情况下,订单实体就是此聚合的聚合根。
聚合的一致性边界
生命周期一致性
生命周期的一致性,聚合对外的生命周期保持一致,聚合根生命周期结束,聚合的内部所有对象的生命周期也都应该结束。
事务的一致性
事务的一致性,这里的事务指的是数据库事务,每个数据库事务指包含一个聚合,不应该有垮聚合的事务。
领域事件
领域事件表示领域中所发生的事情,通过领域事件可以实现微服务内的信息同步,同时也可以实现对外部系统的解耦。
如上图所示,聚合变更后创建领域事件,领域事件有两种方式进行发布。
- 与聚合事务一起进行存储,比如存储进一个本地事件表,在由事件转发器转发到消息队列,这样保证的事件不会丢失。
- 直接进行转发到消息队列,但是此时因为事件还未入口,因此需要在聚合事务与消息队列发布事件之间做XA的2PC事务提交,因为有2PC存在,通常性能不会太好。
除了向外部系统发布事件,限界上下文内部的多个聚合也可以通过一些本地事务发布器来进行事务的发布,比如Spring Event 或 EventBus等
资源库
资源库是保存聚合的地方,将聚合实例存放在资源库(Repository)中,之后再通过该资源库来获取相同的实例。
- Save: 聚合对象由Repository的实现,转换为存储所支持的数据结构进行持久化
- Find: 根据存储所支持的数据结构,由Repository的实现转换为聚合对象
应用服务
应用服务负责流程编排,它将要实现的功能委托给一个或多个领域对象来实现,本身只负责处理业务用例的执行顺序以及结果的拼装同时也可以在应用服务做些权限验证等工作。
DDD推荐的架构模式
本章我们来聊一聊DDD推荐的架构模式,这些架构模式用于指导服务内的具体实现,对于服务内的逻辑分层,职能角色,依赖关系都有现实的指导意义。
DDD分层
在一个典型的DDD分层架构中,分为用户界面层(Interfacce) , 应用层(Application), 领域层(Domain) ,基础设施层 (Infrastructure), 其中领域层是DDD分层架构中的核心,它是保存领域知识的地方。
分层架构的一个重要原则是:每层只能与位于其下方的层发生耦合。
在传统的DDD分层中,下图是他们的依赖关系。
如果读者没有使用过DDD可能对此理解不是很直观,可以将用户界面层想象为Controller,应用层与领域层想象为Service,基础设施层想象为Repository或者DAO,可能会好理解一些
可以看到,在传统的DDD分层架构中,基础层是被其它层所共同依赖的,它处于最底层,这可能导致重心偏移(想象一下在Service依赖DAO的场景),然而在DDD中领域层才是核心,因此要改变这种依赖。
如何改变这种依赖关系呢,在面向对象设计中有一种设计原则叫做依赖导致原则( Dependence Inversion Principle,DIP)。
DIP的定义为:
高层模块不应该依赖于底层模块,二者都应该依赖于抽象。
抽象不应该依赖于细节,细节应该依赖于抽象。
根据DIP改进以后的架构如下图所示。
改进后的DDD分层,将整个依赖过程反过来了,但实际上仅仅是反过来了这么简单吗?在DIP的理论中,高层模块与低层模块是不相互依赖的,他们都依赖于一个抽象,那么这么看来,模块之间就不在是一种强耦合的关系了。
比如,在DIP之前,领域层之间依赖于基础设施层。
改进后,他们后依赖于IUserRepository的抽象,抽象由基础层去实现,领域层并不关心如何实现。
由此各模块可以对内实现强内聚对外提供松耦合依赖。
六边形架构(端口适配器架构)
六边形架构,对于每种外界类型,都有一个适配器与之相对应。业务核心逻辑被包裹在内部,外界通过应用层API与内部进行交互,内部的实现无须关注外部的变化,更加聚焦。在这种架构下还可以轻易地开发用于测试的适配器。
同时六边形架构又名“端口适配器架构”, 这里的端口不一定指传统意义上的服务端口,可以理解为一种通讯方式,比如在一个服务中,我们可能会提供给用户浏览器的基于HTTP的通讯方式,提供给服务内部的基于RPC的通讯方式,以及基于MQ的通讯方式等,适配器指的是用于将端口输入转换为服务内部接口可以理解的输入。
刚才我们讨论的是外部向领域服务内部输入部分的端口+适配器模式,同时输出时也同样,比如当我们的要将领域对象进行存储时,我们知道有各种各样的存储系统,比如Mysql、ES、Mongo等,假如说我们可以抽象出一个适配器,用于适配不同的存储系统,那么我们就可以灵活的切换不同的存储介质,这对于我们开发测试,以及重构都是很有帮助的,而在DDD中这个抽象的适配器就资源库。
理解到这些以后,我们来看下六边形架构的整体架构。
在此中架构下,业务层被聚焦在内部的六边形,内部的六边形不关心外部如何运作,只关注与内部的业务实现,这也是DDD推崇的方式,研发人员应该更关注于业务的实现也就是领域层的工作,而不是
聚焦在技术的实现。结合分层架构的思想,外部的六边形可以理解为接口层与基础层,内部理解为应用层与领域层,内部通过DIP与外部解耦。
在《实现领域驱动设计》一书中,作者认为它是一种具有持久生命力的架构。
充血模型编码实践
本章我们将对通过《重构》一书中的案例,回顾贫血模型与充血模型,为后面的编码做知识储备,在DDD实践中,我们将大量用到充血模型的编码方式,如果你对贫血模型与充血模型已经了解了,可以跳过本章。
什么是贫血模型与充血模型?
回答这个问题,我们从《重构》一书中的一个影片租赁的案例,以及一个订单的开发场景,分别使用贫血模型与充血模型来实现,读者可以从中感受其差别理解它们的不同。
影片租赁场景
需要说明的是下面的代码基本与《重构》一书中的代码相同,但笔者省略了重构的各个代码优化环节,只展示了贫血模型与充血模型代码的不同。书中源代码,笔者也手写了一份实现,感兴趣可以通过以下链接点击查看。
需求描述
根据顾客租聘的影片打印出顾客消费金额与积分
- 积分规则
- 默认租聘积一分,如果是新片且租聘大于1天,在加一分
- 费用规则
- 普通片 ,租聘起始价2元,如果租聘时间大于2天,每天增加1.5元
- 新片 ,租聘价格等于租聘的天数
- 儿童片 ,租聘起始价1.5元,如果租聘时间大于3天,每天增加1.5元
基于贫血模型的实现
下面是影片 Movie 、租赁 Rental 两个贫血模型类,下面这样的代码在我们日常开发中是比较常见,简单来说它们就是只包含数据,不包含业务逻辑的类,从面向对象角度来说也违背了面向对象里面封装的设计原则。
面向对象封装:隐藏信息、保护数据,只暴露少量接口,提高代码的可维护性与易用性;
1 | arduino复制代码public class Movie { |
1 | csharp复制代码public class Rental { |
接着是我们的Customer类,Customer类的问题是里面包含了原本应该是Movie与Reatal的业务逻辑,给人感觉很重,Customer可以类别我们日常开发的XxxService,想想我们是不是在Service层中不断的堆砌业务逻辑。
1 | arduino复制代码public class Customer { |
最后我们运行主程序类,进行输出,得到下面结果,记住这个结果,我们会通过重新模型重构后,保证同样的输出。
1 | markdown复制代码张三的租聘记录 |
主程序类
1 | java复制代码public class Main { |
基于充血模型的实现
我们的类没有变化,只是类里面的实现发生了变化,接下来就逐一看看类的实现都发生了那些改变。
重构后影片 Movie 类
- 删除了不必要setXXX方法
- 增加了 getCharge 获取费用电影费用的方法,将原本 Customer 的逻辑交由Movie类实现。
注:Movie类还有优化空间,但不是本文的重点,读者感兴趣可以查看此链接
gitee.com/izhengyin/s…
1 | arduino复制代码public class Movie { |
重构后租赁 Rental 类
- 移除了部分不必要的 get / set 方法
- 增加一个 getPoint 方法,计算租赁积分,将原本 Customer 的逻辑交由获取积分的业务交由getPoint实现,但总积分的计算还是在Customer。
- 增加一个 getCharge 方法,具体调用Movie::getCharge传入租赁天数得到租赁的费用,因为在这个需求中主体是租赁
1 | csharp复制代码public class Rental { |
瘦身后的Customer
1 | arduino复制代码public class Customer { |
最后我们运行主程序类,得到同样的输出。
源码地址: gitee.com/izhengyin/d…
订单的场景
需求描述
- 创建订单
- 设置订单优惠
订单场景贫血模型实现
Order 类 , 只包含了属性的Getter,Setter方法
1 | arduino复制代码@Data |
OrderService ,根据订单创建中的业务逻辑,组装order数据对象,最后进行持久化
1 | scss复制代码 /** |
在此种方式下,核心业务逻辑散落在OrderService中,比如获取订单总额与订单可支付金额是非常重要的业务逻辑,同时对象数据逻辑一同混编,在此种模式下,代码不能够直接反映业务,也违背了面向对象的SRP原则。
设置优惠
1 | scss复制代码 /** |
贫血模型在设置折扣时因为需要考虑到折扣引发的支付总额的变化,因此还需要在从新的有意识的计算支付总额,因为面向数据开发需要时刻考虑数据的联动关系,在这种模式下忘记了修改某项关联数据的情况可能是时有发生的。
订单场景充血模型实现
Order 类,包含了业务关键属于以及行为,同时具有良好的封装性
1 | csharp复制代码/** |
OrderService , 仅仅负责流程的调度
1 | java复制代码 /** |
在此种模式下,Order类完成了业务逻辑的封装,OrderService仅负责业务逻辑与存储之间的流程编排,并不参与任何的业务逻辑,各模块间职责更明确。
设置优惠
1 | scss复制代码 |
在充血模型的模式下,只需设置具体的优惠金额,因为在Order类中已经封装了相关的计算逻辑,比如获取支付总额时,是实时通过优惠金额来计算的。
1 | ini复制代码 /** |
写到这里,可能读者会有疑问,文章都在讲充血模型的业务,那数据怎么进行持久化?
数据持久化时我们通过封装的 OrderRepository 来进行持久化操作,根据存储方式的不同提供不同的实现,以数据库举例,那么我们需要将Order转换为PO对象,也就是持久化对象,这时的持久化对象就是面向数据表的贫血模型对象。
比如下面的伪代码
1 | arduino复制代码public class OrderRepository { |
通过上面两种实现方式的对比,相信读者对两种模型已经有了明确的认识了,在贫血模型中,数据和业务逻辑是割裂的,而在充血模型中数据和业务是内聚的。
电商消息系统编码实践
编码实践部分需要涉及大量的源码信息,请根据下面的链接访问源码进行查阅
本文源码地址:gitee.com/izhengyin/d…
参考:
- 《重构,改善既有代码的设计》马丁·福勒(Martin Fowler)
- 《领域驱动设计:软件核心复杂性应对之道》(修订版)埃里克 埃文斯(Eric Evans)
- 《实现领域驱动设计》Vaughn.Vernon(沃恩.弗农)
- 《架构整洁之道》Robert C. Martin(罗伯特C.马丁)
- 《DDD 实战课》极客时间 , 欧创新
- Thoughworks 洞见技术博客,DDD 专栏 insights.thoughtworks.cn/tag/domain-…
- 微软 Azure 技术博客,云原生架构设计模式专栏 docs.microsoft.com/en-us/azure…
- github.com/citerus/ddd…
- github.com/e-commerce-…
- github.com/ouchuangxin…
本文转载自: 掘金