编辑 | 邵一帆
Eric Evans在其2003出版的《Domain-Driven Design》中首次提出此概念,翻译成中文即领域驱动设计,缩写为DDD,通过该作的副标题(软件核心复杂性应对之道)即可大致了解到DDD是一种可降低软件复杂性的方法理论。到了2013年,Vaughn Vernon[1]出版了另一本著作《Implementing Domain-Driven Design》则对DDD做出了更加全面的阐述。
众所周知Java是门面向对象的语言,但在我们传统的三层架构中却有着过程式的编码,数据模型仅当做数据的载体,几乎所有业务逻辑都是由业务逻辑层的相应方法来完成的。这样的对象只有属性(字段)没有行为(方法)是不完整的、是不符合现实世界的抽象的,比如一个人只有属性没有行为,那他就是一个植物人,是不正常的,Vaughn Vernon在《Implementing Domain-Driven Design》一书中称之为贫血领域对象。
在业务初期这种过程式的做法省略了建模和相应的结构设计,虽然暂时性的可以快速推进业务,但是留下了很多的技术债。随着时间推进,业务逻辑的复杂度不断提升,代码变得越加臃肿,对于不熟悉逻辑的人,别说修改了,光是阅读就得耗费大量的时间。既影响了业务交付速度,又降低了系统稳定性。
基于此背景,经过调研和尝试后我们决定使用DDD来进行新系统的建设与老系统的改进,以战略设计和战术设计两个阶段指导我们从系统需求分析一直到编码实现的落地。
站在更高维度对业务进行划分、设计,划分领域,确立限界上下文。
领域即业务,是一个组织所做的事情以及其中所包含的一切。
在《实现领域驱动设计》一书中为了区分领域的含义,进一步定义了子领域,根据功能不同划定核心子域、支撑子域、通用子域。如图所示,电子商户系统作为一个大的领域,再对其进行进一步的细分,有拆分出了订单子域、发票子域等。可以看出领域划分和微服务的业务划分是天然切合的。
限界上下文是一个显式的边界,领域模型的定义存在于这个边界之内。每个模型的属性操作在这个边界之内都有着特殊的含义。在很多情况下,在不同模型中存在名字相同或相近的对象,但是它们的意思却不同。当模型被一个显式的边界所包围时,其中每个概念的含义便是确定的了。因此,限界上下文主要是一个语义上的边界,我们应该通过这一点来衡量对一个限界上下文的使用正确与否。《实现领域驱动设计》一书中的一个例子,Account账户这个模型,在银行和证券这两个不同的上下文中有着不同的含义,他们的属性和行为也存在巨大的差别。与技术组件保持一致,将限界上下文想成是技术组件并无大碍,只是我们需要记住:技术组件并不能定义限界上下文。当使用IDE时,比如Eclipse或者IntelliJ IDEA,—个限界上下文通常就是一个工程项目。项目的源代码可以只包含领域模型,也可以包含一些周边的层或六边形区域等。对于项目的划分是很灵活的。在使用Java时,顶层包名通常表示限界上下文中顶层模块的名字。Vernon给了个限界上下文定义包名的例子:
com.mycompany.xxx
实际开发中大多限界上下文应该定义在部门路径下:
com.公司名.部门名.限界上下文
限界上下文的源代码结构可以根据架构职责做进一步分解。下面是一些二级包名:
com.mycompany.team.xxx.presentation
com.mycompany.team.xxx.application
com.mycompany.team.xxx.domain.model
com.mycompany.team.xxx.infrastructure
在开始采用DDD时,首先你应该为你当前的项目绘制一个上下文映射图,其中应该包含你项目中当前的限界上下文和它们之间的集成关系,上图表示一个抽象的上下文映射图
以上这个简单的框图便可以作为你团队的上下文映射图。其他团队在实施DDD时应该创建他们自己的上下文映射图。上下文映射图主要帮助我们从解决方案空间的角度看待问题。
在上下文映射图中,我们使用以下缩写来表示各种关系:
上面展示了传统三层架构,DDD的一大好处便是它并不需要使用特定的架构,由于核心域位于限界上下文中,我们可以在整个系统中使用多种风格的架构。有些架构包围着领域模型,能够全局性地影响系统,而有些架构则满足了某些特定的需求。我们的目标是选择适合于自己的架构和架构模式。Vernon在原文中介绍了很多种架构,这里挑选出一些进行介绍。
上图展示了一个DDD系统所采用的传统分层架构。核心域只位于其中一层(领域层),其上为用户接口层和应用层,其下为基础设施层。分层架构一个重要原则:每层只能与位于其下方的层发生耦合。分层架构也分为几种:在 严格分层架构(Strict Layers Architecture) 中,某层只能与直接位于其下方的层发生耦合;而松散分层架构(Relaxed Layers Architecture)则允许。任意上方层与任意下方层发生耦合。由于用户界面层和应用服务通常需要与基础设施打交道,许多系统都是基于松散分层架构设计的。
这里需重点看下应用服务,其位于应用层中,和位于领域层的领域服务(下文介绍)不同。其可以用于控制持久化事务、安全性校验或向其他系统发送事件消息通知等。它应当是轻量级的,主要用于协调领域对象,比如接受用户入参通过资源库获取聚合实例后执行相应操作,比如调用领域工厂生成聚合实例,再调用资源库进行持久化,还可以调用领域服务来完成相关的无状态操作。
在一些其他地方看到有人实践时将资源库接口放在领域层,资源库实现放在基础设施层,没有遵循依赖倒置原则的情况下这种做法违背了分层架构。领域层依赖基础设施层,基础设施层的资源库实现就需要依赖领域层的资源库接口,这里产生了循环依赖,如果这两层放在不同的jar中,编译都无法通过。
在这种架构中,不同的客户端通过“平等”的方式与系统交互,需要新的客户端接入只需要添加一个新的适配器将客户端输入转化成能被系统API所理解的参数就行了。每种客户端都有自己的适配器,将其转化为内部程序锁能理解的输入。六边形的不同的边代表了不同种类的端口,用于处理输入输出的请求。通常来说,我们都不用自己实现端口,举个例子,这些端口可以是http端口,消息队列端口,而适配器相应的就是Java Servlet及消息监听器。
查询,尤其是些复杂查询,比如用户数据需要来自多个聚合,比如一些多条件过滤的分页查询,这种情况下单单使用资源库难以用来解决这个问题。领域模型中仅保留命令方法,查询也只保留一个通过id查询的方法,不提供通过属性过滤的方法,这样就建立了一个命令模型。在通过事件订阅的方式将数据异步更新到查询模型的存储中去,后续查询都走到查询处理器,查询处理器中处理查询问题也就不再使用领域模型,而是直接的以请求→查询处理器→数据源→DTO→客户端的方式处理查询请求。这里将查询处理器和命令处理器分在了两个不同的系统进程中,数据源也分了两个,个人认为实际操作中放在一个系统进程,一个数据源中即可。
在细分领域中进行建立领域模型,下面介绍一下常用的领域模型:
一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续地变化。我们可以对实体做多次修改,故一个实体对象可能和它先前的状态大不相同。但是,由于它们拥有相同的身份标识(identity),它们依然是同一个实体。
实体重在有唯一标识,属性改变,唯一标识不变,就还是同一个实体。比如,在中华人民共和国这个上下文中,你作为一个实体,有着身份证号码这个唯一标识,不管是改姓名还是迁户籍,你的身份证号码都是不会变的,那就还是同一个实体。
用于描述领域的某个方面而本身没有概念标识的对象称为VALUE OBJECT(值对象)。VALUE OBJECT被实例化之后用来表示一些设计元素,对于这些设计元素,我们只关心它们是什么,而不关心它们是谁。
其有以下特征[Vernon-实现领域驱动设计[2]]:
它度量或者描述了领域中的一件东西; 它可以作为不变量; 它将不同的相关的属性组合成一个概念整体(Conceptual Whole); 当度量和描述改变时,可以用另一个值对象予以替换; 它可以和其他值对象进行相等性比较; 它不会对协作对象造成副作用[Evans]。 特殊情况(何时允许可变性)[Evans-领域驱动设计 : 软件核心复杂性应对之道[3]]:
如果VALUE频繁改变; 如果创建或删除对象的开销很大; 如果替换(而不是修改)将打乱集群(像前面示例中讨论的那样); 如果VALUE的共享不多,或者共享不会提高集群性能,或其他某种技术原因。再次强调:如果一个VALUE的实现是可变的,那么就不能共享它。无论是否共享VALUE OBJECT,在可能的情况下都要将它们设计为不可变的。
可以看出值对象重在属性,无唯一标识,大多数情况下被设计成不可变对象,从而使它在上下文传递、跨线程传递时是安全的。Eric Evans也指出了值对象不是无论如何都需要是不可变的,这个要看具体的业务情况,具体问题具体分析,不能限定死,容易逼死强迫症。举个比较常见比较典型的例子,金钱这个字段在我们大多数业务线都会出现,我们不会赋予其唯一标识,将其作为实体,这样单独出现的金钱没有任何意义,往往都是依附于订单这样的实体。大多数时候都会将其定义成一个bigint,单位为分的简单字段,这样做虽然可行,但是却不合理,这样来表达现实世界中的金钱的含义是模糊的、不完整的,缺少单位、缺少币种,等等。"你经常对你爸说:给我一百块",这里的金钱有数量,有单位,中国人币种当然默认是人民币。因此一个完整的金钱至少应当包含以下属性:
{
"amount":"数量123",
"unit":"单位Yuan/Fen",
"currency":"币种CNY/USD"
}
//金钱值对象
public class Money {
private String amount;
private MoneyUnit moneyUnit;
private Currency currency;
public long getFen() {}
public String getYuan() {}
public long getTargetCurrencyFen(BigDecimal exchangeRate) {}
}
有时,对象不是一个事物。在某些情况下,最清楚、最实用的设计会包含一些特殊的操作, 这些操作从概念上讲不属于任何对象。与其把它们强制地归于哪一 类,不如顺其自然地在模型中引入一种新的元素,这就是SERVICE(服务)。一些领域概念不适合被建模为对象。如果勉强把这些领域功能归为ENTITY或VALUE OBJECT的职责,那么不是歪曲了基于模型的对象定义,就是人为地增加了一些无意义的对象。SERVICE是作为接口提供的一种操作,它在模型中是独立的,它不像ENTITY和VALUE OBJECT那样具有封装的状态。SERVICE是技术框架中的一种常见模式,但它们也可在领域层中使用。
使用SERVICE时应谨慎,它们不应该替代ENTITY和VALUE OBJECT的所有行为。好的SERVICE有以下3个特征:
与领域概念相关的操作不是ENTITY或VALUE OBJECT的一个自然组成部分。 接口是根据领域模型的其他元素定义的。 操作是无状态的。 这里所说的无状态是指任何调用方都可以使用某个SERVICE的任何实例,而不必关心该实例的历史状态。SERVICE执行时将使用可全局访问的信息,甚至会更改这些全局信息(也就是说,它可能具有副作用)。但SERVICE不保持影响其自身行为的状态,这一点与大多数领域对象不同。
领域服务就是将一些无法归为领域对象的行为单独提取出来,作为一个只有行为(方法)没有属性的独立存在。但应当注意区别一下传统的service,领域服务侧重的应该是流程的串联,核心的逻辑代码不应该在领域服务而是在领域对象中。传统三层架构的service则是将pojo作为数据载体,其自身包含了处理数据载体的全部逻辑。
领域专家所关心的发生在领域中的一些事件。将领域中所发生的活动建模成一系列的离散事件。每个事件都用领域对象来表示……领域事件是领域模型的组成部分,表示领域中所发生的事情。
顾名思义即领域内所发生的的一些事件,这些事件可能发布至本地限界上下文,也可能是远程限界上下文。个人实践下来,将事件本身作为一个聚合存储下来,之后再异步的在本地处理或转发至远程上下文是最为实用的实践。
AGGREGATE就是一组相关对象的集合,我们把它作为数据修改的单元。每个AGGREGATE 都有一个根(root)和一个边界(boundary)。边界定义了AGGREGATE 的内部都有什么。根则是AGGREGATE所包含的一个特定ENTITY。对 AGGREGATE而言,外部对象只可以引用根,而边界内部的对象之间则可以互相引用。除根以外的其他ENTITY都有本地标识,但这些标识只在AGGREGATE内部才需要加以区别,因为外部对象除了根ENTITY之外看不到其他对象。AGGREGATE外部的对象不能引用除根ENTITY之外的任何内部对象。根ENTITY可以把对内部ENTITY的引用传递给它们,但这些对象只能临时使用这些引用,而不能保持引用。根可以把一个VALUE OBJECT的副本传递给另一个对象,而不必关心它发生什么变化,因为它只是一个VALUE,不再与AGGREGATE有任何关联。
Vernon在书中指出一个好的聚合应当满足以下原则:
同时给出了些在某些场景下可以打破以上原则的理由:
Vernon所列出的这些原则是帮助我们建立更好的聚合,也给出了打破这些原则的理由,个人认为打破这些原则的理由也不局限于以上,而在于项目实际开发过程中的利弊权衡。
//订单聚合
public class OrderAggrRoot {
private String id; //订单id
private Seller seller; //卖家信息快照,值对象
private Buyer buyer; //买家信息快照,值对象
private TradeInfo tradeInfo; //交易信息,购买的相应商品、服务的标题、内容等快照,值对象
private Money money; //交易金额,值对象
private OrderStatus status; //订单状态,值对象
private List<TransactionEntity> transactionList; //交易流水列表,一笔订单可以有支付、撤单、退款等流水,实体
//通过订单状态和遍历流水列表来判断当前该订单是否可退款、可撤单
public boolean isRefundable() {}
public boolean isCanBeCancelled() {}
}
当创建一个对象或创建整个AGGREGATE时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用FACTORY进行封装。对象的功能主要体现在其复杂的内部配臵以及关联方面。我们应该一直对对象进行提炼,直到所有与其意义或在交互中的角色无关的内容被完全剔除为止。一个对象在它的生命周期中要承担大量职责, 如果再让复杂对象负责自身的创建,那么职责过载将会导致问题。但将职责转交给另一个相关方(调用方),会产生更严重的问题。调用方知道需要完成什么工作,并依靠领域对象来执行必要的计算。如果指望调用方来装配它需要的领域对象,那么它必须要了解一些对象的内部结构。为了确保所有应用于领域对象各部分关系的固定规则得到满足,调用方必须知道对象的一些规则,甚至调用构造函数也会使调用方与所要构建的对象的具体类产生耦合。结果是,对领域对象实现所做的任何修改都要求调用方做出相应修改,这使得重构变得更加困难。当调用方负责创建对象时,它会牵涉不必要的复杂性,并将其职责搞得模糊不清。这违背了领域对象及所创建的AGGREGATE的封装要求。更严重的是,如果调用方是应用层的一部分,那么职责就会从领域层泄漏到应用层中。应用层与实现细节之间的这种耦合使得领域层抽象的大部分优势荡然无存,而且导致后续更改的代价变得更加高昂。
创建领域对象本应由领域对象自身完成,但考虑到领域对象本身已承载了核心业务逻辑,因此将创建领域对象的能力单独剥离至工厂。为什么领域对象需要有其本身的类来创建呢,一是因为其本身更清楚当其被创建时所应满足的必要规则,二是将来必要规则变动时所付出的代价最小,因此产生了领域工厂。下面举个伪代码例子:
public class CarAggrRoot {
//构造器的访问权限限定为同一个包下可访问,领域对象与领域工厂在同一个包目录下
CarAggrRoot() {}
private String id;
private String color;
private Engine engine;
private List<Wheel> wheels;
private Chassis chassis;
}
public class CarFactory {
//嫌方法入参过长可以使用构建器模式,build时做规则校验,保证聚合的完整性
public static CarAggrRoot genCar(String id, String color, Engine engine
, List<Wheel> wheels, Chassis chassis) {
//存在性校验
if (Objects.isNull(engine)) {
xxx;
}
if (Objects.isNull(wheels) && wheels.size != 4) {
xxx;
}
if (Objects.isNull(chassis)) {
xxx;
}
//具体业务校验
engine.check();
wheels.forEach(s -> s.check());
chassis.check();
//其他初始化动作
//生成聚合
return new CarAggrRoot(id, color, engine, wheels, chassis);
}
}
资源库通常表示一个安全的存储区域,并且对其中所存放的物品起保护作用。当你从资源库中取出一个物品时,你希望该物品和其先前存放时的状态是一样的。有时,你有可能会从资源库中移除某些物品。这个基本的原则对于DDD的资源库(Repository)来说也是适用的。通常我们将聚合实例存放在资源库中,之后再通过该资源库来获取相同的实例。如果你修改了某个聚合,那么这种改变将被资源库所持久化。如果你从资源库中移除了某个实例,那么从那以后你将无法重新获取该实例。
一个完整的聚合可能并不是来源于同一张数据表,资源库就是将其分别持久化到对应的数据表中,查询时通过某个属性遍历出全部实体组成一个完整的聚合,向上屏蔽了聚合序列化和反序列化的细节。在CQRS中,个人认为应用层也不应当直接操作ORM,也应该有个Repository介于其中,只是这个Repository不在位于领域层中,而是位于基础设施层,用于帮助我们屏蔽底层数据存储的具体实现。
首先进行领域的划分。支付领域内,最为重要的是订单与流水,因此确立收单作为支付领域的核心子域,承载核心业务。
收单生成流水后需要将交易请求发送至支付通道,与通道的交互逻辑较多又比较独立,因此划分出一个单独的通道支撑子域,用于与三方支付通道交互。营销是商业活动中重要的一部分,收单的核心是订单与流水,订单流水的核心则是金额,而决定金额的要素不光是商品服务本身,还有商家、平台、通道等各方的让利,因此划分出活动子域,为收单提供支撑。风控是支付环节中的重要保障,守护着交易的安全,决定一笔交易能否进行下去需要依赖众多因素,比如商户状态、用户行为、交易环境等等,因此划分出风控支撑子域。
支付领域内还有一重要子域,其包含着商户这一重要角色的相关数据,收单依赖其获取交易配置,风控依赖其获取或修改商户基础信息、状态,活动依赖其获取商户基础信息判断活动准入,领域内多数子域都对其有依赖,因此划分出商户通用子域。
上面我们划分了收单核心子域、通道支撑子域、活动支撑子域、风控支撑子域以及商户通用子域,支撑子域与通用子域的区别在于支撑子域在领域内只为核心子域提供服务,而通用子域也为除核心子域外的其他子域提供服务。
各上下文映射关系如下,收单通过防腐层访问商户、风控、通道、活动等子域,线下收款和智慧门店为支付外部领域,由收单上下文为其提供开放主机服务(省略了一些网关)。
//订单聚合,位于领域层
public class OrderAggrRoot {
private String id; //订单id
private Seller seller; //卖家信息快照,值对象
private Buyer buyer; //买家信息快照,值对象
private TradeInfo tradeInfo; //交易信息,购买的相应商品服务的标题、明细等,值对象
private Money money; //交易金额,值对象
private OrderStatus status; //订单状态,值对象
private List<TransactionEntity> transactionList; //交易流水列表,一笔订单可以有支付、撤单、退款等流水,实体
//通过订单状态和遍历流水列表来判断当前该订单是否可退款、可撤单
public boolean isRefundable() {}
public boolean isCanBeCancelled() {}
//储值支付、退款等交易结果
public void processPayResult(PayResult result) {}
public void processRefundResult(RefundResult result) {}
}
以上买家信息、卖家信息、交易信息等都属于外部上下文的领域模型,因此我们在此将其设为值对象,在一笔订单的生命周期中大概率不再修改,即便这些领域模型在外部上下文中有改动,在订单聚合中也不应该跟着变动。交易金额、订单状态都是依赖于订单而存在,离开了订单独立出去无实际意义,因此将其设为值对象。而交易流水反应一笔订单不同生命周期的变化,支付、退款、撤单等都会有独立的流水生成,有独立的id,状态会变更,因此将其设为实体。
//内部支付结果模型,位于基础设施层,起防腐作用,隔离外部上下文
public class PayResult {
//一些支付结果字段
//根据通道侧返回状态判断交易成功
public boolean isSuccess() {}
//根据通道侧返回状态判断交易失败
public boolean isFailure() {}
//根据通道侧返回状态判断交易状态不明确,后续需要通过查单的方式进一步确定状态
public boolean isUnknown() {}
}
//交易应用服务,位于应用层
public class TradeApplicationServiceImpl {
@Resource
private OrderRepository orderRepository;
@Resource
private OrderDomainService orderDomainService;
@Resource
private TradeConfigClient tradeConfigClient;
@Resource
private ChannelRoutingClient routingClient;
@Transactional
PayResponse pay(PayContext context) {
//校验入参
context.checkParams();
//获取交易参数
TradeConfig tradeConfig = tradeConfigClient.queryTradeConfig(context);
//检查交易参数
tradeConfig.check();
//创建订单聚合
OrderAggrRoot orderAggrRoot = orderDomainService.createOrder(context);
//存储订单
orderRepository.add(orderAggrRoot);
//通道路由
PayResult result = routingClient.pay(context.genPayRoutingRequest);
//处理支付结果
orderAggrRoot.processPayResult(result);
//更新订单
orderRepository.update(orderAggrRoot);
return genResponse();
}
}
//订单领域服务,位于领域层
public class OrderDomainServiceImpl {
@Resource
private PreferentialServiceClient preferentialServiceClient;
//PayContext位于应用层,领域服务位于领域层,应用层的模型不能侵入领域层
//所以PayContext继承位于领域层的BasePayContext,参数通过方法传递
public OrderAggrRoot createOrder(BasePayContext context) {
//获取优惠信息,优惠信息和生成订单密切相关,因此需要放在领域服务中
DiscountInfo discountInfo = preferentialServiceClient.queryDiscountInfo();
//生成订单聚合
OrderAggrRoot orderAggrRoot = OrderFactory.createOrder(context, discountInfo);
//其他xxx
return orderAggrRoot;
}
}
//防腐层,位于基础设施层
public class PreferentialServiceClient {
//外部优惠服务接口
@Resource
private PreferentialService preferentialService;
public DiscountInfo queryDiscountInfo(DiscountQueryRequest request) {
//获取优惠信息
DiscountResult result = preferentialService.queryDiscount(request.genRequest());
//转成内部模型
return genDiscountInfo(result);
}
private DiscountInfo genDiscountInfo(DiscountResult result) {}
}
访问外部服务应当通过防腐层。将外部模型翻译成本地模型,避免外部模型在本地上下文中“乱窜”,因为外部模型存在很多不可控因素,即使将来依赖的功能变更外部服务,也只需要在此替换外部服务,将新的外部模型翻译成本地已存在的模型,而核心逻辑依赖的是本地模型,因此无需改动核心逻辑,从而保证了系统的整体稳定。
了解DDD有几年了,但真正的付诸实践用于线上业务也就始于去年(2021),当时我们团队准备接手并推倒重构某重要系统,此任务被分配到我这,也就有了DDD的第一次实践。过程是艰辛的,有着很多困惑,比如值对象一定要设计成final类,当时也没有时间去细读两本著作,后来知道了这不是必须的,只是设计成final类会减少很多副作用,再比如聚合中的根实体和其他实体是一对多的关系,为保证聚合完整性一次查出根实体和其他所有关联实体,但有时候我们的查询业务请求往往只需要查询根实体或者其他关联实体,查询出完整的聚合就显得很死板,浪费内存浪费带宽,后来知道这种情况下没必要走领域,直接通过应用层查询出对应的数据展示即可,等等问题,但结果是美好的,线上稳定,代码易于理解可读性强、结构清晰易扩展,后续迭代快速,在领域划分方面也能给出些战略性的指导等等。
文章是本人结合两本著作以及自身实践后的一点理解而成,希望能给大家提供一点有价值的参考,鉴于本人水平有限,如有理解不到位或者错误的地方还请见谅。
颜太平,来自基础业务开发部
Vaughn Vernon: https://vaughnvernon.com/
[2]实现领域驱动设计 Vaughn Vernon / 滕云 / 电子工业出版社 / 2014-3: https://book.douban.com/subject/25844633/
[3][美] Eric Evans / 赵俐 / 盛海艳 / 刘霞 / 人民邮电出版社 / 2016-6-1]: https://book.douban.com/subject/26819666/