《领域驱动设计》读书笔记(三):通过重构来加深理解

《领域驱动设计》读书笔记(三):通过重构来加深理解

我们面临的真正挑战是找到深层次的模型,这个模型不但能够捕捉到领域专家的微妙的关注点,还可驱动切实可行的设计。我们的最终目的是开发出能够捕捉到领域深层含义的模型。

要想成功地开发出实用的模型,需要注意以下 3 点:

  1. 复杂巧妙的领域模型是可以实现的,也是值得我们去花费力气实现的。

  2. 这样的模型离开不断的重构是很难开发出来的,重构需要领域专家和热爱学习领域知识的开发人员密切参与进来。

  3. 要实现并有效地运用模型,需要精通设计技巧。

重构就是在不改变软件功能的前提下重新设计它。

自动化的单元测试套件能够保证对代码进行相对安全的试验。

  • 设计模式重构 — 为实现更深层模型而进行的重构。

  • 代码细节重构

简称为“领域模型重构”。 学习以更高维度去看待问题。

《重构》一书中所列出的重构分类涵盖了大部分常用的代码细节重构。这些重构主要是为了解决一些可以从代码中观察到的问题。

领域模型会随着新认识的出现而不断变化,由于其变化如此多样,以至于根本无法整理出一个完整的目录。

建模和设计都需要你发挥创造力。

对象分析的传统方法是先在需求文档中确定名词和动词,并将其作为系统的初始对象和方法。

事实上,初始模型通常都是基于对领域的浅显认知而构建的,既不够成熟也不够深入。

深层模型能够穿过领域表象,清楚地表达出领域专家们的主要关注点以及最相关的知识。

戴久了的手套在手指关节处会变得柔软;而其他部分则依然硬实,可起到保护的作用。

柔性设计除了便于修改,还有助于改进模型本身。 Model-Driven Design 需要以下两个方面的支持:深层模型使设计更具表现力;同时,当设计的灵活性可以让开发人员进行试验,而设计又能清晰地表达出领域含义时,那么这个设计实际上就能够将开发人员的深层理解反馈到整个模型发现的过程中。

由于模型和设计之间具有紧密的关系,因此如果代码难于重构,建模过程也会停滞不前。

你需要富有创造力,不断地尝试,不断地发现问题才能找到合适的方法为你所发现的领域概念建模,但有时你也可以借用别人已建好的模式。

第 8 章 突破

重构/突破
Figure 1. 重构/突破

小改进可防止系统退化,成为避免模型变得陈腐的第一道防线。

重构的原则是始终小步前进,始终保持系统正常运转。

过渡到真正的深层模型需要从根本上调整思路,并且对设计做大幅修改。

不要试图去制造突破,那只会使项目陷入困境。通常,只有在实现了许多适度的重构后才有可能出现突破。

要为突破做好准备,应专注于知识消化过程,同时也要逐渐建立健壮的 Ubiquitous Language 。

第 9 章 将隐式概念转变为显式概念

深层建模的第一步就是要设法在模型中表达出领域的基本概念。

若开发人员识别出设计中隐含的某个概念或是在讨论中受到启发而发现一个概念时,就会对领域模型和相应的代码进行许多转换,在模型中加入一个或多个对象或关系,从而将此概念显式地表达出来。

概念挖掘

倾听领域专家使用的语言。有没有一些术语能够简洁地表达出复杂的概念?他们有没有纠正过你的用词(也许是很委婉的提醒)?当你使用某个特定词语时,他们脸上是否已经不再流露出迷惑的表情?这些都暗示了某个概念也许可以改进模型。

有些概念可能需要你自己去挖掘和创造。要挖掘的地方就是设计中最不足的地方,也就是操作复杂且难于解释的地方。

看书与咨询领域专家并不冲突。即便能够从领域专家那里得到充分的支持,花点时间从文献资料中大致了解领域理论也是值得的。

开发人员还有另一个选择,就是阅读在此领域中有过开发经验的软件专业人员编写的资料。

阅读书籍并不能提供现成的解决方案,但可以为她提供一些全新的实验起点,以及在这个领域中探索过的人总结出来的经验。

如何为那些不太明显的概念建模

显式的约束

约束是模型概念中非常重要的类别。它们通常是隐含的,将它们显式地表现出来可以极大地提高设计质量。

将约束条件提取到其自己的方法中,这样就可以通过方法名来表达约束的含义,从而在设计中显式地表现出这条约束。

下面是一些警告信号,表明约束的存在正在扰乱其“宿主对象”(Host Object)的设计:

  1. 计算约束所需的数据从定义上看并不属于这个对象。

  2. 相关规则在多个对象中出现,造成了代码重复或导致不属于同一族的对象之间产生了继承关系。

  3. 很多设计和需求讨论是围绕这些约束进行的,而在代码实现中,它们却隐藏在过程代码中。

如果约束的存在掩盖了对象的基本职责,或者如果约束在领域中非常突出但在模型中却不明显,那么就可以将其提取到一个显式的对象中,甚至可以把它建模为一个对象和关系的集合。

将过程建模为领域对象

对象是用来封装过程的,这样我们只需考虑对象的业务目的或意图就可以了。

过程是应该被显式表达出来,还是应该被隐藏起来呢?区分的方法很简单:它是经常被领域专家提起呢,还是仅仅被当作计算机程序机制的一部分?

模式:Specification

业务规则通常不适合作为 Entity 或 Value Object 的职责,而且规则的变化和组合也会掩盖领域对象的基本含义。但是将规则移出领域层的结果会更糟糕,因为这样一来,领域代码就不再表达模型了。

逻辑编程提供了一种概念,即“谓词”这种可分离、可组合的规则对象,但是要把这种概念用对象完全实现是很麻烦的。同时,这种概念过于通用,在表达设计意图方面,它的针对性不如专门的设计那么好。

Specification(规格)中声明的是限制另一个对象状态的约束,被约束对象可以存在,也可以不存在。 Specification 有多种用途,其中一种体现了最基本的概念,这种用途是: Specification 可以测试任何对象以检验它们是否满足指定的标准。

因此:为特殊目的创建谓词形式的显式的 Value Object。 Specification 就是一个谓词,可用来确定对象是否满足某些标准。

为特殊目的创建谓词形式的显式的 Value Object。 Specification 就是一个谓词,可用来确定对象是否满足某些标准。

Specification 将规则保留在领域层。由于规则是一个完备的对象,所以这种设计能够更加清晰地反映模型。

Model-Driven Design 要求我们开发出一个能够把概念表达出来的有效实现。

另一种常见需求是根据某些标准从对象集合中选择一个子集。

第 10 章 柔性设计

软件的最终目的是为用户服务。

为了使项目能够随着开发工作的进行加速前进,而不会由于它自己的老化停滞不前,设计必须要让人们乐于使用,而且易于做出修改。这就是柔性设计(Supple Design)。

当复杂性阻碍了项目的前进时,就需要仔细修改最关键、最复杂的地方,使之变成一个柔性设计,

模式: Intention-Revealing Interfaces

一些有助于获得柔性设计的模式
Figure 2. 一些有助于获得柔性设计的模式

如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰。

Kent Beck 曾经提出通过 Intention-Revealing Selector(释意命名选择器)来选择方法的名称,使名称表达出其目的。设计中的所有公共元素共同构成了接口,每个元素的名称都提供了揭示设计意图的机会。类型名称、方法名称和参数名称组合在一起,共同形成了一个 Intention-Revealing Interfaces(释意接口)。

因此:在命名类和操作时要描述它们的效果和目的,而不要表露它们是通过何种方式达到目的的。这样可以使客户开发人员不必去理解内部细节。这些名称应该与 Ubiquitous Language 保持一致,以便团队成员可以迅速推断出它们的意义。在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它。

所有复杂的机制都应该封装到抽象接口的后面,接口只表明意图,而不表明方式。

模式: Side-Effect-Free Function

多个规则的相互作用或计算的组合所产生的结果是很难预测的。开发人员在调用一个操作时,为了预测操作的结果,必须理解它的实现以及它所调用的其他方法的实现。如果开发人员不得不“揭开接口的面纱”,那么接口的抽象作用就受到了限制。如果没有了可以安全地预见到结果的抽象,开发人员就必须限制“组合爆炸”,这就限制了系统行为的丰富性。

如果一个操作把逻辑或计算与状态改变混合在一起,那么我们就应该把这个操作重构为两个独立的操作。

尽可能把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的、非常简单的操作中。当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑移到 Value Object 中,这样可以进一步控制副作用。

Side-Effect-Free Function,特别是在不变的 Value Object 中,允许我们安全地对多个操作进行组合。

模式: Assertion

使用 Assertion(断言)可以把副作用明确地表示出来,使它们更易于处理。

如果操作的副作用仅仅是由它们的实现隐式定义的,那么在一个具有大量相互调用关系的系统中,起因和结果会变得一团糟。理解程序的唯一方式就是沿着分支路径来跟踪程序的执行。封装完全失去了价值。跟踪具体的执行也使抽象失去了意义。

简言之,“后置条件”描述了一个操作的副作用,也就是调用一个方法之后必然会发生的结果。“前置条件”就像是合同条款,即为了满足后置条件而必须要满足的前置条件。

把操作的后置条件和类及 Aggregate 的固定规则表述清楚。如果在你的编程语言中不能直接编写 Assertion,那么就把它们编写成自动的单元测试。还可以把它们写到文档或图中(如果符合项目开发风格的话)。寻找在概念上内聚的模型,以便使开发人员更容易推断出预期的 Assertion,从而加快学习过程并避免代码矛盾。

测试首先设置前置条件,在执行之后,再检查后置条件是否被满足。

把固定规则、前置条件和后置条件清楚地表述出来,这样开发人员就能够理解使用一个操作或对象的后果。

Intention-Revealing Interfaces 清楚地表明了用途,Side-Effect-Free Function 和 Assertion 使我们能够更准确地预测结果,因此封装和抽象更加安全。

模式: Conceptual Contour

如果把模型或设计的所有元素都放在一个整体的大结构中,那么它们的功能就会发生重复。外部接口无法给出客户可能关心的全部信息。由于不同的概念被混合在一起,它们的意义变得很难理解。

而另一方面,把类和方法分解开也可能是毫无意义的,这会使客户更复杂,迫使客户对象去理解各个细微部分是如何组合在一起的。更糟的是,有的概念可能会完全丢失。铀原子的一半并不是铀。而且,粒度的大小并不是唯一要考虑的问题,我们还要考虑粒度是在哪种场合下使用的。

把设计元素(操作、接口、类和 Aggregate )分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层 Conceptual Contour。使模型与领域中那些一致的方面(正是这些方面使得领域成为一个有用的知识体系)相匹配。

当连续的重构往往只是做出一些局部修改(而不是对模型的概念产生大范围的影响)时,这就是模型已经与领域相吻合的信号。

Intention-Revealing Interface使客户能够把对象表示为有意义的单元,而不仅仅是一些机制。 Side-Effect-Free Function 和 Assertion 使我们可以安全地使用这些单元,并对它们进行复杂的组合。 Conceptual Contour 的出现使模型的各个部分变得更稳定,也使得这些单元更直观,更易于使用和组合。

模式: Standalone Class

Module 和 Aggregate 的目的都是为了限制互相依赖的关系网。

即使是在 Module 内部,设计也会随着依赖关系的增加而变得越来越难以理解。这加重了我们的思考负担,从而限制了开发人员能处理的设计复杂度。隐式概念比显式引用增加的负担更大。

低耦合是对象设计的一个基本要素。尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变得完全独立了,这就使得我们可以单独地研究和理解它。每个这样的独立类都极大地减轻了因理解Module 而带来的负担。

尽力把最复杂的计算提取到 Standalone Class (独立的类)中,实现此目的的一种方法是从存在大量依赖的类中将 Value Object 建模出来。

低耦合是减少概念过载的最基本办法。独立的类是低耦合的极致。

消除依赖性并不是说要武断地把模型中的一切都简化为基本类型,这样只会削弱模型的表达能力。

模式: Closure Of Operation

当我们对集合中的任意两个元素组合时,结果仍在这个集合中,这就叫做闭合操作。

大部分引起我们兴趣的对象所产生的行为仅用基本类型是无法描述的。

另一种对设计进行精化的常见方法就是我所说的 Closure Of Operation(闭合操作)。

在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。如果实现者(Implementer)的状态在计算中会被用到,那么实现者实际上就是操作的一个参数,因此参数和返回值应该与实现者有相同的类型。这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他概念的任何依赖。

这种模式更常用于 Value Object 的操作。

Model-Driven Design 的作用受细节设计的质量和实现决策的质量影响很大,而且只要有少数几个开发人员没有弄清楚它们,整个项目就会偏离目标。

声明式设计

从模型属性的声明来生成可运行的程序是 Model-Driven Design 的理想目标,

声明式语言并不足以表达一切所需的东西,它把软件束缚在一个由自动部分构成的框架之内,使软件很难扩展到这个框架之外。

代码生成技术破坏了迭代循环——它把生成的代码合并到手写的代码中,使得代码重新生成具有巨大的破坏作用。

切入问题的角度

  1. 分割子领域

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

柔性设计在很大程度上取决于详细的建模和设计决策。柔性设计的影响可能远远超越某个特定的建模和设计问题。

第 11 章 应用分析模式

在《分析模式》一书中, Martin Fowler 这样定义分析模式:分析模式是一种概念集合,用来表示业务建模中的常见结构。它可能只与一个领域有关,也可能跨越多个领域。

分析模式的最大作用是借鉴其他项目的经验,把那些项目中有关设计方向和实现结果的广泛讨论与当前模型的理解结合起来。脱离具体的上下文来讨论模型思想不但难以落地,而且还会造成分析与设计严重脱节的风险,而这一点正是 Model-Driven Design 坚决反对的。

开发。当我们应用一种分析模式时,所得到的结果通常与该模式的文献中记载的形式非常相像,只是因具体情况不同而略有差异。

有一个误区是应该避免的。当使用众所周知的分析模式中的术语时,一定要注意,不管其表面形式的变化有多大,都不要改变它所表示的基本概念。

一个模型,甚至一个通用框架,都是一个完整的整体,而分析则相当于一个工具包,它被应用于模型的一些部分。分析模式专注于一些最关键和最艰难的决策,并阐明了各种替代和选择方案。

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

模式: Strategy(也称为 Policy)

策略模式
Figure 3. 策略模式

定义了一组算法,将每个算法封装起来,并使它们可以互换。 Strategy 允许算法独立于使用它的客户而变化。

领域模型包含一些并非用于解决技术问题的过程,将它们包含进来是因为它们对处理问题领域具有实际的价值。当必须从多个过程中进行选择时,选择的复杂性再加上多个过程本身的复杂性会使局面失去控制。

我们需要把过程中的易变部分提取到模型的一个单独的“策略”对象中。将规则与它所控制的行为区分开。按照 Strategy 设计模式来实现规则或可替换的过程。策略对象的多个版本表示了完成过程的不同方式。

通常,作为设计模式的 Strategy 侧重于替换不同算法的能力,而当其作为领域模式时,其侧重点则是表示概念的能力,这里的概念通常是指过程或策略规则。

模式:Composite

组合模式
Figure 4. 组合模式

当嵌套容器的关联性没有在模型中反映出来时,公共行为必然会在层次结构的每一层重复出现,而且嵌套也变得僵化(例如,容器通常不能包含同一层中的其他容器,而且嵌套的层数也是固定的)。客户必须通过不同的接口来处理层次结构中的不同层,尽管这些层在概念上可能没有区别。通过层次结构来递归地收集信息也变得非常复杂。

当在领域中应用任何一种设计模式时,首先关注的问题应该是模式的意图是否确实适合领域概念。

定义一个把 Composite 的所有成员都包含在内的抽象类型。在容器上实现那些查询信息的方法时,这些方法返回由容器内容所汇总的信息。而“叶”节点则基于它们自己的值来实现这些方法。客户只需使用抽象类型,而无需区分“叶”和容器。

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

有三件事情是必须要关注的:

  1. 以领域为本;

  2. 用一种不同的方式来看待事物;

  3. 始终坚持与领域专家对话。

即使在代码看上去很整洁的时候也可能需要重构,原因是模型的语言没有与领域专家保持一致,或者新需求不能被自然地添加到模型中。

当开发人员通过学习获得了更深刻的理解,从而发现了一个得到更清晰或更有用的模型的机会。

如何找到问题的病灶往往是最难和最不确定的部分。

保证重构的效率,需要注意几个关键事项:

  • 自主决定。

  • 注意范围和休息。

  • 练习使用 Ubiquitous Language。

软件不仅仅是为用户提供的,也是为开发人员提供的。

柔性设计主要通过减少依赖性和副作用来减轻人们的思考负担。

,当发生以下情况时,就应该进行重构了:

  • 设计没有表达出团队对领域的最新理解;

  • 重要的概念被隐藏在设计中了(而且你已经发现了把它们呈现出来的方法);

  • 发现了一个能令某个重要的设计部分变得更灵活的机会。

通过重构得到更深层理解是一个持续不断的过程。