一、概述
重构就是在不改变软件功能的前提下重新设计它。开发人员无需在着手开发之前做出详细的设计决策,只需要在开发过程中不断小幅调整设计即可,这不但能够保证软件原有的功能不变,还可使整个设计更加灵活易懂。
我们面临的真正挑战是找到深层次的模型,这个模型不但能够捕捉到领域专家的微妙的关注点,还可驱动切实可靠的设计。最终目的是抽象出一套能够捕捉到领域深层次含义的思想方法和模式。以这种方式设计出来的软件不但更加贴近领域专家的思维方式,而且能更好的满足用户的需求。
为达到这一目标,其实现可以分为几个过程,同时也需要应用某些设计原则和模式。其过程可分为重构突破——发现深层模型——柔性设计——应用设计原则和模式。
领域驱动设计中最令人兴奋的事件就是突破。有时,当我们拥有了Model-Driven Design和显式概念,就能够产生突破。这让我们有机会使软件更富表达力、更加多样化,甚至会使它变得超乎我们的想象。这可以为软件带来新特性,或者意味着我们可以用简单灵活的方式来表达更深层次的模型。尽管这种突破不会时常出现,但它们非常有价值,当我们有机会进行突破时,一定要懂得识别并抓住机会。
二、突破
重构的投入与回报并非呈线性关系。一般来说,持续重构让事物逐步变得有序。代码和模型的每一次精华都让开发人员有了更加清晰的认识。这使得理解上的突破成为可能。之后,一系列快速的改变得到了更符合用户需要并更加切合实际的模型。其功能性及说明性急速增强,而复杂性却随之消失。
这种突破不是某种技巧,而是一个事件。它的困难之处在于你需要判断发生了什么,然后决定如何处理。
重构的原则是始终小步前进,始终保持系统正常运转。但突破时往往需要重构深层模型需要修改大量的支持代码,在突破时系统几乎无法正常运转。与大部分重构相比,这种变化的回报更多,风险也更高。而且突破出现的时机可能很不合时宜,这时需冷静决策。不要试图去制造突破,在不断重构、精化中,突破事件自然而然会到来。
通常,在经过一次真正的突破并获得深层模型之后,所获得的新设计变得更加清晰简单,新的Ubiquitous Language也会增进沟通,于是又促成了下一次建模突破。
三、将隐式概念转变为显式概念
为得到深层次模型,需开发人员识别出设计中隐含的某个概念或是在讨论中受到启发而发现一个概念,此时会对领域模型和相应的代码进行许多转换,在模型中加入一个或多个对象或关系,从而将隐性概念显式地表达出来。
1、概念挖掘
通常概念挖掘可以通过以下几种方法:
- 倾听语言,倾听领域专家或用户的语言发现隐藏概念。
- 检查不足之处,设计中最不足的地方可能是最需挖掘之处。
- 思考矛盾之处,逻辑上不一至的地方可能隐含某种深奥规律。
- 查阅书籍,查阅书籍并与领域专家合作可能一开始就能形成深层的认识。
- 尝试,再尝试,在多个路线上做不同的尝试可能会找到新的隐式概念。
2、如何为那些不太明显的概念建模
面向对象范式会引导我们去寻找和创造特定类型的概念。所有事物及其操作行为是大部分对象模型的主要部分,如面向对象设计中涉及的“名词和动词”。但其他重要类别的概念也可以在模型中显式地表现出来。
A、显式的约束
约束是模型概念中非常重要的类别。它们通常是隐含的,将它们显式地表现出来可以极大地提高设计质量。
如果约束的存在掩盖了对象的基本职责,或者如果约束在领域中非常突出但在模型中却不明显,那么就可以将其提取到一个显式的对象中,甚至可以把它建模为一个对象和关系的集合。
B、将过程建模为领域对象
如果过程的执行有多种方式,那么我们可以将算法本或其中的关键部分放到一个单独的对象中。这样选择不可的过程就变成了选择不同的对象,每个对象都表示一种不同的Strategy。
过程是应该被显式表达出来,还是应该被隐藏起来呢?区分的方法很简单:它是经常被领域专家提起呢,还是仅仅被当作计算机程序的一部分?
约束和过程是两大模型概念,当我们面向对象语言编程时,不会立即想到它们,然而它们一旦被我们视为模型元素,就真的可以让我们的设计更为清晰。
C、模式:Specification
Specification可以简单看成是一组检查规则,Specification中声明的是限制另一个对象状态的约束,被约束对象可以存在也可以不存在。其最基本的用途是测试任何对象以检验它们是否满足指定的标准。
为特殊目的创建谓词形式的显式的Value Object。Specification就是一个谓词,可用来确定对象是否满足某些标准。
D、Specification的应用和实现
Specification最有价值的地方在于它可以将看起来完全不同的应用功能统一起来。出于以下3个目的中的一个或多个,我们可能需要指定对象的状态。
(1)验证对象,检查它是否能满足某些需求或者是否已经为实现某个目标做好了准备。
(2)从集合中选择一个对象(符合某些条件)
(3)指定在创建对象时必须满足某种需求
四、柔性设计
一旦我们挖掘出隐式概念,并把它们显式地表达出来之后,就需要循环迭代,建立深层模型,并用代码实现。这时柔性设计是对深层建模的有益补充。
通常,开发人员扮演着两个角色,一、客户开发人员,负责将领域对象组织成应用程序代码或其他领域层代码;二、重构代码的开发人员,不断重构深化、精化领域模型。
设计需要同时满足这两种角色的需求,特别是第二种开人员的需求容易在早期设计的版本中被忽略。特别是在大型程序中当复杂性阻碍了项目的前进时,就需要仔细修改最关键、复杂之处,使之变成一个柔性设计,帮助我们继续突破,推进项目。
设计这样的软件并没有公式,但有一些模式,可作为柔性设计的参考。
一些有助于获得柔性设计的模式
1、模式:语义化接口(Intention-Revealing Interface)
如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰了。
因此,在命名类和操作时要技术它们的效果和目的,而不要表露它们是通过何种方式达到目的的。这样可以使客户开发人员不必去理解内部细节。这些名称应该与Ubiquitous Language保持一致,以便团队成员可以迅速推断出它们的意义。在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它。
所有复杂的机制都应该封装到抽象接口的后面,接口只表明意图,而不表明方式,这就是语义化接口(Intention-Revealing Interface)。
在领域的公共接口中,可以把关系和规则表述出来,但不要说明规则是如何实施的;可以把事件和动作描述出来,但不要描述它们是如何执行的;可以给出方程式,但不要给出解方程式的数学方法。可以提出问题,但不要给出获取答案的方法。
2、模式:无副作用方法(Side-Effect-Free Function)
我们可以宽泛地把操作分为两大类:命令和查询。查询是从系统获取信息,查询的方式可能只是简单地访问变量中的数据,也可能是用这些数据执行计算。命令是修改系统的操作。在标准英语中,“副作用”这个词暗示着“意外的结果”,但在计算机科学中,任何对系统状态产生的影响都叫副作用。
在复杂的系统中大多数操作都会调用其他操作,而后者又会调用另外一些操作。一旦形成这种任意深度的嵌套,就很难预测调用一个操作将要产生的所有后果。
多个规则的相互作用或计算的组合所产生的结果是很难预测的。开发人员在调用一个操作时,为了预测操作的结果 ,必须理解它的实现以及它所调用的其他方法的实现。如果开发人员不得不“揭开接口的面纱”,那么接口的抽象作用就受到了限制。如果没有可以安全地预见到结果的抽象,开发人员就必须限制“组合爆炸”,这就限制了系统行为的丰富性。
返回结果而不产生副作用的操作称为函数。因此我们可以使用一些设计,来降低产生副作用的风险。尽可以把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的、非常简单的操作中。当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑移到Value Object中,这样可以进一步控制副作用。
Side-Effect-Free Function,特别是在不变的Value Object中,允许我们安全地对多个操作进行组合。当通过Intention-Revealing Interface把一个Function呈现出来的时候,开发人员就可以在无需理解其实现细节的情况下使用它。
3、模式:Assertion
把复杂的计算封装到Side-Effect-Free Function中可以简化问题,但实体仍然会留有一些有副作用的命令,使用这些Entity的人必须了解使用这些命令的后果。在这种情况下,使用Assertion(断言)可以把副作用明确表示出来,使它们更易于处理。
如果操作的副作用仅仅是由它们的实现隐式定义的,那么在一个具有大量相互调用关系的系统中,起因和结果会变得一团糟。理解程序的唯一方式就是沿着分支路径来跟踪程序的执行。封装完全失去了价值。跟踪具体的执行也使抽象失去了意义。
我们需要在不深入研究内部机制的情况下理解设计元素的意义和执行操作的后果。Intention-Revealing Interface可以起到一部分作用,但这样的接口只能非正式地给出操作的用途,这常常是不够的。“契约式设计”(Design By Contract)向前推进了一步,通过给出类和方法的“断言”使开发人员知道肯定会发生的结果。简言之,“后置条件”描述了一个操作的副作用,也就是调用一个方法后必然后发生的结果。“前置条件”就像是合同条款,即为了满足后置条件而必须要满足的前置条件。类的固定规则规定了在操作结束时对象的状态,也可以把Aggregate作为一个整体来为它声明固定规则,这些都是严格定义的完整性规则。
所有这些断言都描述了状态,而不是过程,因此他们更易于分析。类的固定规则在描述类的意义方面起到帮助作用,并且使客户开发人员能够更准确地预测对象的行为,从而简化他们的工作。如果你确信后置条件的保证,那么就不必考虑方法是如何工作的。断言应该已经把调用其他操作的效果考虑在内了。
因此,把操作的后置条件和类及Aggregate的固定规则表述清楚。如果在你的编程语言中不能直接编写Assertion,那么就把它们编写成自动的单元测试。还可以把它们写到文档或图中。
寻找在概念上内聚的模型,以便使开发人员更容易推断出预期的Assertion,从而加快学习过程并避免代码矛盾。
Intention-Revealing Interface清楚地表明了用途,Side-Effect-Free Function和Assertion使我们能够更准确地预测结果,因此封装和抽象更加安全。
4、模式:概念轮廓(Conceptual Contour)
有时,人们会对功能进行更细的分解,以便灵活组合;有时却要把功能合成大块,以便封装复杂性。有时,人们为了使所有类和操作具有相似的规模而寻找一种一致的粒度。这些方法过于简单不能作为通用的规则。
大部分领域中隐含着某种逻辑一致性,通过不断重构最终会发现柔性设计,并且Conceptual Contour(概念轮廓)也就逐渐形成。
从单个方法的设计,到类和Module的设计,再到大型结构的设计,高内聚低耦合这一对基本原则都起到重要作用。这两条原则即适用于代码,也适用于概念。为避免机械使用这两原则,设计时需经常问自己“这是代码技巧还是底层领域的某种轮廓?”
寻找在概念上有意义的功能单元,这样可以使设计即灵活又易懂。因此,把设计元素(操作、接口、类和Aggregate)分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层Conceptual Contour。使模型与领域中那些一致的方面(正是这些方面使得领域成为一个有用的知识体系)相匹配。
Conceptual Contour的出现使模型的各个部分变得更稳定,也使得这些单元更直观,更易于使用和组合。
5、模式:独立类(Standalone Class)
互相依赖使模型和设计变得难以理解、测试和维护。而且,互相依赖容易越积越多。即使在Module内部,设计也会随着依赖关系的增加而变得越来越难以理解。这加重了我们的思考负担,从而限制了开发人员能处理的设计复杂度。隐式概念比显式引用增加的负担更大。
在每种编程环境中,都有一些非常基本的概念,如数字、字符串和集合等基本概念。若将我们的领域模型一直精炼下去,直到每个剩下的概念关系都表示出概念的基本含义为止。这一个重要的子集中,依赖关系的个数可以减小到零,这样就得到一个完全独立的类。
我们应该对每个依赖关系提出质疑,直到证实它确实表示对象的基本概念为止。这个仔细检查依赖关系的过程从提取模型概念本身开始。然后需要注意每个单独地研究和理解它。每个这样的独立类都极大地减轻了因理解Module而带来的负担。
当一个类与它所在的模块中的其他类存在依赖关系时,比它与模型外部的类有依赖关系要好得多。同样,当两个对象具有自然的紧密耦合关系时,这两个对象共同涉及的多个操作实际上能够把它们的关系本质明确地表示出来。我们的目标不是消除所有依赖,而是消除所有不重要的依赖。当无法消除所有的依赖关系时,每清除一个依赖对开发人员而言都是一种解脱,使他们能够集中精力处理剩下的概念依赖关系。
尽力把最复杂的计算提取到StandAlone Class中,实现些目的的一种方法是从存在大量依赖的类中将Value Object建模出来。
低耦合是减少概念过载的最基本的方法。独立的类是低耦合的极致。消除依赖并不是说要武断地把模型中的一切都简化为基本类型,这样做只能削弱模型的表达能力。
6、模式:闭合操作(Closure Of Operation)
当我们对集合中的任意两个元素组合时,结果仍在这个集合中,这就叫做闭合操作。
在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。如果实现者(Implementer)的状态在计算中会被用到,那么实现者实际上就是操作一个参数,因此参数和返回值应该与实现都有相同的类型。这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他概念的任何依赖。这种模式更常用于Value Object的操作。
一个操作可能是在某一抽象类型之下的闭合操作,在这种情况下,具体的参数可能是不同的具体类型。例如,加法是实数之下的闭合运算,而实数可以是有理数,也可以是无理数。
在尝试和寻找减少互相依赖并提高内聚的过程中,有时我们会遇到“半个闭合操作”这种情况。参数类型与实现者的类型一致,但返回类型不同;或者返回类型与接收者(Receiver)的类型相同但参数类型不同。这些操作都不是闭合操作,但它们确实具有Closure Of Operation的某些优点。当没有形成闭合操作的那个多出来的类型是基本类型或基础库类时,它几乎与Closure Of Operation一样减轻了我们的思考负担。
7、声明式设计
使用Assertion可以得到更好的设计,虽然我们只是用一些相对非正式的方式来检查Assertion。但实际上我们无法保证手写软件的正确性。上面所述的Intention-Revealing Interface和其他模式虽然有一定的帮助作用,但它们永远也不会使传统的面向对象技术达到非常严密的程度。
以上这些正是采用声明式设计的部分动机。声明式设计对于不同的人来说具有不同的意义,但通常是指一种编程方式——把程序或程序的一部分写成一种可执行的规格(Specification)。使用声明式设计时,软件实际上是由一些非常精确的属性描述来控制的。声明式设计有多种实现方式,例如,可以通过反射机制或在编译时通过代码生成来实现。这种方法使其他开发人员能够根据字面意义来使用声明。它是一种绝对的保证。
从模型属性的声明来生成可运行的程序是Model-Driven Design的理想目标,但在实践中这种方法也有自己的缺陷。
- 声明式语言并不足以表达一切所需的东西,它把软件束缚在一个由自动部分构成的框架之内,使软件很难扩展到这个框架之外。
- 代码生成技术破坏了迭代循环——它把生成的代码合并到手定的代码中,使得代码重新生成具有巨大的破坏作用。
声明式设计发挥的最大价值是用一个范围非常窄的框架来自动处理设计中某个特别单调且易出错的方面,如持久化和对象关系映射。最好的声明式设计能够使开发人员不必去做那些单调乏味的工作,同时又完全不限制他们的设计自由。
另外 ,领域特定语言(Domain-Specific Language)是对声明式设计和语言的一种有趣的尝试。
8、声明式设计风格
使用逻辑运算对Specification进行组合。当使用Specification时,我们很容易会遇到需要把它们组合起来使用的情况。Specification是谓词的一个例子,而谓词可以用 And 、 Or和Not等运算进行组合和修改。这些逻辑运算都是谓词这个类别之下的闭合操作,因此Specification组合也是Closure Of Operation。
随着Specification的通用性逐渐提高,创建一个可用于各类型的Specification的抽象类或接口会变得很有用。这需要把参数类型定义为某种高层的抽象类。
9、切入问题的角度
可以用以下几种方法设计处理更大的系统设计。
分割子领域
尽可能利用已有的形式
把命令和Side-Effect-Free Function分开
把隐式概念变为显式概念
在进一步理解之后,把Share Pie变成一个Value Object
新设计的柔性
五、参考文档
DOMAIN-DRIVERN DESIGN
TACKLING COMPLEXITY IN THE HEART OF SOFTWARE
领域驱动设计
软件核心复杂性应对之道
【美】Eric Evans 著 赵俐 盛海艳 刘霞 等 译