查看原文
其他

DDD的优势

张建飞 技术琐话 2021-08-08

你可以,不代表你应该。

(Just because you can, doesn’t mean you should.)

——施莉琳•凯尼恩

7.4 DDD的优势

通过上面的介绍,相信读者对DDD有了一些认识,接下来对DDD的优势会更容易接受。如果一开始不能接受DDD也没有关系,我其实也经历过“排斥—接受—使用”的过程。当真正领会DDD的精髓之后,我就再不愿意回到以前的开发模式了。下面将DDD带来的核心好处总结如下。 

7.4.1 统一语言

统一语言(Ubiquitous Language)的主要思想是让应用能和业务相匹配,这是通过在业务与代码中的技术之间采用共同的语言达成的。业务语言起源于公司的业务侧,业务侧拥有需要实现的概念。业务语言中的术语由公司的的业务侧和技术侧通过协商来定义(意味着业务侧也不能总是选到最好的命名),目标是创造可以被业务、技术和代码自身无歧义使用的共同术语,即统一语言。代码、类、方法、属性和模块的命名必须和统一语言相匹配,必要的时候需要对代码进行重构!

试想,在PRD文档、设计文档、代码以及团队日常交流中,如果有一套领域术语是统一无歧义的,是否会极大地提升沟通和工作效率?在日常工作中,因为概念理解不一致,或者语言表达上的问题,导致沟通效率低,甚至发生误解的情况实在太多了。所以,明确概念、形成统一语言至关重要。

7.4.2 面向对象

DDD的核心是领域模型,这一方法论可以通俗地理解为先找到业务中的领域模型,以领域模型为中心,驱动项目开发。领域模型的设计精髓在于面向对象分析、对事物的抽象能力,一个领域驱动架构师必然是一个面向对象分析的大师。

DDD鼓励我们接触到需求后第一步就是考虑领域模型,而不是将其切割成数据和行为,然后用数据库实现数据,用服务实现行为,最后造成需求的首尾分离。DDD会让你首先考虑业务语言,而不是数据。DDD强调业务抽象和面向对象编程,而不是过程式业务逻辑实现。重点不同,导致编程世界观不同。

7.4.3 业务语义显性化

统一语言也好,面向对象也好,最终的目都是为代码的可读性和可维护性服务。统一语言使得我们的核心领域概念可以无损地在代码中呈现,从而提升代码的可理解性。例如,在银行转账的案例中,按照事务脚本的写法来写“透支策略”的业务概念,其含义完全被淹没在代码逻辑中没有突显出来。但是,如果我们使用策略模式将其抽象出来,让业务语义得到显性化的表达,代码的可读性就会提升很多。

面向对象也是让代码尽量体现领域实体和实体之间的关系原貌,所以目的也是业务语义被显性化地表达,显性化的结果是代码更容易被理解和维护,殊途同归,一切都是为了控制复杂度。在软件的世界里,任何的方法论如果最终不能落在“减少代码复杂度”这个焦点上,那么都是有待商榷的。

7.4.4 分离业务逻辑和技术细节

代码复杂度是由业务复杂度和技术复杂度共同组成的。实践DDD还有一个好处,是让我们有机会分离核心业务逻辑和技术细节,让两个维度的复杂度有机会被解开和分治。如图7-8所示,核心业务逻辑是整个应用的核心,最好只是简单Java类(Plan Old Java Object,POJO)。也就是说,核心业务逻辑对技术细节没有任何依赖,依赖都是由外向内的,即使有由内向外的依赖,也应该通过依赖倒置来反转依赖的方向。通过这样的划分,Entities只要安安心心地处理业务逻辑就好,业务逻辑越复杂,这样划分带来的好处越明显。

图7-8 业务逻辑和技术细节分离的架构

为什么说数据库、框架和UI都是技术细节呢?

  • 数据库:业务逻辑不应该受限于存储方式,也就是不论你是使用关系型数据库还是NoSQL,都不应该影响业务逻辑的实现。数据本身很重要,但数据库技术仅仅是一个实现细节。

  • UI:UI只是一种I/O设备的呈现,Web、WAP和Wireless都是不同的I/O,我们的核心业务逻辑应该与如何呈现解耦,以及针对不同的端可以使用不同的适配器(Adaptor)去做适配。

  • 框架:不要让框架侵入我们的核心业务代码,以Spring为例,最好不要在业务对象中到处写@autowired注解。业务对象不应该依赖框架。

这么说来,这些技术细节是不重要了吗?不是的,技术细节是一个系统的必要组成部分,也非常重要。技术细节和核心业务逻辑是两个维度的重要性,如果把软件比喻成一个人,那么核心业务逻辑是大脑,技术细节是身体,二者都很重要,分开处理主要是为了降低复杂度。

7.5 DDD的核心概念

7.5.1 领域实体

毫不夸张地说,我们的软件系统就是对现实世界的真实模拟。如图7-9所示,现实世界中的事物在软件世界中可以被模拟成一个对象:该事物在现实世界中被赋予什么职责,在软件世界中就被赋予什么职责;在现实世界中拥有什么特性,在软件世界中就拥有什么属性;在现实世界中拥有什么行为,在软件世界中就拥有什么函数;在现实世界中与哪些事物存在怎样的关系,在软件世界中就应当与它们发生怎样的关联。这正是面向对象编程的核心思想,也是DDD中寻找领域实体的核心思想。

图7-9 现实世界与软件世界

假如现在你需要设计一个中介系统,一个典型的User Story是“小明去找工作,中介让他留个电话,有工作机会就会通知他”。我们要如何寻找该业务中的关键领域实体呢?一个简单的方式就是“找名词”,分析这些名词,不难得到以下可能成为实体的候选项。

  • 小明:一个求职者。

  • 电话:求职者的相关信息,可以是一个属性。

  • 中介:可以拆解为中介公司和中介公司的员工两个概念。

  • 工作机会:对于中介系统来说,工作机会应该是最关键的实体之一。

  • 通知:作为名词是一个实体,但是作为一个动词是在暗示我们可以使用Notify。

是的,对于这个简单的User Story,这样分析就可以了。当然,随着更多的Story被加入,我们会补充更多的实体,比如增加了“中介费是按照小明第一个月工资的30%收取”,那么就可能要引入“订单”和“支付”等实体。

以上就是我在实际工作中寻找领域实体的大致过程。从方法论的角度来说,也叫作“用例分析法”,详细的步骤会在7.6.1节中介绍。

7.5.2 聚合根

聚合根(Aggregate Root)是DDD中的一个概念,是一种更大范围的封装,会把一组有相同生命周期、在业务上不可分割的实体和值对象放在一起,只有根实体可以对外暴露引用,这也是一种内聚性的表现。

确定聚合边界要满足固定规则(Invariant),是指在数据变化时必须保持的一致性规则,具体规则如下。

  • 根实体具有全局标识,最终负责检查规定规则。

  • 聚合内的实体具有本地标识,这些标识在Aggregate内部才是唯一的。

  • 外部对象不能引用除根Entity之外的任何内部对象。

  • 只有Aggregate的根Entity才能直接通过数据库查询获取,其他对象必须通过遍历关联来发现。

  • Aggegate内部的对象可以保持对其他Aggregate根的引用。

  • Aggregate边界内的任何对象在修改时,整个Aggregate的所有固定规则都必须满足。

仍以银行转账的例子来说明,如图7-10所示,账号(Account)是客户信息(CustomerInfo)Entity和值对象(Address)的聚合根,交易(Tansaction)是流水(Journal)的聚合根,流水是因为交易才产生的,具有相同的生命周期。

图7-10 聚合根示例 

7.5.3 领域服务

有些领域中的动作是一些动词,看上去并不属于任何对象。它们代表了领域中的一个重要的行为,所以不能忽略它们或者简单地把它们合并到某个实体或者值对象中。当这样的行为从领域中被识别出来时,推荐的实践方式是将它声明成一个服务。这样的对象不再拥有内置的状态,其作用仅仅是为领域提供相应的功能。Service往往是以一个活动来命名,而不是Entity来命名。

例如在银行转账的例子中,转账(transfer)这个行为是一个非常重要的领域概念,但是它发生在两个账号之间,归属于账号Entity并不合适,因为一个账号Entity没有必要去关联它需要转账的账号Entity。在这种情况下,使用MoneyTransferDomainService就比较合适了。识别领域服务,主要看它是否满足以下3个特征。

(1)服务执行的操作代表了一个领域概念,这个领域概念无法自然地隶属于一个实体或者值对象。

(2)被执行的操作涉及领域中的其他对象。

(3)操作是无状态的。

7.5.4 领域事件

领域事件(Domain Event)是在一个特定领域由一个用户动作触发的,是发生在过去的行为产生的事件,而这个事件是系统中的其他部分或者关联系统感兴趣的。

为什么领域事件如此重要?因为在分布式环境下,很少有业务系统是单体的(Monolithic),消息作为分布式系统间耦合度最低、最健壮、最容易扩展的一种通信机制,是我们实现分布式系统互通的重要手段。关于领域事件,我们需要注意两点,分别是事件命名和事件内容。

1.事件命名

事件是表示发生在过去的事情,所以在命名上推荐使用Domain Name + 动词的过去式 + Event,这样可以更准确地表达业务语义。例如,在银行转账的例子中,对于转账成功和失败我们都需要发出事件通知,可以定义两个领域事件如下。

(1)MoneyTransferedEvent:表示转账成功发出的事件。

(2)MoneyTransferFailedEvent:表示转账失败发出的事件。

2.事件内容

事件内容在计算机术语中叫作payload,有以下两种形式。

(1)自恰(Enrichment):就是在事件的payload中尽量多放数据,这样consumer不需要回查就能处理消息,也就是自恰地处理消息。

(2)回查(Query-Back):这种方式是只在payload放置id属性,然后consumer通过回调的形式获取更多数据。这种形式会加重系统的负载,可能会引起性能问题。

7.5.5 边界上下文

领域实体的意义是有上下文的,比如同样是Apple,在水果店和苹果手机专卖店中表达出的含义就完全不一样。边界上下文(Bounded Context)的作用是限定模型的应用范围,在同一个上下文中,要保证模型在逻辑上统一,而不用考虑它是不是适用于边界之外的情况。

那么不同上下文之间的业务实体要如何实现交互呢?就像关系数据库和对象之间需要ORM一样,不同上下文之间的实体也需要映射。在DDD中,这种机制叫作上下文映射(Context Mapping),我们可以使用防腐层(Anti-Corruption)来完成映射的工作。

如图7-11所示,在我们开发的CRM系统中,商家的客户大部分是来自于ICBU网站的会员,虽然二者有很多属性都是一样的,但我们还是有必要引入防腐层来做上下文映射,主要有以下两个原因。

(1)虽然属性大部分一样,但二者的作用和行为在各自上下文中是不一样的。

(2)解耦影响,加入了防腐层之后,网站的会员变化就不会影响到CRM系统了。

图7-11 边界上下文示例

7.6 领域建模方法

7.6.1 用例分析法

1.方法介绍

用例分析法是进行领域建模中最简单可行的方式,其步骤如下。

(1)获取用例描述

既然领域模型指的是问题域模型,那么建模也一定要从问题域入手。那么问题域的知识如何表现出来呢?一种最常见的方式是通过用例,也可以通过场景(Scenario)来分析,总之就是一段格式化的需求文字描述。

(2)寻找概念类

寻找概念类就是对获取的用例描述进行语言分析,识别名词和名词短语,将其作为候选的概念类。当然,需求描述中的名词不可能完全等价于概念类,自然语言中的同义词和多义词都需要在此处进行区分。还有很多名词可能只是概念类的属性,不过没关系,在这一步骤中可以都提取出来,在第4步中再区分出概念类和属性。

(3)添加关联

关联意味着两个模型之间存在语义联系,在用例中的表现通常为两个名词被动词连接起来,如图7-12所示。

在添加关联关系时要注意以下几点。

  • 并非所有动词关联的概念类都需要作为关联存在,更重要的是我们需要判断两个概念类的关系是否需要被记住。

  • 应该尽量避免加入大量关联。

  • 关联不代表数据流,也不代表系统调用关系。

图7-12 语义分析示例

(4)添加属性

我们需要区分概念类和属性(当然名词列表也会有无用的词语)。例如,对于上文抽取到的名词列表,“品名”是“商品”的属性,“iTouch”为无用的词语。

如何判断一个名词是否是属性?可以用下面两种方式。

  • 能完全通过基本数据类型(数字、文本、日期)表达的大多是属性。

  • 如果一个名词只关联一个概念类,并且其自身没有属性,那么它就是另一个概念类的属性。

(5)模型精化

模型精化是可选的步骤,有时我们希望在领域模型中表达更多的信息,这时会利用一些新的手段来表达领域模型,包括泛化、组合和子域划分等。领域模型可以使用UML的泛化和组合表达模型间的关系,表达的是概念类的“is-a”和“has-a”的关系。子领域划分是常见的拆解领域的方式,通常来说,我们会将更内聚的一组模型划分为一个子领域,形成更高一层的抽象,有利于系统的表达和分工。

2.案例介绍

下面举例说明,内容来自论文“Object-Oriented Analysis from Textual Specifications”,文中讲述了如何通过自然语言分析来做面向对象分析。

用例描述如下所示:

Vendors may be sales employees or companies. Sales employees receive a basic wage and a commission, whereas companies only receive a commission. Each order corresponds to one vendor only, and each vendor has made at least one order, which is identified by an order number. One basic wage may be paid to several sales employees. The same commission may be paid to several sales employees and companies

接下来,我们按照用例分析法的步骤来建模。

(1)寻找概念类

首把所有名词标记出来,作为概念类的候选类:vendors, sales employees, companies, basic wage, commission, order, order number。

(2)添加关联

如图7-13所示,接下来为名词添加关联,连接这些名词的动词会出现在关联的线上。注意,根据上面的用例,我们还不清楚给供应商(Vendor)支付佣金(Commission)的主体是谁,但这并不妨碍在本阶段的建模。

图7-13 添加关联示例

(3)添加属性

最后,为这些候选的概念类选择属性。在本例中,如果一个概念类只处于一个被动的关联关系中(如Basic Wage, Commission, OrderNumber),那么它需要作为关联类的属性,如图7-14所示。

图7-14 添加属性示例

7.6.2 四色建模法

1.方法介绍

四色建模法源于Peter Coad的Java Modeling In Color With UML一书,它是一种模型的分析和设计方法,要把所有模型分为4种类型,用4种颜色表示,如图7-15所示。

图7-15 四色模型

在四色模型中,我们将抽象出来的对象分成4种原型(archetype)。

(1)业务关键时刻(Moment-Interval)

这种对象表示那些在某个时间点存在或者会存在一段时间。这样的对象往往表示了一次外界的请求,比如一次询价(Quotation)、一次下单(Order)或者一次租赁(Rental)。

Moment-Interval是最重要的一类对象,是系统的价值所在,一般用粉红色来表示。这样的对象一般有一个起始时间和终止时间,以及一个唯一的标识号,用来唯一地标识这一次客户请求,比如OrderNo。

注意,“业务关键时刻”是我给“Moment-Interval”起的中文名称,本来想直译为“时刻-时间段”,但感觉“时刻-时间段”不能体现出这种对象类型的重要性。

(2)角色(Role)

这种对象表示一种角色,往往由人或者物来承担,会有相应的责任和权利。一般,一个Moment-interval对象会关联多个Role。例如,一次下单涉及两个Role,分别是客户(Customer)和商品(Product)。

这类对象是除Moment-interval对象之外最重要的一类对象,一般用黄色来表示。

(3)人-事-物(Party,Place or Thing)

这种对象往往表示一种客观存在的事物,例如人、组织、产品或者配件等,这些事物会在一种moment-interval 中扮演某个Role。例如,某个人既会在一次购买中扮演Customer的角色,也可以在询价中扮演询价人的角色。这类对象的重要程度排在第三,一般用绿色来表示。

(4)描述(Description)

这种对象一般是用于分类或者描述性的对象,它的属性一般是这一类事物都有的属性,一般用蓝色来表示。

2.案例介绍

下面通过一个电商业务场景,来介绍如何通过四色模型进行建模,该案例来自InfoQ的文章《运用四色建模法进行领域分析》。

用户故事如下:

现在你是一家在线电子书店的COO。突然有一天,有一位顾客向你投诉,说他订购的书少了一本,并且价钱算错了,他多给了钱。在承诺理赔之前,你需要核对这位顾客说的是否属实。那么这时你需要知道什么样的信息才能做出准确的判断。

简单来说,你需要知道这位顾客订购了哪些书籍、付了多少钱,以及书店到底为这个顾客递送了哪些书籍。不幸的是,由于科技不够发达,你无法直接驾驶时间机器回到从前去亲眼看看发生了什么事。但幸运的是,你并不需要这么做,你只需要看看这位顾客的订单和网银的支付记录,以及你们书店交给EMS的快递单存根,就可以知道这些信息了。

从上面这个故事中我们可以看到:任何的业务事件都会以某种数据的形式留下足迹。我们对于事件的追溯可以通过对数据的追溯来完成。正如在故事中,你无法回到从前去看看到底发生了什么,但是却可以在单据的基础上,一定程度地还原当时事情发生的场景。当把这些数据的足迹按照时间顺序排列起来,我们几乎可以清晰地推测出在过往的一段时间内发生了哪些事情。

为什么这些业务数据具备可追溯性(Tracibility)呢?因为这些数据都是关键业务流程执行的结果。如图7-16所示,比如订单是业务的起点,而快递存根是业务的终点,正是这些数据在支撑运营体系的关键流程的执行结果。

图7-16 在线电子书店的关键业务流程

除了上述例子之外,对于任何一笔正常的经济往来,我们需要知道如下内容。

  • 如果我付出一笔资金,那么我的权益是什么?

  • 如果我收到一笔资金,那么我的义务是什么?

这些问题都需要业务系统捕捉到相应的足迹才能够回答,所以企业的业务系统的主要目的之一,就是记录这些足迹,并将这些足迹形成一条有效的追溯链。

足迹通常都具有一个有意思的特性,即它们是Moment-interval(要么是“时间时刻”,要么是“时间段”)的。发现这些业务关键时刻对象就是建模的起点。对这些对象稍加整理,我们就能得到图7-17所示的整个领域模型的骨干。

图7-17 在线电子书店的业务关键时刻对象

在得到骨干之后,我们需要丰富这个模型,使它可以更好地描述业务概念。这时我们需要补充一些实体对象,通常实体对象有3类,即人-事-物(Party,Place or Thing),如图7-18所示。

图7-18 在线电子书店的人-事-物对象

在这个基础上,我们可以进一步抽象,将这些实体参与到各种不同的流程中去,这时就需要用到角色(Role),如图7-19所示。

图7-19 在线电子书店的角色对象

最后,把一些需要描述的信息放入描述(Description)对象,如图7-20所示。

图7-20 电子书店的描述对象

这样,我们就得了应用四色建模方法建立的一套领域模型。简要回顾一下上面的过程,不难发现此次建模的次序和重点。

(1)首先以满足管理和运营的需要为前提,寻找需要追溯的事件,或者称为关键业务时刻。

(2)根据这些需要追溯,寻找足迹以及相应的关键业务时刻对象。

(3)寻找“关键业务时刻”对象周围的“人-事-物”对象。

(4)从“人-事-物”中抽象出角色。

(5)把一些描述信息用对象补足。

由于在第一步中我们就将管理和运营目标作为建模的出发点,因此整套模型实际上是围绕“如何有效地追踪这些目标”而建立的,这样可以保证模型能够支撑企业的运营。


编后语:张建飞说过,他最希望将《代码精进之路》推荐给职场新人看,因为如果在你初入职场的时候,就有一个人教你怎么写好代码,那一定是一件很幸运的事情。


“种一棵树最好的时间是在十年前,其次是现在”。我不相信什么“35岁做不了程序员”,也不相信什么“年纪大了,精力不够”。我只知道有些人在持续学习,有些人过早地享受安逸。


愿你在新的一年可以快乐,也能有所收获。


《代码精进之路:从码农到工匠

张建飞

本书是一本专门为专业程序员而写的书,主线是介绍如何化解代码复杂度,帮助程序员写出可读性好、可维护性好的代码。


本书共有13章内容,主要分为技艺部分、思想部分和实践部分。技艺部分详细介绍了编程技巧和方法论,并配以详尽的代码案例,有助于读者提高编写代码的能力,优化代码质量。思想部分主要包括抽象能力、分治思想,以及程序员应该具备的素养等内容。实践部分主要介绍了常见的应用架构模式,以及COLA架构的设计原理。


- END-


往期推荐


技术琐话 



以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。本号由坐馆老司机技术团队维护。



    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存