代码结构
DDD并没有给出标准的代码模型,不同的人可能会有不同理解。
按DDD分层架构的分层职责定义,在代码模型里分别为用户接口层、应用层、领域层和基础层,建立了 interfaces、application、domain 和 infrastructure 四个一级目录。
1 DDD分层架构
1.1 分层架构的基本原则
每层只与位于其下方的层发生耦合。
1.2 分层架构的分类
- 严格分层架构(Strict Layers Architecture)
某层只能与其直接下层耦合,即我的奴隶的奴隶,不是我的奴隶。 - 松散分层架构(Relaxed Layers Architecture)
允许任意上层与任意下层耦合。由于用户接口层和应用服务通常需要与基础设施打交道,许多系统都是该架构。
较低层有时也可与较高层耦合,但只限于采用观察者 (Observer)模式或者调停者(Mediator)模式场景。
较低层绝不能直接访问较高层。例如,在使用调停者模式时,较高层可能实现了较低层的接口,然后将实现对象作为参数传递到较低层。当较低层调用该实现时, 它并不知道实现出自何处。
1.3 分层架构演进
1.3.1 传统四层架构
将领域模型和业务逻辑分离出来,并减少对基础设施、用户界面甚至应用层逻辑的依赖,因为它们不属业务逻辑。将一个夏杂的系统分为不同的层,每层都应该具有良好的内聚性,并且只依赖于比其自身更低的层。
传统分层架构的基础设施层位于底层,持久化和消息机制便位于该层。
这里的消息包含
- MQ消息
- SMTP
- 文本消息(SMS)
可将基础设施层中所有组件看作应用程序的低层服务,较高层与该层发生耦合以复用技术基础设施。即便如此,依然应避免核心的领域模型对象与基础设施层直接耦合
。
1.3.2 改良版四层架构
传统架构的缺陷
DDD初创开发团队发现,将基础设施层放在最底层存在缺点,比如此时领域层中的一些技术实现就很困难:
- 违背分层架构的基本原则
- 难以编写测试用例
何解?
使用依赖反转设计原则:低层服务(如基础设施层)应依赖高层组件(比如用户界面层、应用层和领域层)所提供的接口。
应用依赖反转原则
- 依赖反转原则后的分层方式:基础设施层在最上方,可实现所有其他层中定义的接口
依赖反转原则真的可以支持所有层吗?
有人认为依赖反转原则中只存在两层:最上方和最下方,上层实现下层定义的抽象接口。因此上图的基础设施层将位于最上方,而用户接口层、应用层和领域层应作同层且都位于下方。对此大家可保留自己意见。
2 各层职责
2.1 Interfaces(用户接口层)
一般包括用户接口、Web 服务等。
只处理用户显示和用户请求,不应包含领域或业务逻辑。
有人认为,既然用户接口需验证用户输入,就无可避免应该包含业务逻辑。事实上,用户接口所进行的验证和对领域模型的验证不同:对那些粗制滥造且只面向领域模型的验证行为,应该予以限制。
如果用户接口使用了领域模型中的对象,那么此时领域对象仅限于数据渲染展现。在采用这种方式时,可使用展现模型对用户接口与领域对象进行解耦。
由于用户可能是人,也可能是其他系统,有时用户接口层将采用开放主机服务的方式向外提供API。
用户接口层是应用层的直接用户。
用户接口层在于前后端调用的适配。若你的微服务要提供服务给很多外部应用,而对每个外部应用的入参出参都不同,你不可能开发一堆一对一的应用服务,这时Facade接口就起到了很好的作用,包括DO和DTO对象的组装和转换。
由于主要负责接入各种终端,所以该层有人也叫接入层。
实际开发中,我们都会感受到该层依附于应用层存在。随前后端分离,Restful API 流行,对简单的系统来说该层越来越弱化。对于有终端接入的系统来说,该层并不简单,需要处理各种协议适配:XMPP、websocket、MQTT 等。
复杂度不高时,我们往往把该层和应用层合并部署,主要凭开发经验和理解程度来决定。
存放用户接口层与前端交互、展现数据相关的代码。
前端应用通过这层接口,向应用服务获取展现所需的数据。
该层主要用来处理用户发送的Restful请求,解析用户输入的配置文件,并将数据传递给应用层。
数据的组装、数据传输格式以及Facade接口等代码都会放在这一层目录里。
封装应用服务和对外暴露接口。
特点
- 关心视图和对外的服务,Restful、页面渲染、websocket、XMPP 连接等
- 如果没有多种用户端接入方式,可以和应用层合并
- 对应到分布式系统中的网关、BFF、前台等概念
- 只产生接入异常,例如数据校验,对应 HTTP 状态码 400、415 等
- 一个应用可以有多个接入层
- 接入层做和业务规则无关的 bean validation 验证
- 准单体系统下,按照连接方式分包
该层指的是服务端用于适配端侧的部分,而非端侧本身。因为该层本就依赖应用层,无人使用接口在这里做依赖倒置,所有又被称作主动适配。
1.1 细分结构
- assembler、dto 和 façade
facade
提供较粗粒度的调用接口,将用户请求委派给一个或多个应用服务进行处理。比如调用应用层创建用户的方法。
dto
数据传输的载体,内部不存在任何业务逻辑,可以通过DTO把内部的领域对象与外界隔离。
比如接收请求传入的数据CustomerDTO。
不同的对象在不同的层转换。用户接口层DTO和DO转换,应用层主要是DO,调外部微服务的服务的时候应用层有dto和do的转换。领域层与基础层之间,在基础层有DO和PO的转换。
在接口层定义DTO对象。数据可能来源于多个DO对象。
assembler
实现DTO与DO间的相互转换和数据交换。
一般assembler与dto一同出现。比如创建用户时,将CustomerDTO转换为CustomerEntity。你可以在用户接口层创建DTO类和assembler类。在assembler类里完成映射。
2.2 应用层
特点
- 关心处理完一个完整的业务
- 该层只负责业务编排,对象转换,实际业务逻辑由领域层完成
- 不关心【请求从何处来】,但是关心【谁来、做什么、有没有权限做】
即复制安全认证、权限校验 - 集成不同的领域服务解决问题
应用层位于领域层之上,因为领域层包含多个聚合,所以它可协调多个聚合服务和领域对象完成服务编排和组合,协作完成业务。 - 最终一致性(最终一致性对业务有侵入)事务放到这层
- 对应到分布式系统中的中台等概念
- 方法级别的功能权限控制放到这层
- 只产应用异常,对应 HTTP 状态码 403、401
- 准单体系统下,按照应用划分模块
主要包含应用服务,理论上不应有业务规则或逻辑,而主要是面向用例和流程相关的操作。
- 应用层也是微服务间的交互通道,它可调用其它微服务,完成微服务间的服务组合和编排。
开发设计时,不要将本该放在领域层的业务逻辑放到应用层。庞大的应用层会使领域模型失焦,时间一长,微服务就会退化为MVC。
应用服务是在应用层,负责
- 服务的组合、编排、转发、转换和传递,处理业务用例的执行顺序以及结果的拼装,以粗粒度服务通过API网关发布到前端
- 发送或订阅领域事件
应用层代码目录结构
存放应用层服务组合和编排相关的代码。
应用服务向下基于
- 微服务内的领域服务,或
- 外部微服务的应用服务
完成服务的编排和组合
向上为用户接口层提供各种应用数据展现支持服务。
应用服务和事件等代码会放在这层目录。
Event(事件)
主要存放事件相关代码。包括两子目录:
publish
主要存放事件发布相关代码。比如发布用户创建事件给其它微服务。
subscribe
主要存放事件订阅相关代码(事件处理相关的核心业务逻辑在领域层实现)。
虽然应用层和领域层都可进行事件的发布和处理,但为实现事件的统一管理,推荐将微服务内所有事件的发布和订阅处理都统一放到应用层,事件相关的核心业务逻辑实现放在领域层。通过应用层调用领域层服务,来实现完整的事件发布和订阅处理流程。
Service(应用服务)
应用服务会对多个领域服务或外部应用服务进行封装、编排和组合,对外提供粗粒度的服务。应用服务主要实现服务组合和编排,是一段独立的业务逻辑。
可以将所有应用服务放在一个应用服务类里,也可以把一个应用服务设计为一个应用服务类,以防应用服务类代码量过大。
比如内部服务->创建用户;外部服务->创建日志。
2.3 领域层
主要包含聚合根、实体、值对象、领域服务等领域模型中的领域对象。
实现核心业务逻辑,通过各种校验保证业务正确性。领域层主要体现领域模型的业务能力,它用来表达业务概念、业务状态和业务规则。
领域模型的业务逻辑主要由实体和领域服务实现:
- 实体采用充血模型 实现所有与之相关的业务功能。
实体和领域服务在实现业务逻辑上不是同级,当领域中的某些功能,单一实体或值对象无法实现,就会用到领域服务,它可组合聚合内的多个实体或值对象,实现复杂业务逻辑。
3 Domain(领域层)
存放领域层核心业务逻辑相关的代码。
可包含多个聚合代码包,共同实现领域模型的核心业务逻辑。聚合以聚合内的实体、方法、领域服务和事件等代码会放在该层目录。
领域层包括一个或多个聚合的实体类、事件实体类、领域服务以及工厂、仓储相关代码。一个聚合对应一个聚合代码目录,聚合之间在代码上完全隔离,聚合之间通过应用层协调。
Domain 由一或多个聚合包构成,共同实现领域模型的核心业务逻辑。
聚合内的代码模型是标准和统一的,包括:entity、event、repository、service 子目录
Aggregate(聚合)
聚合软件包的根目录,可根据实际项目的聚合名称命名,比如权限聚合。在聚合内定义聚合根、实体和值对象以及领域服务之间的关系和边界。聚合内实现高内聚的业务逻辑,它的代码可以独立拆分为微服务。
以聚合为单位的代码放在一个包里的主要是为业务内聚,更是为以后微服务之间聚合的重组。聚合之间清晰的代码边界,可让你轻松地实现以聚合为单位的微服务重组。
实例
比如进入用户聚合目录下(如CustomerAggregate)。
假设这样一个场景,主播账户作为一个聚合,优惠券模块作为一个聚合。那主播选券的命令属于主播账户聚合。然后主播账户里的优惠券就是这个聚合里的值对象。
如果有多个聚合, 比如聚合根A和聚合根B, 从业务的角度讲,可以接受AB间数据的最终一致性,但从数据展示的角度考虑, A和B是有强关联性的,也就是说在页面上,他们总是一起在页面的某部分出现, 那可以分别调两个聚合的领域服务,然后将两个聚合根的DO对象转换为一个DTO,就可以给前端提供包含两个聚合数据的数据服务了。
细分结构
Entity(实体)
存放聚合根、实体、值对象以及工厂模式(Factory,工厂模式主要是实现复杂聚合的实体的数据初始化。如果实体太多,聚合根处理起来会很复杂,通过工厂一次初始化)相关代码。实体类采用充血模型,同一实体相关的业务逻辑都在实体类代码中实现。跨实体的业务逻辑代码在领域服务中实现。比如用户聚合根。
Event(事件)
存放事件实体以及与事件活动相关的业务逻辑代码。比如创建用户的事件。
Service(领域服务)
存放领域服务代码。一个领域服务是多个实体组合出来的一段业务逻辑。你可以将聚合内所有领域服务都放在一个领域服务类中,你也可以把每一个领域服务设计为一个类。如果领域服务内的业务逻辑相对复杂,我建议你将一个领域服务设计为一个领域服务类,避免由于所有领域服务代码都放在一个领域服务类中,而出现代码臃肿的问题。领域服务封装多个实体或方法后向上层提供应用服务调用。比如具体的创建用户逻辑,比如用户是否重复校验,分配初始密码等。
Repository(仓储)
存放所在聚合的查询或持久化领域对象的代码,通常包括仓储接口和仓储实现方法。为了方便聚合的拆分和组合,设定原则:一个聚合对应一个仓储。比如将用户信息保存到数据库。
按DDD分层架构,仓储实现本应属基础层代码,但为在微服务架构演进时,保证代码拆分和重组的便利性,把聚合仓储实现的代码放到聚合包内。这样,如果需求或设计发生变化导致聚合需要拆分或重组,就可将包括核心业务逻辑和仓储代码的聚合包整体迁移,轻松实现微服务架构演进。
2.4 基础层
为其它各层提供通用技术基础服务:
- 三方工具
- 驱动
- MQ
- API网关
- 文件
- 缓存
- DB
最常用的
基础层包含基础服务,它采用依赖反转,封装基础资源服务,实现应用层、领域层与基础层解耦。
MVC架构由于上层应用对DB强耦合,很多公司在架构演进最怕换DB,一旦更换,可能需重写一堆代码。
但采用依赖反转,应用层即可通过解耦保持独立核心业务逻辑。当DB变更,只需更换DB基础服务。
4 Infrastructure(基础层)
主要存放基础资源服务相关的代码,为其它各层提供的通用技术能力、三方软件包、数据库服务、配置和基础资源服务的代码都会放在这一层目录里。
Infrastructure 的代码目录结构有:config 和 util 两个子目录。
- Config
主要存放配置相关代码。 - Util
存放平台、开发框架、消息、数据库、缓存、文件、总线、网关、第三方类库、通用算法等基础代码,你可以为不同的资源类别建立不同的子目录。
3 微服务架构演进
领域模型中对象的层次从内到外依次是:值对象、实体、聚合和限界上下文。
实体或值对象的简单变更,一般不会让领域模型和微服务发生大变。但聚合的重组或拆分却可以。因为聚合内业务功能内聚,能独立完成特定业务。那聚合的重组或拆分,势必引起业务模块和系统功能变化。
可以聚合为基础单元,完成领域模型和微服务架构的演进。
聚合可作为整体,在不同领域模型间重组或拆分,或直接将一个聚合独立为微服务。
微服务架构的演进案例
现有
微服务 1:包含聚合 a、b、c
微服务2:
微服务3:包含聚合 d、e、f
- 当发现微服务1中聚合a的功能经常被高频访问,以致拖累了整个微服务1的性能,可把聚合a,从微服务1中剥离,独立为微服务2以应对高性能场景
- 随业务发展,发现微服务3的领域模型变化,聚合d会更适合放到微服务1的领域模型。即可将聚合d整体迁移到微服务1。注意定义好聚合间的代码边界
- 架构演进后,微服务1从最初包含聚合a、b、c,演进为包含聚合b、c、d的新领域模型和微服务
可见,好的聚合和代码模型的边界设计,可让你快速应对业务变化,轻松实现领域模型和微服务架构演进。
微服务内服务的演进
在微服务内部,实体的方法被领域服务组合和封装,领域服务又被应用服务组合和封装。在服务逐层组合和封装的过程中,你会发现这样一个有趣的现象。
服务设计时,你并不一定能完整预测有哪些下层服务会被多少个上层服务组装,因此领域层通常只提供一些原子服务,比如领域服务a、b、c。但随系统功能增强和外部接入越来越多,应用服务不断丰富。终有一日,你会发现领域服务b和c同时多次被多个应用服务调用了,执行顺序也基本一致。这时你可以考虑将b和c合并,再将应用服务中b、c的功能下沉到领域层,演进为新的领域服务(b+c)。这样既减少了服务的数量,也减轻了上层服务组合和编排的复杂度。
这就是服务演进,领域模型会越来越能适应需求快速变化。
从MVC跨越到DDD
由于层间松耦合,可专注本层设计,而不必关心其它层,也不必担心自己的设计会影响其它层。即DDD成功降低层与层之间的依赖。
分层架构使得程序结构更清晰,升级和维护更容易。修改某层代码时,只要本层接口参数不变,其它层不必修改。即使本层接口发生变化,也只影响相邻的上层,修改工作量小且可控。
传统企业应用大多是单体架构,而单体架构则大多是三层架构。三层架构解决了程序内代码间调用复杂、代码职责不清的问题,但这种分层是逻辑概念,在物理上它是中心化的集中式架构,并不适合分布式微服务架构。
DDD分层要类似三层架构,只是在DDD中,这些要素被重新划分了层,确定了层与层之间的交互规则和职责边界。
DDD分层架构相比MVC(只有API)在用户接口层新增了DTO,给前端提供了更多的可使用数据和更高的展示灵活性。
DDD分层架构对三层架构的业务逻辑层进行了更清晰的划分,改善了三层架构核心业务逻辑混乱,代码改动相互影响大的情况。
MVC架构向DDD分层架构演进,主要发生在业务逻辑层和数据访问层。
DDD分层架构将业务逻辑层的服务拆分到了应用层和领域层:
- 应用层快速响应前端的变化
- 领域层实现领域模型的能力
数据访问层和基础层之间:
- 三层架构数据访问采用DAO方式
- DDD分层架构的数据库等基础资源访问,采用了仓储(Repository)设计模式,通过依赖倒置实现各层对基础资源的解耦。
仓储本身属基础层,但考虑到一个聚合对应一个仓储,为了以后聚合代码整体迁移方便,在微服务代码目录设计时,在聚合目录下增加一个Repository的仓储目录,跟仓储相关的代码都在这个目录下。
这个目录下的代码与聚合的其它业务代码是分开的。如果未来换数据库,只需将Repository目录下的代码替换。而如果聚合需要整体迁移到其它微服务中去,仓储的代码也会一并迁移。
仓储又分为两部分:仓储接口和仓储实现。仓储接口放在领域层中,仓储实现放在基础层。原来三层架构通用的第三方工具包、驱动、Common、Utility、Config等通用的公共的资源类统一放到了基础层。
MVC 到 DDD 具体操作如下:
抽象数据存储层
一般将Data Access层做抽象,降低系统对DB的直接依赖。
举个例子:
- 新建Account实体对象:一个实体(Entity)是拥有ID的域对象,除了拥有数据之外,同时拥有行为。Entity和数据库储存格式无关。
对象储存接口类AccountRepository:Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层数据库连接可以通过不同的实现类而替换。
总结
聚合之间的代码边界一定要清晰。聚合之间的服务调用和数据关联应该是尽可能的松耦合和低关联,聚合之间的服务调用应该通过上层的应用层组合实现调用,原则上不允许聚合之间直接调用领域服务。这种松耦合的代码关联,在以后业务发展和需求变更时,可以很方便地实现业务功能和聚合代码的重组,在微服务架构演进中将会起到非常重要的作用。
要有代码分层思想。
写代码时一定要搞清楚代码的职责,将它放在职责对应的代码目录内。
- 应用层代码主要完成服务组合和编排,以及聚合之间的协作,它是很薄的一层,不应该有核心领域逻辑代码
- 领域层是业务的核心,领域模型的核心逻辑代码一定要在领域层实现。如果将核心领域逻辑代码放到应用层,你的基于DDD分层架构模型的微服务慢慢就会演变成传统MVC架构。
在整个微服务架构里面一般微服务上层还有BFF层、聚合服务层,一般BFF层或聚合服务层用来协调多个微服务或者做数据转换。微服务内的应用层主要处理自己的逻辑编排,bff主要处理微服务之间的逻辑。
同一个微服务内,跨领域的方法调用,我们可以在应用层进行组合和编排,那微服务间的领域方法调用是怎样的呢?
从应用层发起。方法是逐层封装,一直到应用服务。微服务内应尽量避免领域服务在不同聚合之间的调用,这样聚合之间耦合度会比较高。
Controller, Service, Repository。Controller相当于用户接口层里的Facade。由于采用了充血模型,之前三层模型中的Service的业务逻辑被封装在了domain的各个聚合下的实体之中。如果需要使用到多个实体来完成某个操作,就要使用聚合中的service。
FAQ
应用服务只能调用领域服务和实体的方法,能调用仓储接口的方法么?按理应该隔离,即应用服务应该调用领域服务的方法,再让领域服务调用仓储接口的方法吧?
如果是应用服务直接调用文件或者缓存,应用服务可以之间调用仓储。但如果中间有领域实体和数据库,则需通过领域服务,然后通过聚合根来调用仓储。
实体的转换只有从用户接口层到应用服务层一次是么?即到应用服务层后,以及之后的仓储接口都是可以直接对领域实体进行操作的?
用户接口层大多是DTO,应用层和领域层大多是DO,基础层则是PO,在不同层之间是需要进行数据转换的。
需要在实体中配置一些和底层存储相关的注解,这样会不会不能把领域层可仓储实现进行隔离?如果这样,那Spring Data Jdbc是不是没有严格遵守DDD?而且它提供的领域事件的发布机制实现,是在对应的实体中产生,例如在某一实体中定义产生领域事件的源头,当对应的实体保存或更新时,就会发出这样一个领域事件。按照咱们文章中讲解的事件的发布是在应用层,那么如果要这样做的话,是不是就需要在应用层重新转发领域层实体内产生的领域事件呢?
如果是这样,确实领域层与数据库层会有耦合。领域事件其实放领域层也可,放应用层主要是为统一管理。如果领域事件放在实体内部,查找和运维起来就不是太方便,而且这个实体还需要对领域事件的实体进行操作。目录结构的设计主要是从边界、分层和便利性考虑的。
参考
- 《实现领域驱动设计》
- DDD分层架构:有效降低层与层之间的依赖
- https://zhuanlan.zhihu.com/p/343388831
- https://zhuanlan.zhihu.com/p/342826364
- https://zhuanlan.zhihu.com/p/353041636?utm_source=wechat_session&utm_medium=social&utm_oi=1122562755827355648