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

深层建模听起来很不错,但是我们要如何实现它呢?深层模型之所以强大是因为它包含了领域的核心概念和抽象,能够以简单灵活的方式表达出基本的用户活动、问题以及解决方案。深层建模的第一步就是要设法在模型中表达出领域的基本概念。随后,在不断消化知识和重构的过程中,实现模型的精化。但是实际上这个过程是从我们识别出某个重要概念并且在模型和设计中把它显式地表达出来的那个时刻开始的。

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

有时,这种从隐式概念到显式概念的转换可能是一次突破,使我们得到一个深层模型。但更多的时候,突破不会马上到来,而需要我们在模型中显式表达出许多重要概念,并通过一系列重构不断地调整对象职责、改变它们与其他对象的关系、甚至多次修改对象名称,在这之后,突破才会姗姗而来。最后,所有事情都变得清晰了。但是要实现上述过程,必须首先识别出以某种形式存在的隐含概念,无论这些概念有多么原始。

9.1 概念挖掘

开发人员必须能够敏锐地捕捉到隐含概念的蛛丝马迹,但有时他们必须主动寻找线索。要挖掘出大部分的隐含概念,需要开发人员去倾听团队语言、仔细检查设计中的不足之处以及与专家观点相矛盾的地方、研究领域相关文献并且进行大量的实验。

9.1.1 倾听语言

你可能会想起这样的经历:用户总是不停地谈论报告中的某一项。该项可能来自各种对象的参数汇编,甚至还可能来自一次直接的数据库查询。同时,应用程序的另一部分也需要这个数据集来进行显示、报告或其他操作。但是,你却一直认为没有必要为此创建一个对象。也许你一直没有真正理解用户想通过某个特定术语传达的东西,也没有意识到它的重要性。

然后,你突然灵机一动。原来,报告中该项名称给出了一个重要的领域概念。你高兴地与专家谈起了这个新发现。他们可能会松一口气,因为你终于明白了。也可能会觉得很平常,因为他们一直认为这是理所当然的。不管专家们如何反应,你开始在白板上画模型图了(之前你也一直这么做)。用户会帮助你修正新模型连接方面的细节,但你明显感到讨论的质量有所提高。你和用户可以更加准确地理解对方,并且可以更加自然地用模型交互来演示特定场景。领域模型的语言也变得更加强大。然后,你可以重构代码来反映新模型,同时也会发现你的设计变得更加清晰了。

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

这不同于原来的“名词即对象”概念。听到新单词只是个开头,然后我们还要进行对话、消化知识,这样才能挖掘出清晰实用的概念。如果用户或领域专家使用了设计中没有的词汇,这就是个警告信号。而当开发人员和领域专家都在使用设计中没有的词汇时,那就是一个倍加严重的警告信号了。

或者,应该把这种警告看成一次机会。UBIQUITOUS LANGUAGE是由遍布于对话、文档、模型图甚至代码中的词汇构成的。如果出现了设计中没有的术语,就可以把它添加到通用语言中,这样也就有机会改进模型和设计了。

示例 听出运输模型中缺失的一个概念

团队已经开发出了可用来预订货物的有效应用程序。现在他们开始开发“作业支持”应用程序,此程序可帮助工作人员管理工作单,这些工作单用于安排起始地和目的地的货物装卸以及在不同货轮之间转运时需要的货物装卸。

预定应用程序使用一个路线引擎来安排货物行程。运输过程的每段行程都作为一行数据存储在数据库表中,其中指定了装载该货物的船名航次(某一货轮的某一航次)ID、装货地点以及卸货地点,如图9-1所示。

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

让我们来听听开发人员和运输专家之间的对话吧(对话已被高度简化)。

开发人员:我想要确认一下cargo bookings(货物预订)表中是否已包含了作业应用程序所需的全部数据。

专家:他们需要Cargo的全部航海日程(Itinerary)。现在表中有哪些信息?

开发人员:货物ID、船名航次以及每个航段的装货港口和卸货港口。

专家:那么日期呢?需要按照预计的时间来进行装卸工作。

开发人员:嗯,日期可以从船名航次安排中获得。该表的数据已经得到了规范化处理。

专家:是的,日期通常都是必需的数据。作业人员会用这类航海日程来安排后面的装卸工作。

开发人员:嗯……好的。他们肯定可以得到日期数据。作业管理应用程序可以提供全部装货和卸货信息以及每次装卸作业的日期。我猜这也就是你所说的“航海日程”。

专家:很好。航海日程是他们需要的主要数据。事实上,你知道,预订应用程序包含了一个菜单项,可以打印出航海日程或将航海日程通过电子邮件发送给顾客。你能想办法利用这个功能吗?

开发人员:我想那只是个报表。我们无法据此来开发作业应用程序。

[开发人员陷入了沉思,然后开始兴奋起来。]

开发人员:那么,航海日程实际上把预订程序和作业程序连接起来了。

专家:是的。它同时还连接了一些客户关系。

开发人员:[在白板上画出了一个草图。]那么,你觉得是这样的吗?

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

专家:是的,基本上是这样。在每段行程中,我们都希望看到船名航次、装货和卸货地点以及时间。

开发人员:所以,我们一旦创建了Leg(般段)对象,就能够从航次安排中获取时间信息。我们可以将Itinerary(航海日程)对象作为与作业应用程序联系的主要连接点。同时,还可以用这种方式重新编写航海日程报表,这样领域逻辑就重新回到领域层中了。

专家:有些地方我不太明白,但是你说对了Itinerary的两个主要用途,一是用在预订应用程序报表功能中,二是用在作业应用程序中。

开发人员:嘿!我们可以让Routing Service(路线服务)接口返回航程对象,而不用将数据写入数据库表。这样一来,路线引擎就不需要知道数据库表了。

专家:嗯?

开发人员:我是说,我可以让路线引擎只返回一个Itinerary。然后,预订应用程序在保存剩下的信息时把它一起存储到数据库中。

专家:你是说现在的程序并没有这么做吗?!

这位开发人员回去与负责路线处理的人员进行讨论。他们仔细研究了这会给模型和设计带来什么影响和变化,在必要的时候也去请教了运输专家。最后,他们得到了图9-3所示的模型。

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

接下来,开发人员对代码进行了重构,以使它能反映出新的模型。在一周内,他们很快对代码作出了一系列的修改,每次修改都进行两到三次重构。但是他们还没有对预订应用程序中的航海日程报告进行简化,而简化工作将会在接下来的一周开始进行。

这位开发人员一直都在仔细倾听运输专家的见解,并注意到“航海日程”概念的重要性。事实上,所有的数据都已收集,在航海日程报告中也已隐含了操作行为,但是,把显式的Itinerary对象作为模型的一部分给他们带来了新的机会。

通过重构得到显式的Itinerary对象的益处是:

(1) 更明确地定义Routing Service接口;

(2) 将Routing Service与预订数据库表解耦——Routing Service无需关心存储逻辑;

(3) 明确了预订应用程序和作业支持应用程序之间的关系(即共享Itinerary对象);

(4) 减少重复,因为Itinerary可同时为预订报表和作业支持应用程序提供装货/卸货时间;

(5) 从预订报表中删除领域逻辑,并将其移至独立的领域层;

(6) 扩充了UBIQUITOUS LANGUAGE,使得开发人员和领域专家之间或者开发人员内部能够更准确地讨论模型和设计。

9.1.2 检查不足之处

你所需要的概念并不总是浮在表面上,也绝不仅仅是通过对话和文档就能让它显现出来。有些概念可能需要你自己去挖掘和创造。要挖掘的地方就是设计中最不足的地方,也就是操作复杂且难于解释的地方。每当有新的需求时,似乎都会让这个地方变得更加复杂。

有时,你很难意识到模型中丢失了什么概念。也许你的对象能够实现所有的功能,但是有些职责的实现却很笨拙。而有时,你虽然能够意识到模型中丢失了某些东西,但是却无法找到解决方案。

这个时候,你必须积极地让领域专家参与到讨论中来。如果你足够幸运,这些专家可能会愿意一起思考各种想法,并通过模型来进行验证。如果你没那么幸运,你和你的同事就不得不自己思索出不同的想法,让领域专家对这些想法进行判断,并注意观察专家的表情是认同还是反对。

示例 摸索利息计算模型

第9章 将隐式概念转变为显式概念 - 图4 图9-4 笨拙的模型

下面的故事以一家假想的金融公司为背景,该公司经营商业贷款和其他一些生息资产。公司开发了一个用于跟踪这些投资及收益的应用程序,通过一项一项地添加功能来使它不断地发展。每天晚上,公司都会运行一个批处理脚本,用于计算当天所生成的利息和费用,并把它们相应地记录到公司的财务软件中。

晚间批处理脚本会遍历每笔Asset(资产),并让其执行calculateInterestForDate(),按照当天的日期来计算利息。然后,该脚本会接收返回值(收益金额),并将它和指定分类账的名称一起发送给一个SERVICE(这个SERVICE提供了记账程序的公共接口)。再由记账软件将收入金额过账到指定的分类账中。这个脚本还会对每笔Asset当日的手续费作类似的处理,并记录到另一个不同的分类账中。

负责这个程序的一位开发人员一直在费力地应对日益复杂的利息计算。她开始怀疑应该能找到一个更适合完成此项任务的模型。于是,她向她熟识的领域专家寻求帮助,希望专家可以协助她深入研究这个问题。

开发人员:我们的Interest Calculator(利息计算器)太复杂了。

专家:这一部分确实很复杂。还有很多情况我们都推迟考虑了。

开发人员:我知道。我们可以使用另一个不同的Interest Calculator来添加新的利息类型。但现在最大的麻烦是,如果没有按时支付利息,该如何去处理由此引发的各种特殊情况。

专家:其实这些不算是特殊情况。人们支付利息的方式可以非常灵活。

开发人员:记得之前我们重构Asset,将Interest Calculator从中分离出来,这对开发工作大有帮助。我们可能还需要进一步分解Asset。

专家:没问题。

开发人员:我在想你们在讨论这种利息计算时可能有另外的方式。

专家:你指的是什么?

开发人员:举个例子,假设我们正在跟踪会计期(accounting period)内到期的未付利息。这种利息有名字吗?

专家:哦,实际上我们并不会这么做。利息收入和付款是完全独立的过账。

开发人员:所以你们不需要这个数字(到期的未付利息)?

专家:有时我们也许会看看,但这不是我们处理业务的方式。

开发人员:好吧。如果付款和利息是彼此独立的,也许我们应该这样建模。这看起来怎么样?[在白板上画出草图。]

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

专家:我想这是合乎情理的。但你只是把它从一个地方移到了另一个地方。

开发人员:不过现在Interest Calculator只负责追踪利息收入了,付款的数目则是由Payment单独管理。这个模型并没有简化什么,但它是不是能够更好地反映出业务惯例呢?

专家:啊。我懂了。我们能够同时保留利息的历史记录吗?就像之前模型中的 Payment History (付款历史)一样。

开发人员:可以。这已经被作为一项新功能提出来了。但是,它本来应该在初始设计中就加进来。

专家:哦。是这样,我看到你以这种方式分离利息和Payment History,还以为你们要把利息分解并组织成类似于Payment History的结构。你对应计制会计(accrual basis accounting)有所了解吗?

开发人员:请解释一下。

专家:我们每天(或根据计划安排)都会把应计利息过账到收支总账中。而支付的过账方法则完全不同。你在这里把应计利息累加起来有点不大合适。

开发人员:你是说,如果我们保留“应计利息”列表,那么这些利息就可以根据需要来进计算总计或者……“过账”。

专家:应该是在应计日期过账,但是可以在任意时间内累加。费用的处理与此相同,当然,是要提交到另一个分类账中。

开发人员:事实上,如果只计算一天或一段时间的利息,问题就会简单得多。然后,我们就能够解决所有这些问题了。这看起来怎么样?

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

专家:不错。这看起来很好。我不明白为什么这对你来说会简单得多。但基本上,资产之所以有价值,就是因为通过它可以累积利息、费用等。

开发人员:你是说手续费也是一样的吗?它们……怎么说来着?……要过账到不同的分类账中?

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

开发人员:在这个模型中,我们将Interest Calculator中的利息计算(或者说是应计费用的计算逻辑)与跟踪利息的功能分开了。直到现在我才注意到Fee Calculator与Interest Calculator有很多重复的地方。此外,现在不同类型的费用也可以轻松地添加进来了。

专家:是的。之前的计算也是正确的,但现在变得一目了然了。

由于Calculator类并没有直接与设计中的其他部分相关联,所以这其实是一个非常简单的重构。这位开发人员只需花几个小时就能够通过重写单元测试来验证新的语言,第二天新的设计就可以用了。最终,她得到了下面的模型。

第9章 将隐式概念转变为显式概念 - 图8 图9-8 重构后的深层模型

在重构后的应用程序中,夜间批处理脚本会通知每个Asset执行第9章 将隐式概念转变为显式概念 - 图9第9章 将隐式概念转变为显式概念 - 图10。其返回值是Accrual的集合,而其中的每笔金额都会过账到指定的分类账中。

新模型具有几个优点,包括:

(1) 术语“应计费用(accrual)”使UBIQUITOUS LANGUAGE更丰富;

(2) 将应计费用从付款中分离出来;

(3) 将领域知识(如过账到哪个分类账)从脚本中移出来,并放到领域层中;

(4) 将费用与利息统一,既能够符合业务逻辑,又可消除重复代码;

(5) 新形式的费用和利息可以通过Accrual Schedule直接添加到模型中。

这一次,开发人员不得不自己挖掘所需的新概念。她能够看出利息计算的不足之处,并坚持不懈地寻找更深层次的解决方案。

她很幸运地找到一位聪明且热忱的银行专家作为合作伙伴。如果合作的专家不那么主动的话,她可能会在初期犯更多的错误,而后则需要更多地依赖与其他开发人员进行头脑风暴来解决问题。这样,程序开发的进度会放慢,但还是有可能获得进展。

9.1.3 思考矛盾之处

由于经验和需求的不同,不同的领域专家对同样的事情会有不同的看法。即使是同一个人提供的信息,仔细分析后也会发现逻辑上不一致的地方。在挖掘程序需求的时候,我们会不断遇到这种令人烦恼的矛盾,但它们也为深层模型的实现提供了重要线索。有些矛盾只是术语说法上的不一致,有些则是由于误解而产生的。但还有一种情况是专家们会给出相互矛盾的两种说法。

天文学家伽利略曾提出过一个悖论。我们的感觉清楚地表明地球是静止的:人们既不会被吹走也不会被抛出去。然而哥白尼提出了一个很有说服力的观点,即地球是围绕着太阳飞速转动的。将这一矛盾统一起来可能会揭示出大自然运转的某种深奥的规律。

于是,伽利略设计了一个假想实验。如果一个骑手在奔跑的马背上丢下一个球,这个球会掉到哪里?显然,这个球会随着马一起向前移动,直到它落在马蹄旁边的地面上,就像马一直站着没动时一样。根据这个实验,伽利略推导出了惯性参考系思想的早期雏形,它可以解决前面提到的悖论并可引出更为实用的物理运动模型。

我们遇到的矛盾通常不会这么有趣,也不会具有如此深刻的意义。尽管如此,采用同样的思考模式通常可以帮助我们透过问题领域的表面获得更深层的理解。

要解决所有矛盾是不太现实的,甚至是不需要的。(第14章将会深入探讨如何取舍以及如何处理结果。)然而,即使不去解决矛盾,我们也应该仔细思考对立的两种看法是如何同时应用于同一个外部现实的,这会给我们带来启示。

9.1.4 查阅书籍

在寻找模型概念时,不要忽略一些显而易见的资源。在很多领域中,你都可以找到解释基本概念和传统思想的书籍。你依然需要与领域专家合作,提炼与你的问题相关的那部分知识,然后将其转化为适用于面向对象软件的概念。但是,查阅书籍也许能够使你一开始就形成一致且深层的认识。

示例 借助参考书来设计利息计算模型

让我们设想一下前面讨论的投资跟踪应用程序的另一个场景。与前面一样,这个故事的开头也是开发人员意识到设计变得越来越笨拙,特别是Interest Calculator。但是在这个场景中,领域专家主要负责其他工作,他对帮助软件开发项目并不十分感兴趣。在这里,开发人员不能指望专家与其一起进行头脑风暴,帮助她探寻隐藏于表象之下的遗漏概念。

于是,她去了书店。随意翻阅了几本书之后,她找到了一本自己比较喜欢的会计学入门书籍,并把它粗略浏览了一遍。她发现书中有一整套明确定义的概念体系。其中一段文字给了她特别大的启发:

应计制会计。这种方法把所有已经产生的收入均计到收入中(即使尚未支付),所有支出也均在产生时显示出来(无论是已经支付还是以后才支付)。所有到期债务,包括税金,都列入费用。

——Suzanne Caplan的Finance and Accounting:How to Keep Your Books and Manage Your Finances Without an MBA,a CPA or a Ph.D[Adams Media,2000]

开发人员再也不用自己去重新编造一个会计学出来了。在与其他开发人员进行了一些讨论之后,她设计出了一个模型,如图9-9所示。

第9章 将隐式概念转变为显式概念 - 图11 图9-9 通过阅读书籍而得来的略为深层的模型

她还没有认识到收入是由Asset产生的,所以模型中依然含有Calculator。分类账的概念还是包含在应用程序中,而不是在它本应归属的领域层中。但是,她确实将付款从应计收入中分离出来了(这曾是最大的问题)并且她将“应计费用”这个词引入到模型和UBIQUITOUS LANGUAGE中。在之后的迭代过程中,模型还会得到进一步的精化。

当这位开发人员终于有机会与领域专家讨论时,专家大吃一惊。她是专家遇到的第一个对其工作表现出些许兴趣的程序人员。由于职责分配的原因,专家从未像之前例子那样密切配合过——坐下来与她共同商讨模型问题。但是,这位开发人员从书中获取了知识,这使她能够提出很好的问题,所以自此以后,专家开始认真倾听她的见解,并尽力及时地回答她的问题。

当然,看书与咨询领域专家并不冲突。即便能够从领域专家那里得到充分的支持,花点时间从文献资料中大致了解领域理论也是值得的。虽然许多业务并不会像会计学或金融行业那样具有极其细致的模型,但大多数领域中都有一些擅于思考的人,他们已组织并抽象出了业务的一些通用的惯例。

开发人员还有另一个选择,就是阅读在此领域中有过开发经验的软件专业人员编写的资料。例如,《分析模式》[Fowler 1997]一书的第6章可能会为她提供一个完全不同的思考方向——无论这个方向会让开发变得更好还是更糟。阅读书籍并不能提供现成的解决方案,但可以为她提供一些全新的实验起点,以及在这个领域中探索过的人总结出来的经验。这样可以避免开发人员重复设计已有的概念。第11章将更深入地探讨这一主题。

9.1.5 尝试,再尝试

上面的例子并没有显示出不断尝试和出错的次数。在讨论过程中,我可能尝试六七种不同的思路,然后找到一个看起来足够清晰且实用的概念,并在模型中尝试它。后面,随着经验的积累和知识的消化,我们会有更好的想法,最终,这个概念至少会被替换一次。因此,建模人员/设计人员绝对不能固执己见。

并不是所有这些方向性的改变都毫无用处。每次改变都会把开发人员更深刻的理解添加到模型中。每次重构都使设计变得更灵活并且为那些可能需要修改的地方做好准备。

我们其实别无选择。只有不断尝试才能了解什么有效什么无效。企图避免设计上的失误将会导致开发出来的产品质量低劣,因为没有更多的经验可用来借鉴,同时也会比进行一系列快速实验更加费时。

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

面向对象范式会引导我们去寻找和创造特定类型的概念。所有事物(即使是像“应计费用”这种非常抽象的概念)及其操作行为是大部分对象模型的主要部分。它们就是面向对象设计入门书籍所讲到的“名词和动词”。但是,其他重要类别的概念也可以在模型中显式地表现出来。

下面我将会描述3个这样的类别,我在开始接触对象时,对它们的认识并不够清晰。我每学会一个这样的类别,就会让设计变得更加清晰深刻。

9.2.1 显式的约束

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

有时,约束很自然地存在于对象或方法中。Bucket(桶)对象必须满足一个固定规则——内容(contents)不能超出它的容量(capacity),如图9-10所示。

第9章 将隐式概念转变为显式概念 - 图12 图9-10

这样一个简单的固定规则可以在每次可改变内容的操作中使用一个逻辑判断来保证。

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

这里的逻辑非常简单,规则也很明显。但是不难想象,在更复杂的类中这个约束可能会丢失。让我们把这个约束提取到一个单独的方法中,并用清晰直观的名称来表达它的意义。

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

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

这两个版本的代码都实施了约束,但是第二个版本与模型的关系更为明显(这也是MODEL-DRIVEN DESIGN的基本需求)。这个规则十分简单,使用最初形式的代码也很容易理解,但如果要是执行的规则比较复杂的话,它们就会像所有隐式概念一样淹没掉被约束的对象或操作。将约束条件提取到其自己的方法中,这样就可以通过方法名来表达约束的含义,从而在设计中显式地表现出这条约束。现在这个约束条件就是 一个“有名有姓”的概念了,我们可以用它的名字来讨论它。这种方式也为约束的扩展提供了空间。比这更复杂的规则很容易就会产生比其调用者(在这里就是pourIn()方法)更长的方法。这样,调用者就可以简单一些,并且只专注于处理自己的任务,而约束条件则可以根据需要进行扩展。

这种独立方法为约束预留了一定的增加空间,但是在很多时候,约束条件是无法用单独的方法来轻松表达的。或者,即使方法自身能够保持其简单性,但它可能会调用一些信息,但对于对象的主要职责而言,这些信息毫无用处。这种规则可能就不适合放到现有对象中。

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

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

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

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

如果约束的存在掩盖了对象的基本职责,或者如果约束在领域中非常突出但在模型中却不明显,那么就可以将其提取到一个显式的对象中,甚至可以把它建模为一个对象和关系的集合。(The Object Constraint Language:Precise Modeling with UML[Warmer and Kleppe 1999]一书中提供了关于这个问题的半正式的深入解决方案。)

示例 复核:超订策略

在第1章中,我们讨论了一个常见的运输业务惯例:预订超出运输能力10%的货物。(货运公司的经验表明,这种程度的超定可以抵消因客户临时取消订单而空出来的舱位,这样货轮基本能够满载起航。)

通过加入一个新类来反映Voyage和Cargo关联中的约束,该约束不管是在图表中还是在代码中都能显式地体现出来,如图9-11所示。

第9章 将隐式概念转变为显式概念 - 图16 图9-11 为显式表达超订策略而重构的模型

要查看完整例子的代码和思路,请参阅1.4节示例。

9.2.2 将过程建模为领域对象

首先要说明的是,我们都不希望过程变成模型的主要部分。对象是用来封装过程的,这样我们只需考虑对象的业务目的或意图就可以了。

在这里,我们讨论的是存在于领域中的过程,我们必须在模型中把这些过程表示出来。否则当这些过程显露出来时,往往会使对象设计变得笨拙。

本章的第一个例子描述了用来安排货运路线的运输系统。安排路线的过程具有业务意义。SERVICE是显式表达这种过程的一种方式,同时它还会将异常复杂的算法封装起来。

如果过程的执行有多种方式,那么我们也可以用另一种方法来处理它,那就是将算法本身或其中的关键部分放到一个单独的对象中。这样,选择不同的过程就变成了选择不同的对象,每个对象都表示一种不同的STRATEGY。(第12章将会更详细地讨论如何在领域中使用STRATEGY。)

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

约束和过程是两大类模型概念,当我们用面向对象语言编程时,不会立即想到它们,然而它们一旦被我们视为模型元素,就真的可以让我们的设计更为清晰。

有些类别的概念很实用,但它们可应用的范围要窄很多。为了使本章的讨论更全面,我会探讨一个更特殊但也非常常用的概念——规格(specification)。“规格”提供了用于表达特定类型的规则的精确方式,它把这些规则从条件逻辑中提取出来,并在模型中把它们显式地表示出来。

SPECIFICATION是我与Martin Fowler[Evans and Fowler 1997]协作开发出来的。这个概念看起来很简单,但是应用和实现中起来却很微妙,因此在本节中会有大量的细节描述。在第10章中还会继续讨论SPECIFICATION,并对这种模式进行扩展。在阅读完接下来对该模式的初步解释后,你可以跳过9.2.4节,等到你真正想要应用这种模式时再回来阅读也不迟。

9.2.3 模式:SPECIFICATION

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

在所有类型的应用程序中,都会有布尔值测试方法,实际上它们只是些小规则。只要它们很简单,就可以用测试方法(如anIterator.hasNext()或anInvoice.isOverdue())来处理它们。在Invoice类中,isOverdue()的代码是计算一条规则的算法。例如:

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

但是并非所有规则都如此简单。在同一个Invoice(发票)类中,还有另外一个规则anInvoice.isDelinquent(),它一开始也是用来检查Invoice是否过期的,但仅仅是开始部分。根据客户账户状态的不同,可能会有宽限期政策。一些拖欠票据正准备再一次发出催款通知,而另一些则准备发给收账公司。此外,还要考虑客户的付款历史纪录、公司在不同产品线上的政策等。Invoice作为付款请求是明白无误的,但它很快就会消失在大量杂乱的规则计算代码中。Invoice还会发展出对领域类和子系统的各种依赖关系,而这些领域类和子系统与Invoice的基本含义无关。

到了这一步,为了简化Invoice类,开发人员通常会将规则计算代码重构到应用层中(在这里就是账单收集应用程序)。现在规则已经从领域层中分离出来,留下了一个纯粹的数据对象,它将不再表达本来应该在业务模型中表示的规则。这些规则需要保留在领域层中,但是把它们放到被其约束的对象(在这里是Invoice)里又不合适。此外,计算规则的方法中到处都是条件代码,这也使得规则变得复杂难懂。

那些使用逻辑编程范式的开发人员会用一种不同的方式来处理这种情况。这种规则被称为谓词。谓词是指计算结果为“真”或“假”的函数,并且可以使用操作符(如AND和OR)把它们连接起来以表达更复杂的规则。通过谓词,我们可以显式地声明规则并在Invoice中使用这些规则。但前提是必须使用逻辑范式。

认识到这一点后,人们已经开始尝试以对象的形式来实现逻辑规则。在这些尝试中,有些很成熟,有些则很幼稚。有些很激进,有些则很谨慎。有些被证明很有价值,有些则被当作失败的试验丢到一边。虽然项目允许进行几次这样的尝试,但是,有一件事情是很清楚的:无论这个想法多么吸引人,完全用对象来实现逻辑可是个大工程。(毕竟,逻辑编程本身就是一套建模和设计范式。)

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

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

幸运的是,我们并不真正需要完全实现逻辑编程即可从中受益。大部分规则可以归类为几种特定的情况。我们可以借用谓词概念来创建可计算出布尔值的特殊对象。那些难于控制的测试方法可以巧妙地扩展出自己的对象。它们都是些小的真值测试,可以提取到单独的VALUE OBJECT中。而这个新对象则可以用来计算另一个对象,看看谓词对那个对象的计算是否为“真”。

第9章 将隐式概念转变为显式概念 - 图19 图9-12

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

因此:

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

许多SPECIFICATION都是具有特殊用途的简单测试,就像在拖欠票据示例中的规格一样。当规则很复杂时,可以扩展这种概念,对简单的规格进行组合,就像用逻辑运算符把多个谓词组合起来一样。(这种技术将在下一章中讨论。)基本模式保持不变,并且提供了一种从简单模型过渡到复杂模型的途径。

拖欠票据的例子可以使用SPECIFICATION来建模,如图9-13所示。在规格中声明拖欠的含义,对任意的Invoice对象进行计算并做出判断。

SPECIFICATION将规则保留在领域层。由于规则是一个完备的对象,所以这种设计能够更加清晰地反映模型。利用工厂,可以用来自其他资源(如客户账户或者企业政策数据库)的信息对规格进行配臵。之所以使用FACTORY,是为了避免Invoice直接访问这些资源,因为这样会使得Invoice与这些资源发生不正确的关联(Invoice的基本职责是请求付款,而这些资源与这一职责无关)。在这个例子中,我们将创建Delinquent Invoice Specification(拖欠发票规格)来对一些发票进行评估,这个SPECIFICATION用过之后就被丢掉,因此可以将评估日期直接放在SPECIFICATION中,这真是一次不错的简化。我们可以用简单直接的方式为SPECIFICATION提供完成其职责所需的信息。

第9章 将隐式概念转变为显式概念 - 图20 图9-13 作为SPECIFICATION被提取出来的更为详细的拖欠规则

SPECIFICATION的基本概念非常简单,这能帮助我们思考领域建模问题。但是MODEL-DRIVEN DRSIGN要求我们开发出一个能够把概念表达出来的有效实现。要实现这个目标,必须要更深入地挖掘应用这个模式的方法。领域模式不仅仅是UML图中好的想法,也应该可以为MODEL-DRIVEN DRSIGN中的编程问题提供解决方案。

只要恰当地应用模式,就可以得出一整套如何解决领域建模问题的思路,同时也可以从这种长时间搜寻有效实现的经验中受益。下面的SPECIFICATION讨论详细介绍了功能和实现方法的多种选择。模式并不像菜谱那么死板。它可以让你以模式的经验为起点来开发自己的解决方案,并为你讨论手头工作提供了语言。

在第一次阅读时,你可以快速浏览关键概念。以后碰到具体情况时,可以再回过头来阅读并从细节讨论中获取经验。然后就可以开始设计你自己的解决方案了。

9.2.4 SPECIFICATION的应用和实现

SPECIFICATION最有价值的地方在于它可以将看起来完全不同的应用功能统一起来。出于以下3个目的中的一个或多个,我们可能需要指定对象的状态。

(1) 验证对象,检查它是否能满足某些需求或者是否已经为实现某个目标做好了准备。

(2) 从集合中选择一个对象(如上述例子中的查询过期发票)。

(3) 指定在创建新对象时必须满足某种需求。

这3种用法(验证、选择和根据要求来创建)从概念层面上来讲是相同的。如果没有诸如SPECIFICATION这样的模式,相同的规则可能会表现为不同的形式,甚至有可能是相互矛盾的形式。这样就会丧失概念上的统一性。通过应用SPECIFICATION模式,我们可以使用一致的模型,尽管在实现时可能需要分开处理。

验证

规格的最简单用法是验证,这种用法也最能直观地展示出它的概念,如图9-14所示。

第9章 将隐式概念转变为显式概念 - 图21 图9-14 应用SPECIFICATION进行验证的模型

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

现在,假设当销售人员看到一个欠账客户的信息时,系统需要显示一个红旗标识。我们只需要在客户类中编写一个方法即可,类似于下面这段代码:

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

选择(或查询)

验证是对一个独立的对象进行测试,检查它是否满足某些标准,然后客户可能根据验证的结论来采取行动。另一种常见需求是根据某些标准从对象集合中选择一个子集。SPECIFICATION概念同样可以在此应用,但是实现问题会有所不同。

假设应用程序的需求是列出所有拖欠发票的客户。那么从理论上来说,我们依然可以使用之前定义的Delinquent Invoice Specification,但实际上我们可能不得不去修改它的实现。为了证明二者的概念是相同的,让我们首先假设发票的数量很少,可能已经全部装入内存了。在这种情况下,验证功能的最直接实现方式依然可用。Invoice Repository可以用一个一般化的方法来基于SPECIFICATION选择Invoice:

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

这样,用一行代码即可获得所有拖欠发票的集合:

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

上面这行代码建立了操作背后的概念。当然,Invoice对象可能并不在内存中。也有可能会有成千上万个Invoice对象。在典型的业务系统中,数据很可能会存储在关系数据库中。我们在前面的章节中曾经指出,在与其他技术交互使用时,很容易分散我们对模型的注意力。

关系数据库具有强大的查询能力。我们如何才能充分利用这种能力来有效解决这一问题,同时又能保留SPECIFICATION模型呢?MODEL-DRIVEN DESIGN要求模型与实现保持同步,但它同时也让我们可以自由选择能够准确捕捉模型意义的实现方式。幸运的是,SQL是用于编写SPECIFICATION的一种很自然的方式。

下面是个简单的例子,其中查询被封装在验证规则所在的类中。我们在Invoice Specification中添加了一个方法,该方法在Delinquent Invoice Specification子类中得以实现:

*

SPECIFICATION与REPOSITORY的搭配非常合适,REPOSITORY作为一种构造块机制,提供了对领域对象的查询访问,并且把数据库接口封装起来(参见图9-15)。

第9章 将隐式概念转变为显式概念 - 图27 图9-15 REPOSITORY和SPECIFICATION之间的交互

现在的设计有一些问题。最重要的问题是,表结构的细节本应该被隔离到一个映射层中(这个映射层把领域对象关联到关系表),现在却泄漏到了DOMAIN LAYER中。这样一来,这些表结构信息发生了隐性的重复,因此导致对Invoice和Customer对象的修改和维护变得很麻烦,因为现在必须在多个地方跟踪它们的映射变化。但是,这个例子只是一个简单的例证,用来说明如何将规则放在一个地方。一些对象关系映射框架提供了用模型对象和属性来表达这种查询的方式,并在基础设施层中创建实际的SQL语句。这样就可以两全其美了。

如果无法把SQL语句创建到基础设施中,还可以重写一个专用的查询方法并把它添加到Invoice Repository中,这样就把SQL语句从领域对象中分离出来了。为了避免在REPOSITORY中嵌入规则,必须采用更为通用的方式来表达查询,这种方式不捕捉规则但是可以通过组合或放臵在上下文中来表达规则(在这个例子中,使用的是双分派模式)。

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

Invoice Specification中的asSql()方法被替换为satisfyingElementsFrom(Invoice-Repository),并在Delinquent Invoice Specification中以如下的方式实现:

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

这段代码将SQL臵于REPOSITORY中,而应该使用哪个查询则由SPECIFICATION来控制。SPECIFICATION中并没有定义完整的规则,但规则的核心已位于其中——指明了什么条件构成了拖欠(即超过宽限期)。

现在,REPOSITORY中包含的查询非常具有针对性,可能只适用于这种情况。虽然这是可以接受的,但是根据拖欠发票在过期发票中所占数量的不同,我们可以选择一种更通用的REPOSITORY解决方案,使得性能仍然很好,同时又使SPECIFICATION的使用更易理解。

*

因为我们取出了更多Invoice并在内存中对其进行筛选,上面的代码会有性能方面的影响。这种以降低性能来实现更好的职责分离的代价是否可以接受完全取决于环境因素。SPECIFICATION和REPOSITORY之间的交互有很多种实现方式,不但能够利用开发平台的优势,还可以保证基本职责的实施。

有时,为了改善性能(或者更有可能是为了加强安全性),我们可能把查询实现为服务器上的存储过程。在这种情况下,SPECIFICATION可能只带有存储过程允许的参数。除此之外,这些不同实现之间的模型并没有什么不同。我们可以自由选择实现方式,除非模型中有特别的约束条件。这么做的代价是更加难于编写和维护查询。

上面的讨论基本上没有涉及将SPECIFICATION与数据库结合时所面临的挑战,我并不想在这里说明所有可能需要考虑的问题,而只是想简单介绍一下必须要做出的选择。Mee和Hieatt在[Fowler 2002]中讨论了用规格设计REPOSITORY时遇到的一些技术问题。

根据要求来创建(生成)

如果五角大楼需要一架新式的喷气式战斗机,政府官员们会先编写规格。在规格中可能会要求这架喷气机的速度达到2马赫,航程1800英里,并且成本不高于5000万美元,等等。无论规格有多详细,它都不是飞机的设计,更不是飞机本身。航空航天工程公司将接受这份规格并且据此创建出一个或多个设计。各个竞争公司可能会提出不同的设计,所有这些方案都需要满足原始规格。

很多计算机程序都能够生成一些工件,这些工件是需要被指定的。当你在字处理软件文档中插入图片时,文字会环绕在图片周围。你已指定了图片的位臵,可能也指定了文字环绕的样式。这样,字处理软件就可以按照你指定的规格来将页面上的文字摆放到正确的位臵。

尽管乍看起来并不明显,但是这种SPECIFICATION概念与应用于验证和选择的规格并无二致。都是在为尚未创建的对象指定标准。但是,SPECIFICATION的实现则会大不相同。这种SPECIFICATION与查询不同,它不用来过滤已存在对象;也与验证不同,并不用来测试已有对象。在这里,我们要创建或重新配臵满足SPECIFICATION的全新对象或对象集合。

如果不使用SPECIFICATION,可以编写一个生成器,其中包含可创建所需对象的过程或指令集。这种代码隐式地定义了生成器的行为。

反过来,我们也可以使用描述性的SPECIFICATION来定义生成器的接口,这个接口就显式地约束了生成器产生的结果。这种方法具有以下几个优点。

生成器的实现与接口分离。SPECIFICATION声明了输出的需求,但没有定义如何得到输出结果。

接口把规则显式地表示出来,因此开发人员无需理解所有操作细节即可知晓生成器会产生什么结果。而如果生成器是采用过程化的方式定义的,那么要想预测它的行为,唯一的途径就是在不同的情况下运行或去研究每行代码。

接口更为灵活,或者说我们可以增强其灵活性,因为需求由客户给出,生成器唯一的职责就是实现SPECIFICATION中的要求。

最后一点也很重要。这种接口更加便于测试,因为接口显式地定义了生成器的输入,而这同时也可用来验证输出。也就是说,传入生成器接口的用于约束创建过程的同一个SPECIFICATION也可发挥其验证的作用(如果实现方式能够支持这一点的话),以保证被创建的对象是正确的。(这是ASSERTION的例子,将会在第10章中讨论。)

根据要求来创建可以是从头创建全新对象,也可以是配臵已有对象来满足SPECIFICATION。

示例 化学品仓库打包程序

假设有一个仓库,里面用类似于货车车厢的大型容器存放各种化学品。有些化学品是惰性的,可以随意摆放。有些则是易挥发的,必须放于特制的通风容器中。还有一些是易爆品,必须保存于特制的防爆容器中。还有一些规则是关于如何在容器中混装化学品的。

我们的目标是编写出一个软件,用于寻找一种安全而高效地在容器中放臵化学品的方式,如图9-16所示。

第9章 将隐式概念转变为显式概念 - 图31 图9-16 仓库存储模型

我们可以首先从编写一个过程——取出一个化学品并将其放臵在一个容器中——开始,但是让我们从验证问题开始着手吧。这种方式让我们必须显式描述规则,同时也提供了一种测试最终实现的方式。

每种化学品都有一个容器SPECIFICATION。

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

现在,如果将这些规格编写成Container Specification,就可以提出一种把化学品混装在容器中的配臵方法,并测试它是否满足这些约束条件。

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

Container Specification的isSatisfied()方法用来检查是否存在所需的ContainerFeature。例如,易爆化学品的规格会寻找“防爆”特性:

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

下面是设臵易爆化学品的客户端示例代码:

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

Container对象的isSafelypacked()方法用来保证Container具有Chemical要求的所有特性。

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

到了这一步,我们就可以编写一个监控程序,用来监视库存数据库并报告不安全状况。

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

客户并没有要求我们编写这样一个软件。让业务人员知道这个程序当然很好,但客户的要求是设计一个打包程序。而现在我们得到的是打包的测试程序。这些对领域的理解和基于SPECIFICATION的模型使我们有能力为服务定义一个清晰而简单的接口,这个服务可接受Drum和Container集合并将它们按照规则进行打包。

* *

现在,为履行Packer服务的职责,我们的任务就是设计一个优化的约束求解方案。这一任务已经与程序中的其他部分分离开来,因此其他部分的实现机制不会对这个部分的设计产生影响。(详见第10章和第15章。)然而,控制打包的规则并没有从领域对象中提取出来。

示例 仓库打包程序的可工作的原型

为了让仓库打包软件有效工作而编写优化逻辑,这是一项艰巨的工作。一个小组的开发人员和业务专家已经分头开始工作了,但是编码工作尚未进行。同时,另一个小组正在开发一个应用程序,该程序允许用户从数据库中获取库存并提交给Packer处理,最后分析打包结果。这个小组是面向预期的Packer进行设计的。但是他们能做的只是模拟一个用户界面,编写一些数据库集成代码。他们无法为用户显示一个具有实际行为的界面,因此无法获得良好的反馈。同样,Packer小组也在闭门造车。

通过仓库打包程序示例中创建的领域对象和SERVICE接口,开发应用程序的小组认识到他们可以构建一个非常简单的Packer实现代码,这有助于开发工作获得进展,同时可以与其他小组协同工作并建立起反馈循环,但这只有在端到端的系统中才可以完全发挥作用。

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

* *

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

当然,上述代码有很多不足之处。它可能会将砂打包到特制容器中,这就导致在打包危险化学品时,特制容器已经没有多余的空间了。显然,它没有对空间的利用进行优化。但是很多优化方面的问题无论怎样都无法得到完美的解决。而这段实现代码确实遵循了到目前为止已声明过的所有规则。

通过可工作的原型来摆脱开发僵局

有的团队必须要等待另一个团队编写出代码后才可以继续工作。而这两个团队都要等到代码完全整合后才可以测试组件或从用户那里获取反馈。这种僵局通常可以通过关键组件的模型驱动原型来缓解,即使原型并不满足所有需求也可以。当实现与接口分离时,只要有可以工作的实现,项目工作就可以并行地开展下去。时机成熟的时候,可以用更为高效的实现来替代原型。同时,系统中的其他部分也能在开发期间与原型进行交互。

有了这个原型,应用程序的开发人员就可以全速开展工作了,包括进行所有与外部系统的集成。在领域专家对原型进行研究并确认自己的想法后,Packer开发小组也能够得到专家的反馈,从而帮助他们自己理清需求和优先级。Packer小组决定接管这个原型并对其进行调整,以便测试他们的想法。

同时,他们还使接口与最新设计保持同步,以推动应用程序和一些领域对象的重构,从而尽早解决集成问题。

一旦完成复杂的Packer程序,集成就是轻而易举的事情了,因为它有一个描述得很清楚的接口,应用程序在与原型交互的时候也是根据相同的接口和ASSERTION编写的。

专家们花费了几个月的时间才得到了正确的优化算法。用户与原型交互时的反馈使他们受益匪浅。同时,系统中的其他部分在开发期间也能够与原型进行交互。

这里的例子演示了如何通过更巧妙的模型使“最简单却可能非常最有效的事物”成为可能。我们可以用几十行简单易懂的代码编写出复杂组件的功能原型。如果不用MODEL-DRIVEN DESIGN,系统会更难理解和升级(因为Packer与设计的其他部分更紧密地耦合在一起),在这种情况下,开发原型可能会更加耗时。