简介
一文教会你如何写复杂业务代码一般来说实践 DDD 有两个过程:
- 套概念阶段。了解了一些 DDD 的概念,然后在代码中“使用”Aggregation Root,Bounded Context,Repository 等等这些概念。更进一步,也会使用一定的分层策略。然而这种做法一般对复杂度的治理并没有多大作用。
- 融会贯通阶段。术语已经不再重要,理解 DDD 的本质是统一语言、边界划分和面向对象分析的方法。
作者认为自己处于1.7阶段。在现实业务中,很多的功能都是用例特有的(Use case specific)的,如果“盲目”的使用 Domain 收拢业务并不见得能带来多大的益处。相反,这种收拢会导致 Domain 层的膨胀过厚,不够纯粹,反而会影响复用性和表达能力。我们承认模型不是一次性设计出来的,而是迭代演化出来的。不强求一次就能设计出 Domain 的能力,也不需要强制要求把所有的业务功能都放到 Domain 层,而是采用实用主义的态度,即只对那些需要在多个场景中需要被复用的能力进行抽象下沉,而不需要复用的,就暂时放在 App 层的 Use Case 里就好了。PS:Use Case 是《架构整洁之道》里面的术语,简单理解就是响应一个 Request 的处理过程。
DDD被高估了吗?提出并识别模式,给它们起个名字,并使用它们来给出系统结构,保证系统统一性。我们可以并且应该发明自己的语言,并将任何 DDD 材料视为起点,而不是最终结果。如果你所做的一切都是按照现有的 DDD 标准术语定义,并试图将任何问题都硬塞到现有的结构中,那么你的生活将非常悲惨。有一种生活超越了 DDD,尽管我认可它应该总是由领域驱动的,只是不一定是 DDD 意义上的。
革新的对象——面向数据库的架构/传统分层架构
领域驱动设计(DDD:Domain-Driven Design)提到服务器后端发展三个阶段
- UI+DataBase的两层架构,这种面向数据库的架构没有灵活性。
- UI+Service+DataBase的多层SOA架构,这种服务+表模型的架构易使服务变得囊肿,难于维护拓展,伸缩性能差
- DDD+SOA的事件驱动的CQRS读写分离架构,应付复杂业务逻辑,以聚合模型替代数据表模型,以并发的事件驱动替代串联的消息驱动。真正实现以业务实体为核心的灵活拓展。
领域驱动设计和开发实战不投入资源去建立和开发领域模型,会导致应用架构出现“肥服务层”和“贫血的领域模型”,在这样的架构中
- 外观类(通常是无状态会话 Bean)开始积聚越 来越多的业务逻辑,而领域对象则成为只有 getter 和 setter 方法的数据载体。
- 这种做法还会导致领域特定业务逻辑和规则散布于多个的外观类中(有些 情况下还会出现重复的逻辑)。
- 在大多数情况下,贫血的领域模型没有成本效益;它们不会给公司带来超越其它公司的竞争优势,因为在这种架构里要实现业务需求变更,开发并部署到生产环境中去要花费太长的时间。
基于数据库设计
多视角理解领域驱动
分解复杂性视角
领域驱动设计在互联网业务开发中的实践 解决复杂和大规模软件的武器可以被粗略地归为三类:抽象、分治和知识。
- 分治 把问题空间分割为规模更小且易于处理的若干子问题。分割后的问题需要足够小,以便一个人单枪匹马就能够解决他们;其次,必须考虑如何将分割后的各个部分装配为整体。分割得越合理越易于理解,在装配成整体时,所需跟踪的细节也就越少。
- 抽象 使用抽象能够精简问题空间,而且问题越小越容易理解。举个例子,从北京到上海出差,可以先理解为使用交通工具前往,但不需要一开始就想清楚到底是高铁还是飞机,以及乘坐他们需要注意什么。PS:《原则》中也有类似的表述,你在思考高层次的事情时,一定不要考虑低层次的细节。《重构》中讲一个方法只要 包含跟该方法同级层次的代码。 谈谈业务开发中的抽象思维 抽象思维的三个阶段
- 经验归纳,只要我们认真做好手头的工作,就事论事地保持积累和总结,把这些经验进行系统化的记录、归纳、整理、分类,对于较复杂的、专业性较强的业务领域,甚至可以著书立说。
- 建模,有一点哲学认识论的味道,涉及到人类知识如何对客观世界进行刻画的问题。类比到物理学领域,物理定律对于客观的物理规律的描绘,实际上也是一种「建模」。经验归纳的思维方法,主要是对信息进行收集,以及简单的加工整理;而建模方法考验的主要是逻辑思维过程,对于信息的了解,所占比重已大幅下降。建模可以看作是对信息的深度加工整理。
- 高层抽象,主要是为了应对问题规模,把握更「大」的东西。首先,并不是所有人都会遇到规模足够大的问题需要解决。只有对于规模庞大的公司、组织、政府机构,这种思维方式才是必不可少的。更进一步,高层抽象需要处理模型和模型之间,甚至是体系和体系之间的关系问题。还是类比到物理学领域,如果把牛顿运动定律、相对论和量子力学看作三个不同的模型,那么高层抽象就相当于要描述清楚这三个理论体系之间的关系。类似这种「大一统」的思维方式,自然是抽象层次最高,也最难的。
- 知识 顾名思义,DDD可以认为是知识的一种。DDD提供了这样的知识手段,让我们知道如何抽象出限界上下文以及如何去分治。
西瓜可以横着切也可以纵着切,分治怎么分也要找到一个切口。
在系统复杂之后,我们都需要用分治来拆解问题。一般有两种方式,技术维度和业务维度。技术维度是类似MVC这样,业务维度则是指按业务领域来划分系统。微服务架构更强调从业务维度去做分治来应对系统复杂度,而DDD也是同样的着重业务视角。DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。
领域驱动设计学习输出「DDD」则把大多数的业务逻辑都包含在了「聚合」、「实体」、「值对象」里面,简单理解也就是实现了对象自治,把之前暴露出来的一些业务操作隐藏进了「域」之中。每个不同的区域之间只能通过对外暴露的统一的聚合根来访问,这样就做了收权的操作,这样数据的定义和更改的地方就聚集在了一处,很好的解决了复杂度的问题。
软件架构设计视角
Evic Evans在《领域驱动设计》中将软件系统的设计分为2个部分:战略设计和战术设计。
- 战略设计部分指导我们如何拆分一个复杂的系统。 PS: 和微服务的划分不谋而合
- 战术部分指导我们对于拆分出来的单个子系统如何进行落地,在落地过程中应该遵循哪些原则。PS:对应大部分技术开发同学的工作。
为什么是“领域”驱动,而不是什么别的东西驱动?比如服务驱动?对象驱动?
- 简单的系统数据库CRUD就可以搞定,只有足够复杂且多变(二者缺一不可)的系统才用得上领域驱动
- 开发人员经常把业务流程实现成系统流程,业务流程复杂、多变的时候, 系统流程也必须做出改变,因而需要在“业务流程”和“系统流程”之间提出一层,即领域模型
- 领域就是现实世界的业务,是复杂多变的,我们看到的只是现象。而领域模型就是要找到这些现象背后不变的部分,也就是本质,也就是“变化”背后的“不变性”
- 就像任何一门语言,最基本的是单词。领域驱动设计的一系列概念:实体、值对象、聚合根、领域事件、Specification,就是领域模型这门“建模”语言的“单词”。给了我们一系列分析工具,帮我们分析出“领域”现象背后的“本质”。
所以,换句话说,本质是业务流程和系统流程不一致带来许多问题, 需要抽一个中间层,这个中间靠近业务/领域,所以以“领域”方式描述,但又不能易变,所以必须找到业务中不变的部分(即本质),来减少系统流程的变动。
如何用代码有效描述业务视角
领域驱动设计学习输出DDD 改变了传统软件开发工程师针对数据库进行的建模方法,从而将要解决的业务概念和业务规则转换为软件系统中的类型以及类型的属性与行为,通过合理运用面向对象的封装、继承、多态等设计要素,降低或隐藏整个系统的业务复杂性,并使得系统具有更好的扩展性,应对纷繁多变的现实业务问题。
- 传统项目中,架构师交给开发的一般是一本厚厚的概要设计文档,里面除了密密麻麻的文字就是分好了域的数据库表设计。言下之意:数据库设计是根本,一切开发围绕着这本数据字典展开
- 我经常会做一个假设:假设你的机器内存无限大,永远不宕机,在这个前提下,我们是不需要持久化数据的,也就是我们可以不需要数据库,那么你将会怎么设计你的软件?这就是我们说的 Persistence Ignorance:持久化无关设计。首先一点,领域模型无法通过数据库表来表示了,就要基于程序本身来设计了。
- 按照 object domain 的思路,领域模型存在于内存对象里,意味着得 通过 类图 而不是ER图来描述业务。用类 比用 数据库表 有更丰富的表达方式:通过引用来表达多对多关系、封装、继承和多态等。
领域驱动设计和开发实战从设计和实现的角度来看,典型的 DDD 框架应该支持以下特征:应该是一个以 POJO(这里说的应该是充血对象)为基础的架构;领域第一,基础设施第二:PersistentObject只是表达了一种存储方式而已, 跟业务毫无关系。
领域模型设计
主要来自极客时间《软件设计之美》
- 战术设计(Tactic DDD):Entity, Value Object; Aggregate, Root Entity, Service, Domain Event; Factory, Repository。
- 战略设计(Strategic DDD):Bounded Context, Context Map; Published Language, Shared Kernel, Open Host Service, Customer-Supplier, Conformist, Anti Corruption Layer (context relationship types)。
战略设计:子域、限界上下文和上下文映射图
- 软件开发是解决问题,而解决问题要分而治之。所谓分而治之,就是要把问题分解了,对应到领域驱动设计中,就是要把一个大领域分解成若干的小领域,而这个分解出来的小领域就是子域(Subdomain)
- 对于一个真实项目而言,划分出来的子域可能会有很多,但并非每个子域都一样重要。所以,我们还要把划分出来的子域再做一下区分,分成核心域(Core Domain)、支撑域(Supporting Subdomain)和通用域(Generic Subdomain)。核心域是整个系统最重要的部分,是整个业务得以成功的关键。关于核心域,Eric Evans 曾提出过几个问题,帮我们识别核心域:为什么这个系统值得写?为什么不直接买一个?为什么不外包?
- 有了切分出来的子域,怎样去落实到代码上呢?首先要解决的就是这些子域如何组织的问题,是写一个程序把所有子域都放在里面呢,还是每个子域做一个独立的应用,抑或是有一些在一起,有一些分开。这就引出了领域驱动设计中的一个重要的概念,限界上下文(Bounded Context)。它形成了一个边界,一个限定了通用语言自由使用的边界,一旦出界,含义便无法保证。比如,同样是说“订单”,一旦定义了限界上下文,那交易上下文的“订单”和物流上下文的“订单”肯定是不同的。
- 很自然地,我们就可以把限界上下文看作是一个独立的系统,比如,每个限界上下文都可以成为一个独立的服务。限界上下文的重点在于,它是完全独立的,不会为了完成一个业务需求要跑到其他服务中去做很多事,而这恰恰是很多微服务出问题的点,比如,一个业务功能要调用很多其他系统的功能。
- 有了对限界上下文的理解,我们就可以把整个业务分解到不同的限界上下文中,但是,尽管我们拆分了系统,它们终究还是一个系统,免不了彼此之间要有交互。所以,我们就要有一种描述方式,将不同限界上下文之间交互的方式描述出来,这就是上下文映射图(Context Map),DDD 给我们提供了一些描述这种交互的方式,比如:合作关系(Partnership);共享内核(Shared Kernel);客户 - 供应商(Customer-Supplier);跟随者(Conformist);防腐层(Anticorruption Layer);开放主机服务(Open Host Service);发布语言(Published Language);各行其道(Separate Ways);大泥球(Big Ball of Mud)。
- 当我们知道了不同的限界上下文之间采用哪种交互方式之后,不同的交互方式就可以落地为不同的协议。比如REST API、RPC 或是MQ
战术设计
战术设计包含了很多概念,比如,实体、值对象、聚合、领域服务、应用服务等等。有这么多概念,我们该如何区分和理解他们呢?我们同样需要一根主线。
- 首要任务就是设计角色,在战术设计中,我们的角色就是各种名词。识别名词也是很多人对于面向对象的直觉反应。有一些设计方法会先建立数据库表,这种做法本质上也是从识别名词入手的。我们在战术设计中,要识别的名词包括了实体和值对象。什么是实体呢?实体(Entity)指的是能够通过唯一标识符标识出来的对象。在业务处理中,有一类对象会有一定的生命周期。以电商平台上的订单为例,它会在一次交易的过程中存在,而在它的生命周期中,它的一些属性可能会有变化,比如说,订单的状态刚开始是下单完成,然后在支付之后,变成了已支付,在发货之后就变成了已发货。但是这个订单始终都是这个订单,因为这个订单有唯一的标识符,也就是订单号
- 还有一类对象称为值对象,它就表示一个值。比如,订单地址,它是由省、市、区和具体住址组成。它同实体的差别在于,它没有标识符。实体的属性是可以变的,只要标识符不变,它就还是那个实体。但是,值对象的属性却不能变,一旦变了,它就不再是那个对象,所以,我们会把值对象设置成一个不变的对象。我们为什么要将对象分为实体和值对象?其实主要是为了分出值对象,也就是把变的对象和不变的对象区分开。一方面,我们会把一些值对象当作实体,但其实这种对象并不需要一个标识符;另一方面,也是更重要的,就是很多值对象我们并没有识别出来,比如,很多人会用一个字符串表示电话号码,会用一个 double 类型表示价格,而这些东西其实都应该是一个值对象。之所以说这里缺少了对象,原因就在于,这里用基本类型是没有行为的。在 DDD 的对象设计中,对象应该是有行为的。比如,价格其实要有精度的限制,计算时要有自己的计算规则。如果不用一个类将它封装起来,这种行为就将散落在代码的各处,变得难以维护。只有数据的对象是封装没做好的结果,一个好的封装应该是基于行为的。在 DDD 的相关讨论中,经常有人批评所谓的“贫血模型”,说的其实就是这种没有行为的对象。
- 选定了角色之后,接下来,我们就该考虑它们的关系了。
- 在传统的开发中,我们经常会遇到一个难题。比如,如果有一个订单,它有自己对应的订单项。问题来了,取订单的时候,该不该把订单项一起取出来呢?取吧,怕一次取出来东西太多;不取吧?要是我用到了,再去一条一条地取,太浪费时间了。这就是典型的一对多问题,也是一种用技术解决业务问题的典型思路。我们之所以这么纠结,主要就是因为我们考虑问题的出发点是技术,如果我们把考虑问题的出发点放到业务上呢?战术设计就给了我们这样一个思考的维度:聚合。聚合(Aggregate)就是多个实体或值对象的组合,这些对象是什么关系呢?你可以理解为它们要同生共死。比如,一个订单里有很多个订单项,如果这个订单作废了,这些订单项也就没用了。所以,我们基本上可以把订单和订单项看成一个单元,订单和订单项就是一个聚合。
- 一个聚合里可以包含很多个对象,每个对象里还可以继续包含其它的对象,就像一棵大树一层层展开。但重点是,这是一棵树,所以,它只能有一个树根,这个根就是聚合根。聚合根(Aggregate Root),就是从外部访问这个聚合的起点。其实,我们可以把所有的对象都看成是一种聚合。只不过,有一些聚合根下还有其他的对象,有一些没有而已。这样一来,你就有了一个统一的视角看待所有的对象了。那如果不同的聚合之间有关系怎么办?比如,我要在订单项里知道到底买了哪个产品,这个时候,我在订单项里保存的不是完整的产品信息,而是产品 ID。还记得吗?实体是有唯一标识符的。有了对于聚合的理解,做设计的时候,我们就要识别出哪些对象可以组成聚合。一对多问题也就不再是问题了:是聚合的,我们可以一次都拿出来;不是聚合的,我们就靠标识符按需提取。
- 有角色了,也确定关系了。接下来,就要安排互动了
- 事件风暴 识别出了事件和动作,而故事的来龙去脉其实就是这些事件和动作。因为有了各种动作,各种角色才能够生动地活跃起来,整个故事才得以展开。动作的结果会产生出各种事件,也就是领域事件,领域事件相当于记录了业务过程中最重要的事情。那各种动作又是什么呢?在战术设计中,领域服务(Domain Service)就是动词。只不过,它操作的目标是领域对象,更准确地说,它操作的是聚合根。动词,是我们在学习面向对象中最为缺少的一个环节,很多教材都会教你如何识别名词。在实际编码中,我们会大量地使用像 Handler、Service 之类的名字,它们其实就是动词。PS:面向对象单纯描述对象和对象之间的关系, 还是不能说清楚全貌啊
- 动作不应该在实体或值对象上吗?确实是这样的,能放到这些对象上的动作固然可以,但是,总会有一些动作不适合放在这些对象上面,比如,要在两个账户之间转账,这个操作牵扯到两个账户,肯定不能放到一个实体类中。这样的动作就可以放到领域服务中。还有一类动作也比较特殊,就是创建对象的动作。显然,这个时候还没有对象,所以,这一类的动作也要放在领域服务上。这种动作对应的就是工厂(Factory)(其实就是设计模式中常提到的工厂)。工厂创建聚合根,聚合根创建聚合里的各种子对象(以便保证二者之间的关联)。
- 对于这些领域对象,无论是创建,还是修改,我们都需要有一个地方把变更的结果保存下来,而承担这个职责的就是仓库(Repository)。你可以简单地把它理解成持久化操作(当然,在不同的项目中,具体的处理还是会有些差别的)。
- 当我们把领域服务构建起来之后,核心的业务逻辑基本就成型了。但要做一个系统,肯定还会有一些杂七杂八的东西,比如,用户要修改一个订单,但首先要保证这个订单是他的。在 DDD 中,承载这些内容的就是应用服务。应用服务和领域服务之间最大的区别就在于,领域服务包含业务逻辑,而应用服务不包含。一些与业务逻辑无关的内容都会放到应用服务中完成,比如,监控、身份认证等等。
深入理解领域驱动设计中的聚合 聚合的本质就是建立了一个比对象粒度更大的边界,聚集那些紧密关联的对象,形成了一个业务上的对象整体。使用聚合根作为对外的交互入口,从而保证了多个互相关联的对象的一致性。通过把对象组织为聚合,在基本的对象层次之上构造了一层新的封装。封装简化了概念,隐藏了细节,在外部需要关心的模型元素数量进一步减少,复杂性下降。但是不是所有相关对象都聚合到一块呢?聚合划分的原则
- 生命周期一致性。生命周期一致性是指聚合边界内的对象,和聚合根之间存在“人身依附”关系。即:如果聚合根消失,聚合内的其他元素都应该同时消失。
- 问题域一致性。个图书网站,用户可以对图书发表评论。如果只是因为文章删除和评论删除之间存在逻辑上的关联,就让文章聚合持有评论对象,那么显然就约束了评论的适用范围。一目了然的事实是,评论这一个概念,在本质上和文章这个概念相去甚远。
- 场景频率一致性
- 聚合内的元素尽可能少
读写分离
DDD读写对待不一样的,写需要严格遵守分层结构。读不一定,看情况。
领域模型是用于领域操作的,当然也可以用于查询(read),不过这个查询是有代价的。在这个前提下,一个 aggregate 可能内含了若干数据,这些数据除了类似于 getById 这种方式,不适用多样化查询(query),领域驱动设计也不是为多样化查询设计的。 查询是基于数据库的(比如 获取某数据的列表,这是一个查询需求,不算业务模型之内。业务模型一般侧重于 几个抽象 以及 抽象之间的相互作用),所有的复杂变态查询其实都应该绕过 Domain 层,直接与数据库打交道。
横着看
细节:贫血模型和充血模型
我们必须将应用程序的业务逻辑从服务层迁移到领域模型类中,为何呢? 先来看看贫血模型和充血模型的对比。
举个具体的例子,假设一个用户有很多收货地址
class User{
List<Address> addresses;
setter
getter
}
那么在为用户添加收货地址时,不得不有很多判空操作
class UserService{
void addAddress(User user,Address address){
List<Address> addresses = user.getAddresses();
if(null == addresses){
addresses = new ArrayList<Address>();
user.setAddresses(addresses);
}
addresses.add(address);
}
}
想象一下
- 如果有多个位置操作User的Address(这个例子针对这一点不是很适当),
if(null == addresses){...}
会大量出现,代码量不大, 但会很丑。如果是电商业务,每一次购物都要做优惠券、红包、满减检查、余额不足检查等,这些逻辑有可能重复在各个Service中。 - 更复杂的成员变量
List<List>
或者List<Map<String,String>>
- 更复杂的逻辑,比如设定默认地址,地址判重等。
UserService.addAddress
吐血表示,我只想添加个地址而已。
换成充血模型
class User{
List<Address> addresses;
public User(){
addresses = new ArrayList<Address>();
}
void addAddress(Address address){
addresses.addAddress(address)
}
}
class UserService{
void addAddress(User user,Address address){
...
user.addAddress(address);
...
}
}
从中可以看到,addresses的 初始化和 添加都由User 负责,代码简洁很多。
PersistentObject一般由框架自动生成,不适合做改动,只提供setter/getter方法,或者说除了set/get什么都做不了。这样不得不很多逻辑放在XXService中,造成XXService的臃肿。直接暴露set/get很多时候是有不安全的。
如何从容应对复杂性基于贫血模型的传统开发模式,将数据与业务逻辑分离,违反了 OOP 的封装特性,实际上是一种面向过程的编程风格。充血模型是一种有行为的模型,模型中状态的改变只能通过模型上的行为来触发,同时所有的约束及业务逻辑都收敛在模型上。User的行为交由自己去管理, 而不是交给各种Service去管理。面向对象设计主张将数据和行为绑定在一起也就是充血模型,而贫血领域模型则更像是一种面向过程设计,贫血领域模型的根本问题是,它引入了领域模型设计的所有成本,却没有带来任何好处。最主要的成本是将对象映射到数据库中,从而产生了一个O/R(对象关系)映射层。只有当你充分使用了面向对象设计来组织复杂的业务逻辑后,这一成本才能够被抵消。
DDD不只是指导写代码
领域驱动设计学习输出面对客户的业务需求,由领域专家与开发团队展开充分的交流,经过需求分析与知识提炼,获得清晰的问题域。通过对问题域进行分析和建模,识别限界上下文,利用它划分相对独立的领域,再通过上下文映射建立它们之间的关系,辅以分层架构与六边形架构划分系统的逻辑边界与物理边界,界定领域与技术之间的界限。之后,进入战术设计阶段,深入到限界上下文内对领域进行建模,并以领域模型指导程序设计与编码实现。若在实现过程中,发现领域模型存在重复、错位或缺失时,再进而对已有模型进行重构,甚至重新划分限界上下文。
小结
2018.6.20 补充 大家一直在谈的领域驱动设计(DDD),我们在互联网业务系统是这么实践的 本文字字珠玑,适合细读。