尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:
谈谈你的DDD落地经验?
谈谈你对DDD的理解?
如何保证RPC代码不会腐烂,升级能力强?
微服务如何拆分?
微服务爆炸,如何解决?
你们的项目,DDD是怎么落地实操的?
所以,这里尼恩给大家做一下系统化、体系化的梳理,使得大家可以充分展示一下大家雄厚的 "技术肌肉",让面试官爱到 "不能自已、口水直流"。
也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典PDF》V132版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,后台回复:领电子书
除了本文,尼恩输出了一个 《从0到1,带大家精通DDD》系列,帮助大家彻底掌握DDD,链接地址是:
《阿里大佬:DDD 落地两大步骤,以及Repository核心模式》
《极兔面试:微服务爆炸,如何解决?Uber 是怎么解决2200个微服务爆炸的?》
《阿里大佬:DDD中Interface层、Application层的设计规范》
大家可以先看前面的文章,再来看本篇,效果更佳。
另外,尼恩会结合一个工业级的DDD实操项目,在第34章视频《DDD的学习圣经》中,给大家彻底介绍一下DDD的实操、COLA 框架、DDD的面试题。
DDD现在非常火爆,是有其巨大生产价值,经济价值的, 绝不仅仅是一套概念那么简单。
DDD的绝大价值,具体请参见以下视频:
从腾讯视频DDD重构案例,看看DDD极大价值
文章目录
阿里单据系统的DDD最佳实践
作者:少岚,阿里同城履约物流技术团队
本篇以电商购物场景为背景,探讨了领域驱动设计(DDD)在实际应用中的实践过程。你会发现,DDD 的核心理念在于,通过一系列实用技巧,挖掘出能揭示问题本质的领域模型,并通过模型间的协作解决领域问题,从而驾驭问题领域的复杂性。对于 DDD 爱好者来说,它犹如一个充满挑战和智慧的玩具,在深入思考问题本质和构建抽象知识模型的过程中,让人沉浸于心流状态。
一、前言
领域驱动设计(Domain-Driven Design),简称 DDD,并非一种框架或具体的架构设计,而是一种架构设计思想。其代表性著作便是"领域驱动设计之父"Eric Evans 的经典书籍《领域驱动设计》。DDD的核心目标是通过各种实用方法和技巧提炼出具有体现问题实质的领域模型,并通过保护和组织模型协作来解决领域问题,从而掌控问题领域本身的复杂性,也就是为什么DDD会被认为是软件核心复杂性的应对之道。
DDD的理想应用场景是具有固定领域体系且复杂性较高的应用软件系统设计的各个环节和过程,但这无疑是一项艰巨的任务。DDD要求技术人员高度协同,提升建模技巧,精通领域设计,并通过不断的时间推移和领域知识的吸收消化,以达成应对复杂性的目标。只有这样,DDD的价值才能在项目的中后期得到充分体现。本文旨在带领大家从第一视角体验这种实践过程,感受DDD的独特魅力,掌握其精髓,为在DDD中探索的朋友们指明方向。
我个人对面向对象编程有着浓厚的兴趣,编写代码如同孩子玩玩具般充满乐趣。DDD让我有机会玩得更高级、更复杂、更具挑战性的玩具。对于一个始终保持少年心态的程序员来说,构建领域模型极易让人进入心流状态。这种深入思考问题本质,构建抽象知识模型的过程,让我对DDD情有独钟。
我想用两个词来表达我体会到的魅力:知识、思考。
知识 :Eric Evans发行的《领域驱动设计》一书中第一章介绍的就是知识,特别指领域知识 ,但是这里的知识并不是简单的问题的表象,而是深入到问题的本质,只有获取到真正的知识,运用好各种DDD模式和优秀的战术,打造具有丰富知识设计的模型,才能充分发挥领域驱动设计的好处。
思考 :获取知识并不容易。例如,给你一批地球日出月落的数据,你可以用地心说、日心说和地平说等不同模型来拟合地球的各种现象,究竟哪个模型的知识最适合呢?产品和业务提出需求时,很多时候难以触及问题的本质。因此,设计模型、选择模型都需要设计者做到深入思考,挖掘概念,并和领域专家(如果存在)达成一致。
本文是一篇关于DDD实践的典型案例文章,读者也可认为它类似于一种多字段单据的设计模式。全文将以一个简化的电商购物背景作为领域上下文,重点介绍领域组件的形成过程,并突出DDD的核心要点。但同时需要注意到,本文专注于单个领域上下文的战术实践,不涉及多个领域上下文的协作。文章核心内容将按照4个小节展开:
- 从实体生命周期出发,围绕一个聚合根的设计作介绍,包括原因、好处;
- 从单据字段的性质,特点等,挖掘出一类命令对象集合;
- 是体现如何从深层领域本质 修正一个状态机模型,从而改变了我的组件设计为状态同步模型;
- 根据防腐层的一些好处,以及如何在防腐层中通过重构去捡回来重要的领域实体;
通过本文,希望大家能更好地理解和应用领域驱动设计,为复杂业务场景找到解决问题的方法。
二、单据
1、生命周期
1)领域概念:贫血实体
为了简化问题,本文将以简化的电商交易平台领域为例,探讨其中的核心概念。简而言之,即消费者在某个购物平台下单购买商品,支付完成后,商品按照计划送达消费者手中。
其中系统比较重要的就是订单,订单作为单据,是一种交易凭证,表达了交易关系的事实依据。它主要涵盖了客户、商品、时间、支付等要素,可作为会计核算的原始资料和重要依据。电商交易单据以电子化形式存在于信息系统中,我们统一称之为交易主订单。
通常情况下,一个交易主订单代表一次交易行为。其中的交易内容,会用交易子订单表示,例如:用户一次性购买5个苹果,3个梨子,那就对应为一个交易主订单,它刻画了用户的购买行为,其中有两个交易子订单,一个描述5个苹果,一个描述3个梨子。
如果我们系统的子单可以单独发货,甚至多仓发货的,那么我们再加一个发货单的概念,用作和包裹一一对应,一个包裹可以放任意交易子单的物品,例如上面的两个子单可以放到两个包裹,用两个发货单表示,一个发货单4个苹果,另一个发货单1个苹果加3个梨子,当然我们的电商系统还有商品、客户、收件人、供应商等实体,现在我们在系统中有了这些实体,如下图所示。
注意:请点击图像以查看清晰的视图!
在系统中,实体具有各自的生命周期。一个交易主订单可能包含多个交易子订单,一个包裹可以随意组合子订单进行发货。但这些模型相对较弱,因为难以充实。如何将这些需求封装为知识,以设计出更完善的模型,只有在实际操作中才能找到答案。这也是系统初期面临的实际情况,不应过度设计,往往一开始就是一个简单的CRUD系统。
2)领域知识:生命周期
以上介绍的实体都有自己的生命周期,生命周期体现在系统行为中。以简单电商系统为例,从下单到服务结束,基本经历以下过程行为:
下单:
-
用户提交订单
-
商品的库存占用
-
用户在规定时间内进行支付
-
订单阶段性状态推进:待支付、支付完成、待发货、运输中、配送中、妥投等等
查询:
- 生命周期中发生查询请求
取消:
-
订单有效期到期取消订单
-
用户取消订单
以上流程,都和上面提到的实体相关,但具有相同生命周期的实体组合较少。例如,订单实体的生命周期与客户完全不同。客户从注册到注销,一直存在,而订单仅在一次完整交易行为中存在。商品和订单也不同,订单被取消生命周期结束,而商品可以重新售卖。因此,在商品、供应商、客户、交易主订单、交易子订单、发货单等实体中,只有交易主订单和交易子订单具有相同生命周期,过程还包括发货单。
另一方面,我们看一下会改变交易主订单和交易子订单状态的一些代码行为(通常我们会封装到服务类中),代码在系统刚开始基本会写成如下这样:
注意:请点击图像以查看清晰的视图!
但以上的依赖链路,会导致各种单据实体异常稳定,无人敢于轻易更改。随着需求的复杂化,管理这种依赖关系显得尤为重要。如果不加改进,这种方式可能会引发诸多问题,以下列举了一些问题及其特点:
事务一致性:在某种程度上,这些服务都需要改变订单单据的状态。以提交订单服务、订单支付服务、取消逻辑处理服务、时效管理服务、物流服务等服务为例,它们都需要独立保证事务的逻辑一致性。这涉及到并发和乱序问题,保持一致性的逻辑代码复杂且易错,开发人员维护起来也会感到疲惫。跨进程操作订单更是灾难性的后果。
共同闭包性:我们发现,大部分交易主订单的状态是由子订单或发货单推进的。例如(妥投一致性规则例子):如果包裹从不同仓库发出,可以走不同的路线。只有当发货单Asub1、Asub2、Asub3都妥投后,才能将交易主订单AOrder修订为完成。每个包裹更新事件基本上都是独立的一次事务。假设Asub1的妥投事件同步过来,我们必须将Asub2、Asub3、AOrder从数据库中取出进行检查和处理。实际上,子单的状态也可能被发货单推动。如果发现实体组合中有许多这样的同时修改需求,说明它们基本上是一个共同闭包的整体,我们可以考虑将这样的组合进行抽象封装。
共性逻辑散落:例如,以上提到的维护妥投一致性规则的代码,如果还有一些乱序状态回传处理的代码,记录状态变化流水的代码,这些代码各自的本质其实是基本相同的。这些重复的逻辑散落在各类服务中,每次修改一个需求的时候可能需要修改各个服务。例如,我需要在记录流水类换了接口签名实现,那么我就需要在各个服务类中都去更换这个接口签名,这样的共性逻辑散落对修改就是关闭的。
3)领域模式:聚合与聚合根
实际上,对于熟悉DDD的人来说,他们会很容易接触到聚集的概念。我们需要考虑是否可以将这些单据构建为聚集,以及构建后是否存在其他潜在问题。在经过对系统各个方面的权衡之后,我们基本上可以确定,得大于失;
知识拓展(聚合与聚合根) :在具有复杂关联的模型中,确保对象更改的一致性是具有挑战性的。不仅相互无关的对象需要遵循一定的规则,紧密关联的对象组也需要遵循一定的规则。然而,过于保守的锁定机制会导致多个用户之间无谓地相互干扰,从而使系统不可用。针对这些模型,我们采用一个抽象来封装它们之间的引用。聚集(Aggregate)是一种相关对象的抽象封装,被视为数据修改的基本单位。每个聚集都有一个根(Root)和一个边界(boundary),边界用于定义聚集内包含什么,而聚合根则是唯一对外的引用。------摘自《领域驱动设计》。
如下图所示,我们先来观察将交易主订单、交易子订单和发货单(包裹)构建为聚集,并以交易主订单作为聚集根后的效果。然后,我将列举几个方面,说明通过这些改变,使得交易实体从贫血模型转变为充血模型,还有这样做的理由:
注意:请点击图像以查看清晰的视图!
聚合根一致性:聚合模式的核心特征是,所有涉及交易子订单的操作都会通过其聚合根交易主订单来执行,并由聚合根负责保持它们之间的规则一致性。聚合之间的实体能够相互引用,下面我们将详细介绍一些关于聚合根一致性的规则:
- 主子单一致性 :不仅上面提到的妥投一致性规则 ,还可以有出仓库一致性规则,也就是说,交易子单的出仓操作是独立的,当所有发货单都已完成出仓,交易主单才将状态更新为出仓。这一规则的逻辑维护责任将转移到聚合根交易主订单实体中,每次聚合状态发生变化时,都会触发一次检查;
- 发货单与子单一致性:如前所述,包裹是存放子单的部分数量的地方。每个包裹中包含哪些子单以及数量多少,所有包裹的子单总数需要与源交易子订单逻辑保持一致。现在,这一一致性得以由交易主订单保证,不再分散。在发现不一致的情况下,只需在一个地方进行报警监控。
聚合根封装细节:所以很自然的,我们也可以把一些散落在各个操作交易主订单和交易子订单的逻辑都封装到聚合根中:
- 节点流水记录:由于单据作业流水记录的节点与单据状态相对应,因此流水记录逻辑可以被封装到聚合根中。每当状态发生变化(例如从提交订单(Accepted)到支付完成(Paid)),都会记录一条流水。在2.3节中,我们将专门介绍封装在聚合根内的状态同步模型。需要注意的是,这里并不是让主订单直接操作数据库,它只需负责生成流水,而将流水记录到数据库则应由领域服务负责;
- 订单状态推进:各种事件(支付、发货、妥投)同步及异步回传的处理代码,都将会封装到交易主订单中,让主订单变更子订单和发货单状态,逻辑只有一份,可维护性强;另外一个状态的变更用状态机是前期可以考虑的方案。
事务修改的基本单元:有了聚合,仓储必须得到整合。而仓储整合的关键是确保聚合的修改成为事务的基本单元,这具有诸多好处:
- 没有数据库概念:取消逻辑服务、查询服务、支付逻辑处理服务等服务,不在需要写一遍SELECT交易主订单,交易子订单,UPDATA交易主订单,交易子订单等逻辑,甚至没有INSERT这种逻辑,而它们都只需两个动作:拿出交易主订单聚合,放交易主订单聚合回仓库;
- 副作用的保护:以前的模型中,各个服务都会对单据产生副作用。而现在只有交易主订单会对包裹和子单产生副作用。这种副作用还可以被监控,下一节我们将详细介绍如何通过深入演进命令实体模型来保护这些副作用。
注意:请点击图像以查看清晰的视图!
有了以上的设计,可以想得到,如果需要添加新的状态或一致性逻辑,只需在交易主订单聚合操作中进行即可。此外,新增拒收回传服务也无需重新编写保障业务事务的逻辑,无需编写一行记录流水的代码,封装性和可维护性的价值得到了很好的保障。同时,支付处理服务、取消逻辑处理服务和妥投逻辑处理服务等服务的职责变得单一,代码逻辑变得更加轻松,可读性也得到了提升。
聚合的坏处:正如没有完美的架构一样,聚合模式也有其利弊。以下是实施聚合根后需要面临的问题及解决方法:
- 查询性能:显然,如果你只想修改交易主订单的一个字段,仓储将加载所有相关的交易子订单,这无疑会对性能产生负面影响。另一方面,如果你一次性加载所有聚合实体,那么不需要修改的实体也必须写回数据库,但这可以通过一些微小的设计优化来解决,例如,根据聚合根修改了哪个实体,为该实体添加不同的版本,这样仓储就只会根据版本按需更新对象。另外,有些状态变化可能对一致性没有影响,但仍然会触发一致性检查,这类性能影响不大。
- 无谓的更新:例如你只想更新单据的一个字段,而你的SQL是这样写的,UPDATE TABLE A SET A.name = "Marry" WHERE XX,而使用了聚合根之后,就需要更新整个DO的多个字段。如果你不小心设置了其他字段,它们也会被更新,从而减少了犯错的成本。但这并不会成为大问题,可以加入断言或显式打印每次修改字段的日志,以便开发者迅速发现错误。
- 属性访问:访问单据的问题显而易见,例如,某个服务需要访问交易子订单的数据,只能通过交易主订单进行交互,这是否让人难以接受?其实这个问题很容易解决,只要将查询分离出来,创建一套聚合的访问视图(访问模型),让交易主订单的充血方法返回这个访问视图,让服务操作这个视图即可。而且这个视图可以在各种地方使用,不必担心会产生副作用,性价比非常高。
其实聚合根的设计不应该过大,里面的实体种类最好不要太多,上面例子提到的聚合只有3个Entity刚刚好,但实际问题中最多三到四个实体就到了一个比较合适的度了,而且这个时候聚合根的好处会体现的更明显。
2、隐式概念
1)领域知识:单据字段
这节,我们将会直面单据类CURD最讨厌的问题,它就是单据的字段。单据字段在MVC三层架构中,程序员很可能会去偷懒直接用一个DO对象捅到业务层去,最多加一个DTO对象。而在聚合根中,字段更加会难以管理,但如果你愿意用心去细细思考字段的一些特性,说不定也能发现很多不一样的世界。
单据字段多样性:单据最重要的作用是承载属性,而且属性非常多,如下面的交易子订单实体的属性,而且还有各种用作关联的属性,再加上拓展字段,如果这些字段全部由聚合根去维护,那么聚合根的方法会臃肿成怎么样子?
java
@Getter
@Setter
public class TradeSubOrder {
private Long id;
private Date gmtCreate;
private Date gmtModified;
private boolean test;
private StatusEnum status;
// more field
private String size;
private boolean repositoryTrace = false;
private String extendAttribute;
//还有更多
// getter setter toString
}
如果聚合根有20个属性,发货单有15个属性,交易子订单有20个属性,那么聚合根就要有(20+20+15) * 2 = 110个属性访问器对外,这个充血对象和DTO感觉是没有差别的,而且新加一个字段需要加两遍,这样看的话,子单据、发货单等实体必须单独自己去管理自己的字段比较好,而聚合根只需维护一致性的时候去访问该字段即可。
动态拓展字段:如果要你做一个属性经常动态变化的实体,你应该很容易想起把属性打平(建立一个表存key、value、关联id),或者直接加一个extAttribute的Map实现,把属性打平后,我们也不用担心实体的搜索问题,因为现在的查询分离的宽表、NoSQL索引都比较强大了,如果一些字段属性只是在单据上作展示和透传用的,并无多少行为关联,那么很建议这样做。
字段的内聚性:分析一下订单不难发现,一个订单的字段可以归类,从每一类的修改入参可以看出,各自都具有相同的修改原因,如果字段是具有内聚性的,那么多样性的字段就应该是可以分类治理的:
- 交易主订单:"收货地址","收货人姓名","联系电话","邮箱";共同变化的原因:<联系人信息类>
- 交易主订单:"客户id","客户姓名","会员等级","账号";;共同变化的原因:<购买者信息类>
- 交易主订单:"支付方式","支付单号","支付状态","支付时间","实付金额",共同变化原因<支付行为>
- 发货单:"送达时间"、"服务时效"、"配送员"、"物流订阅商";共同变化的原因:<物流节点>
- 交易子订单:"规格"、"数量"、"价格"、"图片"、"货主"、"优惠价";共同变化的原因:<商品编码>
- 其他归类...
有意识的程序员,已经开始把以上各类获取、设置字段属性的代码分别归类到各个不同的函数中,或者不同的类(可能叫商品表达类、物流信息Handler类等)中等等,这种方式在一定程度上是提高了复用性,提高了可维性,但这还远远不够;
字段变化难跟踪:单据承载了很多的信息,各种字段信息是什么时候变化的,单据字段的变化也可能是多次变化的,这些变化的时间和轨迹对于业务的意义有时候也很重要,通常有些特殊的字段产品和业务同学会明确给你提用例需求去做,我们来展示一个真实例子"修改地址":
- 用户修改地址:对于电子商务类服务,无论是各种快递、淘宝等都是支持在未妥投之前让用户去修改地址的,做的好的产品,甚至可以支持多次地址的修改,那么用户在什么时间修改了地址呢?修改前是什么?修改后是什么?这些信息都必须在某个地方很明显的表达出来。
- 加一个服务类:当业务需要的时候,我们自然可以专门开发一个服务类插入系统去支持,但这种需求又有多少呢?未来有没有?能不能有一套设计方案可以保护核心流程,保留可选项,又不失优雅的去支持这类业务呢?
2)概念突破:命令实体
知识拓展:本小节将会介绍一个叫命令实体的领域概念,在许多DDD框架和介绍文中,Command通常被描述为一个简单的贫血DTO加上参数验证逻辑,而不承担业务逻辑的角色。如果经常使用DDD框架,可能会对这个概念产生混淆。这种用法可以类比应用Service和领域Service。为了避免误解,我们将在下文中明确指出Command与CQRS架构中的Command的区别,并建议将下文的Command替换为Operation,以符合领域逻辑。
沟通获取知识:在DDD中,想要和领域专家通过沟通获取知识,统一语言是很重要的,本文的电商领域入门其实比较低,所以基本上沟通会很顺畅,但这不代表知识挖掘是一件容易的事情,下面来自我和产品经历的一段对话:
我:业务最近上了新的话费充值特惠版产品,那个客户的手机号他不让用户输入,要我从账号中心获取,为什么?
产品:是的,他们这款是优惠充值产品,只能给客户自己充值,所以省略用户自己填写的步骤,提高体验。
我:明白了,其实本质都是填写发货单的手机号,只不过是实现方式不同,在我们工程领域是一个标准,实现方式不同。
产品:我大概明白你的意思,这样做没问题,当然肯定还会有第三种方式的,但他们都是做一件事。
我:我记得在电脑端下单和手机端下单,发货单的地址填充方式也不同。
产品:嗯,是的,手机端可以提供精准下单地址,电脑不行,这也是不同的方式,而且这些和产品无关了,所以你也要支持组合使用哦。
我:我知道怎么实现了,我做一个发货单手机号填充命令,但是实现类不同,下次你们变化,我就让你们自己配置。
产品:可以,命令我能听懂,上次小冰跟我说什么interface就不知道是啥了。
我:哦,interface你就不用管了,其实我也是用的interface哈哈哈哈。
其实通过以上沟通,我明白的是,产品需要的是这个补充字段是可以配置的,但大多数人拿到需求立马代码就出来了,也不考虑一下这样写的原因,其实很多时候,只有在写代码的时候,你才会知道除了业务和产品表面上的需求,内部可能蕴含着更深知识可以挖掘;
专业知识:查询与命令的划分是常见的做法。我们经常将代码分为两类:一类用于改变状态,这类代码称为命令;另一类用于获取状态但不改变,这类代码称为查询。单据的字段通常都会改变单据实体的状态,因此,如果我们将这类逻辑视为命令,那么很明显,如果我们看到一个类的名称带有Command后缀,我们可以很容易地想到,这个类必然会改变状态,而单据的状态就是字段。
知识拓展(柔性设计):在Eric Evans的《领域驱动设计》一书中,他建议我们将逻辑代码组织成无副作用的函数,让函数返回Value Object。然后,让简单的副作用命令根据返回的Value Object来更改对象状态。如果可能的话,尽可能将这些逻辑代码封装到Value Object中,形成一个无副作用、可组合复用的Value Object。由于无副作用,我们可以自由地组合和复用这些函数。(见《领域驱动设计》------柔性设计 p174)
说到组合复用,再结合产品要的配置,以及我需要的柔性设计,那么把以前所有的改变状态的代码,都组织为命令对象,让命令返回修改后的单据的编辑稿版本(Value Object),最后让聚合根自己把编辑稿(Value Object)更新到自己的字段上,这样就基本符合Eric Evans的这种模式。整个过程类似于编辑表单的过程,用户点击编辑命令后,会获得可编辑的界面(表单草稿),编辑完成后提交按钮触发后台操作,将表单草稿应用于实际生效的表单中。
注意:请点击图像以查看清晰的视图!
命令模式:这个过程和命令模式是差不多的。我们会把命令交给聚合根去执行,对比上面命令模式的图我们可以看出,其实运维人员就是Client,他把封装好的命令间接设置给交易主订单聚合根,而Invoker,则是聚合根,他负责执行具体的命令,同时也会记录命令的执行,改变自身状态。例如下面的代码所示,为聚合根执行命令的过程。
java
public class SubmitOrderUsercase{
public void sumit(Request request) {
TradeMainOrder mainOrder = getMainOrder();
//获取命令的具体实现
IPhoneNumberCompleteCommand command = getCommand(request,IPhoneNumberCompleteCommand.class);
//聚合根执行手机号完善命令
mainOrder.execute(command);
// ......
//获取命令的具体实现
IDiscountCalculateCommand command = getCommand(request,IDiscountCalculateCommand.class);
//聚合根执行折扣计算命令
mainOrder.execute(command);
// ......
}
public IPhoneNumberCompleteCommand getCommand(Request request,Class clazz){
// 业务配置好的,什么场景用什么命令.......
}
}
上面还提到字段内聚性,那么我可以把所有相同原因变化的逻辑设计为一个个命令对象 来管理我的单据字段。这个对象会封装逻辑所需的入参,甚至查询外部服务(实际上只是查询,没有状态变更,查询结果也是入参的一种,它只依赖于入参)。因此,我们可以创建发货单完善命令、支付信息完善命令、购买者信息完善命令、商品信息完善命令 等等对象,我还可以给他们做一个最大的分类,按照不同实体有不同的命令修改接口得到交易主订单变更命令、交易子订单变更命令,这些命令只能由聚合根(交易主订单)执行。最后,通过修改依赖关系,我们可以得到如下图所示的组件结构:
注意:请点击图像以查看清晰的视图!
如此的灵机一闪,引入命令实体后,以上所有的字段问题,都刚好被这个模型拟合了,我列举几个好处:
设计良好:很明显的倒置依赖,保护聚合根的独立性;函数式编程,可以组合而不担心逻辑错误,有人可能会质疑,命令内部是不是会直接访问对象呢?如下图的命令接口所示,如果这样设计该接口明显是有副作用的,但如果我们传入的是编辑稿(类似视图),然后我们编辑视图,最后更新回到实体就可以了。
java
public interface TradeSubOrderChangeCommand {
String getSubOrderId();
void execute(TradeSubOrderDraft subOrder);
}
字段分治管理:有了命令后,加上适当的命令命名,字段的管理再也不混乱。每个字段都应该有其对应的设置命令进行管理,而不是让各种服务类去进行赋值管理。同时,对字段的处理也可以封装到命令中。你可以随时定位一个字段的变更命令,只需要思考一下字段的归类。最重要的是,这种字段的分类的独立性可以让你操作字段的代码独立分离,使其具有更好的开闭性。这一点正好可以解决字段的多样性问题。
命令封装逻辑:命令可以封装action调用。赋值只是命令的目的。既然封装了action的调用,那么对action的入参和结果的处理也可以封装到命令中。更重要的是,只要是符合触发源的目的、职责单一,部分业务逻辑也可以封装到命令中。在以往很多贫血系统中,这些都是由service负责的,似乎没有service不知道该如何安置代码一样。
随时随地跟踪:下面是一个简单版本的聚合根执行命令的代码示例。其中record方法根据命令本身的属性提供有选择性地记录执行结果的能力。如果有重要的字段,你可以找到该单据对应命令的执行流水,并进行可视化管理。这种粒度的管理在业务运维和开发疑难问题排查上都非常有用。
java
public class TradeMainOrder{
public void onCommand(TradeSubOrderChangeCommand command) {
if (!tradeSubOrderDict.isEmpty()) {
TradeSubOrder subOrder = findSubOrder(command.getSubOrderId());
// 变更前的快照代码
command.execute(subOrder);
// 变更后的对比逻辑代码,记录字段变化个数、时间
record();
// ......
makeStateConsistent();
} else {
log.error("子单变更命令执行失败,子单列表为空,{}", EagleEye.getTraceId());
}
}
public void onCommand(TradeOrderChangeCommand command) {
// 变更前的快照代码
command.execute(this);
// 变更后的对比逻辑代码,记录字段变化个数、时间
record();
// ......
makeStateConsistent();
}
}
组合命令:如前所述,无副作用的函数可以方便地进行组合复用。举个例子,一个提交订单的场景中调用了一个名为Combine的发货单完善命令。这个Combine命令充当容器的角色,包含了手机完善、邮箱完善等几个命令。它的执行逻辑就是依次执行这些命令,因为它们具有相同的接口,所以实现这个组合非常简单。此外,它还具备以下特点:
- 只要给每个命令一个id,那么命令组合就可以在外界进行配置化;
- 由于命令组合可以进行配置化,因此哪些命令被执行是在运行时决定的,从而体现了灵活性;
- 命令组合的实现都是基于函数式的,因此组合后的命令不会出现"组合爆炸"的问题,整个过程也是透明和安全的;
有了组合命令,我们可以轻松地根据业务需求将命令定制为组合并上线。能够被管理和配置的独立代码是程序员追求的最高艺术境界。
3、深层模型
1)领域知识:状态推进本质
发货单也具有状态:已接单、待发货、运输中、揽收、妥投、拒收、取消。这些状态的变化驱动是接收外部事件进行推动的,但因为要考虑事件丢失、乱序问题,当一个事件到来后,但前置事件已经丢失、延迟未到,那单据应该决策成为什么状态呢?自然而言,我们很容易联想到状态机,开始我们也是这样做的,状态图如下:
注意:请点击图像以查看清晰的视图!
问题空间(物流实操):业务流程的真正设置如下,且中间流程不允许跳过,例如如果没有在运输中,那么揽收就不可能发生,这说明实际业务状态转换与状态机的解空间不匹配,后者包含了很多不必要的部分。另一方面,如果我们设计一个游戏机的投币程序,用一个状态机实例来表示游戏机的状态:投币状态、空闲状态、游戏状态,那么状态机就完全没问题,这里的根本原因在于,问题空间本身就是解空间的模型驱动的。
注意:请点击图像以查看清晰的视图!
不纯粹的解空间:为什么解空间中有多余的连线?例如:运输中会跳到妥投,这是因为解空间考虑了计算机和架构的细节问题,如事件传播中的异常和速度问题。如果事件保证顺序消费,那这条连线就不需要;如果按照这种思路组织代码和编写代码,必然会在领域实体中加入不属于领域的逻辑,这是领域驱动设计(DDD)所禁忌的。另一方面,如果使用状态机,需求变更添加一种新状态,那么新的连线也会让很多人感到困扰。是时候调整模型,把该逻辑给去掉了,那么怎么去掉呢?
代码职责问题:假设业务要求在每个状态节点经历时,记录节点流水。状态机的实现方式会如何呢?
如果正确的事件顺序是:
1、运输事件,2、揽收事件,3、妥投事件,
但实际顺序是:
2、揽收事件,1、运输事件,3、妥投事件;
当状态顺序混乱时,状态机在揽收状态,运输事件到达,记录运输节点流水应该让messageHandler
处理还是揽收节点处理呢?后者明显不合理,前者也显得勉强。如果除了记录流水,还需要发送外部消息呢?那么messageHandler
的职责就会越来越重。
2)深层模型:修正状态机模型
不存在状态推进 :我们讨论的是发货单的状态,它代表者物流的操作过程,所以其操作进度要反馈到订单的进度,这个过程其实更多的是一种状态同步过程,而不是状态流转的过程,所以我们的解决办法是**:换个角度思考订单状态变更这件事,是状态同步,而不是状态推进**。我们用一个流程实例** (也可以设计为无状态的流程)来解决整个问题。
注意:请点击图像以查看清晰的视图!
我们现在把更新状态的算法换了,从状态推进 变为状态同步,如上图所示,首先刻画整个问题空间的状态流程作为解空间模型,我们发现这个流程是绝对的无环的,一种拓扑排序。它和状态机有几点不同:
- 有序性:状态机的节点是无序的,或者说只能相对有序,而我们的同步模型则是有序的;这与问题空间的工序顺序一致,问题空间的每一步都是有序的。
- 拓扑结构:状态机的节点可能存在环状结构,但我们的同步模型是拓扑排序的,符合业务节点的特性。每个节点都没有环,拓扑结构是该领域的特有属性,这一点很重要,因为我们采用领域驱动设计。
- 运作机制:状态机的运作核心是围绕事件和当前状态寻找下一个流转状态,而同步模型则以流程实例为核心。每当有事件到来时,我们将该流程的节点标记为已同步。如上图所示,1、2、4、5对应的事件均已到达,因此它们呈绿色。每次事件处理完成后,我们比较最大序号的节点和单据当前状态的序号,将序号较大的节点更新为单据状态。
- 计算机无关性:模型不再关注事件是否乱序、延迟。只要事件到达,我们就将其对应的节点标记为已同步,并触发相应节点的业务逻辑,如计费消息的发送和流水的记录。
逻辑封装到节点上:显然,上述流水记录代码无需放入messageHandler,发送节点的外部消息发送代码也不需要绑定messageHandler。它们可以封装在运输节点内,或采用观察者模式,监听运输节点以完成相应行为。这样的代码更具扩展性,灵活性较高。
与状态机相比,新的状态同步模型在开发效率和代码维护方面都有所提升。状态机具有线性复杂度,而状态同步模型则是常数级别的复杂度。这个例子充分证明了领域驱动设计的核心本质**:领域的重要性、知识的重要性**。**
4、边界模型
1)领域知识:边界隐式概念
上面我们讨论完核心模型,我们这节主要讨论的是边界的模型,软件设计的一个关键在于恰当地区分边缘。的确,单一订单处理系统与许多外部系统(如账户中心、商品中心、库存中心、决策中心、支付中心和履约中心等)具有丰富的交互。这些交互主要通过调用各系统的接口来获取或写入数据,其依赖关系如下所示:
注意:请点击图像以查看清晰的视图!
零散的隐式概念 :很明显在整个接单的系统中,这些边界很多概念是应该属于我们的领域上下文中的,例如赠品、计划、库存、会员等级等概念,但这些概念往往只是存在于字段属性中,例如会员等级就只存在账号实体的属性中,并没有专门为他们创建实体,但需不需要为他们创建实体,也是一个问题,这种发现实体的契机,其实也是需要另一个因素决定的,那就是有没有行为和这些属性绑定,所以一开始,我们先不为这些散落的领域逻辑设计实体,但我们应该为以后需要这些实体而做好准备 ,下面的防腐层正是最重要的一步。
2)领域模式:防腐层
知识拓展(防腐层):设计一个隔离层,以便根据客户自己的领域模型为其提供相关功能。这个层通过与另一个系统的现有接口进行对话,而对系统的修改最小。在内部,这个层负责在两个模型之间进行必要的双向转换。------摘自《领域驱动设计》
如上图所示的依赖关系,我们显然与其他领域进行了绑定。为确保内部逻辑的独立性,实现对修改的封闭和对扩展的开放,我们必须改变依赖关系。这是明确划分外部边界的关键一步,如下图所示:
注意:请点击图像以查看清晰的视图!
设计防腐层会带来一定的编程困扰。你需要在内部设计一个进出参模型或内部接口,并添加一层适配器层。适配器层负责实现内部与外部实体的对接。尽管这种方法较为复杂,但我们仍需了解使用防腐层的理由:
-
保护核心层概念:
-
例子:
例如,你在公司中的角色是老板,但在家里的角色是父亲。如果你将老板实体放在家庭中为孩子做饭,这个家庭就会依赖不必要的逻辑,这违反了整洁架构的原则,可能导致变更和稳定性问题;
-
例子:
在交易系统这种复杂的系统中,例如一个在供应商系统中代表它自己编码的merchantCode可能来到交易系统这边会变成supplyMerchantCode,同一个值,用角色字段区分他们这自然是很重要的;
-
关注点分离:
-
说明:外部接口的非逻辑依赖变更不会影响核心逻辑。你只需确保返回字段的含义一致即可;
-
适配逻辑的代码:
-
说明:有很多代码你只是用来做外部实体的处理的,变成内部可识别的实体,例如决策中心传给你的是2021-07-12 ~ 2021-07-13,但你内部用的是一个stat的Date变量和一个end的Date变量;那就需要适配了,这些代码如果编写在核心逻辑中,那你在维护核心逻辑的时候也不得不多思考一件事,不仅代码臃肿,还消耗你的精力。
-
说明:有一个点很重要,为什么要做这种设计,因为设计就是需要把代码放在它该呆的地方,这种转换的代码,总要有一个适配器处理;
-
可随时挖掘隐式概念:
-
说明:例如用户的会员等级,这个会员等级字段属性,就是一个隐藏的概念,它存在于用户账号中,所以你难以发觉。但日后不断的需求变更中,你或许会发现它可能是一个封装性很好的实体。下一节我们将具体介绍如何挖掘除会员等级这个实体。
严格遵守防腐层并不容易,首先要求编写代码的人具备这方面的意识,以免违反规则。其次,编写代码的人应对整个系统架构有一定了解。虽然如此,如果有人把控这部分代码,新手也可以参与系统建设。这一思想来源于《人月神话》中的外科手术医生只有一个的观点。
实际上,划分边缘有两种方式:防腐层和基础设施、应用类业务划分。当将与领域逻辑无关的逻辑划分边界后,六边形架构就出来了。
3)隐式概念:重构中发现模型
以上提到,在划分和外部边界的时候,先不考虑散落的逻辑概念抽象为实体,有了防腐层之后,当有新实体的产生需求即可把这些概念实体化了;这篇我们用一个例子来说明如何通过重构,从防腐层代码中,抽象一个实体出来;首先下面是一个简化版本账号中心域的防腐设计,我们专门为计划的返回做了一个内部的Entity --- 用户账号;
java
@Data
public class UserAccount {
// 其他字段 ......
/**
* 会员等级
*/
private int userLevel;
// 其他字段 ......
}
现在有另一段获取账号信息,根据会员等级获取对应折扣比例的信息,这个代码是这样写的:
java
public class XxxxxxxService {
public Double getDiscount(UserAccount account) {
switch(account.getUserLeval){
case 1:
return 0.99;
case 2:
return 0.98;
case 3:
return 0.97;
case 5:
return 0.95
default:
return 1.00;
}
}
}
特别的,我们在其他service中也发现了一样的代码,当你注意到这点的时候,就是一个领域实体出现的时候了,那么我们可以复用这段逻辑,并把逻辑和账号关联起来,把该行为封装到账号中,如下所示:
java
@Data
public class UserAccount {
/**
* 客户等级
**/
int userLevel;
public Double getDiscount() {
switch(userLevel){
case 1:
return 0.99;
case 2:
return 0.98;
case 3:
return 0.97;
case 5:
return 0.95
default:
return 1.00;
}
}
}
但这还不够,我们忘记了一个领域概念 遗留了,那就是会员等级 ,现在是时候把它显性化为一个领域实体了,所以最终的重构结果是:
java
@Data
public class UserAccount {
/**
* 客户等级
*/
private UserLevel userLevel;
}
public class UserLevel {
int userLevel;
public Double getDiscount() {
switch(userLevel){
case 1:
return 0.99;
case 2:
return 0.98;
case 3:
return 0.97;
case 5:
return 0.95
default:
return 1.00;
}
}
}
领域上下文:在代码重构的过程中,可能会遇到这样一个情况:UserLevel类在账户中心也有,名称一样,但数据绑定行为却大相径庭。这是因为我们所关注的字段所处的领域上下文发生了改变,从账户中心领域转向了交易领域。在不同的领域中,针对相同字段的行为封装是各具特色的,这也是领域驱动设计(DDD)的一个显著特点。
重构是领域驱动设计的引擎:在重构过程中,借助领域知识来引导设计方向,确保领域逻辑的独立性,发掘领域实体和聚合根,具有至关重要的意义。这个例子虽然简单,但在很多情况下,我们要突破深层模型,发掘更优质的设计,都离不开重构。掌握重构技巧是程序员的必备素质。若你认为代码难以重构,可以尝试引入单测和小步快跑的方法。
5、领域服务
知识拓展:有时候,对象不是一个事物,在某些情况下的操作你可能找不到合适的Entity或者Value Object去封装,强制把他们归于一类,不如顺其自然引入一种新元素:SERVICE(服务)。其中,这个SERVICE元素在DDD的各个层中也会有体现,所以会存在应用层的SERVICE,领域层的SERVICE和基础设施层的SERVICE。
领域服务:如何设计领域服务是一个值得探讨的问题。本文借鉴了《架构整洁之道》中的用例划分领域服务。在需求分析阶段,用例对于问题分析非常有帮助。将一个用例设计为一个服务,有助于区分应用层服务和领域服务。
- 应用层服务用作和输入输出相关的逻辑,并且负责调用领域层服务
- 领域层服务用作和领域模型交互,负责组织和协调的领域模型工作的逻辑
因此,针对本文"生命周期"小节介绍的单据的流程,自然就有以下的领域服务:
- 提交订单领域服务:执行读取命令配置、执行命令、库存占用、价格计算、定时失效等逻辑代码;
- 支付领域服务:读取命令配置、执行命令,负责支付校验、调用支付服务、订单各种命令执行等逻辑代码;
- 取消领域服务:读取命令配置、执行命令,负责释放库存、取消订单、取消定时任务等逻辑代码;
- ...
此外,还有应用层服务,如提交订单应用服务、支付应用服务和取消应用服务。区分它们的关键在于逻辑是否具有领域概念。例如,导出操作并无领域含义,但获取运维针对不同产品身份的命令配置、命令组合及执行命令结果等业务逻辑,则应放到领域服务层。
从上面可以看出,许多领域或应用层服务是基于实体(Entity)和价值对象(Value Object)构建的。例如,提交订单服务涉及操作单据(Entity)和命令(Value Object)。从这个角度看,他们的行为类似于将领域中的潜在功能组织起来以执行特定任务的脚本。由于文章重点关注单据模型设计和介绍,此处不再赘述。
三、后记
领域驱动设计的核心目标:我们在文章开头阐述了领域驱动设计(DDD)的关键目的,它旨在利用多种实用策略和技巧来提炼出一个能够真正反映实际问题本质领域的模型,并且保护和组织好这个模型之间的相互作用以便解决领域内的问题。我们已经在文章中运用了聚合根模式、统一语言交流、防腐层模式和重构技术等方式来进行探讨,然而,在实际应用中,可用于解决该问题的方法和知识远远不止于此。有时候,我们还需要对现有的模式进行调整和创新来克服建模过程中遇到的问题。这就需要我们的团队技术人员全面掌握DDD的相关知识,同时具备精湛的建模技术和丰富的实践经验,还包括灵活的思维能力和敏锐的洞察力。这些品质对于我们技术人员的日常工作的开展和自身的专业发展都有着重要的意义。
领域模型协作与组织:由于文章的篇幅和主题的限制,我们并没有在文章中详细地探讨关于领域模型之间合作与组织的问题,然而这实际上是非常重要的一部分内容。通常情况下,我们需要考虑的不仅仅是领域模型的纯度,还有其性能和交易属性。例如,领域服务如何管理领域实体,怎样将领域服务和应用程序服务区分开,以及如何将构建程序和执行程序独立开来等等。如果没有具体的案例作为参考,那么上述的问题可能会让人感到有些抽象,但是我们可以通过参考一些优秀的设计规范,比如提高模块间的凝聚力和降低它们之间的耦合度,SOLID原则,以及软件架构的基本原则等,以此来指导我们的实践工作。读者可以在自己的实践中慢慢领悟这些原则的重要性。
演进与重构:本文以业务单据系统为例,系统初始阶段可能非常简单,直接采用三层架构即可。但随着需求的增长和变化,简单的系统将面临复杂性问题。我们必须掌握每次需求变化,实践Martin Fowler的两顶帽子原则------重构+编写新功能。不断重复这个过程,系统得以逐步演进。若重构+编写新功能始终围绕领域知识统一模型进行设计,那么这个过程就是所谓的领域驱动设计。这也是为什么DDD会如此重视那些随着时间的推移而逐渐演化的强大领域模型。
总结:领域驱动设计是一个不断发展和重构的过程。在实际情况当中,我们可以采用多种方式和技术,比如说聚合根模式、统一的语言交流、防腐层模式等等,来解决领域内的问题。团队的技术人员应该具备丰富的DDD的知识,同时也需要有出色的建模技巧和丰富的实践经验。在设计的过程中,我们还需要考虑到性能、交易属性等方面的因素,以确保领域模型的纯度。通过不断地进行重构和添加新的功能的工作,我们就能很好地应对系统的发展和复杂性的问题。最终,领域驱动设计的目标是将投入的成本与业务需求的行为价值保持平衡。
参考书籍
1.《重构》
2.《架构整洁之道》
3.《领域驱动设计》
说在最后
DDD架构如何落地,是非常常见的面试题。
以上的内容,如果大家能对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。
在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典PDF》,并且在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。
最终,让面试官爱到 "不能自已、口水直流"。offer, 也就来了。
当然,关于DDD,尼恩即将给大家发布一波视频 《第34章:DDD的学习圣经》, 帮助大家彻底穿透DDD。
尼恩技术圣经系列PDF
- 《NIO圣经:一次穿透NIO、Selector、Epoll底层原理》
- 《Docker圣经:大白话说Docker底层原理,6W字实现Docker自由》
- 《K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由》
- 《SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由》
- 《大数据HBase学习圣经:一本书实现HBase学习自由》
- 《大数据Flink学习圣经:一本书实现大数据Flink自由》
- 《响应式圣经:10W字,实现Spring响应式编程自由》
- 《Go学习圣经:Go语言实现高并发CRUD业务开发》
......完整版尼恩技术圣经PDF集群,请找尼恩领取
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓