领域驱动设计(DDD)

领域驱动设计是指由领域模型驱动的架构设计.
现实世界业务与预先构想的模型往往有很大出入, 以至于在漫长的重构过程中有时会突破性地发现更合适的深层模型.

DDD在不同语境下是两种东西:
一种是"由领域模型驱动出来的设计", 关注点在让软件设计符合现实世界的模型, 为此需要领域专家和开发人员的协作.
一种是"由领域模型驱动出来的架构", 关注点在于让软件架构插入一个名为领域层的专用层,
DDD定义了关于这个领域层的许多术语.
通常情况下, 在人们谈论DDD时, 多半指的是"架构"的DDD, 而不是"设计"的DDD.

实际上声称使用DDD架构的项目多半使用的是已经存在的软件架构, DDD只占其中很少一部分.
例如微服务架构, 六边形架构, 干净架构.
这些架构并不根据DDD发展而来, 而是根据"解耦", "分离关注点", "降低认知负荷"等软件设计的基本原则发展出来的.

DDD显然是使用OOP建模的(OOD), 因此绝大多数概念都只与OOP有关, 存在着局限性.
甚至连DDD的创建者Eric Evans也坦承: DDD只是好的OOD.

领域模型是一种由特定领域的专家精炼出的模型, 或称之为"公共语言",
与具体的代码无关, 是代码编写者与领域专家之间的桥梁.

由领域模型驱动出来的架构下的软件代码与专家具备的领域知识高度吻合.
例如, 在领域驱动设计的项目里, 对于专家来说的"策略"(字面意义),
在代码层面上, 它以策略设计模式体现出来(尽管可能至始至终只有一种策略, 而不需要在多个策略之间切换).

领域模型通过UML图和文档进行表示:
UML图作为一种快速沟通和解释模型的手段, 无法用图解释的内容由文本完成.
文档用于澄清设计意图, 让人们理解架构的整体脉络, 而不应该与代码在功能上重叠(即解释具体的行为).

模型驱动的设计不是一成不变的, 而是与所有参与开发和设计的开发人员共同发展的,
因此模型与代码之间的同步不能被断开.

DDD关注"模型的行为"有没有业务价值(贴合现实世界的行为).
举例来说, 开发人员最容易创建出来的数据持有器(data holder), 尽管包含了一堆setter和getter,
却不能称之为"具有业务价值", 因为现实世界的业务并不会以这种往模型里填充数据或获取数据的方式运作.
此外, setter和getter仅仅与属性名有关, 并不能让人意识到这些方法有什么意义.
根据DDD创建出来的模型, 不仅具有现实世界的业务价值, 也能从方法的名称对其功能一目了然.

对于同一个概念, 在不同的上下文里, 可能会出现多种不同的领域模型, 这种上下文被称为限界上下文.
拿"图书"举例:
对市场营销部门来说, 图书是一种需要营销的商品, 涉及到面向消费者的广告活动;
对图书编辑部门来说, 图书是一种未完成的创作物, 涉及到作者, 封面设计, 插画, 印刷, 版式;
对消费者来说, 图书是一种需要购买的商品, 涉及到折扣, 单价, 库存.
对读者来说, 图书是一种可读的对象, 涉及到阅读进度的保存.

限界上下文之间应该是相互隔离的, 位于一个限界上下文里的概念不应该"溢出"到其他上下文里.

限界上下文被认为是微服务架构的前身.

CQRS实际上就是所谓的"读写分离"或"在这里读, 在那里写", 在不同的语境下, 这个词会有不同的含义.

在代码层面, CQRS要求一个方法不能同时具备如下两条属性:

  • 修改状态
  • 返回(查询到的)数据
    如果一个方法同时满足这两条属性, 其完备的方法名就会变成"DoSomethingAndReturnSomething",
    这无疑降低了代码的可维护性, 而省略其中的部分又会导致方法名不能准确反映其行为.

在DDD层面, CQRS会催生出专门用于读取和写入操作的两个独立的领域模型, 从而将各自的逻辑, 甚至存储基础设施分开.
举例来说, 读取操作经常会利用缓存, 而写入操作需要直接访问数据库, 按照CQRS模式, 可以拆分成3个对象:

  • 读取缓存的对象(使用频率高)
  • 写入数据库的对象(使用频率中等)
  • 读取数据库的对象(使用频率低)

在架构语境下, 数据基础设施根据CRUD被分为CUD数据库(写数据库)和R数据库(读数据库):

  • CUD数据库是CRUD数据库去掉读取职责的结果, 服务不直接从该数据库执行查询,
    因为查询的效率可能很低下, 并且影响并发写入的性能.
  • R数据库是一个单独的高性能数据库或物化视图, 它为CUD数据库的内容提供高性能查询.
    R数据库使用事件或其他方式同步CUD数据库的数据, 实现最终一致性.
    R数据库使用的数据结构和写数据库可能完全不同.

CQRS/ES是架构语境下的CQRS的ES版本.
由于状态直接被事件取代, R数据库的增量更新直接变成了相应的事件追加,
而无需单独设计基于事件的增量更新机制.

ES指的是用仅追加的不可变事件序列来替代状态的一种模式.
当需要获得最新状态值时, 通过重放(replay)序列里的事件来重建状态.

该模式最常见于金融业和大型企业系统.
ES可用来实现与事件重建相关的特性, 例如审计历史记录, 逆转/补偿事件造成的影响等.
在实际使用过程中, 事件经常被永久保存, 与比特币那样的日志数据库类似.

ES经常与CQRS模式一同使用, 参见CQRS/ES一节.

  • 实施繁琐.
  • 占用更多的存储空间.
  • 需要重放才能获取当前状态, 性能低下, 需要依赖Projection这样的缓存.
  • 为了应对遗留事件, 需要保留一定的旧代码.
  • 只能使用基于主键或MapReduce的查询, 不能使用基于条件的查询.

ES对大多数项目都不是那么有用, 由于ES的复杂性, 将状态变成事件序列的做法经常会变成糟糕的过度设计.

对大多数需要历史记录的项目来说, 将事件作为一种独立于状态的日志来记录会比实施事件溯源方便:

  • 状态是确定的, 不需要从事件序列里得出.
  • 存储基础设施简单, 不需要设计单独用于读取的视图.
  • 模式变更更容易.

随着软件升级, 已经存在的事件可能需要被修改以适配当前版本.
但由于ES是不可变的, 不能直接修改Event Store里的事件序列.
因此应用程序在读取到旧事件时, 只会在应用程序里将其转换为最新版本(该操作被称为向上转换, upcaster),
尽管这种转换不会反映到数据库, 但也足够使用了.

一种专门存储事件的以时间顺序排序的有序列表, 通常是数据库.

典型的结构:

interface Event {
eventId: number
eventType: string // 事件名里的动词必须是过去式
entityType: string
entityId: string
eventData: JSON
}

ES的物化视图形式, 表示与之对应的某个事件下的状态, 可能保存在内存或某个持久化存储里.
以函数式的观点来看, 可以将Projection视作事件序列的左折叠.
之所以需要Projection, 是因为在事件量很大时, 从事件序列重建状态效率非常低, Projection在此时作为缓存使用.

Projection的持久化, 使用快照的目的是永久归档掉某个时间点之前的旧事件.

一个对象应该对其他对象的了解应该尽可能少, 也称为最小知识原则.

任何对象的任何方法只能调用以下对象中的方法:

  1. 1.
    该对象自身
  2. 2.
    所传入的参数对象
  3. 3.
    它创建的对象
  4. 4.
    自身包含的其他对象

指的是外部代码在访问某个服务时, 不应该询问服务的某项信息再做下一步的判断,
而是应该直接让服务自己处理不同的情况.
此原则减少了服务细节的泄漏, 降低了代码的耦合性.

具有唯一标识符的一种抽象概念, 是领域模型的基本元素.

没有唯一标识符的实体, 是领域模型的基本元素.

值对象具有以下特性:

  • 不可变.
  • 具有相同属性的副本与本体等价, 可以互相替代.

典型例子:

  • 字面量: 字符串, 数字, 布尔值.
  • 具有单位的, 所代表的事物不具有唯一性的对象: 货币.

领域服务指的是一种无状态的专门任务处理者, 它以单例类或静态类的形式出现, 供外部调用.
通常当一种类型的任务无法建模的时候, 才会把它作为领域服务使用.

与服务(Service)是相似的概念, 只不过领域服务处于领域层(通常会暴露给用户层), 而服务处于应用层.

在实践中, 领域服务指的可能就是一个API网关及其背后包含的所有东西.

一个聚合是组合了同一领域实体和值对象的复合领域模型.
出于松耦合目的(方便持久化), 聚合内部通常不保存实体的引用, 而是保存实体的标识符.

聚合遮蔽了它的内部细节, 一般被当作暴露给应用层访问的唯一接口, 起到划分边界, 隐藏内部状态的作用.

在实践中, 聚合可能代表一个事务, 事务下所有相关的操作都通过这个聚合来管理.

用于创建聚合/实体/值对象.

DDD之所以专门设立了工厂这种概念, 是因为对象的创建过程可能会很复杂, 无法直接通过构造函数完成.

仓库被设计用于持久化保存和读取聚合/实体.
仓库与被保管的对象在类型上通常是一对一的关系, 只在极少情况下一个仓库会保管多种类型的对象.

仓库可以被视作是一个HashMap, 通过聚合的唯一标识符作为键名.
仓库应该是持久化的, 它可以以各种形式, 比如数据库, 缓存等形式存档.

人们可能会注意到, 在实践中, DAO与DDD的Repository非常相似.
一种观点认为, DAO是以数据库表(Table)为中心, Repository是以实体(Entity)为中心
(一个实体背后可能对应着多张表).

  1. 1.
    表示层: 调用业务逻辑层
  2. 2.
    业务逻辑层: 调用数据访问层, 封装业务逻辑
  3. 3.
    数据访问层: 实现DAO, 封装数据库的访问

领域模型被用于软件分层架构中的领域层, 与用户界面层, 应用层, 基础设施层进行切割, 成为单独的一层.
这种架构有助于保持代码的可维护性, 避免不同层的代码杂糅在一起.

  1. 1.
    用户界面层
  2. 2.
    应用层: 很薄的一层, 用于把领域层封装成适合直接调用的接口, 供用户界面层使用.
  3. 3.
    领域层: DDD架构的核心, 用于以DDD的思维方式处理业务.
  4. 4.
    基础设施层
    在"严格分层架构"中, 每一层都具有高度内聚性,
    且只依赖于其直接下层, 上层无法绕过直接下层接触到更低的层次.
    在"松散分层架构"中, 上层可以调用任意下层,
    在这种架构里, 基础设施层在提供数据功能以外, 还提供层与层之间的通信等必要功能.

注: 术语"六边形"容易产生误导, 该架构没有任何部分与数字6有关.

六边形架构将系统分为内部和外部两个部分, 内部是应用的业务逻辑, 外部是基础设施等其他服务.
内部与外部通过特定的端口(API)进行通信, 端口由一层被称作"适配器(Adapter)"的组件包装起来.
由于适配器是一种可替换的解耦组件, 因此易于进行测试.

六边形架构被一部分人认为是"微服务架构"的起源.

代码的依赖关系只能从外部向内部产生依赖, 而不能反过来.

该原则的名称实际上产生误导, 因为在实践中被贯彻的原则实为"向外保持最大程度的无知"和"依赖倒置":
位于内部的软件对外部世界的理解应该极为抽象(定义为接口, 外部环境实现此接口).
只有这样外部环境才能很容易被替换.

Smart UI模式指的是那些支持GUI设计器的开发环境下诞生的软件开发模式.

在这种软件开发模式下, 由于开发人员能够傻瓜化地创建用户界面,
用户界面与其他层不解耦, 容易导致意大利面条式的代码, 软件的可维护性和可扩展性大幅下降.
因此Smart UI在简单项目以外都被视作是反模式.
对比软件分层架构, Smart UI可以看作是由用户界面层主导的一种不分层架构,
功能实现最多只有一层简单的包装(例如包装成单独的类或模块).

对象数据库模式指的是整个架构通过同一个对象数据库各自实施自身的实现, 毫无疑问这种设计不包含领域层,
因为每个应用层的实现都可以直达基础设施层(对象数据库).
这种模式很快就会导致复杂性上升, 而且数据库的一处修改会导致多处代码的变更, 极速降低可维护性.

事务脚本是一种过程式编程的建模方式, 在支持OOP的语言里, 事务脚本可能是一些以"名词+动词"进行命名的类.
事务脚本可能是最常见的模式, 因为它最符合直觉.

事务脚本的缺陷在于, 业务逻辑与数据访问合并在了一个层,
每个业务逻辑都有自己的数据访问代码, 这会导致代码难以维护.

在DDD里, 应用层才会处理业务逻辑, 数据访问的部分则由领域层封装起来.

表模块是一种围绕数据库表进行建模的方式, 例如一张表是用户表(User),
那么就会催生出一个名为UserTalbe或User的类.
这实际上更像是对ORM的一种包装, 将相关的业务逻辑强加在数据库表上.

在DDD的领域层里,
像User这样的用户表会在不同的上下文里以Author, Editor, Manager, Administrator等具体的类体现.

UML作为草稿(正向工程或逆向工程, 用于交流)是好的.
UML作为蓝图(正向工程, 从制订蓝图, 到编写代码)是坏的,
有大量编程实例告诉我们, 软件开发是逐步改善的过程, 而不是一个可以从计划中诞生的过程,
即使是最好的程序员也无法一次设计完整个项目.
UML作为编程语言(正向工程或逆向工程)是坏的, 因为UML与编程语言过耦合了, 会拖累实现.

图标种类太多, 难以记忆, 甚至不如直接用文字表示.

用例图: 描述不同身份的参与者(actor)能够使用哪些功能.
类图: 描述对象模型. UML类图可能导致双向关联, 两个类之间具备双向关联可能使类过分耦合, 将会破坏程序设计.
包图: 描述package之间的依赖关系.

序列图/时序图: 表示多个类之间如何进行互动.
状态机图/状态图: 描述状态变化的过程.
活动图: 类似于流程图.

瀑布式开发将软件开发按阶段分解为: 需求分析, 设计, 编码, 测试, 每个阶段完成每个阶段的工作.
在一个长达一年的软件项目里, 可能有一半的时间用在编码以外的情况,
如果开发中途停止, 那么这个项目可能只剩下一堆文档作为遗产.

迭代式开发将按软件功能将开发分解为功能的子集:
每次完成其中的一部分(包含完整的需求分析, 设计, 编码, 测试阶段), 接着再逐步实现剩余的部分.
即使开发中途停止, 我们也能得到其中一部分已经被实现的核心功能, 这些功能可能在其他的项目里派上用场.