DDD领域驱动设计-设计规范-Ⅵ

不以规矩,不能成方圆。
                                                                    -战国·邹·孟轲《孟子·离娄章句上》

1. 前言

为什么要使用DDD领域设计?请参考以下博客:
DDD领域驱动设计,对比(dao+service)的脚本式编程,主要还是将以前的脚本代码拆散,以实体为载体,协调各个模块实现业务功能。DDD领域设计有如下好处:
  1. 强调实体的概念,将现实世界与软件系统关联起来,便于不同岗位的人达成统一的认知。有助于业务理解和需求讨论。
  2. 明确业务规则和业务流程,将系统中的隐形业务逻辑在领域层显性展示出来。
  3. 分模块编写代码,有助于明确和细化代码功能,编写的代码质量更好,可以最大化满足SOLID原则。后续系统升级改造时,可精确定位到需要调整的模块,便于高效的维护业务代码。
SOLID原则:指单一职责原则(SRP),开闭原则(OCP),里氏替换原则(LSP),接口隔离原则(ISP)和依赖倒置原则(DIP)。

2. DDD规范说明

2.1. 实体建模

实体的定义在原书《领域驱动设计》中的描述如下:
一些对象主要不是由它们的属性定义的。它们实际上表示了一条“标识线”,这条线跨越时间,而且常常经历多种不同的表示。
在 DDD 中有这样一类对象,它们拥有唯一标识符,且标识符在历经各种状态变更后仍能保持一致。对这些对象而言,重要的不是其属性,而是其延续性和标识,对象的延续性和标识会跨越甚至超出软件的生命周期。我们把这样的对象称为实体,实体有如下特征:
  1. 在同一类模型实中需要区别开来,一个实体是唯一的东西;
  2. 每个实体有唯一标识来区别彼此;
  3. 实体有生命周期,我们可以对它多次修改,但它仍然还是同一个实体。
一个实体包括: 唯一身份标识 , 可变性状态(属性),行为(方法或领域事件或领域服务)。
实体建模应根据业务场景来建立,不同的人对概念的理解不同,业务场景不同,设计出来的模型也会不同。初版本的DDD建模也不能完全匹配复杂业务,只能说尽可能的满足业务,在项目实践中去完善。检验实体设计的是否合理,还得看落地到编码层的实现难度,后续业务迭代多个版本后,实体模型是否还能快速响应,在迭代中完善实体模型。

2.2. 聚合根

聚合根本身也是一个实体,外部不可直接操作聚合根内部的实体或对象,聚合根内部的对象必须通过聚合根统一调用。
设置聚合根存在一个取舍问题,如售后补偿业务,整体就是围绕补偿单的创建,审批,处理流程。若完全设置为一个补偿单聚合根,该聚合根就会偏大,聚合根尽可能的细化拆小,但又需避免过度设计,所以对于简单很短的补偿单操作,就当作是补偿单聚合的一种行为。对于复杂的,应该单独出来。创建和处理具有不同的生命周期,也是不同的场景,就应设置不同的聚合根。现在审批偏简单,若其他的一些业务需要很复杂的审批环节,那么审批也可以设置专门的审批聚合根。具体还是以业务为主,灵活应用。

2.2.1. 聚合根配置

当实体为一个聚合根时,一个聚合根通常配置以下三个模块:
  1. 工厂(Factory):只是负责创建聚合根,聚合根内部的子实体,与实体的行为无关。创建与使用分开,保证类的单一权责规范。
  2. 领域服务(DomainService):完成聚合根内实体的相关行为,处理所有的业务逻辑,如业务判断,业务数据生成等。对于跨多个实体的应用,单独编写第三方领域服务处理。第三方领域服务的功能不属于单独某一个实体的行为。
  3. 仓库(Repository):仓库提供聚合根与底层数据的存储功能。仓库仅保存数据,查询数据,不做实体行为的逻辑处理。

2.2.2. 配置原则

  1. 对于简单的实体创建,可基于构造函数,或直接设置值生成实体,不一定非要使用工厂创建。
  2. 工厂,领域服务等都不能直接与底层的数据存储系统交互,他们都要通过仓库层来获取数据,存储数据。
  3. 聚合根统一配置仓库等模块,内部的子实体不用再单独配置仓库和工厂了。

2.3. 实体应用规范

每一个实体可设置一个验证行为,编写一个init()方法。 在实体对象初始化后,在调用验证方法做数据的基础验证,如数据的合规性,必填,大小区间等验证。此处的验证不应该包括数据的业务逻辑验证。

2.3.1. 实体必须干净

实体本身是一种充血模型,对比普通的POJO对象,只是多了行为。不可在实体中依赖注入外部的服务,实体只能保留自有的状态。不能因为实体要实现一些功能,需要依赖外部的服务,就通过注入的方式引入外部服务,这样会污染实体且使实体单测变得复杂。正确的引用方式是通过方法参数引入(Double Dispatch)。

2.3.2. 不可以强依赖其他聚合根实体或领域服务

一个实体的原则是高内聚、低耦合,即一个实体类不能直接在内部直接依赖一个外部的实体或服务。这个原则和绝大多数ORM框架都有比较严重的冲突,所以是一个在开发过程中需要特别注意的。这个原则的必要原因包括:对外部对象的依赖性会直接导致实体无法被单测;以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。

2.3.3. 任何实体的行为只能直接影响到本实体

任何实体的行为只能直接影响到本实体(和其子实体),这个原则更多是一个确保代码可读性、可理解的原则,即任何实体的行为不能有“直接”的”副作用“,即直接修改其他的实体类。这么做的好处是代码读下来不会产生意外。另一个遵守的原因是可以降低未知的变更的风险。在一个系统里一个实体对象的所有变更操作应该都是预期内的,如果一个实体能随意被外部直接修改的话,会增加代码bug的风险。

2.4. 领域服务

实体行为的具体业务规则实现,单独编写一个实现类,这种类在DDD里被叫做领域服务(Domain Service)。

2.4.1. 单实体-领域服务

一个实体的相关行为,编写在实现类中,且这些行为只影响单个实体。

2.4.2. 跨对象事务型(多实体)-第三方领域服务

当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。该领域服务是一个多实体的综合服务实现类,不是任何一个单独实体的领域实现类。
如账户转账模块:有两个账户实体,一个支出,一个收入,两个实体的行为同时完成支出和收入才算转账完成。

2.4.3.领域层操作实体是一种内存操作行为

从原则上讲,Domain层(包括DomainService)只负责业务规则,不负责业务流程。应用层(Application)只负责业务流程。保存数据这个属于业务流程,所以不应该在Domain层操作,应该在
应用层中处理。
DomainService的职责很简单,就是根据入参做计算。可以认为DomainService的所有方法都是纯内存操作,无外部的副作用。(有点类似于拦截器的前置操作)
原则上,应该在应用层(Application)里调用Repo或第三方服务获取所有需要的实体和DTO,然后通过入参的方式传入到DomainService里做跨实体的计算,最后在应用层(Application)里调用Repo保存状态和调用第三方服务/发消息等。
注意:
  1. 在领域层中直接调用仓库层接口保存数据,是否可行。从代码上来说是可以的,单从职能上来说,领域层是一种内存模式的业务规则操作,不应该直接去保存数据。
  2. 若在领域层处理过程中需调用外部服务接口,包括外部服务的创建数据接口(一种特殊的非内存操作),这种还是放在领域服务中实现。因为实际场景中,经常存在调用外部服务接口,基于返回内容再次做业务逻辑处理的情况。

2.5. 事件通知

领域事件是一个在领域里发生了某些事后,希望领域里其他对象能够感知到的通知机制。领域事件的好处就是将这种隐性的副作用“显性化”,通过一个显性的事件,将事件触发和事件处理解耦,最终起到代码更清晰、扩展性更好的目的。
事件通知主要是基于观察者模式,当一个实体行为结束后发出事件通知,一个或多个其他的观察者监听到了事件后,可做后续的衍生业务处理。

2.5.1. 副作用

实体完成一个行为完成后,可能会产生副作用。一般的副作用发生在核心领域模型状态变更后,同步或者异步对另一个对象的影响或行为。
如:打死怪物了,可以获得经验值,经验值达到一定的数量,可以升级。那么获得经验值是该领域的一个业务,但升级就是这个业务衍生出来的副作用了。

2.5.2. 事件通知与第三方领域服务的区别

注意区分领域事件与第三方领域服务的应用场景。第三方的领域服务主要是保证不同实体的强一致性,完成一个业务必须是多个实体同时完成。领域事件则不同,当前领域的核心行为完成后,后续副作用就算失败了,也不关心。判断的唯一标准就是,一个业务完成需多个实体协调处理,是否需要多个实体强一致性的处理。不需要,就可用事件模式了。

2.6. 工厂使用注意事项

在基于工厂创建实体时,若工厂仅做传入数据的初始化组装工作,功能显得鸡肋。
工厂创建出的实体应具备如下条件:
  1. 实体必要数据完整,如传入订单号,订单号有效,且能获取到订单的信息,订单的信息能完善实体的必要数据;
  2. 实体合法,如业务审批实体,它一定是一个满足审批条件的实体;
创建出来的实体是一个可靠的实体,就不需要在使用实体时,还去验证实体的合法性。但基于此就会存在在工厂内部,调用外部Repo或外部服务去验证数据或获取数据的需求。
可参考案例:DDD工厂责任
在工厂内部调用外部的Repo或外部服务:
  1. 好处:可以保证工厂创建出来的实体是一个合规可用的实体,对于传入的命令,先做数据层面的合规性验证,同时工厂可从数据层拿到数据,便于做数据加工和验证。
  2. 弊端:在工厂的内部去调用外部Repo或外部服务,会导致工厂类不够纯,单元测试需要mock所有的外部服务,测试覆盖度会有影响。
首先明确目标,肯定是要生成一个可靠的实体的,若能接收工厂内部去调用外部服务,就直接使用。若为了保证工厂便于测试,同时保证工厂类足够的干净,可以通过参数的方式传入外部Repo,或者一些基础数据,可以在应用层拿到数据后,将拿到的数据传入到工厂进行处理。
关于是应用层调用工厂还是领域层调用工厂,网上也有不同的观点。个人认为不同的场景应该分开分析:
  1. 当系统中已经存在一个实体时,一般来说不需要工厂在去创建实体了,就不存在调用工厂的说法。
  2. 当系统中不存在实体时,需要初始化实体数据,此刻由应用层调用工厂创建。这个理论的依据是实体的创建与实体的使用要分离,领域层中的实体是实体的使用。本例不认可那种在实体中加一个创建实体的行为,创建行为调用工厂创建实体的模式,这种模式还是把实体的创建和实体的其他行为都放在了一起,这样就不需要引入工厂了,也达不到使用与创建分离的目的。
  3. 工厂是否可以直接接收CE(命令,事件消息)数据,本例设计的是可以,工厂接收到传入的指令,生成对应的实体。

2.7. 防腐层使用场景

防腐层相关知识:DDD防腐层应用
简单来说,需要明白那些代码逻辑写到领域层,哪一些应该写入到防腐层模块。确保外部系统变化后,或本系统其他领域模块变化后,尽可能的不影响当前领域的领域层代码。常用场景如:
  1. 发送短信,消息;
  2. 跨系统查询数据,调用外部系统业务接口;
  3. 调用支付宝,网银,微信等转账;

2.8. 仓库层注意事项

  1. 仓库层入参不应该使用底层数据格式,Repository操作的是Entity对象(实际上应该是Aggregate Root),而不应该直接操作底层的数据对象(数据表映射的贫血对象)。更近一步,Repository接口实际上应该存在于Domain层,根本看不到数据层的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
  2. 实体状态变更,行为处理,仓库层入参可以接收处理命令(如XxxUpdateCommand)。
  3. 仓库层接口放在领域层模块,但仓库层的实现放在基础层模块。
  4. 当发现数据存储要求更多的字段,实体缺乏某些数据项时(如一些加工生成的中间数据),不要将缺少的数据,通过参数的方式传递到仓库层。应反思实体是否设计的完善合理,尽可能的完善实体后,再存储数据。
  5. 仓库层的业务接口入参一般为实体,实体的唯一身份标识,部分基础数据类型。
  6. 仓库层的查询接口入参可以为Query对象,单个主键编码。
  7. 仓库层的数据操作接口,原则上由应用层调用,不要在领域层中调用,领域层一般调用查询接口。

2.9. 业务处理如何依赖实体行为

一个业务的完成,往往会关联多个实体,需要多个实体的不同行为协调运行。
如在售后补偿中,补偿单整体流程结束:包括履约单完成和补偿单状态完成。两个实体都完成了,才算整个补偿业务完成。不同实体之间不能直接调用,参考实体的行为规范,针对多个实体的情况,一般存在以下三个常用场景:
  1. 多实体强一致性:完成一个业务必须保证相关的实体同时完成,具有事务性质。可采用第三方领域服务处理,处理完成后,在应用层加上事务保证数据一致性的存储到系统。
  2. 实体副作用:完成一个实体后,其他实体监听处理,实体不依赖于其他实体的处理结果。如履约单完成后,发出一个事件。
  3. 多实体先后处理:在应用层,应用服务调用领域层实体相关的功能,做业务编排,先执行一个实体的行为,在执行其他实体的行为。如补偿单审批通过后,调用履约单的处理功能。

2.10. 领域驱动数据传输

DTO Assembler:在Application层,Entity到DTO的转化器有一个标准的名称叫DTO Assembler。Martin Fowler在P of EAA一书里对于DTO 和 Assembler的描述:Data Transfer Object。DTO Assembler的核心作用就是将1个或多个相关联的Entity转化为1个或多个DTO。
Data Mapper:在Infrastructure层,实体到数据表映射对象的转化器没有一个标准名称,暂定称这种转化器为Data Mapper。
虽然Assembler/Mapper是非常好用的对象,但是当业务复杂时,手写Assembler/Mapper是一件耗时且容易出bug的事情,所以业界会有多种Bean Mapping的解决方案,从本质上分为动态和静态映射。动态映射方案包括比较原始的BeanUtils.copyProperties、能通过xml配置的Dozer等,其核心是在运行时根据反射动态赋值。动态方案的缺陷在于大量的反射调用,性能比较差,内存占用多,不适合特别高并发的应用场景。不采用动态映射,推荐使用MapStruct(MapStruct官网)。基于MapStruct在基础层写一个统一的对象转换工具类既可,专门处理不同类型对象之间的数据转换工作。

2.11. 领域驱动设计常规流程

领域驱动设计的代码通常有类似的结构:应用层通常不做任何决策(Precondition除外),仅仅是把所有决策交给DomainService或Entity,把跟外部交互的交给Infrastructure接口,如Repository或防腐层。

2.11.1. 产生实体模式

产生实体指系统中还不存在实体,基于外部服务传入的数据,生成一个新的实体并保存实体的过程。如:创建订单,创建一个售后补偿单。这种模式主要是处理实体从无到有的过程。一般操作流程如下:
  1. 应用层准备数据,包括从网关接收传入的数据,调用外部服务得到的数据。
  2. 应用层准备好数据后,调用工厂创建实体。对于比较简单的实体,可不基于工厂创建,直接在应用层设置实体的值即可。
  3. 工厂生成实体,根据获取到的各个数据对象,组合生成领域实体。
  4. 领域层执行操作:基于传入的实体调用领域对象的方法对其进行操作。需要注意的是这个时候通常都是纯内存操作,非持久化。
  5. 应用层持久化:将操作结果调用仓库层数据保存接口,持久化实体数据,或操作外部系统产生相应的影响,包括发消息等异步操作。
  6. 仓库层接收到实体后,转换实体为数据表映射对象,最终保存数据;

2.11.2. 应用实体模式

应用实体指系统中已经存在实体了,对存在的实体做其他的行为操作,如修改实体中的部分信息。一般操作流程如下:
  1. 客户端在不同的场景(Command,Event)下,传入数据到应用层(application),此刻是普通的DTO数据;
  2. 应用层基于传入的数据调用仓库层接口,仓库层基于实体唯一编码返回一个已经存在的实体信息;
  3. 实体在领域层实现核心业务规则后,返回对应的实体对象,实体的行为或DomainService的所有方法都是纯内存操作,无外部的副作用。(若实体信息修改无业务规则,应用层获取实体后,直接调用仓库层保存数据);
  4. 应用层得到领域层返回的实体对象后,调用仓库层接口,传入实体对象(或命令对象)保存数据;
  5. 仓库层接收到传入数据后,转换数据为数据表映射对象,最终保存数据;

2.12. 领域驱动注意事项

  1. 实体保存时,仓库层的入参不能为基础的数据表对象,入参对象应设置为实体;
  2. 其他命令操作时,应用层调用仓库层保存数据,入参可根据实际的情况传入对应的实体或入参命令对象(比如只是修改数据的个别字段可传入修改命令,修改大量字段信息,传入实体)。
  3. 领域层只做纯内存的业务规则操作,原则上不能在领域层中直接调用仓库层存储数据;
  4. 应用层的出参设置为DTO或基础数据对象(如主键编码),不能直接返回实体;
  5. 当一个业务涉及到多个实体时,不能在一个实体中直接调用另外一个实体(聚合根可调用内部的子实体),应该基于应用层或第三方领域服务协调处理;
  6. 明确各个层的职能,不要混用;
  7. 领域层做业务规则处理,针对不同的规则场景,建议采用策略设计模式,多用设计模式实现领域服务便于系统后续的扩展和调整;
  8. 涉及到主子记录的情况(如订单,子订单),一般建立聚合根,基于聚合根统一的保存数据;
  9. 当业务需要多个实体同时处理时,可在应用层统一加上事务管理;
  10. 针对一些基础配置信息,或比较简单的业务(CRUD),采用DDD模式也很费力,此刻不一定非要使用DDD模式,生搬硬套DDD模式感觉把简单事情复杂化了。 
posted @ 2021-11-01 11:13  无涯Ⅱ  阅读(9509)  评论(0编辑  收藏  举报