上部分模型驱动设计的构造块为维护模型和实现之间的关系打下了基础。在开发过程中使用一系列成熟的基本构造块并运用一致的语言,能够使开发工作更加清晰而有条理。
我们面临的真正挑战是找到深层次的模型,这个模型不但能够捕捉到领域专家的微妙的关注点,还可以驱动切实可行的设计。我们的最终目的是开发出能够捕捉到领域深层含义的模型。以这种方式设计出来的软件不但更贴近领域专家的思维方式,而且能更好地满足用户的需求。
要想成功地开发出实用的模型,需要注意以下三点:
(1)复杂巧妙的领域模型是可以实现的,也是值得我们去花费力气实现的。
(2)这样的模型离开不断的重构是很难开发出来的,重构需要领域专家和热爱学习领域知识的开发人员密切参与进来。
(3)要实现并有效地运用模型,需要精通设计技巧。
重构的层次
重构就是在不改变软件功能的前提下重新设计它。开发人员无需在着手之前做出详细的设计决策,只需要在开发过程中不断小幅调整设计即可,这不但能够保证软件原有的功能不变,还可以使整个设计更加灵活易懂。
然而,几乎所有关于重构的文献都专注于如何机械地修改代码,以使其更具有可读性或在非常细节的层次上有所改进。如果开发人员能够看准时机,利用成熟的设计模式进行开发,那么“通过重构得到模式”这种方式就可以让重构过程更上一层楼。不过,这依然是从技术视角来评估设计的质量。
有些重构能够极大地提高系统的可用性,它们要么源于对领域的新认知,要么能够通过代码清晰地表达出模型的含义。这种重构不能取代设计模式重构和代码细节重构,这两种重构应该持续进行。但是前者添加了一种重构层次:为实现更深层次模型而进行的重构。在深入理解领域的基础上进行重构,通常需要实现一系列的代码细节重构,但这么做绝不仅仅是为了改进代码状态。相反,代码细节重构是一组操作方便的修改单元,通过这些重构可以得到更深层次的模型。其目标在于:开发人员通过重构不仅能够了解代码实现的功能,还能明白个中原因,并把它们与领域专家的交流联系起来。
与所有的探索活动一样,建模本质上是非结构化的。要跟随学习与深入思考所指引的一切道路,然后据此重构,才能得到更深层次的理解。
深层模型
对象分析的传统方法是先在需求文档中确定名词和动词,并将其作为系统的初始对象和方法。这种方式太过于简单,只适用于教导初学者如何进行对象建模。事实上,初始模型通常都是基于对领域的浅显认知而构建的,既不成熟也不够深入。
深层模型能够穿过领域表象,清楚地表达出领域专家们的主要关注点以及最相关知识。以上定义并没有涉及抽象。事实上,深层模型通常含有抽象元素,但在切中问题核心的关键位置也同样会出现具体元素。
恰当反映领域的模型通常都具有功能多样性、简单易用和解释力强的特性。这种模型的共同之处在于:它们提供了一种业务专家青睐的简单语言,尽管这种语言可能也是抽象的。
深层模型/柔性设计
在不断重构的过程中,设计本身也需要支持重构所带来的变化。后面部分将探讨如何使设计更易于使用,不但方便修改还能够简单地将其与系统其他部分集成。
设计自身的某些特性就可以使其更易于修改和使用。这些特性并不复杂,却很有挑战性。
幸运的是,如果每次对模型和代码所进行的修改都能反映出对领域的新理解,那么通过不断的重构就能给系统最需要修改的地方增添灵活性,并找到简单快捷的方式来实现普通的功能。戴久了的手套在手指关节处会变得柔软;而其他部分则依然硬实,可起到保护作用。同样道理,用这种方式来进行建模和设计时,虽然需要反复尝试、不断改正错误,但是对模型和设计的修改却因此而更容易实现,同时反复的修改也能让我们越来越接近柔性设计。
柔性设计除了便于修改,还有助于改进模型本身。Model- Driven Design 需要以下两个方面的支持:深层模型使设计更具有表现力;同时,当设计的灵活性可以让开发人员进行试验,而设计又能清晰地表达出领域含义时,那么这个设计实际上就能够将开发人员的深层理解反馈到整个模型发现的过程。这段反馈回路是很重要的,因为我们所寻求的模型并不仅仅只是一套好想法,它还应该是构建系统的基础。
发现过程
要想创建出确实能够解决当前问题的设计,首先必须拥有可捕捉到领域核心概念的模型。文章后面会介绍如何主动搜寻这些概念,并将它们融入设计中。
由于模型和设计之间具有紧密的关系,因此如果代码难于重构,建模过程也会停滞不前。柔性设计部分将会探讨如何为软件开发者(尤其是为你自己)编写软件,以使开发人员能够高效地扩展和修改代码。这一设计过程与模型的进一步精化是密不可分的。它通常需要更高级的设计技巧以及更严格的模型定义。
需要富有创造力,不断地尝试,不断地发现问题才能找到合适的方法为你所发现的领域概念建模,但有时也可以借用别人已建好的模式。文章后面将会讨论“分析模式”和“设计模式”的应用。这些模式并不是现成的解决方案,但是它们可以帮助我们消化领域知识并缩小研究范围。
一、突破
重构的投入与回报并非呈线性关系。通常,小的调整会带来小的回报,小的改进也会积少成多。小改进可以防止系统退化,成为避免模型变得陈腐的第一道防线。但是,有些最重要的理解也会突然出现,给整个项目带来巨大的冲击。
可以确定的是,项目团队会积累、消化知识,并将其转化成模型。微小的重构可能每次只涉及一个对象,在这里加上一个关联,在那里转移一项职责。然而,一系列微小的重构会逐渐汇聚成深层模型。
一般来说,持续重构让事物逐步变得有序。代码和模型的每一次精化都让开发人员有了更加清晰的认识。这使得理解上的突破成为可能。之后,一系列快速的改变得到了更符合用户需要并更加切合实际的模型。其功能性及说明性急速增强,而复杂性却随之消失。
这种突破不是某种技巧,而是一个事件。它的困难之处在于你需要判断发生了什么,然后再决定如何处理。
重构的原则是始终小步前进,始终保持系统正常运转。
机遇
当突破带来更深层的模型时,通常会令人感到不安。与大部分重构相比,这种变化的回报更多,风险也更高。而且突破出现的时机可能很不合时宜。
过渡到真正的深层模型需要从根本上调整思路,并且对设计做大幅修改。
关注根本
不要试图去制造突破,那只会使项目陷入困境。通常,只有在实现了许多适度的重构后才有可能出现突破。在大部分时间里,我们都在进行微小的改进,而在这种连续的改进中模型深层含义也会逐渐显现。
要为突破做好准备,应专注于知识消化过程,同时也要逐渐建立起健壮的 Ubiquitous Language。寻找那些重要的领域概念,并在模型中清晰地表达出来。精化模型,使其更具有柔性。提炼模型。利用这些更容易掌握的手段使模型变得更清晰,这通常会带来突破。
不要犹豫着不去做小的改进,这些改进即使脱离不开常规的概念框架,也可以逐渐加深我们对模型的理解,不要因为好高骛远而使项目陷入困境。只要随时注意可能出现的机会就够了。
二、将隐式概念转变为显式概念
深层模型之所以强大是因为它包含了领域的核心概念和抽象,能够以简单灵活的方式表达出基本的用户活动、问题以及解决方案。深层建模的第一步就是要设法在模型中表达出领域的基本概念。随后,在不断消化知识和重构的过程中,实现模型的精化。但是实际上这个过程是从我们识别出某个重要概念并且在模型和设计中把它显式地表达出来的那个时刻开始的。
若开发人员识别出设计中隐含的某个概念或是在讨论中受到启发而发现一个概念时,就会对领域模型和相应的代码进行许多转换,在模型中加入一个或多个对象或关系,从而将此概念显式地表达出来。
1、概念挖掘
开发人员必须能够敏锐地捕捉到隐含概念的蛛丝马迹,但有时他们必须主动寻找线索。要挖掘出大部分的隐含概念,需要开发人员去倾听团队语言、仔细检查设计中的不足之处以及与专家观点相矛盾的地方、研究领域相关文献并且进行大量的实验。
1、倾听语言
倾听领域专家使用的语言。有没有一些术语能够简洁地表达出复杂的概念?他们有没有纠正过你的用词(也许是很委婉的提醒)?当你使用某个特定词语时,他们脸上是否已经不再流露出迷惑的表情?这些都暗示了某个概念也许可以改进模型。
这不同于原来的“名词即对象”概念。听到新单词只是个开头,然后我们还要进行对话、消化知识,这样才能挖掘出清晰实用的概念。如果用户或领域专家使用了设计中没有的词汇,这就是个警告信号。而当开发人员和领域专家都在使用设计中没有的词汇时,那就是一个倍加严重的警告信号了。
2、检查不足之处
你所需的概念并不总是浮在表面上,也绝不仅仅是通过对话和文档就能让它显现出来。有些概念可能需要你自己去挖掘和创造。要挖掘的地方就是设计中最不足的地方,也就是操作复杂且难于解释的地方。每当有新的需求时,似乎都会让这个地方变得更加复杂。
有时,你很难意识到模型中丢失了什么概念。也许你的对象能够实现所有的功能,但是有些职责的实现却很笨拙。而有时,你虽然能够意识到模型中丢失了某些东西,但是却无法找到解决方案。
这个时候,你必须积极地让领域专家参与到讨论中来。如果你足够幸运,这些专家可能会愿意一起思考各种想法,并通过模型来进行验证。如果你没那么幸运,你和你的同事就不得不自己思索出不同的想法,让领域专家对这些想法进行判断,并注意观察专家的表情是认同还是反对。
3、思考矛盾之处
由于经验和需求的不同,不同的领域专家对同样的事情会有不同的看法。即使是同一个人提供的信息,仔细分析后也会发现逻辑上不一致的地方。在挖掘程序需求的时候,我们会不断遇到这种令人烦恼的矛盾,但它们也为深层模型的实现提供了重要线索。有些矛盾只是术语说法上的不一致,有些则是由于误解而产生的。但还有一种情况是专家们会给出互相矛盾的两种说法。
要解决所有矛盾是不太现实的,甚至是不需要的。然而,即使不去解决矛盾,我们也应该仔细思考对立的两种看法是如何同时应用于同一个外部现实的,这会给我们带来启示。
4、查阅书籍
5、尝试,再尝试
我们其实别无选择。只有不断尝试才能了解什么有效什么无效。企图避免设计上的食物将会导致开发出来的产品质量低劣,因为没有更多的经验可用来借鉴,同时也会比进行一系列快速试验更加费时。
2、如何为那些不太明显的概念建模
面向对象范式会引导我们去寻找和创造特定类型的概念。所有事物及其操作行为是大部分对象模型的主要部分。它们就是面向对象设计入门所讲到的“名词和动词”。但是,其他重要类别的概念也可以在模型中显式地表现出来。
1、显式的约束
约束是模型概念中非常的类别。它们通常是隐含的,将它们显式地表现出来可以极大地提高设计质量。
有时,约束很自然地存在于对象或方法中。Bucket(桶)对象必须满足一个固定规则——内容(contents)不能超过它的容量(capacity)。
public class Bucket { private float capacity; private float contents; public Bucket() { } public void PourIn(float addedVolumn) { if (contents + addedVolumn > capacity) { contents = capacity; } else { contents = contents + addedVolumn; } } }
这里的逻辑非常简单,规则也很明显。但是不难想象,在更复杂的类中这个约束可能会丢失。让我们把这个约束提取到一个单独的方法中,并用清晰直观的名称来表达它的意义。
public class Bucket { private float capacity; private float contents; public Bucket() { } public void PourIn(float addedVolumn) { var volumnPresent = contents + addedVolumn; contents = ConstrainedToCapacity(volumnPresent); } ////// 容量限制 /// /// /// private float ConstrainedToCapacity(float volumnPlacedIn) { if (volumnPlacedIn > capacity) { return capacity; } else { return volumnPlacedIn; } } }
这两个版本的代码都实施了约束,但是第二个版本与模型的关系更加明显(这也是 Model- Driven Design 的基本需求)。这个规则十分简单,使用最初形式的代码也很容易理解,但如果要是执行的规则比较复杂的话,它们就会想=像所有隐式概念一样被约束的对象或操作淹没掉。
将约束条件提取到其自己的方法中,这样就可以通过方法名来表达约束的含义,从而在设计中显式地表现出来这条约束。现在这个约束条件就是一个“有名有姓”的概念了,我们可以用它的名字来讨论它。这种方式也为约束的扩展提供了空间。比这更复杂的规则很容易就产生比其调用者更长的代码方法。这样,调用者就可以简单一些,并且只专注于处理自己的任务,而约束条件则可以根据需要进行扩展。
这种独立方法为约束预留了一定的增加空间,但是在很多时候,约束条件是无法用单独的方法来轻松表达的。或者,即使方法自身能够保持其简单性,但它可能也会调用一些信息,但对于对象的主要职责而言,这些信息毫无用处。这种规则可能就不适合放到现有对象中。
下面是一些警告信号,表明约束的存在正在扰乱其“宿主对象”(Host Object)的设计。
(1)计算约束所需的数据从定义上看并不属于这个对象。
(2)相关规则在多个对象中出现,造成了代码重复或导致不属于同一族的对象之间产生了继承关系。
(3)很多设计和需求讨论是围绕这些约束进行的,而在代码实现中,它们却隐藏在过程代码中。
如果约束的存在掩盖了对象的基本职责,或者如果约束在领域中非常突出但在模型中却不明显,那么就可以将其提取到一个显式的对象中,甚至可以把它建模为一个对象和关系的集合。
2、将过程建模为领域对象
首先要说明的是,我们都不希望过程变成模型的主要部分。对象是用来封装过程的,这样我们只需考虑对象的业务目的或意图就可以了。
在这里,我们讨论的是存在于领域中的过程,我们必须在模型中把这些过程表示出来。否则当这些过程显露出来时,往往会使对象设计变得笨拙。
如果过程的执行有多种方式,那么我们也可以用另一种方法来处理它,那就是将算法本身或其中关键部分分放到一个单独的对象中。这样,选择不同的过程就变成选择不同的对象,每个对象都表示一种不同的 Strategy。
过程是应该被显式表达出来,还是应该被隐藏起来?区分的方法很简单:它是经常被领域专家提起呢,还是仅仅被当作计算机程序机制的一部分?
约束和过程是两大类模型概念,当我们用面向对象语言编程时,不会立即想到它们,然而它们一旦被我们视为模型元素,就真的可以让我们的设计更为清晰。
有些类别的概念很实用,但它们可应用的范围要窄很多。有一个特殊但常用的概念——规格(specification)。“规格”提供了用于表达特定类型的规则的精确方式,它把这些规则从条件逻辑中提取出来,并在模型中把它们显式地表示出来。
3、模式:Specification
业务规则通常不适合作为 Entity 或 Value Object 的职责,而且规则的变化和组合也会掩盖领域对象的基本含义。但是将规则移出领域层的结果会更糟糕,因为这样一来,领域代码就不再表达模型了。
逻辑编程提供了一种概念,即“谓词”这种可分离、可组合的规则对象,但是要把这种概念用对象完全实现是很麻烦的。谓词是指计算结果为“真”或“假”的函数,并且可以通过操作符(如AND和OR)把它们连接起来以表达更复杂的规则。同时,这种概念过于通用,在表达设计意图方面,它的针对性不如专门的设计那么好。
幸运的是,我们并不真正需要完全实现逻辑编程即可从中受益。大部分规则可以归类为几种特定的情况。我们可以借用谓词概念来创建可计算出布尔值的特殊对象。那些难于控制的测试方法可以巧妙地扩展出自己的对象。它们都是些小的真值测试,可以提取到单独的 Value Object 中。而这个新对象则可以用来计算另一个对象,看看谓词对那个对象的计算是否为“真”。
换言之,这个新对象就是一个规格。Specification(规格)中声明的是限制另一个对象状态的约束,被约束对象可以存在,也可以不存在。Specification 有多种用途,其中一种体现了最基本的概念,这种用途是:Specification 可以测试任何对象以检验它们是否满足指定的标准。
因此:
为特殊目的创建为此形式的显式的 Value Object。Specification 就是一个谓词,可用来确定对象是否满足某些标准。
Specification 将规则保留在领域层。由于规则是一个完备的对象,所以这种设计能够更加清晰地反映模型。利用工厂,可以用来自其他资源的信息对规格进行配置。之所以用工厂,是为了避免实体直接访问这些资源,因为这样会使得实体与这些资源发生不正确的关联。
4、Specification 的应用与实现
Specification 最有价值的地方在于它可以将看起来完全不同的应用功能统一起来。出于以下3个目的中的一个或多个,我们可能需要指定对象的状态。
(1)验证对象,检查它是否能满足某些需求或者是否已经为实现某个对象做好了准备。
(2)从集合中选择一个对象。
(3)指定在创建新对象时必须满足某种需求。
这三种用法(验证、选择和根据要求来创建)从概念层面上来讲是相同。如果没有诸如 Specification 这样的模式,相同的规则可能会表现为不同的形式,甚至有可能是相互矛盾的形式。这就会丧失概念上的统一性。通过应用 Specification 模式,我们可以使用一致的模型,尽管在实现时可能需要分开处理。
验证
规格的最简单用法是验证,这种用法也最能直观地展示出它的概念。
选择(或查询)
验证是对一个独立的对象进行测试,检查它是否满足某些标准,然后客户可能根据验证的结果来采取行动。另一种常见的需求是根据某些标准从对象集合中选择一个子集。Specification 概念同样可以在此应用,但是现实问题会有所不同。
在典型的业务系统中,数据很可能会存储在关系数据库中。关系数据库具有强大的查询能力。我们如何才能充分利用这种能力来有效解决这一问题,同时又能保留Specification模型呢?Model-Driven Design 要求模型与实现保持同步,但它同时也让我们可以自由选择能够准确捕捉模型意义的实现方式。
一些对象关系映射框架提供了用模型对象和属性来表达查询的方式,并在基础设施层中创建实际的SQL语句。
根据要求来创建(生成)
我们可以使用描述性的 Specification 来定义生成器的接口,这个接口就显式地约束了生成器产生的结果。这种方法具有以下几个优点。
(1)生成器的实现与接口分离。Specification 声明了输出的需求,但没有定义如何得到输出结果。
(2)接口把规则显式地表示出来,因此开发人员无需理解所有操作细节即可知晓生成器会产生什么结果,而如果生成器是采用过程化的方式定义的,那么要想预测它的行为,唯一的途径就是在不同的情况下运行或去研究每行代码。
(3)接口更为灵活,或者说我们可以增加其灵活性,因为需求由客户给出,生成器唯一的职责就是实现 Specification 中的要求。
(4)最后一点也很重要。这种接口更加便于测试,因为接口显式地定义了生成器的输入,而这同时也可用来验证输出。也就是说,传入生成器接口的用于约束创建过程的同一个 Specification 也可发挥其验证的作用(如果实现方式能够支持这一点的话),以保证被创建的对象是正确的。
根据要求来创建可以是从头创建全新对象,也可以是配置已有对象来满足 Specification。
三、柔性设计
软件的最终目的是为用户服务。但首先它必须为开发人员服务。在强调重构的软件开发过程中尤其如此。
当具有复杂行为的软件缺乏良好的设计时,重构或元素的组合会变得很困难。一旦开发人员不能十分肯定地预知计算的全部,就会出现重复。当设计元素都是整块的而无法重新组合的时候,重复就是一种必然的结果。我们可以对类和方法进行分解,这样可以更好地重用它们,但这些小部分的行为又变得很难跟踪。如果软件没有一个条理分明的设计,那么开发人员不仅不愿意仔细地分析代码,他们更不愿意修改代码,因为修改代码会产生问题——要么加重了代码的混乱状态,要么由于某种未预料的依赖而破坏了某些东西。在任何一种系统中,这种不稳定性使我们很难开发出丰富的功能,而且限制了重构和迭代式的精化。
为了使项目能够随着开发工作的进行加速前进,而不会由于它自己的老化停滞不前,设计必须要让人们乐于使用,而且易于做出修改。这就是柔性设计(supple design)。
柔性设计是对深层模型的补充。一旦我们挖掘出隐式概念,并把它们显示地表达出来,就有了原料。通过迭代循环,我们可以把这些原料打造成有用的形式:建立的模型能够简单而清晰地捕获主要关注点;其设计可以让客户开发人员真正使用这个模型。在设计和代码的开发过程中,我们将获得新的理解,并通过这些理解改善模型概念。
我们一次又一次回到迭代循环中,通过重构得到更深刻的理解。但我们究竟要获得什么样的设计?在这个过程中应该进行哪些试验?这就是下面要讨论的内容。
很多过度设计借着灵活性的名义而得到合理的外衣。但是,过多的抽象层和间接设计常常成为项目的绊脚石。看一下真正为用户带来强大功能的软件设计,常常会发现一些简单的东西。简单并不容易做到。为了把创建的元素装配到复杂系统中,而且在装配之后仍然能够理解它们,必须坚持模型驱动的设计方法,与此同时还要坚持适当严格的设计风格。要创建或使用这样的设计,可能需要我们掌握相对熟练的设计技巧。
柔性设计能够揭示深层次的底层模型,并把它潜在的部分明确地展示出来。客户开发人员可以灵活地使用一个最小化的、松散耦合的概念集合,并用这些概念来表示领域中的众多场景。设计元素非常地组合到一起,其结果也是健壮的,可以被清晰地刻画出来,而且也是可以预知的。
早期的设计版本通常达不到柔性设计的要求。由于项目的时间期限和预算的缘故,很多设计一直就是僵化的。但是,当复杂性阻碍了项目的前进时,就需要仔细修改最关键、最复杂的地方,使之变成一个柔性设计,这样才能突破复杂性带给我们的限制,而不会陷入遗留代码维护的麻烦中。
设计这样的软件并没有公式,运用一些模式可能获得柔性设计。
1、模式:Intention-Revealing Interfaces (释意接口)
在领域驱动的设计中,我们希望看到有意义的领域逻辑。如果代码只是在执行规则后得到结果,而没有把规则显式地表达出来,那么我们就不得一步一步地去思考软件的执行步骤。那些只是运行代码然后给出结果的计算——没有显式地把计算逻辑表达出来,也有同样的问题。如果不把代码与模型清晰地联系起来,我们很难理解代码的执行效果,也很难预测修改代码的影响。
对象的强大功能是它能够把所有这些细节封装起来,如此一来,客户代码就能够很简单,而且可以用高层概念来解释。
但是,客户开发人员想要有效地使用对象,必须知道对象一些信息,如果接口没有告诉开发人员这些信息,那么他就必须深入研究对象的内部机制,以便理解细节。这就失去了封装的大部分价值。我们需要避免出现“认识过载”的问题。如果客户开发人员必须总是思考组件工作方式的大量细节,那么就无暇理清思路来解决客户设计的复杂性。
如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某个人开发的对象或操作被别人使用时,如果使用这个组件的新开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被无用,两位开发人员的意图也是背道而驰。
当我们把概念显式地建模为类或方法时,为了真正从中获取价值,必须为这些程序元素赋予一个能够反映出其概念的名字。类和方法的名称为开发人员之间的沟通创造了很好的机会,也能够改善系统的抽象。
所有复杂的机制都应该封装到抽象接口的后面,接口只表明意图,而不是表明方法。
在领域的公共接口中,可以把关系和规则表述出来,但不要说明规则是如何实施的;可以把事件和动作描述出来,但不要描述它们是如何执行的;可回忆给出方程式,但不要给出解方程式的数学方法。可以提出问题,但不要给出获取答案的方法。
整个子领域可以被划分到独立的模块中,并用一个表达了其用途的接口把它们封装起来。这种方法可以使我们把注意力集中在项目上,并控制大型系统的复杂性,这个将在后面详细讨论。
2、模式:Side-Effect-Free Function(无副作用函数)
我们可以宽泛地把操作分为两个大的类别:命令和查询。查询是从系统获取信息,查询的方式可能只是简单地访问变量中的数据,也可能是用这些数据执行计算。命令(也称为修改器)是修改系统的操作(一个简单的例子,设置变量)。任何对未来操作产生影响的系统状态改变都可以称为副作用。
为什么人们会采用“副作用”这个词来形容那些显然是有意影响系统状态的操作呢?这大概是来自于复杂系统的经验。大多数操作都会调用其他的操作,而后者又会调用另外一些操作。一旦形成这种任意深度的嵌套,就很难预测调用一个操作将要产生的所有后果。第二层和第三层的影响可能并不是客户开发人员有意为之,于是它们就变成了完全意义上的副作用。在一个复杂的设计中,元素之间的交互同样也会产生无法预料的结果。副作用这个词强调了这种交互的不可避免性。
多个规则的相互作用或计算的组合产生的结果是很难预测的。开发人员在调用一个操作时,为了预测操作的结果,必须理解它的实现以及它所调用的其他方法的实现。如果开发人员不得不“揭开接口的面纱”,那么接口的抽象作用就受到了限制。如果没有了可以安全地预见结果的抽象,开发人员就必须限制“组合爆炸”,这就限制了系统行为的丰富性。
在大多数软件系统中,命令的使用都是不可避免的,但有两种方法可以减少命令产生的问题。首先,可以把命令和查询严格地放在不同的操作中。确保导致状态改变的方法不返回领域数据,并尽可能保持简单。在不引起任何可观测到的副作用的方法中执行所有查询和计算。
第二,总是有一些替代的模型和设计,它们不要求对现有对象做任何修改。相反,它们创建并返回一个 Value Object,用于表示计算结果。这是一种很常见的技术。
Value Object 是不可变的,这意味着除了创建期间调用的初始化程序之外,它们的所有操作都是函数。像函数一样,Value Object 使用起来很安全,测试也很简单。如果一个操作把逻辑或计算与状态改变混合在一起,那么我们就应该把这个操作重构为两个独立的操作。但从定义上来看,这种把副作用隔离到简单的命令方法中的做法仅适用于 Entity 。在完成了修改和查询的分离之后,可以考虑再进行一次重构,把复杂计算的职责转移到 Value Object 中。通过派生出一个 Value Object (而不是改变现有状态),或者通过把职责完全转移到一个 Value Object 中,往往可以完全消除副作用。
因此:
尽可能把程序的放到函数中,因为函数只是返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的、非常简单的操作中。当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑移到 Value Object 中,这样就可以进一步控制副作用。
3、模式:Assertion(断言)
把复杂的计算封装到 Side-Effect-Free Function 中可以简化问题,但实体仍然会留有一些副作用的命令,使用这些 Entity 的人必须了解使用这些命令的后果。在这种情况下,使用 Assertion 可以把副作用明确地表示出来,使它们更易于处理。
确实,一条不包含复杂计算的命令只需查看一下就能理解。但是,在一个软件设计中,如果较大的部分是由较小部分构成的,那么一个命令可能会调用其他命令。开发人员在使用高层命令时,必须了解每个底层命令所产生的后果,这时封装也就没有什么价值了。而且,由于对象接口并不会限制副作用,因此实现相同接口的两个子类可能会产生不同的副作用。使它们的开发人员需要知道哪个副作用是由哪个子类产生的,以便预测后果。这样,抽象和多态也就失去了意义。
我们需要在不深入研究内部机制的情况下理解设计元素的意义和执行操作的后果。Intention-Revealing Interface 可以起到一部分作用,但这样的接口只能非正式地给出操作的用途,这常常是不够的。“契约式设计”(design by contract)向前推进了一步,通过给出类和方法的“断言”使开发人员知道肯定会发生的结果。简言之,“后置条件”描述了一个操作的副作用,也就是调用一个方法之后必然会产生的结果。“前置条件”就像是合同条款,即为了满足后置条件而必须要满足的前置条件。类的固定规则规定了在操作结束时对象的状态。也可以把 Aggregate 作为一个整体来为它声明固定规则,这些都是严格定义的完整性规则。
所有这些断言都描述了状态,而不是过程,因此它们更易于分析。类的固定规则在描述类的意义方面起到帮助作用,并且使客户开发人员能够更准确地预测对象的行为,从而简化他们的工作。如果你确信后置条件的保证,那么就不必考虑方法是如何工作的。断言已经把调用其他操作的效果考虑在内了。
因此:
把操作的后置条件和类及 Aggregate 的固定规则表达清楚。如果在你的编程语言中不能直接编写 Assertion ,那么就把它们编写成自动化的单元测试。还可以把它们写到文档或图中(如果符合项目开发风格的话)。
寻找在概念上内聚的模型,以便使开发人员更容易推断出预期的 Assertion,从而加快学习过程并避免代码矛盾。
4、模式:Conceptual Contour
有时,人们会对功能进行更细的分解,以便灵活地组合它们,有时却要把功能合成大块,以便封装复杂性。有时,人们为了使所有类和操作都具有相似的规模而遵照一种一致的力度。这些方法都过于简单了,并不能作为通用的规则。但使用这些方法的动机都来自于一系列基本的问题。
如果把模型或设计的所有元素都放在一个整体的大结构中,那么它们的功能就会发生重复。外部接口无法给出客户可能关心的全部信息。由于不同的概念被混合在一起,它们的意义变得很难理解。
而另一方面,把类和方法分解开也可能是毫无意义的,这会使客户更复杂,迫使客户对象去理解各个细微部分是如何组合在一起的。更糟的是,有的概念可能会完全丢失。而且,粒度的大小并不是唯一要考虑的问题,我们还要考虑粒度是在哪种场合下使用的。
大部分领域都深深隐含着某种逻辑一致性,否则它们就形不成领域了。这并不是说领域就是绝对一致的,而且人们讨论领域的方式肯定也不一样。但是领域中一定存在着某种十分复杂的原理,否则建模就失去了意义。由于这种隐藏在底层的一致性,当我们找到一个模型,它与领域的某个部分特别温和,这个模型很可能也会与我们后续发现的这个领域的其他部分一致。有时,新的发现可能与模型不符,在这种情况下,就需要对模型进行重构,以便获取更深层的理解,并希望下一次新发现能与模型一致。
通过反复重构最终会实现柔性设计,以上就是其中的一个原因。随着代码不断适应新理解的概念或需求,Conceptual Contour(概念轮廓)也就逐渐形成了。
从单个方法的设计,到类和 Module 的设计,再到大型结构的设计,高内聚低耦合这一对基苯原则都起着重要的作用。这两条原则既适用于代码,也适用于概念。为了避免机械化地遵循它,我们必须经常根据我们对领域的直观认识来调整技术思路。在做每个决定时,都要问自己:“这是根据当前模型和代码中的特定关系做出的权宜之计,还是反映了底层领域的某种轮廓?”
寻找在概念上有意义的功能单元,这样可以使得设计既灵活又易懂。在做任何领域中,都有一些细节是用户不感兴趣的。把哪些没必要分解或重组的元素作为一个整体,这样可以避免混乱,并且使人们更容易看到那些真正需要重组的元素。
因此:
把设计元素(操作、接口、类和 Aggregate)分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层 Conceptual Contour。使模型与领域中那些一致的方面(正式这些方面使得领域成为一个有用的知识体系)相匹配。
我们的目标是得到一组可以在逻辑上组合起来的简单接口,使我们可以用 Ubiquitous Language 进行合理的表述,并且使那些无关的选项不会分散我们的注意力,也不增加维护负担。但这通常是通过重构才能得到的结果,很难在前期就实现。而且如果仅仅是从技术角度进行重构,可能永远也不会出现这种结果。只用通过重构得到更深层的理解,才能实现这样的目标。
5、模式:Standalone Class
互相依赖使模型和设计变得难以理解、测试和维护。而且,相互依赖很容易越积越多。
当然,每个关联都是一种依赖,要想理解一个类,必须它与哪些对象有联系。与这个类有联系的其他对象还会与更多的对象发生联系,而这些联系也是必须要弄清楚的。每个方法的每个参数的类型也是一个依赖,每个返回值也都是一个依赖。
Module 和 Aggregate 的目的都是为了限制互相依赖的关系网。当我们识别出一个高度内聚的子领域并把它提取到一个 Module 中的时候,一组对象也随之与系统的其他部分解除了联系,这样就把互相联系的概念的数量控制在一个有限的范围内。但是,即使系统分成了各个 Module ,如果不严格控制 Module 内部的依赖的话,那么 Module 也一样会让我们耗费很多精力去考虑依赖关系。
即使是在 Module 内部,设计也会随着依赖关系的增加而变得越来越难以理解。这加重了我们的思考负担,从而限制了开发人员能处理的设计复杂度。隐式概念比显式引用增加的负担更大。
我们可以将模型一直精炼下去,直到每个剩下的概念关系都表示出概念的基本含义为止。在一个重要的子集中,依赖关系的个数可以减小到零,这样就得到一个完全独立的类,它只有很少的几个基本类型和基础哭概念。
隐式概念,无论是否已被识别出来,都与显式引用一样会加重思考负担。虽然我们通常可以忽略像整数和字符串这样的基本类型值,但无法忽略它们所表示的意义。
我们应该对每个依赖关系提出质疑,直到证实它确实表示对象的基本概念为止。这个仔细检查依赖关系的过程从提取模型概念本身开始。然后需要注意每个独立的关联和操作。仔细选择模型和设计能够大幅减少依赖关系——常常能减少到零。
低耦合是对象设计的一个基本要素。尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变得完全独立了,这就使得我们可以单独地研究和理解它。每个这样的独立类都极大地减轻了因理解 Module 而带来的负担。
当一个类与它所在的模块中的其他类存在依赖关系时,比它与模块外部的类有依赖关系要好得多。同样,当两个对象具有自然的紧密耦合关系时,这两个对象共同设计的多个操作实际上能够把它们的关系本质明确地表示出来。我们的目标不是消除所有以来,而是消除所有不重要的依赖。当无法消除所有的依赖关系时,每清除一个依赖对开发人员而言都是一种解脱,使他们能够集中精力处理剩下的依赖关系。
尽力把最复杂的计算提取到 Standalone Class 中,实现此目的一种是从存在大量依赖的类中将 Value Object 建模出来。
低耦合是减少概念过载的最基本方法。独立的类是低耦合的极致。
消除依赖并不是说要武断地把模型中的一切都简化为基本类型,这样只会削弱模型的表达能力。下面一个模式 Closure Operation (闭合操作)就是一种减小依赖的同时保持丰富接口的技术。
6、模式:Closure Operation(闭合操作)
两个实数相乘,结果仍为实数。由于这一点永远成立,因此我们说实数的“乘法运算是闭合的”:乘法运算的结果永远无法脱离实数这个集合。当我们对集合中的任意两个元素组合时,结果仍在这个集合中,这就叫闭合操作。
闭合的性质极大地简化了对操作的理解,而且闭合操作的链接和组合也很容易理解。
因此:
在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。如果实现者(implementer)的状态在计算中会被用到,那么实现者实际上就是操作的一个参数,因此参数和返回值应该与实现者具有相同的类型。这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他该概念的任何依赖。
这种模式更常用于 Value Object 的操作。由于 Entity 的生命周期在领域中十分重要,因此我们不能为了解决某一问题而草率创建一个 Entity。有一些操作是 Entity 类型之下的闭合操作。例如,我们可以通过查询一个 Employee(员工)对象来返回其主管。
在尝试和寻找减少相互依赖并提高内聚的过程中,有时我们会遇到“半个闭合操作”这种情况。参数类型与实现者的类型一致,但返回类型不同;或者返回类型与接收者(receiver)的类型相同但参数类型不同。这些操作都不是闭合操作,但它们确实具有 Closure Of Operation 的某些优点。当没有形成闭合操作的那个多出来的类型是基本类型或基础类库时,它几乎与 Closure Of Operation 一样减轻了我们的思考负担。
以上的模式介绍了通用的设计风格和思考设计的方式。把软件设计得意图明显、容易预测且富有表达力,可以有效地发挥抽象和封装的作用。我们可以对模型进行分解,使得对象更易于理解和使用,同时仍具有功能丰富的、高级的接口。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.e1idc.net