软件的最终目的是为用户服务。但首先它必须为开发人员服务。在强调重构的软件开发过程中尤其如此。随着程序的演变,开发人员将重新安排并重写每个部分。他们会把原有的领域对象集成到应用程序中,也会让它们与新的领域对象进行集成。甚至几年以后,负责维护的程序员还将修改和扩充代码。人们必须要做这些工作,但他们是否愿意呢?
当具有复杂行为的软件缺乏良好的设计时,重构或元素的组合会变得很困难。一旦开发人员不能十分肯定地预知计算的全部含意,就会出现重复。当设计元素都是整块的而无法重新组合的时候,重复就是一种必然的结果。我们可以对类和方法进行分解,这样可以更好地重用它们,但这些小部分的行为又变得很难跟踪。如果软件没有一个条理分明的设计,那么开发人员不仅不愿意仔细地分析代码,他们更不愿意修改代码,因为修改代码会产生问题——要么加重了代码的混乱状态,要么由于某种未预料到的依赖而破坏了某些东西。在任何一种系统中(除非是一些非常小的系统),这种不稳定性使我们很难开发出丰富的功能,而且限制了重构和迭代式的精化。
为了使项目能够随着开发工作的进行加速前进,而不会由于它自己的老化停滞不前,设计必须要让人们乐于使用,而且易于做出修改。这就是柔性设计(supple design)。柔性设计是对深层建模的补充。一旦我们挖掘出隐式概念,并把它们显示地表达出来之后,就有了原料。通过迭代循环,我们可以把这些原料打造成有用的形式:建立的模型能够简单而清晰地捕获主要关注点;其设计可以让客户开发人员真正使用这个模型。在设计和代码的开发过程中,我们将获得新的理解,并通过这些理解改善模型概念。我们一次又一次回到迭代循环中,通过重构得到更深刻的理解。但我们究竟要获得什么样的设计呢?在这个过程中应该进行哪些实验?这正是本章要讨论的内容。
很多过度设计(over engineering)借着灵活性的名义而得到合理的外衣。但是,过多的抽象层和间接设计常常成为项目的绊脚石。看一下真正为用户带来强大功能的软件设计,你常常会发现一些简单的东西。简单并不容易做到。为了把创建的元素装配到复杂系统中,而且在装配之后仍然能够理解它们,必须坚持模型驱动的设计方法,与此同时还要坚持适当严格的设计风格。要创建或使用这样的设计,可能需要我们掌握相对熟练的设计技巧。
开发人员扮演着两个角色,一个角色是程序开发人员,另一个角色是代码的修改人员。而设计必须要为这两个角色服务。同一个人可能会同时承担这两种角色,甚至在几分钟之内来回变换角色,但角色与代码之间的关系是不同的。
对于程序开发人员来说,负责将领域对象组织成应用程序代码或其他领域层代码,以便发挥设计的功能。柔性设计能够揭示深层次的底层模型,并把它潜在的部分明确地展现出来。应用开发人员可以灵活地使用一个最小化的、松散耦合的概念集合,并用这些概念来表示领域中的众多场景。设计元素非常自然地组合到一起,其结果也是健壮的,可以被清晰地刻画出来,而且也是可以预知的。对于修改代码的开发人员来说,为了便于修改,设计必须易于理解,必须把客户开发人员正在使用的同一个底层模型表示出来。我们必须按照领域深层模型的轮廓进行设计,以便大部分修改都可以灵活地完成。代码的结果必须是完全清晰明了的,这样才容易预见到修改的影响。
早期的设计版本通常达不到柔性设计的要求。由于项目的时间期限和预算的缘故,很多设计一直就是僵化的。没有哪个大型程序自始至终都是柔性的。但是,当复杂性阻碍了项目的前进时,就需要仔细修改最关键、最复杂的地方,使之变成一个柔性设计,这样才能突破复杂性带给我们的限制,而不会陷入遗留代码维护的麻烦中。
设计这样的软件并没有公式,本文精选了一组模式,这些模式如果运用得当的话,就有可能获得柔性设计。这些模式和示例展示了一个柔性设计应该是什么样的,以及在设计中所采取的思考方式。
柔性设计可以极大地提升软件处理变更和复杂性的能力。正如本章的例子所示,柔性设计在很大程度上取决于详细的建模和设计决策。柔性设计的影响可能远远超越某个特定的建模和设计问题。
模式:INTENTION-REVEALING INTERFACES
INTENTION-REVEALING INTERFACE(释意接口)——类、方法和其他元素的名称既表达了初始开发人员创建它们的目的,也反映出了它们将会为客户开发人员带来的价值。
在领域驱动的设计中,我们希望看到有意义的领域逻辑。如果代码只是在执行规则后得到结果,而没有把规则显式地表达出来,那么我们就不得一步一步地去思考软件的执行步骤。那些只是运行代码然后给出结果的计算——没有显式地把计算逻辑表达出来,也有同样的问题。如果不把代码与模型清晰地联系起来,我们很难理解代码的执行效果,也很难预测修改代码的影响。对象的强大功能是它能够把计算或规则的大量细节封装起来,如此一来,客户代码就能够很简单,而且可以用高层概念来解释。
但是,客户开发人员要想有效地使用对象,必须知道对象的一些信息,如果接口没有告诉开发人员这些信息,那么他就必须深入研究对象的内部机制,以便理解细节。阅读客户代码的人也需要做同样的事情。这样就失去了封装的大部分价值。我们需要避免出现"认识过载"的问题。如果客户开发人员必须总是思考组件工作方式的大量细节,那么就无暇理清思路来解决客户设计的复杂性。即便一个人同时扮演两种角色(既需要开发代码,也需要基于已有的代码进行修改或使用已有的代码)的时候也是如此,因为他即使不必去了解那些细节,也不可能一次就把所有的因素都考虑全面。
如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰的。
当我们把概念显式地建模为类或方法时,为了真正从中获取价值,必须为这些程序元素赋予一个能够反映出其概念的名字。类和方法的名称为开发人员之间的沟通创造了很好的机会,也能够改善系统的抽象。
Kent Beck曾经提出通过INTENTION-REVEALING SELECTOR来选择方法的名称,使名称表达出其目的[Beck 1997]。设计中的所有公共元素共同构成了接口,每个元素的名称都提供了揭示设计意图的机会。**类型名称、方法名称和参数名称组合在一起,共同形成了一个INTENTION-REVEALING INTERFACE。**因此:
在命名类和操作时要描述它们的效果和目的,而不要表露它们是通过何种方式达到目的的。这样可以使客户开发人员不必去理解内部细节。这些名称应该与UBIQUITOUS LANGUAGE保持一致,以便团队成员可以迅速推断出它们的意义。在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它。
所有复杂的机制都应该封装到抽象接口的后面,接口只表明意图,而不表明实现方式。
在领域的公共接口中,可以把关系和规则表述出来,但不要说明规则是如何实施的;可以把事件和动作描述出来,但不要描述它们是如何执行的;可以给出方程式,但不要给出解方程式的数学方法;可以提出问题,但不要给出获取答案的方法。
模式:SIDE-EFFECT-FREE FUNCTION
副作用(side effect)——由一个操作产生的任何可观测到的状态改变,不管这个操作是有意的还是无意的(即使是一个有意的更新操作)。
我们可以宽泛地把操作分为两个大的类别:命令和查询。查询是从系统获取信息,查询的方式可能只是简单地访问变量中的数据,也可能是用这些数据执行计算。命令(也称为修改器)是修改系统的操作(举一个简单的例子,设置变量)。在标准英语中,“副作用"这个词暗示着"意外的结果”,但在计算机科学中,任何对系统状态产生的影响都叫副作用。这里为了便于讨论,我们把它的含义缩小一下,任何对未来操作产生影响的系统状态改变都可以称为副作用。
为什么人们会采用"副作用"这个词来形容那些显然是有意影响系统状态的操作呢?这大概是来自于复杂系统的经验。大多数操作都会调用其他的操作,而后者又会调用另外一些操作。一旦形成这种任意深度的嵌套,就很难预测调用一个操作将要产生的所有后果。第二层和第三层操作的影响可能并不是客户开发人员有意为之的,于是它们就变成了完全意义上的副作用。在一个复杂的设计中,元素之间的交互同样也会产生无法预料的结果。副作用这个词强调了这种交互的不可避免性。
多个规则的相互作用或计算的组合所产生的结果是很难预测的。开发人员在调用一个操作时,为了预测操作的结果,必须理解它的实现以及它所调用的其他方法的实现。如果开发人员不得不"揭开接口的面纱",那么接口的抽象作用就受到了限制。如果没有了可以安全地预见到结果的抽象,开发人员就必须限制"组合爆炸"(combinatory explosion,源自离散数学的术语,是指随着问题中元素的增加,所出现的可能组合数剧烈增加),这就限制了系统行为的丰富性。
返回结果而不产生副作用的操作称为函数。一个函数可以被多次调用,每次调用都返回相同的值。一个函数可以调用其他函数,而不必担心这种嵌套的深度。函数比那些有副作用的操作更易于测试。由于这些原因,使用函数可以降低风险。
显然,在大多数软件系统中,命令的使用都是不可避免的,但有两种方法可以减少命令产生的问题。首先,可以把命令和查询严格地放在不同的操作中。确保导致状态改变的方法不返回领域数据,并尽可能保持简单。在不引起任何可观测到的副作用的方法中执行所有查询和计算[Meyer 1988]。第二,总是有一些替代的模型和设计,它们不要求对现有对象做任何修改。相反,它们创建并返回一个VALUE OBJECT,用于表示计算结果。这是一种很常见的技术。VALUE OBJECT可以在一次查询的响应中被创建和传递,然后被丢弃——不像ENTITY,实体的生命周期是受到严格管理的。
VALUE OBJECT是不可变的,这意味着除了在创建期间调用的初始化程序之外,它们的所有操作都是函数。像函数一样,VALUE OBJECT使用起来很安全,测试也很简单。如果一个操作把逻辑或计算与状态改变混合在一起,那么我们就应该把这个操作重构为两个独立的操作[Fowler 1999, p. 279]。但从定义上来看,这种把副作用隔离到简单的命令方法中的做法仅适用于ENTITY。在完成了修改和查询的分离之后,可以考虑再进行一次重构,把复杂计算的职责转移到VALUE OBJECT 中。通过派生出一个VALUE OBJECT(而不是改变现有状态),或者通过把职责完全转移到一个VALUE OBJECT中,往往可以完全消除副作用。
尽可能把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的、非常简单的操作中。当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑移到VALUE OBJECT中,这样可以进一步控制副作用。
SIDE-EFFECT-FREE FUNCTION(无副作用函数),特别是在不变的VALUE OBJECT中,允许我们安全地对多个操作进行组合。当通过INTENTION-REVEALING INTERFACE把一个FUNCTION呈现出来的时候,开发人员就可以在无需理解其实现细节的情况下使用它。
模式:ASSERTION
ASSERTION(断言)是对程序在某个时刻的正确状态的声明,它与如何达到这个状态无关。通常,断言指定了一个操作的结果或者一个设计元素的固定规则。
把复杂的计算封装到SIDE-EFFECT-FREE FUNCTION中可以简化问题,但实体仍然会留有一些有副作用的命令,使用这些ENTITY的人必须了解使用这些命令的后果。在这种情况下,使用ASSERTION(断言)可以把副作用明确地表示出来,使它们更易于处理。
确实,一条不包含复杂计算的命令只需查看一下就能够理解。但是,在一个软件设计中,如果较大的部分是由较小部分构成的,那么一个命令可能会调用其他命令。开发人员在使用高层命令时,必须了解每个底层命令所产生的后果,这时封装也就没有什么价值了。而且,由于对象接口并不会限制副作用,因此实现相同接口的两个子类可能会产生不同的副作用。使用它们的开发人员需要知道哪个副作用是由哪个子类产生的,以便预测后果。这样,抽象和多态也就失去了意义。
如果操作的副作用仅仅是由它们的实现隐式定义的,那么在一个具有大量相互调用关系的系统中,起因和结果会变得一团糟。理解程序的唯一方式就是沿着分支路径来跟踪程序的执行。封装完全失去了价值。跟踪具体的执行也使抽象失去了意义。
我们需要在不深入研究内部机制的情况下理解设计元素的意义和执行操作的后果。INTENTION-REVEALING INTERFACE可以起到一部分作用,但这样的接口只能非正式地给出操作的用途,这常常是不够的。“契约式设计”(design by contract)向前推进了一步,通过给出类和方法的"断言"使开发人员知道肯定会发生的结果。[Meyer 1988]中详细讨论了这种设计风格。简言之,"后置条件"描述了一个操作的副作用,也就是调用一个方法之后必然会发生的结果。"前置条件“就像是合同条款,即为了满足后置条件而必须要满足的前置条件。类的固定规则规定了在操作结束时对象的状态。也可以把AGGREGATE作为一个整体来为它声明固定规则,这些都是严格定义的完整性规则。
所有这些断言都描述了状态,而不是过程,因此它们更易于分析。类的固定规则在描述类的意义方面起到帮助作用,并且使客户开发人员能够更准确地预测对象的行为,从而简化他们的工作。如果你确信后置条件的保证,那么就不必考虑方法是如何工作的。断言应该已经把调用其他操作的效果考虑在内了。
把操作的后置条件和类及AGGREGATE的固定规则表述清楚。如果在你的编程语言中不能直接编写ASSERTION,那么就把它们编写成自动的单元测试。还可以把它们写到文档或图中(如果符合项目开发风格的话)。
寻找在概念上内聚的模型,以便使开发人员更容易推断出预期的ASSERTION,从而加快学习过程并避免代码矛盾。
尽管很多面向对象的语言目前都不支持直接使用ASSERTION,但ASSERTION仍然不失为一种功能强大的设计方法。自动单元测试在一定程度上弥补了缺乏语言支持带来的不足。由于ASSERTION只声明状态,而不声明过程,因此很容易编写测试。测试首先设置前置条件,在执行之后,再检查后置条件是否被满足。
把固定规则、前置条件和后置条件清楚地表述出来,这样开发人员就能够理解使用一个操作或对象的后果。从理论上讲,如果一组断言之间互不矛盾,那么就可以发挥作用。但人的大脑并不会一丝不苟地把这些断言编译到一起。人们会推断和补充模型的概念,因此找到一个既易于理解又满足应用程序需求的模型是至关重要的。
模式:CONCEPTUAL CONTOUR
CONCEPTUAL CONTOUR(概念轮廓)——领域本身的基本一致性,如果它能够在模型中反映出来的话,则有助于使设计更自然地适应变化。
有时,人们会对功能进行更细的分解,以便灵活地组合它们,有时却要把功能合成大块,以便封装复杂性。有时,人们为了使所有类和操作都具有相似的规模而寻找一种一致的粒度。这些方法都过于简单了,并不能作为通用的规则。但使用这些方法的动机都来自于一系列基本的问题。
如果把模型或设计的所有元素都放在一个整体的大结构中,那么它们的功能就会发生重复。外部接口无法给出客户可能关心的全部信息。由于不同的概念被混合在一起,它们的意义变得很难理解。
而另一方面,把类和方法分解开也可能是毫无意义的,这会使客户对象更复杂,迫使客户对象去理解各个细微部分是如何组合在一起的。更糟的是,有的概念可能会完全丢失。铀原子的一半并不是铀。而且,粒度的大小并不是唯一要考虑的问题,我们还要考虑粒度是在哪种场合下使用的。
菜谱式的规则是没有用的。但大部分领域都深深隐含着某种逻辑一致性,否则它们就形不成领域了。这并不是说领域就是绝对一致的,而且人们讨论领域的方式肯定也不一样。但是领域中一定存在着某种十分复杂的原理,否则建模也就失去了意义。由于这种隐藏在底层的一致性,当我们找到一个模型,它与领域的某个部分特别吻合时,这个模型很可能也会与我们后续发现的这个领域的其他部分一致。有时,新的发现可能与模型不符,在这种情况下,就需要对模型进行重构,以便获取更深层的理解,并希望下一次新发现能与模型一致。
通过反复重构最终会实现柔性设计,以上就是其中的一个原因。随着代码不断适应新理解的概念或需求,CONCEPTUAL CONTOUR(概念轮廓)也就逐渐形成了。
从单个方法的设计,到类和MODULE的设计,再到大型结构的设计,高内聚低耦合这一对基本原则都起着重要的作用。这两条原则既适用于代码,也适用于概念。为了避免机械化地遵循它,我们必须经常根据我们对领域的直观认识来调整技术思路。在做每个决定时,都要问自己:“这是根据当前模型和代码中的特定关系做出的权宜之计呢,还是反映了底层领域的某种轮廓?”
寻找在概念上有意义的功能单元,这样可以使得设计既灵活又易懂。例如,如果领域中对两个对象的"相加"(addition)是一个连贯的整体操作,那么就把它作为整体来实现。不要把add()拆分成两个步骤。不要在同一个操作中进行下一个步骤。从稍大的范围来看,每个对象都应该是一个独立的、完整的概念,也就是一个"WHOLE VALUE"(整体值)。WHOLE VALUE(完整值)——对单一、完整的概念进行建模的对象。
把设计元素(操作、接口、类和AGGREGATE)分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层CONCEPTUAL CONTOUR。使模型与领域中那些一致的方面(正是这些方面使得领域成为一个有用的知识体系)相匹配。
我们的目标是得到一组可以在逻辑上组合起来的简单接口,使我们可以用UBIQUITOUS LANGUAGE进行合理的表述,并且使那些无关的选项不会分散我们的注意力,也不增加维护负担。但这通常是通过重构才能得到的结果,很难在前期就实现。而且如果仅仅是从技术角度进行重构,可能永远也不会出现这种结果;只有通过重构得到更深层的理解,才能实现这样的目标。
设计即使是按照CONCEPTUAL CONTOUR进行,也仍然需要修改和重构。当连续的重构往往只是做出一些局部修改(而不是对模型的概念产生大范围的影响)时,这就是模型已经与领域相吻合的信号。如果遇到了一个需求,它要求我们必须大幅度地修改对象和方法的划分,那么这就在向我们传递这样一条信息:我们对领域的理解还需要精化。它提供了一个深化模型并且使设计变得更具柔性的机会。
INTENTION-REVEALING INTERFACE使客户能够把对象表示为有意义的单元,而不仅仅是一些机制。SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们可以安全地使用这些单元,并对它们进行复杂的组合。CONCEPTUAL CONTOUR的出现使模型的各个部分变得更稳定,也使得这些单元更直观,更易于使用和组合。
模式:STANDALONE CLASS
STANDALONE CLASS(独立类)——无需引用任何其他对象(系统的基本类型和基础库除外)就能够理解和测试的类。
然而,我们仍然会遇到"概念过载"(conceptual overload)的问题——当模型中的互相依赖过多时,我们就必须把大量问题放在一起考虑。
互相依赖使模型和设计变得难以理解、测试和维护。而且,互相依赖很容易越积越多。
当然,每个关联都是一种依赖,要想理解一个类,必须理解它与哪些对象有联系。与这个类有联系的其他对象还会与更多的对象发生联系,而这些联系也是必须要弄清楚的。每个方法的每个参数的类型也是一个依赖,每个返回值也都是一个依赖。
如果有一个依赖关系,我们必须同时考虑两个类以及它们之间关系的本质。如果某个类依赖另外两个类,我们就必须考虑这3个类当中的每一个、这个类与其他两个类之间的相互关系的本质,以及这3个类可能存在的其他相互关系。如果它们之间依次存在依赖关系,那么我们还必须考虑这些关系。如果一个类有3个依赖关系,那么问题就会像滚雪球一样越来越多。
MODULE和AGGREGATE的目的都是为了限制互相依赖的关系网。当我们识别出一个高度内聚的子领域并把它提取到一个MODULE中的时候,一组对象也随之与系统的其他部分解除了联系,这样就把互相联系的概念的数量控制在一个有限的范围之内。但是,即使把系统分成了各个MODULE,如果不严格控制MODULE内部的依赖的话,那么MODULE也一样会让我们耗费很多精力去考虑依赖关系。
即使是在MODULE内部,设计也会随着依赖关系的增加而变得越来越难以理解。这加重了我们的思考负担,从而限制了开发人员能处理的设计复杂度。隐式概念比显式引用增加的负担更大。
我们可以将模型一直精炼下去,直到每个剩下的概念关系都表示出概念的基本含义为止。在一个重要的子集中,依赖关系的个数可以减小到零,这样就得到一个完全独立的类,它只有很少的几个基本类型和基础库概念。
在每种编程环境中,都有一些非常基本的概念,它们经常用到,以至于已经根植于我们的大脑中。例如,在Java开发环境中,基本类型和一些标准类库提供了数字、字符串和集合等基本概念。从实际来讲,"整数"这个概念是不会增加思考负担的。除此之外,为了理解一个对象而必须保留在大脑中的其他概念都会增加思考负担。
隐式概念,无论是否已被识别出来,都与显式引用一样会加重思考负担。虽然我们通常可以忽略像整数和字符串这样的基本类型值,但无法忽略它们所表示的意义。
我们应该对每个依赖关系提出质疑,直到证实它确实表示对象的基本概念为止。这个仔细检查依赖关系的过程从提取模型概念本身开始。然后需要注意每个独立的关联和操作。仔细选择模型和设计能够大幅减少依赖关系——常常能减少到零。
**低耦合是对象设计的一个基本要素。**尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变得完全独立了,这就使得我们可以单独地研究和理解它。每个这样的独立类都极大地减轻了因理解MODULE而带来的负担。
当一个类与它所在的模块中的其他类存在依赖关系时,比它与模块外部的类有依赖关系要好得多。同样,当两个对象具有自然的紧密耦合关系时,这两个对象共同涉及的多个操作实际上能够把它们的关系本质明确地表示出来。**我们的目标不是消除所有依赖,而是消除所有不重要的依赖。**当无法消除所有的依赖关系时,每清除一个依赖对开发人员而言都是一种解脱,使他们能够集中精力处理剩下的概念依赖关系。
尽力把最复杂的计算提取到STANDALONE CLASS(独立类)中,实现此目的的一种方法是从存在大量依赖的类中将VALUE OBJECT建模出来。
从根本上讲,油漆的概念与颜色的概念紧密相关。但在考虑颜色(甚至是颜料)的时候却与不必去考虑油漆。通过把这两个概念变为显式概念并精炼它们的关系,所得到的单向关联就可以表达出重要的信息,同时我们可以对Pigment Color类(大部分计算复杂性都隐藏在这个类中)进行独立的分析和测试。
低耦合是减少概念过载的最基本办法。独立的类是低耦合的极致。
消除依赖性并不是说要武断地把模型中的一切都简化为基本类型,这样只会削弱模型的表达能力。本章要讨论的最后一个模式CLOSURE OF OPERATION(闭合操作)就是一种在减小依赖性的同时保持丰富接口的技术。
模式:CLOSURE OF OPERATION
两个实数相乘,结果仍为实数(实数是所有有理数和所有无理数的集合)。由于这一点永远成立,因此我们说实数的"乘法运算是闭合的":乘法运算的结果永远无法脱离实数这个集合。当我们对集合中的任意两个元素组合时,结果仍在这个集合中,这就叫做闭合操作。——The Math Forum, Drexel University
当然,依赖是必然存在的,当依赖是概念的一个基本属性时,它就不是坏事。如果把接口精简到只处理一些基本类型,那么会极大地削弱接口的能力。但我们也经常为接口引入很多不必要的依赖,甚至是整个不必要的概念。
大部分引起我们兴趣的对象所产生的行为仅用基本类型是无法描述的。
另一种对设计进行精化的常见方法就是CLOSURE OF OPERATION(闭合操作)。这个名字来源于最精炼的概念体系,即数学。1 + 1 = 2。加法运算是实数集中的闭合运算。数学家们都极力避免去引入无关的概念,而闭合运算的性质正好为他们提供了这样一种方式,可用来定义一种不涉及其他任何概念的运算。我们都非常熟悉数学中的精炼,因此很难注意到一些小技巧会有多么强大。但是,这些技巧在软件设计中也广为应用。例如,XSLT的基本用法是把一个XML文档转换为另一个XML文档。这种XSLT操作就是XML文档集合中的闭合操作。闭合的性质极大地简化了对操作的理解,而且闭合操作的链接或组合也很容易理解。
在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。如果实现者(implementer)的状态在计算中会被用到,那么实现者实际上就是操作的一个参数,因此参数和返回值应该与实现者有相同的类型。这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他概念的任何依赖。
这种模式更常用于VALUE OBJECT的操作。由于ENTITY的生命周期在领域中十分重要,因此我们不能为了解决某一问题而草率创建一个ENTITY。有一些操作是ENTITY类型之下的闭合操作。我们可以通过查询一个Employee(员工)对象来返回其主管,而返回的将是另一个Employee对象。但是,ENTITY通常不会成为计算结果。因此,大部分闭合操作都应该到VALUE OBJECT中去寻找。
一个操作可能是在某一抽象类型之下的闭合操作,在这种情况下,具体的参数可能是不同的具体类型。例如,加法是实数之下的闭合运算,而实数既可以是有理数,也可以是无理数。
在尝试和寻找减少互相依赖并提高内聚的过程中,有时我们会遇到"半个闭合操作"这种情况。参数类型与实现者的类型一致,但返回类型不同;或者返回类型与接收者(receiver)的类型相同但参数类型不同。这些操作都不是闭合操作,但它们确实具有CLOSURE OF OPERATION的某些优点。当没有形成闭合操作的那个多出来的类型是基本类型或基础库类时,它几乎与CLOSURE OF OPERATION一样减轻了我们的思考负担。
如果返回值与实现者的类型相匹配,那么它们可以像一系列过滤器一样被串接在一起。读写代码都变得很容易。它们并没有引入与选择子集无关的外来概念。典型的例子就是Lambda表达式的使用。
把软件设计得意图明显、容易预测且富有表达力,可以有效地发挥抽象和封装的作用。我们可以对模型进行分解,使得对象更易于理解和使用,同时仍具有功能丰富的、高级的接口。
运用这些技术需要掌握相当高级的设计技巧,甚至有时编写客户端代码也需要掌握高级技巧才能运用这些技术。
MODEL-DRIVEN DESIGN的作用受细节设计的质量和实现决策的质量影响很大,而且只要有少数几个开发人员没有弄清楚它们,整个项目就会偏离目标。
尽管如此,团队只要愿意培养这些建模和设计技巧,那么按照这些模式的思考方式就能够开发出可以反复重构的软件,从而最终创建出非常复杂的软件。
声明式设计
声明式设计(declarative design)——一种编程形式,由精确的属性描述对软件进行实际的控制。它是一种可执行的规格。
使用ASSERTION可以得到更好的设计,虽然我们只是用一些相对非正式的方式来检查这些ASSERTION。但实际上我们无法保证手写软件的正确性。举个简单例子,只要代码还有其他一些没有被ASSERTION专门排除在外的副作用,断言就失去了作用。**无论我们的设计多么遵守MODEL-DRIVEN开发方法,最后仍要通过编写过程代码来实现概念交互的结果。**而且我们花费了大量时间来编写样板代码,但是这些代码实际上不增加任何意义或行为。这些代码冗长乏味而且易出错,此外还掩盖了模型的意义(虽然有的编程语言会相对好一些,但都需要我们做大量繁琐的工作)。本章介绍的INTENTION-REVEALING INTERFACE和其他模式虽然有一定的帮助作用,但它们永远也不会使传统的面向对象技术达到非常严密的程度。
以上这些正是采用声明式设计的部分动机。声明式设计对于不同的人来说具有不同的意义,但通常是指一种编程方式——把程序或程序的一部分写成一种可执行的规格(specification)。使用声明式设计时,软件实际上是由一些非常精确的属性描述来控制的。声明式设计有多种实现方式,例如,可以通过反射机制来实现,或在编译时通过代码生成来实现(根据声明来自动生成传统代码)。这种方法使其他开发人员能够根据字面意义来使用声明。它是一种绝对的保证。
从模型属性的声明来生成可运行的程序是MODEL-DRIVEN DESIGN的理想目标,但在实践中这种方法也有自己的缺陷。例如,下面是两个具体问题:
声明式语言并不足以表达一切所需的东西,它把软件束缚在一个由自动部分构成的框架之内,使软件很难扩展到这个框架之外。
代码生成技术破坏了迭代循环——它把生成的代码合并到手写的代码中,使得代码重新生成具有巨大的破坏作用。
许多声明式设计的尝试带来了意想不到的后果,由于开发人员受到框架局限性的约束,为了交付工作只能先处理重要问题,而搁置其他一些问题,这导致模型和应用程序的质量严重下降。
基于规则的编程(带有推理引擎和规则库)是另一种有望实现的声明式设计方法。但遗憾的是,一些微妙的问题会影响它的实现。
尽管基于规则的程序原则上是声明式的,但大多数系统都有一些用于性能优化的"控制谓词"(control predicate)。这种控制代码引入了副作用,这样行为就不再完全由声明式规则来控制了。添加、删除规则或重新排序可能导致预料不到的错误结果。因此,编写逻辑的程序员必须确保代码的效果是显而易见的,就像对象程序员所做的那样。
很多声明式方法被开发人员有意或无意忽略之后会遭到破坏。当系统很难使用或限制过多时,就会发生这种情况。为了获得声明式程序的好处,每个人都必须遵守框架的规则。
声明式设计发挥的最大价值是用一个范围非常窄的框架来自动处理设计中某个特别单调且易出错的方面,如持久化和对象关系映射。最好的声明式设计能够使开发人员不必去做那些单调乏味的工作,同时又完全不限制他们的设计自由。
领域特定语言
领域特定语言是一种有趣的方法,它有时也是一种声明式语言。采用这种编码风格时,客户代码是用一种专门为特定领域的特定模型定制的语言编写的。例如,运输系统的语言可能包括cargo(货物)和route(路线)这样的术语,以及一些用于组合这些术语的语法。然后,程序通常会被编译成传统的面向对象语言,由一个类库为这些术语提供实现。
在这样的语言中,程序可能具有极强的表达能力,并且与UBIQUITOUS LANGUAGE之间形成最紧密的结合。领域特定语言是一个令人振奋的概念,但是在基于面向对象技术进行实现时,这种语言也存在自身的缺陷。
为了精化模型,开发人员需要修改语言。这可能涉及修改语法声明和其他语言解释功能,以及修改底层类库。我们必须冷静地评估团队当前的技术水平,以及将来维护团队可能的技术水平。此外,用同一种语言实现的应用程序和模型之间是"无缝"的,这一点很有价值。另一个缺点是当模型被修改时,很难对客户代码进行重构,使之与修改之后的模型及与其相关的领域特定语言保持一致。当然,也许有人可以通过技术方法来解决重构问题。
这种技术也许能在非常成熟的模型中发挥出最大的作用,在这种情况下,客户代码可能是由不同的团队编写的。但一般情况下,这样的设置会产生有害的结果——团队被分成两部分,框架由那些技术水平较高的人来构建,而应用程序则由那些技术水平较差的人来构建了,但也并不是非得如此。
领域特定语言可以极大减少或在某些场景下替代编程,但是也会造成设计和开发的分离,但是也精华了模型。一种良好的落地方案是基于领域特定语言生成具有一定规格的接口,然后由设计人员或开发人员来实现接口。
声明式设计风格
一旦你的设计中有了INTENTION-REVEALING INTERFACE、SIDE-EFFECT-FREE FUNCTION和ASSERTION,那么你就具备了使用声明式设计的条件。当我们有了可以组合在一起来表达意义的元素,并且使其作用具体化或明朗化,甚或是完全没有明显的副作用,我们就可以获得声明式设计的很多益处。
柔性设计使得客户代码可以使用声明式的设计风格。为了说明这一点,下一节将会把本章介绍的一些模式结合起来使用,从而使SPECIFICATION更灵活,更符合声明式设计的风格。
用声明式的风格来扩展SPECIFICATION
SPECIFICATION是由"谓词"(predicate)这个众所周知的形式化概念演变来的。谓词还有其他一些有用的特性,我们可以对这些特性进行有选择的利用。
(1)使用逻辑运算对SPECIFICATION进行组合
当使用SPECIFICATION时,我们很容易就会遇到需要把它们组合起来使用的情况。正如我们刚刚提到的那样,SPECIFICATION是谓词的一个例子,而谓词可以用"AND"、"OR"和"NOT"等运算进行组合和修改。这些逻辑运算都是谓词这个类别之下的闭合操作,因此SPECIFICATION组合也是CLOSURE OF OPERATION的。
随着SPECIFICATION的通用性逐渐提高,创建一个可用于各种类型的SPECIFICATION的抽象类或接口会变得很有用。这需要把参数类型定义为某种高层的抽象类。
从简单元素构建复杂规格的能力提高了代码的表达能力。由于SPECIFICATION实现的方法存在不同,提供这些运算符的难易程度也不同。在有些情况下这些运算符组合后的效率很差,而有些情况下则很实用。像任何模式一样,它也有很多实现方式。
(2)包容
最后要讲的这个包容特性并不是经常需要,而且实现起来也很难,但有时它确实能够解决很困难的问题。它还能够表达出一个SPECIFICATION的含义。
更严格的SPECIFICATION包容不太严格的SPECIFICATION。用更严格的SPECIFICATION来取代不严格的SPECIFICATION不会遗漏掉先前的任何需求。
在SPECIFICATION语言中,我们说新的SPECIFICATION包容旧的SPECIFICATION,因为任何满足新SPECIFICATION的对象都将满足旧SPECIFICATION。
如果把每个SPECIFICATION看成一个谓词,那么包容就等于逻辑蕴涵(logical implication)。使用传统的符号,A→B表示声明A蕴涵声明B,因此,如果A为真,则B也为真。当一个SPECIFICATION被修改时,我们想知道新SPECIFICATION是否满足旧SPECIFICATION的所有条件,也即:New Spec → Old Spec。也就是说,如果新规格为真,那么旧规格一定也为真。要证明一般情况下的逻辑蕴涵是很难的,但特殊情况就很容易证明。例如,参数化的SPECIFICATION可以定义它们自己的包容规则。
切入问题的角度
本章展示了一系列技术,它们用于澄清代码意图,使得使用代码的影响变得显而易见,并且解除模型元素的耦合。尽管有这些技术,但要想实现这样的设计还是很难的。我们不能只是看着一个庞大的系统说:"让我们把它设计得灵活点吧。"我们必须选择具体的目标。下面介绍几种主要方法,然后给出一个扩展的示例,它展示了如何把这些模式结合起来使用,并用于处理更大的设计。
分割子领域
我们无法一下子就能处理好整个设计,而需要一步一步地进行。我们从系统的某些方面可以看出适合用哪种方法处理,那么就把它们提取出来加以处理。如果模型的某个部分可以被看作是专门的数学,那么可以把这部分分离出来。如果应用程序实施了某些用来限制状态改变的复杂规则,那么可以把这部分提取到一个单独的模型中,或者提取到一个允许声明规则的简单框架中。随着这些步骤的进行,不仅新模型更整洁了,而且剩下的部分也更小、更清晰了。在剩下的模型中,有的部分是用声明式的风格来编写的——这些可能是根据专门数学或验证框架编写的声明,或者是子领域所采用的任何形式。
重点突击某个部分,使设计的一个部分真正变得灵活起来,这比分散精力泛泛地处理整个系统要有用得多。
尽可能利用已有的形式
我们不能把从头创建一个严密的概念框架当作一项日常的工作来做。在项目的生命周期中,我们有时会发现并精炼出这样一个框架。但更常见的情况是,可以对你的领域或其他领域中那些建立已久的概念系统加以修改和利用,其中有些系统已经被精化和提炼达几个世纪之久。例如,很多商业应用程序涉及会计学。会计学定义了一组成熟的ENTITY和规则,我们很容易对这些ENTITY和规则进行调整,得到一个深层的模型和柔性设计。
有很多这样的正式概念框架,Eric Evans最推荐的框架是数学。数学的强大功能令人惊奇,它可以用基本数学概念把一些复杂的问题提取出来。很多领域都涉及数学,我们要寻找这样的部分,并把它挖掘出来。专门的数学很整齐,可以通过清晰的规则进行组合,并很容易理解。
参考
《领域驱动设计 软件核心复杂性应对之道》 Eric Evans 著, 赵俐 盛海艳 刘霞 等译, 任发科 审校