通过一套合理的代码结构、框架和约束,来降低 DDD 的实践门槛,提升代码质量、可测试性、安全性、健壮性。
废话少说,直接上最终架构图:
项目架构
DDD的架构能够有效的解决传统架构中的问题:
- 高可维护性:当外部依赖变更时,内部代码只用变更跟外部对接的模块,其他业务逻辑不变。
- 高可扩展性:做新功能时,绝大部分的代码都能复用,仅需要增加核心业务逻辑即可。
- 高可测试性:每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖。
- 代码结构清晰:通过POM module可以解决模块间的依赖关系, 所有外接模块都可以单独独立成Jar包被复用。当团队形成规范后,可以快速的定位到相关代码。
DP
就好像 Integer、String 是所有编程语言的Primitive一样,在 DDD 里, DP 可以说是一切模型、方法、架构的基础
优势:
Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object
- DP是一个传统意义上的Value Object,拥有Immutable(不变)的特性
- DP是一个完整的概念整体,拥有精准定义
- DP使用业务域中的原生语言
- DP可以是业务域的最小组成部分、也可以构建复杂组合
使用三原则
- 让隐性的概念显性化
- 让隐性的上下文显性化
- 封装多对象行为
Domain Primitive 和 DDD 里 Value Object 的区别
什么情况下应该用 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<String, List<Integer>> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为
解决问题:1.接口清晰度;2.数据验证和错误处理;3.业务代码清晰度(?);4.可测试性
在真实的项目中,以前散落在各个服务或工具类里面的代码,可以都抽出来放在 DP 里,成为 DP
自己的行为或属性。这里面的原则是:所有抽离出来的方法要做到无状态
DO/DAO-DTO-Entity
-
Data Object
(DO、数据对象):实际上是我们在日常工作中最常见的数据模型。但是在DDD的规范里,DO应该仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。为了简单明了,DO的字段类型和名称应该和数据库物理表格的字段类型和名称一一对应。
- Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity和DO很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity的生命周期应该仅存在于内存中,不需要可序列化和可持久化。
- DTO(传输对象):主要作为Application层的入参和出参,比如CQRS里的Command、Query、Event,以及Request、Response等都属于DTO的范畴。DTO的价值在于适配不同的业务场景的入参和出参,避免让业务对象变成一个万能大对象。 对象间需要通过转化器(Converter/Mapper)来互相转化。而这三种对象在代码中所在的位置也不一样,简单总结如下:
从使用复杂度角度来看,区分了DO、Entity、DTO带来了代码量的膨胀(从1个变成了3+2+N个)。但是在实际复杂业务场景下,通过功能来区分模型带来的价值是功能性的单一和可测试、可预期,最终反而是逻辑复杂性的降低。
Assembler
实现DTO与领域对象之间的互相转换,数据交互,几乎总是与DTO一起出现(有一些系统使用反射机制自动实现DTO与领域对象之间的相互转换,Appache的Commons BeanUtils就提供了类似的功能,使用Assembler进行对象数据交换更为安全与可控,并且接受编译期检查,但是代码量明显偏多。
使用反射机制自动进行象数据交换虽然代码量很少,但大量的反射调用,性能比较差,内存占用多,不适合特别高并发的应用场景,一旦对象属性名发生了变化,数据交互就会失败,并且很难追踪发现。总体来说,Assembler更为直白和稳妥。推荐MapStruct通过注解,在编译时静态生成映射代码,其最终编译出来的代码和手写的代码在性能上完全一致)
Repository仓储(解决贫血模型)
-
接口名称不应该使用底层实现的语法:我们常见的insert、select、update、delete都属于SQL语法,使用这几个词相当于和DB底层实现做了绑定。相反,我们应该把
Repository 当成一个中性的类 似Collection 的接口,使用语法如
find、save、remove。在这里特别需要指出的是区分 insert/add 和 update
本身也是一种和底层强绑定的逻辑,一些储存如缓存实际上不存在insert和update的差异,在这个 case 里,使用中性的 save
接口,然后在具体实现上根据情况调用 DAO 的 insert 或 update 接口。
-
出参入参不应该使用底层数据格式:需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate
Root),而不应该直接操作底层的 DO。
-
应该避免所谓的“通用”Repository模式:很多 ORM
框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity
Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。当然,这里避免通用不代表不能有基础接口和通用的帮助类。
体类和其业务逻辑可以随意更改,每次修改你唯一需要做的就是变更一下Converter,已经和底层实现完全解耦。
Service
在领域驱动设计的架构里,Service的组织粒度和接口设计依据与传统Transaction Script风格的Service是一致的,但是两者的实现却有着质的区别。(Transaction Script风格的Service是实现业务逻辑的主要场所,因此往往非常厚重。而在领域驱动设计的架构里,所有的Service只负责协调并委派业务逻辑给领域对象进行处理,其本身并非真正实现业务逻辑,绝大部分的业务逻辑都由领域对象(Repository)承载和实现了。
Facade
为远程客户端提供粗粒度的调用接口,他的作用就是将一个用户请求委派给一个或多个service进行处理,实践Facade的过程中最难把握的问题就是Facade的粒度问题:传统的Service均以实体为单位进行组织,而Façade应该具有更粗粒度的组织依据,较为合适的粒度依据有:一个高度内聚的模块一个Façade或者是一个“聚合”(特指领域驱动设计中的聚合)一个Façade.
对于façade而言,99%的情况是,它只是把某个Service的某个方法再包裹一下而已,如果把领域对象和DTO的互转换工作移至service中进行,那么façade将彻底变成空壳(非必须),Service的接口是面向用例设计的,是控制事务、安全的适宜场所。如果Façade的某一方法需要调用两个以上的Service方法,需要注意事务问题。
防腐层(ACL)
Anti-Corruption Layer(防腐层或ACL)。很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。
ACL 不仅仅只是多了一层调用,在实际开发中ACL能够提供更多强大的功能:
- 适配器:很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到ACL内部,降低对业务代码的侵入。在这个案例里,我们通过封装了ExchangeRate和Currency对象,转化了对方的入参和出参,让入参出参更符合我们的标准。
- 缓存:对于频繁调用且数据变更不频繁的外部依赖,通过在ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入ACL,能够降低业务代码的复杂度。
- 兜底:如果外部依赖的稳定性较差,一个能够有效提升我们系统稳定性的策略是通过ACL起到兜底的作用,比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在ACL中,更加容易被测试和修改。
- 易于测试:类似于之前的Repository,ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。
- 功能开关:有些时候我们希望能在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,我们可以在ACL配置功能开关来实现,而不会对真实业务代码造成影响。同时,使用功能开关也能让我们容易的实现Monkey测试,而不需要真正物理性的关闭外部依赖。
在一些理论框架里ACL Facade也被叫做Gateway,含义是一样的。
Factories工厂
聚合及聚合根(Aggregate,Aggregate Root)
- 聚合是通过定义领域对象之间清晰的所属关系以及边界来实现领域模型的内聚,以此来避免形成错综复杂的、难以维护的对象关系网。聚合定义了一组具有内聚关系的相关领域对象的集合,我们可以把聚合看作是一个修改数据的单元。
- 聚合根属于实体对象,它是领域对象中一个高度内聚的核心对象。(聚合根具有全局的唯一标识,而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识,不存在这个值对象或那个值对象的说法)
- 若一个聚合仅有一个实体,那这个实体就是聚合根;但要有多个实体,我们就要思考聚合内哪个对象有独立存在的意义且可以和外部领域直接进行交互。
设计规范
-
基于领域对象 +
领域服务的DDD架构:同时要考虑到实体类的内聚和保证不变性(Invariants),也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高。
- 大多数DDD架构的核心都是实体类,实体类包含了一个领域里的状态、以及对状态的直接操作。Entity最重要的设计原则是保证实体的不变性(Invariants),也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。所以几个设计原则如下: