领域模型驱动设计

领域模型驱动设计自然是以提炼和转换业务需求中的领域知识为设计的起点。
在提炼领域知识时,没有数据库的概念,亦没有服务的概念,一切围绕着业务需求而来。尤其是领域建模的分析阶段,应该只关注问题域,模型表达的是业务领域的概念,而非实现的概念。
在分析之初,不考虑任何技术实现手段,一切围绕着领域知识进行建模,是领域模型驱动设计的关键。

领域分析模型与抽象

领域分析模型必须遵循统一语言,由领域概念及它们之间的关系构成。

领域概念

领域概念:

  • 显式:在现实世界中被明确无误地表达出来的,电商领域中的商品、顾客、购物车、订单等概念。
  • 隐式:往往隐藏在领域逻辑中,不被明确地表达,电商领域中促销模型的促销产品(Promotion Product)。

    抽象层次

    抽象具有不同的层次 ——对业务概念粒度和特征的理解 —— 不同的抽象层次传递了不同的知识。

  • 抽象层次越高,需要关注的概念就越少,从而让分析模型变得更简单。

  • 高度的抽象亦可能遮掩住一些存在差异的业务事实,使得模型丢失一些重要而具体的领域知识。

例如:对瀑布、RUP、XP 和 Scrum 这四种不同的软件开发过程进行抽象 => 项目管理模型:
image.png
模型中的一个抽象概念可以代表多个领域概念,从而使得整个领域模型化繁为简,并保持了更好的可扩展性。

  • Iteration 既代表了 XP 的一次迭代,也代表了 Scrum 中的一次冲刺(Sprint)。
  • 同时,这个抽象概念并不能直观地体现 Scrum 冲刺的含义与特征,丢失了之所以命名为冲刺的关键语义。

因此,把握好抽象的分寸,既要传递准确的领域知识,又不至于让整个分析模型变得过于庞大,以至于阻碍领域专家和开发团队之间的交流。

结合具体业务场景-抽象

不同的业务场景会带来观察领域概念视角的差别,这也是领域驱动设计之所以要引入限界上下文的原因之一。
例如:银行系统中,管理客户的业务场景包含了个人(Individual)和组织(Organization)两个抽象概念。为了降低管理客户的复杂度,可以在这两个概念之上建立更高的一层抽象概念:客户(Customer)。
在交易业务场景中,由于个人业务与对公业务的差异较大,具有完全不同的业务流程和业务规则,如果仍然使用客户抽象来建立分析模型,就会因为过度抽象带来不必要的间接层,为设计模型带来错误的指导,例如创建了不合理的继承体系,并在实现的代码中引入频繁的强制类型转换。
这里所谓的“场景”,可以理解为限界上下文,它维护了领域模型的边界:
image.png
抽象 —— 从大量的具体事物中抽取和概括它们共同的方面、本质属性与关系:

  • 分类:由现象到本质的归纳。
  • 共同特征提取:从可变性中找到共性的概况能力。

建模水平 —— 归纳与概括这两种抽象能力。
例如:分析新闻领域,我们寻找到文章(Article)、视频(Vedio)和音频(Audio)等相对具体的概念。

  • 从新闻页面的角度观察这些概念,可以发现这些概念都具备共同特征:为页面提供内容(Content)。抽象模型:image.png
  • 对这些概念进行分类,将新闻网站发布的内容皆认为是文章,文章的内容却可以由文本、音频与视频混合组成。文本、音频与视频其实都属于媒体(Media)分类:image.png

注意控制抽象的范畴,否则会导致创建太多的抽象,形成错误的继承体系。
当我们创建父类与子类的继承体系时,可能会过多考虑子类对父类的重用,却忽略了继承其实是一种“差异化设计(Design by Difference)”的体现。
在对领域概念进行抽象时,我们应该仅针对存在差异的部分进行所谓的“泛化”,并由不同的子类去实现各自差异的部分。不要扩大局部差异,导致对整体概念进行错误的抽象。
例如:软件公司的员工分为需求分析人员、架构师、开发人员和测试人员。在建模时,我们要注意这里的员工分类其实扩大了差异。虽然需求分析人员、架构师、开发人员和测试人员都是(is)员工,但他(她)们之间的差异并非员工的差异,而是角色的差异:
image.png
这种抽象机制其实体现了继承和组合的区别。它提示我们在分析建模时不要因为概念关系上存在“是(is)”的关系时,就主观地做出抽象的判断,而需要深挖这些概念之间的不变性与可变性,然后从变化的部分寻找到抽象的特征。
这种抽象甚至不仅仅包括对概念的抽象,也可以是对行为的抽象。

领域设计模型与设计要素

Eric Evans —— 设计要素 —— 在领域设计模型中扮演了非常重要的角色。
这些设计要素既是对模型的约束,也是对设计的约束,可以认为是领域驱动设计中的设计模式。设计要素:
image.png
领域驱动设计提出的这些设计要素在设计模型中扮演了非常重要的角色。

  • 将分析模型中的领域概念定义为实体(Entity)或值对象(Value Object)。二者的区分有助于管理领域对象的生命周期,通过引入不变的值对象还可以减少并发的成本。
  • 确定聚合(Aggregate)的边界,并明确它包含哪些实体与值对象,使得领域模型可以遵守业务规则中的不变量(Invariable)约束和一致性约束。
  • 领域事件(Domain Event)的识别,可以帮助我们确认业务流程中那些已经发生的事实(Fact),并围绕着领域事件确定事件的发布者与订阅者,从而让这些概念能够流动起来。
  • 通过资源库(Repository)与工厂(Factory)模式的运用,有利于管理领域对象的生命周期,并通过对资源库的抽象保证领域逻辑不受数据库持久化机制的影响。

    对象分类

    领域设计对象分类:
    设计角度:实体、值对象、领域服务与领域事件;
    履行职责角度:领域对象在业务场景中协作时各自扮演的角色。

    角色构造型

    image.png
    角色构造型可以用来集中描述对象的职责,以下是 Rebecca 对这些构造型职责的简单描述:

  • 信息持有者:掌握并提供信息

  • 服务提供者:执行工作,通常为其他对象提供服务
  • 构造者:维护对象之间的关系,以及与这些关系相关的信息
  • 协调者:通过向其他对象委托任务来响应事件
  • 控制器:进行决策并指导其他对象的行为

结合领域驱动设计与职责驱动设计:

  • 实体与值对象视为“信息持有者”角色,优先考虑将与信息相关的行为分配给这些信息的持有者。
  • 领域服务扮演了服务提供者的角色,它能为领域对象提供业务支持,实现单个信息持有者无法完成的功能。
  • 应用服务扮演协调者:
    • 对外公开的接口对应一个具有业务价值的主用例(Use Case);
    • 对内却仅仅做好各个领域对象之间的协调,而将业务逻辑都委派给各自的领域对象。
  • 工厂属于构造者角色,负责创建复杂的领域对象,尤其是聚合根实体。
  • 一些领域服务还扮演了控制器角色,通过它决策并指导其他对象的行为。

整合后的角色构造型:
image.png
报税例子:
以报税功能为例,系统需要定期根据用户提交的收入信息生成税务报告文件。首先,需要获得符合条件的税务报告,然后将其转换为 HTML 格式的数据流,最后以 HTML 格式的呈现方式生成 PDF 文件。对外而言,生成税务报告文件是一个完整的服务,客户端的调用者无需了解该服务的实现细节。这一职责可以分别由 TaxReportResource 远程服务与 TaxReportAppService 应用服务承担,前者响应远程客户端的请求,后者提供具有业务价值的行为。根据领域驱动设计对应用服务的定义,TaxReportAppService 应用服务并不真正实现具体的业务逻辑,而是负责将调用请求委派给 TaxReportGenerator 领域服务。
协作时序:
image.png

实现模型与编码质量

领域设计模型体现了类的静态结构与动态协作,领域实现模型则进一步把领域知识与技术实现连接起来,但同时它必须守住二者之间的边界,保证业务与技术彼此隔离。
边界线应由设计模型明确给出,其中的关键是遵循整洁架构、六边形架构与分层架构,做好基础设施层实现机制的抽象 —— 南向网关。

TDD

测试驱动开发可以很好地满足将领域设计模型转换为领域实现模型的需求。
测试驱动开发并不等于是“测试先行”,也不能简单地将其视为一种编程手段。
此处,测试驱动开发(Test Driven Development,TDD)包含两个 TDD 阶段:

  • 第一个阶段是任务分解驱动设计(Tasking Driven Design):通过对用户故事进行任务分解,可以降低需求复杂度。这一过程恰好与职责驱动设计中对职责的分解相对应,实际上都是一种“分而治之”的思想。每个分解的任务或子任务皆以动宾短语的形式表达,这就相当于寻找到了各个需要履行的职责,以及履行职责的时序。因此,设计模型中的时序图可以作为测试驱动开发的重要输入。
  • 第二个阶段是测试驱动开发:依照事先拆分好的任务,进一步结合业务场景将任务划分为多个可以验证的测试用例,然后开始编写测试,并按照红—绿—重构的节奏开始编码实现。

分解的任务是有层次的,大致可以划分为业务价值、业务功能与业务实现三个层次。

  • 采用自外向内的方向 —— 服务模型驱动设计的设计方向;
  • 选择测试用例时,从最小粒度的原子任务开始,在一定程度上能减少不必要的 Mock 协作,也能够减少最外层服务因为分支覆盖率的原因带来的测试用例组合爆炸。

image.png
测试驱动开发严格遵循 Kent Beck 提出的简单设计原则,内容为:

  • 通过所有测试(Passes its tests)
    • 我们开发的功能满足客户的需求,这是简单设计的底线原则。该原则同时隐含地告知与客户或领域专家(需求分析师)充分沟通的重要性。
  • 尽可能消除重复 (Minimizes duplication)
    • 对代码质量提出的要求,并通过测试驱动开发的重构环节来完成。注意此原则提到的是 Minimizes(尽可能消除),而非 No duplication(无重复),因为追求极致的重用存在设计与编码的代价。
  • 尽可能清晰表达 (Maximizes clarity)
    • 要求代码要简洁而清晰地传递领域知识,在领域驱动设计的语境下,就是要遵循统一语言,提高代码的可读性,满足业务人员与开发人员的交流目的。针对核心领域,甚至可以考虑引入领域特定语言(Domain Specific Language,DSL)来表现领域逻辑。
  • 更少代码元素 (Has fewer elements)
    • 遏制过度设计的贪心,做到设计的恰如其分,即在满足客户需求的基础上,只要代码已经做到了最少重复与清晰表达,就不要再进一步拆分或提取类、方法和变量。
  • 以上四个原则的重要程度依次降低

这四个原则是依次递进的,功能正确、减少重复、代码可读是简单设计的根本要求。
一旦满足这些要求,就不能创建更多的代码元素去迎合未来可能并不存在的变化,避免过度设计。
当简单设计原则与测试驱动开发结合起来之后,测试保证了功能的正确性,重构则保证了代码的质量。由于有大量的测试保护,即使未来发生了变化,也能让开发人员在调整代码结构应对变化时充满信心。
测试、实现与重构共同构成了测试驱动开发的核心:
image.png
重构既可以让领域实现模型满足统一语言的要求,并帮助我们发现隐含概念,又可以让我们的面向对象设计做得更好。
代码也需要不断地打磨,这个过程就是对代码坏味道的识别与消除,进而在重构的过程中,逐渐让我们的实现向着面向对象设计的范式靠拢。
例如,通过识别出“依恋情节(Feature Envy)”的坏味道,就可以结合提取方法(Extract Method)与移动方法(Move Method)等重构手法,将行为转移到拥有数据的模型对象上,避免了贫血模型。又例如识别出“过长参数列表(Long Parameter List)”的坏味道,就可以通过引入参数对象(Introduce Parameter Object)重构手法,获得在分析建模与设计建模中未曾发现的隐式领域概念。

单元测试

单元测试快速反馈 —— Michael Feathers 认为:运行快、不依赖于任何外部资源的测试就是单元测试。
如下所述的测试并非单元测试:

  • 和数据库有交互
  • 进行了网络间通信
  • 调用了文件系统
  • 需要你对环境做特定的准备(如编辑配置文件)才能运行的

这些职责恰好属于业务逻辑需要调用的所谓“南向网关”的部分,被放在整洁架构的最外侧一环,如下图所示的 DB、Devices 与 External Interfaces:
image.png
遵循整洁架构思想与依赖倒置原则(DIP),我们需要对这些职责进行抽象。该抽象正好对应于领域设计模型中的 Gateway 角色,即对“访问外部资源”行为的封装与抽象。
在测试驱动开发中,这些职责可以利用类似 Mockito 这样的模拟框架对其进行模拟,使得我们在编写测试时,可以仅关注具体的业务逻辑,而忽略与外部资源的协作。这既符合测试驱动开发的原则,又能满足领域驱动设计将设计重心放在“领域”的要求,自然而然地做到了业务复杂度与技术复杂度的隔离。

领域模型驱动设计的过程

  1. 领域分析模型从业务系统中抽象出核心的领域概念,与领域专家一起获得领域见解,并提炼出有价值的领域知识,从而建立一个有利于与领域专家沟通的抽象模型。领域分析模型与任何软件开发技术都没有关系,只取决于团队对领域知识的理解。
  2. 领域设计模型则是在领域分析模型基础上的技术演进,例如对领域分析模型中的领域对象进行职责分配,建立抽象接口完成模块以及对象之间的解耦,对代表领域概念的类进行更合理的封装,隐藏不必要的细节,并对领域分析模型中的领域对象运用 Eric Evans 提出的设计要素与模式。
  3. 领域实现模型提供遵循领域设计模型的编程实现,这时需要考虑具体的实现机制,但同时又必须保持业务复杂度与技术复杂度的分离,避免出现复杂度的叠加效应。当然,实现模型总是由编程语言来表示,不同语言有不同的惯用法、不同的语法糖,即使在相同语言下,选择不同的框架,由于框架的设计原则和思路亦有所不同,导致实现模型会有所区别。

整个领域模型驱动设计的过程如下图所示:
image.png
领域分析模型、领域设计模型与领域实现模型共同构成了领域模型,因此这里列出的三个模型并不是独立无关的,与之对应的建模活动也不是独立无关的。
这三个模型是统一的整体,只是在不同的阶段需要有不同的分析建模方法,又因为交流的对象不同,需要有不同的模型呈现形式。因此,要掌握领域驱动设计,在战术设计层面就必须要理解什么才是真正的领域模型。