领域驱动设计-第7~17章笔记

第七章 实例:货物运输系统

1. 需求

  1. 跟踪货物的主要处理部署;
  2. 预约货物;
  3. 货物到达某一处理步骤时自动发送发票。

领域语言

货物: cargo
客户: customer
规格: specification
运输动作: carrier movement

  1. 一个cargo(货物)涉及到多个customer(客户),每个customer承担不同角色。
  2. cargo的运送目标已指定;
  3. 由一系列满足specification(规格)的carrier movement(运输动作)来完成运送目标。

上述类图中Customer包括托运人、收货人、快递员等角色。
(都是我们软件要服务的客户)
Handling Event可以细分为不同种类的事件(装货、卸货、提货…)

隔离领域

要把领域层划分出来,首先识别出3个用户层的应用程序功能:

  1. 跟踪查询: 访问某个cargo过去、现在的处理情况;(Delivery History)
  2. 预定: 注册一个cargo;
  3. 事件日志记录: 为1准备信息。
    三个应用层类:
    Tracking Query,Booking Application,Incident Logging Applicatoin。
    这三个应用层类只是协调者,负责向领域层提问。

区分Entity和Value Object

看对象是必须被跟踪的实体还是仅表示一个基本值。

Customer: Entity
Cargo: Entity
Handling EventCarrier Movement: Entity
Delivery History: Entity
Delivery Specification: Value Object: 可替换,货物满足的规则只要等效即可,并不一定需要是某一个id的规则。
Role: Value Object.

设计关联

双向关联往往会产生问题,因此要研究把双向关联转换成单向关联。
好处:

  1. 双向关联=>单向关联:降低出错概率;
  2. 研究遍历方向过程中:让领域更加清晰。

把低频需求的遍历方向交给数据库实现;
留下的单向遍历作为领域层的单向引用。

Aggregate边界

Customer: 根
Location: 根
Carrier Movement: 根
Cargo: 根,它的边界可以囊括所有因它才存在的弱实体: Delivery History,Delivery Specification
(分发历史、分发规则(VO)、处理事件)

Handling Event

  1. 查找某个Delivery History中的Handling Event;
  2. 查找某次Carrier Movement的操作,需要Handling Event的id,需要Handling Event作为根。

创建Repository

作为根的Entity创建repository.
(其他就不创建了,精简类的数量)

如图有7个Entity,5个根。
应对的需求:

  1. 用户选择承担不同角色:发货方、收货人;Customer;
  2. 货物目的地需要Location;
  3. 用户需要查找装货的Carrier Movement;
  4. 用户需要输入系统哪个Cargo完成了装货: Cargo;
  5. Handling Event的需求待定。
    如下图是加上了repository后:

用例

1. 更改目的地

Delivery Specification是个VO,可以直接创建一个新的,替换旧的即可。

2. 重复预订

常常会需要基于旧的cargo作为原型创建新的cargo。
(重复下单同一种商品)

  1. Delivery History: 应创建新、空的;(其他弱实体同)
  2. Customer Roles: 和原来的cargo引用同样的运输角色Customer。
  3. Tracking ID: 新增。

总结三类:

  1. 弱实体、边界内:创建新的、空的。
  2. 边界外:可以引用相同的。
  3. 根:新增(自增id)。

从需求频次出发简化设计

Handling Event相关需求的频次:
创建、新增:高频
查询: 低频

由于查询Delivery History中的Handling Event是低频需求,因此可以考虑不在Delivery History中直接存储Handling Event数组,这样节省了存储开销,也降低了维护一致性的成本。这里创建、新增Handling Event是高频的,因此如果Delivery History中是存储数组,要频繁维护一致性,而且是Agg边界外的改动引起Agg边界内的变动,属于不合理设计。

综上:可将Delivery History中的Handling Event改为即时查询接口,而不是直接存储数组。

优点:使Handling Event的新建变得简单,不会与Cargo Agg发生争用。

换句话说,类似于我们平时设计表字段的时候,高频查询的字段直接放到同一个表里头(可能有时候会反三范式),低频的抽出来扔另一张表(弱实体)里。原文这里是对象级的讨论。

Module:模块化

将紧密关联的实体封装到一个模块。

引入新特性: 配额检查

Allocation Checker: 确保高利润的商品能够运输完,确保大部分商品不会因为运力不足退单毁约。

  • 输入:
  1. 某cargo: 已经预订了多少; (或这个分类已经预订了多少)
  2. 某cargo: 最大预订配额。(或这个分类最大配额)
  • 输出:
    是否能继续预订。

实现1

如小猿的做法: 配额是存储在商品信息里头的。
(参见warehouse)

实现2:

配额是由另一个系统提供的。
同一个商品可以属于不同的类别,影响不同层面的配额。
这样配额相关属性抽离出来变成一类弱实体。

第三部分 通过重构来加深理解

目标:巧妙的领域模型
手段:不断重构,加深对领域的理解

重构

重构的定义是在不改变功能的前提下重新设计它。

重构的层次:

  1. 微重构;(累积成更深层次重构);// 参见《重构》一书
  2. 源于对领域的新认知;
  3. 设计模式重构。

深层模型

浅层模型: 在需求文档中确定名词和动词,初始建模;(不够成熟深入)(只有具体元素)
深层模型: 穿过领域表象,清楚表达领域专家主要关注点以及最相关知识。(恰当的抽象元素和具体元素)

深层模型与柔性设计

(柔性设计详见第10章)
好的深层模型能方便地支持柔性设计。

发现过程

(发现过程、捕捉领域核心概念详见第9章)
第11章: 分析模式
第12章: 设计模式

第八章 突破

如上图所示,重构在某个节点的投入可能会有很大的回报。
(如果突然孵化了对项目的最重要理解,会给项目带来重大冲击)
即使是小的改进也可以防止系统退化。

突破案例

背景:
管理银团贷款的程序。
基本需求:
跟踪支持整个贷款过程。

大致过程

原先的设计绑定了放贷股份和信贷股份,错误的理解。
突然有一天明白了两者基本无关联,重新设计了模型,得到突破,快速迭代。

总结: 好的模型能让无技术背景的业务方也能快速理解。(而不是抱怨专业性太强看不懂)

第九章 深层模型

倾听语言

线索:

  1. 用户长期抱怨、频繁查询的场景,可能是遗漏了重要领域对象。
  2. 领域专家试图纠正你的术语;
  3. 用户感到困惑的名词。
  4. 始终无法形成DSL。(讨论中的术语经常超出DSL范围)

会计示例

主要讲的是学了会计学以后模型更合理,深层。

约束的提炼

约束包括显式规则、隐式规则。
遇到如下情况时,应将隐式规则提炼到显式对象(显式规则):

  1. 计算约束所需数据从定义上不属于这个对象;
  2. 相关规则在多个对象出现,代码重复;
  3. 设计和需求围绕着这些规则,而这些约束却分散在过程代码中。

将规则显式提炼的案例:超订策略

如上图voyage表示实际座席、cargo表示售出货物。
同时引入overbooking policy来显式封装超订的规则约束。

两个矛盾的点:

  1. 不希望过程变成模型的主要部分;
  2. 重要的过程则必须显露在模型中。
    把握这个边界的诀窍:
    这个过程是否经常被领域专家提起,或者仅作为程序机制的一部分。

模式: Specification

一般性的,可以将约束、规则提取出来,作为领域层的Value Object。
用途包括:

  • 选择
  • 过滤
  • 按规格创建/生成对象。

第十章 柔性设计

柔性设计是深层模型的补充。
当我们把隐式概念抽离显式表达出来以后,就有原料来进行柔性设计了。

过多抽象层、间接设计=>过度设计
适当抽象层、间接设计=>柔性设计

简单并不容易做到。
柔性设计需要揭示深层次的底层模型,把它潜在的部分明确展示出来。

具体方法包括如下:

1. 模式:表现意图的接口(Intention-Revealing Interface)

Intention-Revealing Interface: 表现意图的接口
有了它以后能够区分出:
Side-Effect-Freefunction: 无副作用的函数
StandAloneClass: 松耦合对象
Conceptual Contours: 概念边界\概念轮廓
Closeure of Operation: 闭合操作
甚至能基于接口直接编写单元测试中的断言。
(有时候可能无法达到这么理想,需要在单元测试中写Assert来进一步注释)

设计人员的客户包括其他合作开发人员。
接口中包含更多信息时,开发人员可以更有效地使用对象。
(否则就必须深入研究对象的内部机制、理解细节,失去了封装的价值)

接口设计重点:
给出意图、副作用、作用;
但无需给出具体实现细节。

2. 模式:无副作用函数(Size-Effect-free function)

通过区分有无副作用,可以进一步降低查看底层实现的开销。
常见的无副作用操作:查询。
可以通过VO对象把一些操作也转化成无副作用。

一些复杂操作可以进一步分解成:有副作用、无副作用的两个操作。

挖掘深层模型案例:
油漆:

第一步:把接口意图明确(混合两种油漆)

第二步:原来的方法只修改paint1,不改paint2;不符合常识,后继开发人员也无法理解。改成深层模型,原来的paint改为不可变(Stock Paint),单独引入被混合后的油漆(Mixed Paint)。

3. 模式: Assertion

用断言把副作用明确表示出来。

4. 概念轮廓、概念边界

我们应该对每个依赖关系提出质疑,直到证实它确实表示对象的基本概念。
尽量把模块之间的依赖重构为模块内依赖;
模块内依赖重构为尽量少的对象之间的依赖。

5. 低耦合的对象

6. 模式:闭合操作 Closure of Operation

实数集合上进行加减乘除后结果仍在实数集合中,这就是闭合操作。
像刚才油漆的混合操作之后得到的仍然是油漆,这就极大降低了依赖。

7. 声明式设计

声明式语言常见的有sql、各种配置文件。
比如把nginx的配置文件nginx.conf看作一种语言,则它是声明式的。(无法限定过程细节)
声明式设计就是写一段DSL,然后生成一份满足声明的约束条件的代码。比如mybatis里头用工具(jar包)+xml配置生成orm相关java代码。

  • 好处: 避免开发人员去写单调乏味容易出错的代码;
  • 坏处: 生成的代码不灵活,声明可能不足以表达一切。

8. 声明式设计风格

将上述几个模式组合以后,可以使用声明式设计风格。

声明式风格的Specification

1.用逻辑运算组合Specification (闭包操作模式)

运算组合结果还是Specification

如图,可以通过子类的方法实现这种设计。(开销很大)

如图,还可以通过逻辑算法来实现这种设计,这个栈的含义是:
and ( not (armored) , not(ventilated))
这种实现的优点: 对象个数少,内存使用效率高;
这种实现的缺点: 需要更高级的开发人员。

9. 切入问题的角度 (如何优化设计)

1. 分割子领域

2. 尽可能利用已有的形式

从头创建一个严密的概念框架不能作为一项日常工作。
因此我们经常需要对建立已久的概念系统加以修改和利用。

示例: 股份数学

还钱=>钱的分配按放贷股份

首先第一步: 把有无副作用的函数分离;(查归查,改归改)
3个函数:
计算分配方案;
执行(分配方案);
查询余额。

第二步:把隐式概念变成显式概念

显式引入股份份额的概念(share_pie)。
然后把分配方案的计算委托给share_pie,这样简化了loan对象,可以进行复杂的计算。
share_pie可以作为VO(因为计算是无副作用而且通用的)

第三步: 引入闭合操作(运算)

股份的份额运算变成闭合操作,并且由于是VO(不可变),每次返回新的Share pie.

最后把上层调用代码用声明式的风格改写即可。

核心思想: 把复杂计算封装到无状态的VO中。看情况引入闭合逻辑运算,进一步扩充计算能力。

第11章 分析模式的应用

案例: 账户的利息计算

需求:

  1. 计算利息;
  2. 跟踪借款、付款、手续费;
    (两种过账)
    初始类图:

引入复式记账(简化平账的并发问题)

加入每次的交易记录(不可变条目),类似于所有快照都记录。
(Transaction)

进一步挖掘需求

区分“应计项目”(accrual)和实际过账;
应记项目:立即发生;
实际过账:可以延迟。
例如利息可以每天计算,但只在月末过账。(例如夜间批量过账)
新的类图:

注意到图中获取利息和费用的函数都是无副作用的。

进一步考察过账需求

过账的触发时机:

  1. 立即触发: 每次新增交易(Entry被插入)都触发,进行所有更新;
  2. 手动触发:向Account发送命令来触发过账规则;(进行更新)
  3. 基于规则触发:由代理驱动。
    实际实现中可能根据过账的类型来决定触发时机(是否实时到账)。

第12章 将设计模式应用于模型

有些设计模式可以用作领域模式:

Strategy(Policy)模式

模式中有一些可以灵活更换的策略(无状态)。
如路径查找中,可以选择时间最短或者成本最低等等策略。

Composite模式

复杂领域建模时,会遇到多个部分组成的重要对象。(可能继续嵌套)
例如航线可能由多个航段组成。航段可以进一步划分。

原文: 其他可用的设计模式不再一一列举

第13章 通过重构得到更深层的理解

(1)以领域为本;
(2)用不同的方式看待事物;
(3)坚持与领域专家对话。

开始重构

一段复杂或笨拙的代码:
问题的根源在于领域模型中的概念或者关系发生了错误。

另一种例外就是代码很整洁,但是与领域专家的语言不一致,这可能会埋下隐患,因此依然需要重构。

方法:

  • 请教领域专家:寻找灵感;
  • 借鉴已有的经验、案例。
  • 不用完全证明修改的合理性后再修改,应该掌握一个度然后持续重构。
    (类似于物种进化过程中的爆发变化和间断平衡)

第四部分: 战略设计

三个主题:

  1. 上下文:ContextMap,也就是模块化;(Bounded Context)
  2. 精炼: 重点关心项目中最有价值、特殊的方面,其他组件外包;(Core Domain)
  3. 大比例结构: 大分层。(4个左右)(Responsibility Layer)

第14章 上下文: 保持模型的完整性

需要保证模型的内部统一性,不要有模棱两可的意义、规则的冲突。
(举个案例两个团队使用同一个模型出错,最后分开成两个不同场景的模型了)

Bounded context: 限界上下文,定义每个模型的应用范围;
Context Map: 上下文图,给出项目上下文和它们之间关系的总体视图;

Continuous Integration: 持续集成,小项目使用,模型统一;
Shared Kernel: 共享内核;平等团队合作;
Customer/Supplier Teams: 上下游合作,有共同的直接上级;
Conformist: 跟随者,沿用类似内核;(上游写得不错,直接拿过来增强即可)
Open Host Service: 支持多个客户;(与多个外部系统集成时)
Seperate Ways: 团队自由工作(没有共同直接上级);或者上游写得太烂,直接抛弃重写。
Anticorruption Layer: 隔离层,单向转换。(与遗留系统集成时)或者上游写得太烂,一边重构一边用。

Bounded Context

类似于细胞膜一样,缩小模型的命名空间、覆盖范围。
降低成员之间沟通的成本(DSL中术语太多记不住,洪泛了)

一个模型只在一个上下文中使用。

// Bounded Context和Module还是有所区别。一个是逻辑上的,一个是物理上的。

识别不一致:

  1. 场景发生变化后:接口不匹配了。
  2. 重复的概念和假同源: 使用相同的术语,但其实是不同的模型。

Continuous Integration: 持续集成

在一个Bounded Context中的模型应该持续集成,保持一致性。
(小团队、高频交流)

Context Map: 全局视图

ContextMap同时服务于项目管理和软件设计。
甚至要按照它来安排办公室的物理位置。

案例:预订context和运输context

预订Context: 完成Route Specification=>地点代码的转换;
运输Context: 完成Node标识=>行程表、航程安排的转换。
两个上下文之间的接口非常小,可以由Side_Effect_free function构成,由于
同时使用两个上下文,因此可以应用有效的路线安排算法。

其他要点

  1. 确定Context的边界;(每个人都知道)
  2. 每个上下文应当有名字,方便讨论;(加入DSL)

将这两点文档化。

Context Map中一些常见的模式

持续集成模式:
紧密集成产品的优秀团队:大的统一的模型

Shared Kernel(共享内核)/Customer-supplier(客户供应商):

  • 团队协调能力有限;
  • 为不同的用户群提供服务;

Separate Way(独立自主)模式:

  • 集成并不重要时;

Open Host Service(开放主机服务)/Anticorruption Layer(防护层):

  • 与遗留系统或外部系统进行一定程度集成时。

Shared Kernel (共享内核)

持续集成是开销最大的,开销稍微小一点的是共享内核。
(仅持续集成内核部分)

从领域模型中选出两个团队都同意共享的一个子集。
一个团队在没与另一个团队商量之前不应擅自更改它。

测试
需要自动测试套件

可以每周进行一次内核的合并。

Shared Kernel通常是Core Domain(参见精炼部分,Core Domain就是精炼出来的项目需要解决的最核心逻辑),或者一组Generic Subdomain(通用子领域)。

Customer/Supplier Development Team(客户/供应商模式)

适用情况:

  1. 一个子系统服务于另一个子系统;
  2. 下游很少向上游反馈信息,单向依赖;
  3. 两个子系统为完全不同的用户群服务。

上下游很自然得分割到两个Bounded Context中。

注意事项:

  • 上下游负责的两个团队的行政关系:最好有共同的直接上级;
    原因:需要正式规定团队之间的关系、责任。
    两者有工作依赖关系,相互制约,如果无法互相推动可能导致交付delay。

测试
两个团队一起开发自动验收测试,验证预期的接口。
降低耦合性。上游团队做出修改时不必担心对下游团队产生副作用。
(接力赛时前面的选手不能一直回头看,他需要相信队友能把棒准确交到他手中,否则整个团队的速度都会慢下来)

Conformist(跟随者模式)

适用情况:
依然是上下游关系,但没有共同直接上级。
(管理层次相隔很远,无法推动)

此时下游团队只能靠自己了,3种选择:

  1. 放弃对上游的利用: Separete Way(独立自主模式)
  2. Anticorruption Layer: (防护层模式)上游写得很烂,一边重构一边用;
  3. Conformist: (跟随者模式)上游写得不错,拿过来进行增强即可。

Conformist与Shared Kernel类似都是用了相同内核,但是Conformist中另一个团队对合作没有兴趣。

Anticorruption Layer(防护层模式)

适用情况:
遗留代码写得烂,或上游写得不行但重写代价太高时,只能一边写一边重构。

一般重构不要直接全盘否定,这样工作量太大不可能立即完成。

实现

Facade: 外观模式: 子系统可供替换的接口,方便切换新老实现;
Adapter: 适配器:把新老系统转换成相同的接口。

个人思考:
微服务级别的隔离,部分请求发给新服务(一切都要灰度测试)

Separate Way(独立自主模式)

如果集成的收益很小,代价很高,可以考虑不集成。

Open Host Service(开放主机服务)

适用情况:
需要和大量其他系统集成时。

定义一个协议,使我们的系统可以作为一组Service供其它系统访问。
开放这个协议,让所有需要与我们系统集成的人都可以使用它。
当有新的集成需求时,增强并扩展这个协议。
(如果只是特殊需求,可以写个一次性的转换器,共享协议应该简单而且内聚)

Published Language(公共语言)

两个Bounded Context之间模型转换的时候,
交换信息时如果有共同语言(无歧义)能简化转换。
如Open Host Service模式中,如果能发明一种简单好理解的共享协议,别的系统就能快速接入。

公共语言可能是: XML,JSON…

模型的集成统一

集成的过程中往往会出现互相冲突的领域模型。
因此要适当简化,宁可缺少喷水功能,也不要包含不正确的特性。

Context的边界选择:

大的Context适用情况:

  1. 用一个统一的模型来处理时,用户任务之间流动更顺畅;
  2. 一个内聚模型比两个更容易理解;
  3. 两个模型转换很难;
  4. 共享语言可以使团队沟通起来更清楚。

小的Context适用情况:

  1. 降低了开发之间的沟通开销;
  2. 降低规模后:持续集成更容易了;
  3. 太大的上下文需要更高级的抽象模型:相关技巧人员短缺;
  4. 不同模型满足一些特殊需求。

集成外部系统的经验

  1. 首先考虑不集成:Seperate Way模式;
  2. 外部系统写得好:Conformist模式:
  3. 外部系统写得烂:Anticorruption Layer。

第15章 精炼 (Core Domain)

领域驱动的核心是把领域层提取出来;
还可以进一步把领域层中最核心要解决的问题(项目的立项根因)提取出来:
Core Domain。

核心思想:专注于核心问题,而不被大量次要问题所淹没。

精炼包括:

  1. 帮助成员掌握系统的总体设计及协调;
  2. 找到一个适度规模的核心模型,加入到通用语言,促进沟通;
  3. 指导重构;
  4. 专注于模型中最有价值的部分;
  5. 指导外包、现成组件的使用以及任务委派。

Core Domain模式

尽量压缩Core Domain,在Core Domain中努力开发深层模型和柔性设计。
(让最有才能的人来开发Core Domain,自主开发的软件的最大价值在于对Core Domain的完全控制。应该让最有才能的人+领域专家长期合作开发)

Domain Vision Statement: 领域前景说明
Highlighted Core : 突出的核心
Generic Subdomain: 通用子领域:模型中最普通不特别的部分;
Cohesive Mechanism: 内聚机制
Seperated Core: 隔离的核心:核心外的实现可替换
Abstract Core: 抽象内核:连核心的实现也是可替换的。

Generic Subdomain

通用子领域:与项目目标无直接联系,增加复杂性,不限于仅在本项目可以使用。(如数据库连接池这种纯技术的部分、带时区的日期和时间功能)

它们的解决方案:

  1. 购买现成的;
  2. 使用开源的;
  3. 把实现外包出去;
  4. 内部实现它。

Domain Vision Statement领域前景说明(1页)

不涉及技术指标,但要把项目和其他项目区分开来。
描述支持的功能和目标。
(区别于某个版本的技术规格)

Highlighted Core(3~7页)

在代码级完成Core Domain前,可以先文档级描述Core Domain。
描述Core Domain及内部元素的主要交互。
尽量精简。

Cohesive Mechanism(内聚机制)

分离出去的代码要内聚,用Intention-revealing接口来公开功能。
从而留下更小的Core Domain。
(例如可以分离Specification对象(规格))

Generic Subdomain与Cohesize Mechanism都是为Core domain减负。

Segregated Core(分离内核)

等到上述步骤完成,内核逐渐与其他部分分离开。
进一步重构,彻底去掉代码耦合,把内核分离出来。

Abstract Core(抽象内核)

把模型中最基本的概念识别出来,分离到不同的类、抽象类、接口中。
详细的实现留在子领域定义的module中。

综上
重构时也应当优先重构Core Domain。

第16章 大比例结构

前文:上下文(Bounded Context),精炼(Core Domain/Generic Subdomain)
分离出很多Module后,要找一个类非常困难。
这个时候为了便于管理: 大比例结构(约4层)

Evolving Order: 逐步进化演化。
System Metaphor: 隐喻思维;(用一些比喻、如防火墙)
Responsibility layer: 职责模式
Knowlege level: 知识级别模式;
Plugggable Component Framework: 解耦组件;

System Metaphor模式

隐喻模式。
例如核和外层的比喻,防火墙的比喻。
用比喻来分层。(但是宁缺毋滥)

Responsibility Layer职责分层模式

类似于MVC中Repository等,上层可以访问下层,下层则不能访问上层。
还可以根据访问频率、状态变化频率分层。

运输系统/投资银行案例

作业层
能力层
决策支持层
潜能层
承诺层

(类似于Controller,logic,repository层等等)

Knowledge level知识级别模式

与Reponsibily layer的区别在于两个层之间互相依赖。
案例用的是养老金分配的知识级别模式。
某些模型能根据元数据来工作,知识级别较高。

Pluggable Component Framework

一个中央hub上支持所需的协议,可以灵活替换组件。
这种模式一般是经过很长时间演变后产生。
(起码在Abstract Core之后)

最小化

一开始使用最松散的System metaphor隐喻模式,逐渐深化。

第17章 领域驱动设计的综合运用

综合使用前面的三点:
上下文、精炼、大比例结构。

大比例结合上下文(bounded context)

把不同bounded context放到不同层

大比例结合精炼

帮助理清Core Domain内部关系和Generic subdomain之间关系。
(放到不同层)

大比例结合上下文后的图如上所示。

战略设计决策的6个要点

  1. 决策传达到整个团队;
  2. 决策过程收集反馈;
  3. 计划允许演变;
  4. 架构团队和开发团队都需要聪明人;
  5. 简约、谦逊原则;(不要对开发形成障碍)
  6. 对象职责专一而开发人员是多面手。

推荐文章