初识DDD领域驱动设计

1,786 阅读18分钟

我正在参加「掘金·启航计划」

DDD概念

DDD由Domain-Drive-Design三个单词的首字母组成,翻译成中文为领域驱动设计。它是一种架构思想,目的是为了解决传统基于ORM开发中导致的贫血模型(对象只有属性,没有行为),转而建立各种领域模型,让各个领域模型执行相应的业务逻辑。

Domain Primitive

Domain Primitive的定义

Domain Primitive简称DP,是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的Value Object,是DDD中的“基础数据类型”。它不从任何其他事物发展而来,是一切模型、方法、架构的基础。它具有以下特性:

  • DP是一个传统意义上的Value Object,拥有Immutable的特性。
  • DP是一个完整的概念整体,拥有精准定义。
  • DP使用业务域中的原生语言。
  • DP可以是业务域的最小组成部分、也可以构建复杂组合。

使用Domain Primitive的三原则

  • 将隐性的概念显性化。
  • 将隐性的上下文显性化。
  • 封装多对象行为。

Domain Primitive和DDD里Value Object的区别

Domain Primitive是Value Object的进阶版,在原始VO的基础上要求每个DP拥有概念的整体,而不仅仅是值对象。在VO的Immutable基础上增加了Validity和行为。当然同样的要求无副作用(side-effect free)。

Domain Primitive和Data Transfer Object (DTO)的区别

在日常开发中经常会碰到的另一个数据结构是DTO,比如方法的入参和出参。DP和DTO的区别如下:

DTODP
功能数据传输 属于技术细节代表业务域中的概念
数据的关联只是一堆数据放在一起 不一定有关联度数据之间的高相关性
行为无行为丰富的行为和业务逻辑

什么情况下应该用Domain Primitive

常见的DP的使用场景包括:

  • 有格式限制的String:比如NamePhoneNumberOrderNumberZipCodeAddress等。
  • 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等。
  • 可枚举的int:比如Status(一般不用Enum因为反序列化问题)。
  • DoubleBigDecimal:一般用到的DoubleBigDecimal都是有业务含义的,比如TemperatureMoneyAmountExchangeRateRating等。
  • 复杂的数据结构:比如Map<String, List> 等,尽量能把Map的所有操作包装掉,仅暴露必要行为。

Repository模式

贫血模型

常见Java开发中对Entity的理解仅仅停留在了数据映射层面,忽略了Entity实体的本身行为,造成今天很多的模型仅包含了实体的数据和属性,而所有的业务逻辑都被分散在多个服务、Controller、Utils工具类中,这就是Anemic Domain Model(贫血领域模型)

我们在日常开发中混淆了数据模型和业务模型的概念。

  • 数据模型(Data Model) :指业务数据该如何持久化,以及数据之间的关系,也就是传统的ER模型。
  • 业务模型/领域模型(Domain Model) :指业务逻辑中,相关联的数据该如何联动。

在真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository

模型对象

对象类型

  • Data Object (DO、数据对象) : 仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。为了简单明了,DO的字段类型和名称应该和数据库物理表格的字段类型和名称一一对应。

  • Entity(实体对象) :实体对象是我们正常业务应该用的业务模型,包含了一个领域里的状态,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity和DO很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity的生命周期应该仅存在于内存中,不需要可序列化和可持久化

    1. Entity必须创建即一致,DDD里实体创建的方法有两种:

      1. constructor参数要包含所有必要属性,或者在constructor里有合理的默认值。
      2. 使用Factory模式来降低调用方复杂度。
    2. 尽量避免public setter,需要通过行为方法来修改内部状态。

    3. 通过聚合根保证主子实体的一致性。

      1. 子实体不能单独存在,只能通过聚合根的方法获取到。任何外部的对象都不能直接保留子实体的引用。
      2. 子实体没有独立的Repository,不可以单独保存和取出,必须要通过聚合根的Repository实例化。
      3. 子实体可以单独修改自身状态,但是多个子实体之间的状态一致性需要聚合根来保障。
    4. 不可以强依赖其他聚合根实体或领域服务,正确的对外部依赖的方法有两种:

      1. 只保存外部实体的ID:ID为强类型的ID对象,而不是Long型ID。强类型的ID对象不单单能自我包含验证代码,保证ID值的正确性,同时还能确保各种入参不会因为参数顺序变化而出bug。
      2. 针对于“无副作用”的外部依赖,通过方法入参的方式传入。
    5. 任何实体的行为只能直接影响到本实体(和其子实体)。

  • DTO(传输对象) :主要作为Application层的入参和出参,比如CQRS里的Command、Query、Event(简称CQE),以及Request、Response等都属于DTO的范畴。DTO的价值在于适配不同的业务场景的入参和出参,避免让业务对象变成一个万能大对象。

模型所在模块和转化器

对象间需要通过转化器(Converter/Mapper)来互相转化。总结如下:

DTO Assembler: 在Application层,Entity到DTO的转化器有一个标准的名称叫DTO Assembler。DTO Assembler的核心作用就是将1个或多个相关联的Entity转化为1个或多个DTO。

Data Converter: 在Infrastructure层,Entity到DO的转化器没有一个标准名称,但是为了区分Data Mapper,我们叫这种转化器Data Converter。这里要注意Data Mapper通常情况下指的是DAO,比如Mybatis的Mapper。

模型规范总结

DOEntityDTO
目的数据库表映射业务逻辑适配业务场景
代码层级InfrastructureDomainApplication
命名规范XxxDOXxxXxxDTO XxxCommand XxxRequest等
字段名称标准数据库表字段名业务语言和调用方商定
字段数据类型数据库字段类型尽量是有业务含义的类型,比如DP和调用方商定
是否需要序列化不需要不需要需要
转化器Data ConverterData Converter DTO AssemblerDTO Assembler

Repository代码规范

Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。

接口规范

  1. 接口名称不应该使用底层实现的语法: 我们常见的insertselectupdatedelete都属于SQL语法,使用这几个词相当于和DB底层实现做了绑定。相反,我们应该把Repository当成一个中性的类似Collection的接口,使用语法如findsaveremove。在这里特别需要指出的是区分insert/addupdate本身也是一种和底层强绑定的逻辑,一些储存如缓存实际上不存在insertupdate的差异,在这个case 里,使用中性的save接口,然后在具体实现上根据情况调用DAO的insertupdate接口。
  2. 出参入参不应该使用底层数据格式: 需要记得的是Repository操作的是Entity对象(实际上应该是Aggregate Root),而不应该直接操作底层的DO。更近一步,Repository接口实际上应该存在于Domain层,根本看不到DO的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
  3. 应该避免所谓的“通用”Repository模式: 很多ORM框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。

DAO

在传统的数据库驱动开发中,我们会对数据库操作做一个封装,一般叫做Data Access Object(DAO)。DAO的核心价值是封装了拼接SQL、维护数据库连接、事务等琐碎的底层逻辑,让业务开发可以专注于写代码。

领域服务(Domain Service)

单对象策略型

这种领域对象主要面向的是单个实体对象的变更,但如果涉及到多个领域对象或外部依赖的一些规则,则实体应该通过方法入参的方式传入这种领域服务,然后通过Double Dispatch来反转调用领域服务的方法。

跨对象事务型

当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。

通用组件型

这种类型的领域服务提供了组件化的行为,但本身又不直接绑死在一种实体类上。

在软件系统里,我们通常将复杂的大系统拆分为独立的组件,来降低复杂度。比如网页里通过前端组件化降低重复开发成本,微服务架构通过服务和数据库的拆分降低服务复杂度和系统影响面等。

DDD架构设计

抽象数据存储层

新建对象储存接口类Repository

Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。

抽象第三方服务

所有第三方服务也需要通过抽象解决第三方服务不可控,入参出参强耦合的问题。

防腐层(ACL)

介绍防腐层

防腐层英文为Anti-Corruption Layer(防腐层或ACL),是一种常用的设计模式。很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。

它可以提供的功能如下:

  • 适配器:很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到ACL内部,降低对业务代码的侵入。
  • 缓存:对于频繁调用且数据变更不频繁的外部依赖,通过在ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入ACL,能够降低业务代码的复杂度。
  • 兜底:如果外部依赖的稳定性较差,一个能够有效提升我们系统稳定性的策略是通过ACL起到兜底的作用,比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在ACL中,更加容易被测试和修改。
  • 易于测试:类似于之前的Repository,ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。
  • 功能开关:有些时候我们希望能在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,我们可以在ACL配置功能开关来实现,而不会对真实业务代码造成影响。同时,使用功能开关也能让我们容易的实现Monkey测试,而不需要真正物理性的关闭外部依赖。

ACL防腐层的简单原理

  • 对于依赖的外部对象,我们抽取出所需要的字段,生成一个内部所需的VO或DTO类。
  • 构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类。
  • 针对外部系统调用,同样的用Facade方法封装外部调用链路。

抽象中间件

对各种中间件的抽象的目的是让业务代码不再依赖中间件的实现逻辑。

通过中间件的ACL抽象,减少重复胶水代码,避免序列化/反序列化逻辑通常和业务逻辑混杂在一起。

封装业务逻辑

通过Entity、Domain Primitive和Domain Service封装所有的业务逻辑。

总结

  • 最底层不再是数据库,而是Entity、Domain Primitive和Domain Service。这些对象不依赖任何外部服务和框架,而是纯内存中的数据和操作。这些对象我们打包为Domain Layer(领域层) 。领域层没有任何外部依赖关系。
  • 再其次的是负责组件编排的Application Service,但是这些服务仅仅依赖了一些抽象出来的ACL类和Repository类,而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL等我们统称为Application Layer(应用层) 。应用层依赖领域层,但不依赖具体实现。
  • 最后是ACL,Repository等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为Infrastructure Layer(基础设施层) 。Web框架里的对象如Controller之类的通常也属于基础设施层。
  • 我们在应用DDD编码时顺序为先写Domain层的业务逻辑,然后再写Application层的组件编排,最后才写每个外部依赖的具体实现。

Choreography设计思想

Choreography中文为编排,与事件驱动架构EDA相似,与SOA/微服务的服务编排Service Orchestration不同的是它没有一个中心化的指挥。在Choreography模式中,每个服务都是独立的个体,可能会响应外部的一些事件,但整个系统是一个整体。

Choreography特点

  1. 每一个服务只是做好自己的事,然后通过事件触发其他的服务,服务之间没有直接调用上的依赖。但要注意的是下游还是会依赖上游的代码(比如事件类),所以可以认为是下游对上游有依赖。
  2. 因为服务间没有直接调用关系,可以增加或替换服务,而不需要改上游代码。
  3. 每个服务被动的被外部事件触发,所以是Event-Driven事件驱动的。
  4. 没有主动调用方,每个服务只关心自己的触发条件和结果,没有任何一个服务会为整个业务链路负责。

Transaction Script(事务脚本)

一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。即使在真实代码中这些操作经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。这种很常见的代码样式被叫做Transaction Script(事务脚本) 。他有以下几个很大的问题:可维护性差可扩展性差可测试性差,完全的违背了SRP(Single Responsbility Principle)单一职责原则

分层思想

用DDD的分层思想去重构分为以下步骤:

分离出独立的Interface接口层

Interface接口层负责处理网络协议、统一鉴权、Session管理、限流配置、前置缓存、异常处理、日志相关的逻辑。

  1. 接口层的核心价值是对外,所以如果只是返回DTO或DO会不可避免的面临异常和错误栈泄漏到使用方的情况,包括错误栈被序列化反序列化的消耗。所以,这里提出一个规范:
    1. Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常。
    2. Application层的所有接口返回值为DTO,不负责处理异常。

以上规范可以使用AOP完成。

  1. 一个Interface层的类应该是“小而美”的,应该是面向“一个单一的业务”或“一类同样需求的业务”,需要尽量避免用同一个类承接不同类型业务的需求。

即当一个现有的接口类过度膨胀时,可以考虑对接口类做拆分。

找出具体用例(Use Cases)

从真实业务场景中,找出具体用例(Use Cases),然后将具体用例通过专用的Command指令、Query查询、和Event事件对象来承接。

介绍CQE

  • Command指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)。
  • Query查询:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作。
  • Event事件:指一件已经发生过的既有事实,需要系统根据这个事实作出改变或者响应的,通常事件处理都会有一定的写操作。事件处理器不会有返回值。这里需要注意一下的是,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。

简单总结下:

CommandQueryEvent
语意”希望“能触发的操作各种条件的查询已经发生过的事情
读/写只读通常是写
返回值DTO 或 BooleanDTO 或 CollectionVoid

ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建。

CQE vs DTO

  • CQE:CQE对象是ApplicationService的输入,是有明确的”意图“的,所以这个对象必须保证其”正确性“。
  • DTO:DTO对象只是数据容器,只是为了和外部交互,所以本身不包含任何逻辑,只是贫血对象。

避免复用CQE

针对于不同语意的指令,要避免CQE对象的复用。

分离出独立的Application应用层

Application应用层负责业务流程的编排,响应Command、Query和Event。每个应用层的方法应该代表整个业务流程中的一个节点。

Application Service 是业务流程的封装,不处理业务逻辑。

判断是否业务流程的几个点:

  1. 不要有if/else分支逻辑。也就是说代码的Cyclomatic Complexity(循环复杂度)应该尽量等于1。

通常有分支逻辑的,都代表一些业务判断,应该将逻辑封装到DomainService或者Entity里。但这不代表完全不能有if逻辑,例如一些中断条件。

  1. 不要有任何计算。计算逻辑应该封装到实体里。
  2. 一些数据的转化可以交给其他对象来做,将对象间转化的逻辑沉淀在单独的类中,降低ApplicationService的复杂度。

使用Result还是Exception

在Interface层统一捕捉异常是为了避免异常堆栈信息泄漏到API之外,但是在Application层,异常机制仍然是信息量最大,代码结构最清晰的方法,避免了Result的一些常见且繁杂的Result.isSuccess判断。所以在Application层、Domain层,以及Infrastructure层,遇到错误直接抛异常是最合理的方法。

处理跨层的横切关注点

横切关注点如鉴权、异常处理、校验、缓存、日志等。