术语表
- VALUE OBJECT(值对象)
- 一种描述了某种特征或属性但没有概念标识的对象。
- AGGREGATE(聚合)
- 聚合就是一组相关对象的集合,我们把聚合作为数据修改的单元。外部对象只能引用聚合中的一个成员,我们把它称为根。在聚合的边界之内应用一组一致的规则。
- ASSERTION(断言)
- 断言是对程序在某个时刻的正确状态的声明,它与如何达到这个状态无关。通常,断言指定了一个操作的结果或者一个设计元素的固定规则。
- ENTITY(实体)
- 一种对象,它不是由属性来定义的,而是通过一连串的连续事件和标识定义的。
- 这种定义标识的方法可能来自外部,也可能是由系统创建的任意标识符,但它在模型中必须是唯一的标识。模型必须定义出“符合什么条件才算是相同的事物”。
- FACTORY(工厂)
- 一种封装机制,把复杂的创建逻辑封装起来,并为客户抽象出所创建的对象的类型。
- BOUNDED CONTEXT(限界上下文)
- 特定模型的限界应用。限界上下文使团队所有成员能够明确地知道什么必须保持一致,什么必须独立开发。
- 明确地定义模型所应用的上下文。
- CONCEPTUAL CONTOUR(概念轮廓)
- 领域本身的基本一致性,如果它能够在模型中反映出来的话,则有助于使设计更自然地适应变化。
- 把设计元素(操作、接口、类和AGGREGATE)分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。
- CONCEPTUAL CONTOUR的出现使模型的各个部分变得更稳定,也使得这些单元更直观,更易于使用和组合。
- CONTEXT MAP(上下文图)
- 项目所涉及的限界上下文以及它们与模型之间的关系的一种表示。
- 描述模型之间的联系点,明确所有通信需要的转换,并突出任何共享的内容。先将当前的情况描绘出来。以后再做改变。
- CORE DOMAIN(核心领域)
- 模型的独特部分,是用户的核心目标,它使得应用程序与众不同并且有价值。
- 对模型进行提炼。找到CORE DOMAIN并提供一种易于区分的方法把它与那些起辅助作用的模型和代码分开。最有价值和最专业的概念要轮廓分明。尽量压缩CORE DOMAIN。
- GENERIC SUBDOMAIN
- 识别出那些与项目意图无关的内聚子领域。把这些子领域的通用模型提取出来,并放到单独的MODULE中。任何专有的东西都不应放在这些模块中。
- SEGREGATED CORE
- 模型中的元素可能有一部分属于CORE DOMAIN,而另一部分起支持作用。
- 对模型进行重构,把核心概念从支持性元素(包括定义得不清楚的那些元素)中分离出来,并增强CORE的内聚性,同时减少它与其他代码的耦合。
- ABSTRACT CORE
- 把模型中最基本的概念识别出来,并分离到不同的类、抽象类或接口中。设计这个抽象模型,使之能够表达出重要组件之间的大部分交互。
- 把这个完整的抽象模型放到它自己的MODULE中,而专用的、详细的实现类则留在由子领域定义的MODULE中。
- DOMAIN VISION STATEMENT
- 写一份CORE DOMAIN的简短描述(大约一页纸)以及它将会创造的价值,也就是“价值主张”。
- EVOLVING ORDER
- 让这种概念上的大型结构随着应用程序一起演变,甚至可以变成一种完全不同的结构风格。不要依此过分限制详细的设计和模型决策,这些决策和模型决策必须在掌握了详细知识之后才能确定。
- SYSTEM METAPHOR
- 当系统的一个具体类比正好符合团队成员对系统的想象,并且能够引导他们向着一个有用的方向进行思考时,就应该把这个类比用作一种大型结构。
- RESPONSIBILITY LAYER
- 如果在领域中发现了自然的层次结构,就把它们转换为宽泛的抽象职责。这些职责应该描述系统的高层目的和设计。
- 对模型进行重构,使得每个领域对象、AGGREGATE和MODULE的职责都清晰地位于一个职责层当中。
- KNOWLEDGE LEVEL
- 创建一组不同的对象,用它们来描述和约束基本模型的结构和行为。把这些对象分为两个“级别”,一个是非常具体的级别,另一个级别则提供了一些可供用户或超级用户定制的规则和知识。
- PLUGGABLE COMPONENT FRAMEWORK
- 从接口和交互中提炼出一个ABSTRACT CORE,并创建一个框架,这个框架要允许这些接口的各种不同实现被自由替换。
- 同样,无论是什么应用程序,只要它严格地通过ABSTRACT CORE的接口进行操作,那么就可以允许它使用这些组件。
- HIGHLIGHTED CORE
- 编写一个非常简短的文档(3~7页,每页内容不必太多),用于描述CORE DOMAIN以及CORE元素之间的主要交互过程。
- 把模型的主要存储库中的CORE DOMAIN标记出来,不用特意去阐明其角色。使开发人员很容易就知道什么在核心内,什么在核心外。
- COHESIVE MECHANISM
- 把概念上的COHESIVE MECHANISM(内聚机制)分离到一个单独的轻量级框架中。
- INTENTION-REVEALING INTERFACE(释意接口)
- 类、方法和其他元素的名称既表达了初始开发人员创建它们的目的,也反映出了它们将会为客户开发人员带来的价值。
- REPOSITORY(存储库)
- 一种把存储、检索和搜索行为封装起来的机制,它类似于一个对象集合。
- SERVICE(服务)
- 一种作为接口提供的操作,它在模型中是独立的,没有封装的状态。
- 当领域中的某个重要的过程或转换操作不是ENTITY或VALUE OBJECT的自然职责时,应该在模型中添加一个作为独立接口的操作,并将其声明为SERVICE。
- MODULE
- MODULE为人们提供了两种观察模型的方式,一是可以在MODULE中查看细节,而不会被整个模型淹没,二是观察MODULE之间的关系,而不考虑其内部细节。
- SPECIFICATION
- 为特殊目的创建谓词形式的显式的VALUE OBJECT。SPECIFICATION就是一个谓词,可用来确定对象是否满足某些标准。
- SPECIFICATION将规则保留在领域层。由于规则是一个完备的对象,所以这种设计能够更加清晰地反映模型。
- 验证对象,检查它是否能满足某些需求或者是否已经为实现某个目标做好了准备。
- 从集合中选择一个对象(如上述例子中的查询过期发票)。
- 指定在创建新对象时必须满足某种需求。
- SIDE-EFFECT-FREE FUNCTION(无副作用的函数)
- 尽可能把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。
- STANDALONE CLASS(孤立的类)
- 无需引用任何其他对象(系统的基本类型和基础库除外)就能够理解和测试的类。
- 低耦合是对象设计的一个基本要素。尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变得完全独立了,这就使得我们可以单独地研究和理解它。
- CLOSURE OF OPERATION(闭合操作)
- 模式CLOSURE OF OPERATION(闭合操作)就是一种在减小依赖性的同时保持丰富接口的技术。
- 依赖很少的其他库实现相同的丰富功能。
- STRATEGY(POLICY)
- 我们需要把过程中的易变部分提取到模型的一个单独的“策略”对象中。将规则与它所控制的行为区分开。
- 按照STRATEGY设计模式来实现规则或可替换的过程。策略对象的多个版本表示了完成过程的不同方式。
- COMPOSITE
- 客户只需使用抽象类型,而无需区分“叶”和容器。
- CONTINUOUS INTEGRATION
- 建立一个把所有代码和其他实现工件频繁地合并到一起的过程,并通过自动化测试来快速查明模型的分裂问题。
- SHARED KERNEL
- 从领域模型中选出两个团队都同意共享的一个子集。
- CUSTOMER/SUPPLIER DEVELOPMENT TEAM
- 在两个团队之间建立一种明确的客户/供应商关系。在计划会议中,下游团队相当于上游团队的客户。
- CONFORMIST
- 通过严格遵从上游团队的模型,可以消除在BOUNDED CONTEXT之间进行转换的复杂性。
- ANTICORRUPTION LAYER
- 创建一个隔离层,以便根据客户自己的领域模型来为客户提供相关功能。
- SEPARATE WAY
- 声明一个与其他上下文毫无关联的BOUNDED CONTEXT,使开发人员能够在这个小范围内找到简单、专用的解决方案。
- OPEN HOST SERVICE
- 定义一个协议,把你的子系统作为一组SERVICE供其他系统访问。
- PUBLISHED LANGUAGE
- 把一个良好文档化的、能够表达出所需领域信息的共享语言作为公共的通信媒介,必要时在其他信息与该语言之间进行转换。
- LAYERED ARCHITECTURE(分层架构)
- 一种用于分离软件系统关注点的技术,它把领域层与其他层分开。
- 领域层(domain layer)
- 在分层架构中负责领域逻辑的那部分设计和实现。领域层是在软件中用来表示领域模型的地方。
- 深层模型(deep model)
- 领域专家们最关心的问题以及与这些问题最相关的知识的清晰表示。深层模型不停留在领域的表层和粗浅的理解上。
- 精炼(distillation)
- 精炼是把一堆混杂在一起的组件分开的过程,从中提取出最重要的内容,使得它更有价值,也更有用。在软件设计中,精炼就是对模型中的关键方面进行抽象,或者是对大系统进行划分,从而把核心领域提取出来。
- 大型结构(large-scale structure)
- 一组高层的概念和/或规则,它为整个系统建立了一种设计模式。它使人们能够从大的角度来讨论和理解系统。
- MODEL-DRIVEN DESIGN(模型驱动的设计)
- 软件元素的某个子集严格对应于模型的元素。也代表一种合作开发模型和实现以便互相保持一致的过程。
- 严格按照基础模型来编写代码,能够使代码更好地表达设计含义,并且使模型与实际的系统相契合。
- 领域(domain)
- 知识、影响或活动的范围。
- 领域专家(domain expert)
- 软件项目的成员之一,精通的是软件的应用领域而不是软件开发。并非软件的任何使用者都是领域专家,领域专家需要具备深厚的专业知识。
- 分析模式(analysis pattern)
- 分析模式是用来表示业务建模中的常见构造的概念集合。它可能只与一个领域有关,也可能跨多个领域[Fowler 1997,p.8]。
- 客户(client)
- 一个程序元素,它调用正在设计的元素,使用其功能。
- 内聚(cohesion)
- 逻辑上的协定和依赖。
- 命令,也称为修改器命令(command/modifier)
- 使系统发生改变的操作(例如,设置变量)。它是一种有意产生副作用的操作。
- 上下文(context)
- 一个单词或句子出现的环境,它决定了其含义。参见 BOUNDED CONTEXT。
- 声明式设计(declarative design)
- 一种编程形式,由精确的属性描述对软件进行实际的控制。它是一种可执行的规格。
- 一旦你的设计中有了INTENTION-REVEALING INTERFACE、SIDE-EFFECT-FREE FUNCTION和ASSERTION,那么你就具备了使用声明式设计的条件。
- 当我们有了可以组合在一起来表达意义的元素,并且使其作用具体化或明朗化,甚或是完全没有明显的副作用,我们就可以获得声明式设计的很多益处。
- 设计模式(design pattern)
- 设计模式是对一些互相交互的对象和类的描述,我们通过定制这些对象和类来解决特定上下文中的一般设计问题[Gamma et al.1995,p.3]。
- 函数(function)
- 一种只计算和返回结果而没有副作用的操作。
- 不可变的(immutable)
- 在创建后永远不发生状态改变的一种特性。
- 隐式概念(implicit concept)
- 一种为了理解模型和设计的意义而必不可少的概念,但它从未被提及。
- 固定规则(invariant)
- 一种为某些设计元素做出的断言,除了一些特殊的临时情况(例如,方法执行的中间,或者尚未提交的数据库事务的中间)以外,它必须一直保持为真。
- 迭代(iteration)
- 程序反复进行小幅改进的过程。也表示这个过程中的一个步骤。
- 生命周期(life cycle)
- 一个对象从创建到删除中间所经历的一个状态序列,通常具有一些约束,以确保从一种状态变为另一种状态时的完整性。它可能包括ENTITY在不同的系统和BOUNDED CONTEXT之间的迁移。
- 模型(model)
- 一个抽象的系统,描述了领域的所选方面,可用于解决与该领域有关的问题。
- 建模范式(modeling paradigm)
- 一种从领域中提取概念的特殊方式,与工具结合起来使用,为这些概念创建软件类比。(例如,面向对象编程和逻辑编程。)
- 职责(responsibility)
- 执行任务或掌握信息的责任[Wirfs-Brock et al.2003,p.3]。
- 副作用(side effect)
- 由一个操作产生的任何可观测到的状态改变,不管这个操作是有意的还是无意的(即使是一个有意的更新操作)。
- 无状态(stateless)
- 设计元素的一种属性,客户在使用任何无状态的操作时,都不需要关心它的历史。无状态的元素可以使用甚至修改全局信息(即它可以产生副作用),但它不保存影响其行为的私有状态。
- 战略设计(strategic design)
- 一种针对系统整体的建模和设计决策。这样的决策影响整个项目,而且必须由团队来制定。
- 柔性设计(supple design)
- 柔性设计使客户开发人员能够掌握并运用深层模型所蕴含的潜力来开发出清晰、灵活且健壮的实现,并得到预期结果。同样重要的是,利用这个深层模型,开发人员可以轻松地实现并调整设计,从而很容易地把他们的新知识加入到设计中。
- UBIQUITOUS LANGUAGE(通用语言)
- 围绕领域模型建立的一种语言,团队所有成员都使用这种语言把团队的所有活动与软件联系起来。
- 统一(unification)
- 模型的内部一致性,使得每个术语都没有歧义且没有规则冲突。
- WHOLE VALUE(完整值)
- 对单一、完整的概念进行建模的对象。
模式关系图
运用领域模型
每个软件程序是为了执行用户的某项活动,或是满足用户的某种需求。这些用户应用软件的问题区域就是软件的领域。
领域模型并非某种特殊的图,而是这种图所要传达的思想。它绝不单单是领域专家头脑中的知识,而是对这类知识严格的组织且有选择的抽象。
模型在领域驱动设计中的作用
- 模型和设计的核心互相影响。
- 正是模型与实现之间的紧密联系才使模型变得有用,并确保我们在模型中所进行的分析能够转化为最终产品(即一个可运行的程序)。
- 模型与实现之间的这种紧密结合在维护和后续开发期间也会很有用,因为我们可以基于对模型的理解来解释代码。
- 模型是团队所有成员使用的通用语言的中枢。
- 由于模型与实现之间的关联,开发人员可以使用该语言来讨论程序。他们可以在无需翻译的情况下与领域专家进行沟通。
- 而且,由于该语言是基于模型的,因此我们可借助自然语言对模型本身进行精化。
- 模型是浓缩的知识。
- 模型是团队一致认同的领域知识的组织方式和重要元素的区分方式。透过我们如何选择术语、分解概念以及将概念联系起来,模型记录了我们看待领域的方式。
- 当开发人员和领域专家在将信息组织为模型时,这一共同的语言(模型)能够促使他们高效地协作。
- 模型与实现之间的紧密结合使来自软件早期版本的经验可以作为反馈应用到建模过程中。
软件的核心
软件的核心是其为用户解决领域相关的问题的能力。
消化知识
有效建模的要素
- 模型和实现的绑定。
- 最初的原型虽然简陋,但它在模型与实现之间建立了早期链接,而且在所有后续的迭代中我们一直在维护该链接。
- 建立了一种基于模型的语言。
- 最初,工程师们不得不向我解释基本的PCB问题,而我也必须向他们解释类图的含义。但随着项目的进展,双方都能够直接使用模型中的术语,并将它们组织为符合模型结构的语句,而且无需翻译即可理解互相要表达的意思。
- 开发一个蕴含丰富知识的模型。
- 对象具有行为和强制性规则。模型并不仅仅是一种数据模式,它还是解决复杂问题不可或缺的部分。模型包含各种类型的知识。
- 提炼模型。
- 在模型日趋完整的过程中,重要的概念不断被添加到模型中,但同样重要的是,不再使用的或不重要的概念则从模型中被移除。
- 当一个不需要的概念与一个需要的概念有关联时,则把重要的概念提取到一个新模型中,其他那些不要的概念就可以丢弃了。
- 头脑风暴和实验。
- 语言和草图,再加上头脑风暴活动,将我们的讨论变成“模型实验室”,在这些讨论中可以演示、尝试和判断上百种变化。当团队走查场景时,口头表达本身就可以作为所提议的模型的可行性测试,因为人们听到口头表达后,就能立即分辨出它是表达得清楚、简捷,还是表达得很笨拙。
正是头脑风暴和大量实验的创造力才使我们找到了一个富含知识的模型并对它进行提炼,在这个过程中,基于模型的语言提供了很大帮助,而且贯穿整个实现过程中的反馈闭环也对模型起到了“训练”作用。这种知识消化
将团队的知识转化为有价值的模型。
知识消化
所有这些因素都促使团队成员成为更合格的知识消化者。他们对知识去粗取精。他们将模型重塑为更有用的形式。 由于分析员和程序员将自己的知识输入到了模型中,因此模型的组织更严密,抽象也更为整洁,从而为实现提供了更大支持。 同时,由于领域专家也将他们的知识输入到了模型中,因此模型反映了业务的深层次知识,而且真正的业务原则得以抽象。
模型在不断改进的同时,也成为组织项目信息流的工具。模型聚焦于需求分析。它与编程和设计紧密交互。它通过良性循环加深团队成员对领域的理解,使他们更透彻地理解模型,并对其进一步精化。 模型永远都不会是完美的,因为它是一个不断演化完善的过程。模型对理解领域必须是切实可用的。它们必须非常精确,以便使应用程序易于实现和理解。
持续学习
当开始编写软件时,其实我们所知甚少。
高效率的团队需要有意识地积累知识,并持续学习。对于开发人员来说,这意味着既要完善技术知识,也要培养一般的领域建模技巧。但这也包括认真学习他们正在从事的特定领域的知识。
知识丰富的设计
业务活动和规则如同所涉及的实体一样,都是领域的核心,任何领域都有各种类别的概念。 知识消化所产生的模型能够反映出对知识的深层理解。在模型发生改变的同时,开发人员对实现进行重构,以便反映出模型的变化,这样,新知识就被合并到应用程序中了。
当我们的建模不再局限于寻找实体和值对象时,我们才能充分吸取知识,因为业务规则之间可能会存在不一致。 领域专家在反复研究所有规则、解决规则之间的矛盾以及以常识来弥补规则的不足等一系列工作中,往往不会意识到他们的思考过程有多么复杂。 软件是无法完成这一工作的。正是通过与软件专家紧密协作来消化知识的过程才使得规则得以澄清和充实,并消除规则之间的矛盾以及删除一些无用规则。
public int makeBooking(Cargo cargo, Voyage voyage) {
if ((voyage.bookedCargoSize() + cargo.size()) > maxBooking){
return -1;
}
int confirmation = orderConfirmationSequence.next();
voyage.addCargo(cargo, confirmation);
return confirmation;
}
public int makeBooking(Cargo cargo, Voyage voyage) {
if (!overbookingPolicy.isAllowed(cargo, voyage)) {
return -1;
}
int confirmation = orderConfirmationSequence.next();
voyage.addCargo(cargo, confirmation);
return confirmation;
}
// 现在所有人都清楚超订是一个独特的策略,而且超订规则的实现即明确又独立。
// 新的OverbookingPolicy类包含如下方法:
public boolean isAllowed(Cargo cargo, Voyage voyage) {
return (cargo.size() + voyage.bookedCargoSize()) <= (voyage.capacity() * 1.1);
}
深层模型
有用的模型很少停留在表面。随着对领域和应用程序需求的理解逐步加深,我们往往会丢弃那些最初看起来很重要的表面元素,或者切换它们的角度。 这时,一些开始时不可能发现的巧妙抽象就会渐渐浮出水面,而它们恰恰切中问题的要害。
知识消化是一种探索,它永无止境。
交流与语言的使用
模式:UBIQUITOUS LANGUAGE(通用语言)
为什么
- 如果语言支离破碎,项目必将遭遇严重问题。领域专家使用他们自己的术语,而技术团队所使用的语言则经过调整,以便从设计角度讨论领域。
- 日常讨论所使用的术语与代码(软件项目的最重要产品)中使用的术语不一致。甚至同一个人在讲话和写东西时使用的语言也不一致,这导致的后果是,对领域的深刻表述常常稍纵即逝,根本无法记录到代码或文档中。
- 翻译使得沟通不畅,并削弱了知识消化。
- 然而任何一方的语言都不能成为公共语言,因为它们无法满足所有的需求。
结果
- 将模型作为语言的支柱。确保团队在内部的所有交流中以及代码中坚持使用这种语言。在画图、写东西,特别是讲话时也要使用这种语言。
- 通过尝试不同的表示方法(它们反映了备选模型)来消除难点。然后重构代码,重新命名类、方法和模块,以便与新模型保持一致。解决交谈中的术语混淆问题,就像我们对普通词汇形成一致的理解一样。
- 要认识到,UBIQUITOUS LANGUAGE的更改就是对模型的更改。
- 领域专家应该抵制不合适或无法充分表达领域理解的术语或结构,开发人员应该密切关注那些将会妨碍设计的有歧义和不一致的地方。
“大声地”建模
讨论系统时要结合模型。使用模型元素及其交互来大声描述场景,并且按照模型允许的方式将各种概念结合到一起。找到更简单的表达方式来讲出你要讲的话,然后将这些新的想法应用到图和代码中。
“如果我们向Routing Service提供出发地、目的地和到达时间,就可以查询货物的停靠地点,嗯……将它们存到数据库中。”(含糊且偏重于技术)
“出发地、目的地……把它们都输入到Routing Service中,而后我们得到一个Itinerary,它包含我们所需的全部信息。”(更具体,但过于啰嗦)
“Routing Service查找满足Route Specification的Itinerary。”(简洁)
一个团队,一种语言
有了UBIQUITOUS LANGUAGE之后,开发人员之间的对话、领域专家之间的讨论以及代码本身所表达的内容都基于同一种语言,都来自于一个共享的领域模型。
文档和图
- 设计的重要细节应该在代码中体现出来。
- 良好的实现应该是透明的,清楚地展示其背后的模型。互为补充的图和文档能够引导人们将注意力放在核心要点上。自然语言的讨论可以填补含义上的细微差别。
- 这就是为什么我喜欢把典型的UML使用方法颠倒过来的原因。通常的用法是以图为主,辅以文本注释;而我更愿意以文本为主,用精心挑选的简化图作为说明。
- 务必要记住模型不是图。
- 图的目的是帮助表达和解释模型。
- 经过仔细选择和构造的图可以帮助人们集中注意力,并起到指导作用,当然前提条件是不能强制用图来表示全部模型或设计,因为这样会削弱图的清晰表达的能力。
- 代码可以充当设计细节的存储库。
- 书写良好的Java代码与UML具有同样的表达能力。
- 图的目的是帮助表达和解释模型。
书面设计文档
- 文档应作为代码和口头交流的补充
- 文档不应再重复表示代码已经明确表达出的内容。代码已经含有各个细节,它本身就是一种精确的程序行为说明。
- 其他文档应该着重说明含义,以便使人们能够深入理解大尺度结构,并将注意力集中在核心元素上。当编程语言无法直接明了地实现概念时,文档可以澄清设计意图。
- 应该把书面文档作为代码和口头讨论的补充。
- 文档应当鲜活并保持最新
- 文档必须深入到各种项目活动中去。
- 判断是否做到这一点的最简单方法,是观察文档与UBIQUITOUS LANGUAGE之间的交互。
- 文档是用人们(当前)在项目上讲的语言编写的吗?
- 它是用嵌入到代码中的语言编写的吗?
- 判断是否做到这一点的最简单方法,是观察文档与UBIQUITOUS LANGUAGE之间的交互。
- 通过将文档减至最少,并且主要用它来补充代码和口头交流,就可以避免文档与项目脱节。根据UBIQUITOUS LANGUAGE及其演变来选择那些需要保持更新并与项目活动紧密交互的文档。
- 文档必须深入到各种项目活动中去。
完全依赖可执行代码的情况
尽管代码可能会产生误导,但它仍然比其他文档更基础。要想利用当前的标准技术使代码所传达的消息与它的行为和意图保持一致,需要纪律和思考设计的特定方式。 要有效地交流,代码必须基于在编写需求时所使用的同一种语言,也就是开发人员之间、开发人员与领域专家之间进行讨论时所使用的语言。
解释性模型
模型在讲授有关领域知识的时候也是一种非常有价值的教学手段。 驱动设计的模型是领域的一种视图,它也有助于学习其他视图,这些视图可能仅仅用作教学工具或是交流一般的领域知识。 在这些时候,人们可以使用传达与软件设计无关的其他模型的图片或语言。
绑定模型和实现
模式:MODEL-DRIVEN DESIGN(模型驱动设计)
无论是什么原因,软件的设计如果缺乏概念,那么软件充其量不过是一种机械化的产品——只实现有用的功能却无法解释操作的原因。
为什么
- 如果整个程序设计或者其核心部分没有与领域模型相对应
- 那么这个模型就是没有价值的,软件的正确性也值得怀疑。
- 同时,模型和设计功能之间过于复杂的对应关系也是难于理解的,在实际项目中,当设计改变时也无法维护这种关系。
- 若分析与和设计之间产生严重分歧,那么在分析和设计活动中所获得的知识就无法彼此共享。
结果
- 软件系统各个部分的设计应该忠实地反映领域模型,以便体现出这二者之间的明确对应关系。
- 我们应该反复检查并修改模型,以便软件可以更加自然地实现模型;即使想让模型反映出更深层次的领域概念时也应如此。
- 我们需要的模型不但应该满足这两种需求,还应该能够支持健壮的UBIQUITOUS LANGUAGE(通用语言)。
- 从模型中获取用于程序设计和基本职责分配的术语。让程序代码成为模型的表达,代码的改变可能会是模型的改变。而其影响势必要波及接下来相应的项目活动。
- 完全依赖模型的实现通常需要支持建模范式的软件开发工具和语言,比如面向对象的编程。
软件系统的每一部分只能对应一个模型。单一模型能够减少出错的概率,因为程序设计直接来源于经过仔细考虑而创建的模型。程序设计,甚至是代码本身,都与模型密不可分。
建模范式和工具支持
为了使MODEL-DRIVEN DESIGN发挥作用,一定要在可控范围内严格保证模型与设计之间的一致性。 要实现这种严格的一致性,必须要运用由软件工具支持的建模范式,它可以在程序中直接创建模型中的对应概念。
揭示主旨:为什么模型对用户至关重要
如果程序设计基于一个能够反映出用户和领域专家所关心的基本问题的模型,那么与其他设计方式相比,这种设计可以将其主旨更明确地展示给用户。 让用户了解模型,将使他们有更多机会挖掘软件的潜能,也能使软件的行为合乎情理、前后一致。
模式:HANDS-ON MODELER(亲身实践的建模者)
为什么
- 如果编写代码的人员认为自己没必要对模型负责,或者不知道如何让模型为应用程序服务,那么这个模型就和程序没有任何关联。如果开发人员没有意识到改变代码就意味着改变模型,那么他们对程序的重构不但不会增强模型的作用,反而还会削弱它的效果。
- 同样,如果建模人员不参与到程序实现的过程中,那么对程序实现的约束就没有切身的感受,即使有,也会很快忘记。MODEL-DRIVEN DESIGN的两个基本要素(即模型要支持有效的实现并抽象出关键的领域知识)已经失去了一个,最终模型将变得不再实用。
- 最后一点,如果分工阻断了设计人员与开发人员之间的协作,使他们无法转达实现MODEL-DRIVEN DESIGN的种种细节,那么经验丰富的设计人员则不能将自己的知识和技术传递给开发人员。
结果
- 任何参与建模的技术人员,不管在项目中的主要职责是什么,都必须花时间了解代码。任何负责修改代码的人员则必须学会用代码来表达模型。
- 每一个开发人员都必须不同程度地参与模型讨论并且与领域专家保持联系。
- 参与不同工作的人都必须有意识地通过UBIQUITOUS LANGUAGE与接触代码的人及时交换关于模型的想法。
模型驱动设计的构造块
模型中各个元素的实际设计和实现相对系统化。将领域设计与软件系统中的其他关注点分离会使设计与模型之间的关系非常清晰。 根据不同的特征来定义模型元素则会使元素的意义更加鲜明。对每个元素使用已验证的模式有助于创建出更易于实现的模型。
分离领域
模式:LAYERED ARCHITECTURE(分层架构)
为什么
在面向对象的程序中,常常会在业务对象中直接写入用户界面、数据库访问等支持代码。而一些业务逻辑则会被嵌入到用户界面组件和数据库脚本中。这么做是为了以最简单的方式在短期内完成开发工作。
- 如果与领域有关的代码分散在大量的其他代码之中,那么查看和分析领域代码就会变得异常困难。
- 对用户界面的简单修改实际上很可能会改变业务逻辑,而要想调整业务规则也很可能需要对用户界面代码、数据库操作代码或者其他的程序元素进行仔细的筛查。
- 这样就不太可能实现一致的、模型驱动的对象了,同时也会给自动化测试带来困难。
考虑到程序中各个活动所涉及的大量逻辑和技术,程序本身必须简单明了,否则就会让人无法理解。
结果
- 给复杂的应用程序划分层次。在每一层内分别进行设计,使其具有内聚性并且只依赖于它的下层。采用标准的架构模式,只与上层进行松散的耦合。
- 将所有与领域模型相关的代码放在一个层中,并把它与用户界面层、应用层以及基础设施层的代码分开。
- 领域对象应该将重点放在如何表达领域模型上,而不需要考虑自己的显示和存储问题,也无需管理应用任务等内容。
- 这使得模型的含义足够丰富,结构足够清晰,可以捕捉到基本的业务知识,并有效地使用这些知识。
为网上银行功能分层
如果一个架构能够把那些与领域相关的代码隔离出来,得到一个内聚的领域设计,同时又使领域与系统其他部分保持松散耦合,那么这种架构也许可以支持领域驱动设计。
领域层是模型的精髓
现在,大部分软件系统都采用了LAYERED ARCHITECTURE,只是采用的分层方案存在不同而已。许多类型的开发工作都能从分层中受益。然而,领域驱动设计只需要一个特定的层存在即可。
如果领域逻辑与程序中的其他关注点混在一起,就不可能实现这种一致性。将领域实现独立出来是领域驱动设计的前提。
模式:THE SMART UI“反模式”
为什么
- 如果一个经验并不丰富的项目团队要完成一个简单的项目,却决定使用MODEL-DRIVEN DESIGN以及LAYERED ARCHITECTURE,那么这个项目组将会经历一个艰难的学习过程。
- 团队成员不得不去掌握复杂的新技术,艰难地学习对象建模。(即使有这本书的帮助,这也依然是一个具有挑战性的任务!)
- 对基础设施和各层的管理工作使得原本简单的任务却要花费很长的时间来完成。简单项目的开发周期较短,期望值也不是很高。
- 所以,早在项目团队完成任务之前,该项目就会被取消,更谈不上去论证有关这种方法的许多种令人激动的可行性了。
- 即使项目有更充裕的时间,如果没有专家的帮助,团队成员也不太可能掌握这些技术。最后,假如他们确实能够克服这些困难,恐怕也只会开发出一套简单的系统。因为这个项目本来就不需要丰富的功能。
结果
- 在用户界面中实现所有的业务逻辑。将应用程序分成小的功能模块,分别将它们实现成用户界面,并在其中嵌入业务规则。
- 用关系数据库作为共享的数据存储库。使用自动化程度最高的用户界面创建工具和可用的可视化编程工具。
不将领域和用户界面分离,则很难运用本书后面所要讨论的方法,因此在领域驱动设计中,可以将SMART UI看作是“反模式”。
优点
- 效率高,能在短时间内实现简单的应用程序。
- 能力较差的开发人员可以几乎不经过培训就采用它。
- 甚至可以克服需求分析上的不足,只要把原型发布给用户,然后根据用户反馈快速修改软件产品即可。
- 程序之间彼此独立,这样,可以相对准确地安排小模块交付的日期。额外扩展简单的功能也很容易。
- 可以很顺利地使用关系数据库,能够提供数据级的整合。
- 可以使用第四代语言工具。
- 移交应用程序后,维护程序员可以迅速重写他们不明白的代码段,因为修改代码只会影响到代码所在的用户界面。
缺点
- 不通过数据库很难集成应用模块。
- 没有对行为的重用,也没有对业务问题的抽象。每当操作用到业务规则时,都必须重复这些规则。
- 快速的原型建立和迭代很快会达到其极限,因为抽象的缺乏限制了重构的选择。
- 复杂的功能很快会让你无所适从,所以程序的扩展只能是增加简单的应用模块,没有很好的办法来实现更丰富的功能。
其他的开发风格也有各自的用武之地,但是必须要考虑到各种对于复杂度和灵活性的限制。 在某些条件下,将领域设计与其他部分混在一起会产生灾难性的后果。 如果你要开发复杂应用软件并且决定使用MODEL-DRIVEN DESIGN,那么做好准备,咬紧牙关,从项目初始就应该采用模型驱动的设计。不要使用SMART UI。
其他分离方式
除了基础设施和用户界面之外,还有一些其他的因素也会破坏你精心设计的领域模型。
- 你必须要考虑那些没有完全集成到模型中的领域元素。
- 你不得不与同一领域中使用不同模型的其他开发团队合作。
- 还有其他的因素会让你的模型结构不再清晰,并且影响模型的使用效率。
这方面的问题,会介绍其他模式,如BOUNDED CONTEXT和ANTICORRUPTION LAYER。 非常复杂的领域模型本身是难以使用的,所以,后面会说明如何在领域层内进行进一步区分,以便从次要细节中突显出领域的核心概念。
软件中所表示的模型
关联
模型中每个可遍历的关联,软件中都要有同样属性的机制。
一个显示了顾客与销售代表之间关联的模型有两个含义。
一方面,它把开发人员所认为的两个真实的人之间的关系抽象出来。
另一方面,它相当于两个Java对象之间的对象指针,或者相当于数据库查询(或类似实现)的一种封装。
至少有3种方法可以使得关联更易于控制。
- 规定一个遍历方向。
- 尽可能地对关系进行约束是非常重要的。双向关联意味着只有将这两个对象放在一起考虑才能理解它们。
- 当应用程序不要求双向遍历时,可以指定一个遍历方向,以便减少相互依赖,并简化设计。理解了领域之后就可以自然地确定一个方向。
- 很少会问‚他是哪个国家的总统,简化为从国家到总统的单向关联。
- 添加一个限定符,以便有效地减少多重关联。
- 限定条件把多重关系简化为一对一关系,并且在模型中植入了一条明确的规则。
- 一个国家在一段时期内只能有一位总统。
- 消除不必要的关联。
- 限定多对多关联的遍历方向可以有效地将其实现简化为一对多关联,从而得到一个简单得多的设计。
- 坚持将关联限定为领域所倾向的方向,不仅可以提高这些关联的表达力并简化其实现,而且还可以突出剩下的双向关联的重要性。
- 当双向关联是领域的一个语义特征时,或者当应用程序的功能要求双向关联时,就需要保留它,以便表达出这些需求。
- 当然,最终的简化是清除那些对当前工作或模型对象的基本含义来说不重要的关联。
模式:ENTITY(又称为REFERENCE OBJECT)
很多对象不是通过它们的属性定义的,而是通过连续性和标识定义的。
- 一些对象主要不是由它们的属性定义的。它们实际上表示了一条“标识线”(A Thread of Identity),这条线跨越时间,而且常常经历多种不同的表示。
- 有时,这样的对象必须与另一个具有不同属性的对象相匹配。
- 而有时一个对象必须与具有相同属性的另一个对象区分开。错误的标识可能会破坏数据。
主要由标识定义的对象被称作ENTITY。 ENTITY(实体)有特殊的建模和设计思路。它们具有生命周期,这期间它们的形式和内容可能发生根本改变,但必须保持一种内在的连续性。 为了有效地跟踪这些对象,必须定义它们的标识。它们的类定义、职责、属性和关联必须由其标识来决定,而不依赖于其所具有的属性。 即使对于那些不发生根本变化或者生命周期不太复杂的ENTITY,也应该在语义上把它们作为ENTITY来对待,这样可以得到更清晰的模型和更健壮的实现。
- 当一个对象由其标识(而不是属性)区分时,那么在模型中应该主要通过标识来确定该对象的定义。
- 使类定义变得简单,并集中关注生命周期的连续性和标识。
- 定义一种区分每个对象的方式,这种方式应该与其形式和历史无关。
- 要格外注意那些需要通过属性来匹配对象的需求。
- 在定义标识操作时,要确保这种操作为每个对象生成唯一的结果,这可以通过附加一个保证唯一性的符号来实现。
- 这种定义标识的方法可能来自外部,也可能是由系统创建的任意标识符,但它在模型中必须是唯一的标识。
- 模型必须定义出“符合什么条件才算是相同的事物”。
体育场座位预订程序可能会将座位和观众当作ENTITY来处理。在分配座位时,每张票都有一个座位号,座位是ENTITY。
如果活动采用入场卷的方式,那么观众可以寻找任意的空座位来坐,这样就不需要对座位加以区分。在这种情况下,座位不是ENTITY,因此不需要标识符。
ENTITY建模
ENTITY最基本的职责是确保连续性,以便使其行为更清楚且可预测。保持实体的简练是实现这一责任的关键。
不要将注意力集中在属性或行为上,应该摆脱这些细枝末节,抓住ENTITY对象定义的最基本特征,尤其是那些用于识别、查找或匹配对象的特征。 只添加那些对概念至关重要的行为和这些行为所必需的属性。 此外,应该将行为和属性转移到与核心实体关联的其他对象中。这些对象中,有些可能是ENTITY,有些可能是VALUE OBJECT。 除了标识问题之外,实体往往通过协调其关联对象的操作来完成自己的职责。
customerID是Customer ENTITY的一个(也是唯一的)标识符,name(姓名)没有定义一个人的标识,但它通常是确定人的方式之一。
phone number (电话号码)和address(地址)都经常用来查找或匹配一个Customer(客户),phone和address属性被移到Customer中。
但在实际的项目上,这种选择取决于领域中的Customer一般是如何匹配或区分的。
如果一个Customer有很多用于不同目的的phone number,那么phone number就与标识无关,因此应该放在Sales Contact(销售联系人)中。
设计标识操作
每个ENTITY都必须有一种建立标识的操作方式,以便与其他对象区分开,即使这些对象与它具有相同的描述属性。 不管系统是如何定义的,都必须确保标识属性在系统中是唯一的,即使是在分布式系统中,或者对象已被归档,也必须确保标识的唯一性。
- 有时,某些数据属性或属性组合可以确保它们在系统中具有唯一性,或者在这些属性上加一些简单约束可以使其具有唯一性。这种方法为ENTITY提供了唯一键。
- 当对象属性没办法形成真正唯一键时,另一种经常用到的解决方案是为每个实例附加一个在类中唯一的符号(如一个数字或字符串)。ID通常是由系统自动生成的。
模式:VALUE OBJECT
很多对象没有概念上的标识,它们描述了一个事务的某种特征。
- 跟踪ENTITY的标识是非常重要的,但为其他对象也加上标识会影响系统性能并增加分析工作,而且会使模型变得混乱,因为所有对象看起来都是相同的。
- 软件设计要时刻与复杂性做斗争。我们必须区别对待问题,仅在真正需要的地方进行特殊处理。
- 然而,如果仅仅把这类对象当作没有标识的对象,那么就忽略了它们的工具价值或术语价值。事实上,这些对象有其自己的特征,对模型也有着自己的重要意义。这些是用来描述事物的对象。
用于描述领域的某个方面而本身没有概念标识的对象称为VALUE OBJECT(值对象)。VALUE OBJECT被实例化之后用来表示一些设计元素,对于这些设计元素,我们只关心它们是什么,而不关心它们是谁。
VALUE OBJECT所包含的属性应该形成一个概念整体。
street(街道)、city(城市)和postal code(邮政编码)不应是Person(人)对象的单独的属性。 它们是整个地址的一部分,这样可以使得Person对象更简单,并使地址成为一个更一致的VALUE OBJECT。
设计VALUE OBJECT
在设计VALUE OBJECT时有多种选择,包括复制、共享或保持VALUE OBJECT不变。
以便安全地共享一个对象,必须确保 VALUE OBJECT 对象是不变的——它不能改变,除非将其整个替换掉。
复制和共享哪个更划算取决于实现环境。虽然复制有可能导致系统被大量的对象阻塞,但共享可能会减慢分布式系统的速度。 当在两个机器之间传递一个副本时,只需发送一条消息,而且副本到达接收端后是独立存在的。 但如果共享一个实例,那么只会传递一个引用,这要求每次交互都要向发送方返回一条消息。
以下几种情况最好使用共享,这样可以发挥共享的最大价值并最大限度地减少麻烦:
- 节省数据库空间或减少对象数量是一个关键要求时;
- 通信开销很低时(如在中央服务器中);
- 共享的对象被严格限定为不可变时。
特殊情况:何时允许可变性
- 如果VALUE频繁改变;
- 如果创建或删除对象的开销很大;
- 如果替换(而不是修改)将打乱集群(像前面示例中讨论的那样);
- 如果VALUE的共享不多,或者共享不会提高集群性能,或其他某种技术原因。
定义VALUE OBJECT并将其指定为不可变的是一条一般规则,这样做是为了避免在模型中产生不必要的约束,从而让开发人员可以单纯地从技术上优化性能。 如果开发人员能够显式地定义重要约束,那么他们就可以在对设计做出必要调整时,确保不会无意更改重要的行为。这样的设计调整往往特定于具体项目所使用的技术。
设计包含VALUE OBJECT的关联
模型中的关联越少越好,越简单越好。
如果说ENTITY之间的双向关联很难维护,那么两个VALUE OBJECT之间的双向关联则完全没有意义。
我们应尽量完全清除VALUE OBJECT之间的双向关联。如果在你的模型中看起来确实需要这种关联,那么首先应重新考虑一下将对象声明为VALUE OBJECT这个决定是否正确。 或许它拥有一个标识,而你还没有注意到它。
模式:SERVICE
一些领域概念不适合被建模为对象。如果勉强把这些重要的领域功能归为ENTITY或VALUE OBJECT的职责,那么不是歪曲了基于模型的对象的定义,就是人为地增加了一些无意义的对象。
SERVICE是作为接口提供的一种操作,它在模型中是独立的,它不像ENTITY和VALUE OBJECT那样具有封装的状态。 SERVICE是技术框架中的一种常见模式,但它们也可以在领域层中使用。
所谓SERVICE,它强调的是与其他对象的关系。与ENTITY和VALUE OBJECT不同,它只是定义了能够为客户做什么。 SERVICE往往是以一个活动来命名,而不是以一个ENTITY来命名,也就是说,它是动词而不是名词。 SERVICE也可以有抽象而有意义的定义,只是它使用了一种与对象不同的定义风格。 SERVICE也应该有定义的职责,而且这种职责以及履行它的接口也应该作为领域模型的一部分来加以定义。 操作名称应来自于UBIQUITOUS LANGUAGE,如果UBIQUITOUS LANGUAGE中没有这个名称,则应该将其引入到UBIQUITOUS LANGUAGE中。 参数和结果应该是领域对象。
好的SERVICE有以下3个特征。
- 与领域概念相关的操作不是ENTITY或VALUE OBJECT的一个自然组成部分。
- 接口是
- 操作是无状态的。
当领域中的某个重要的过程或转换操作不是ENTITY或VALUE OBJECT的自然职责时,应该在模型中添加一个作为独立接口的操作,并将其声明为SERVICE。 定义接口时要使用模型语言,并确保操作名称是UBIQUITOUS LANGUAGE中的术语。 此外,应该使SERVICE成为无状态的。
SERVICE与孤立的领域层
这种模式只重视那些在领域中具有重要意义的SERVICE,但SERVICE并不只是在领域层中使用。 我们需要注意区分属于领域层的SERVICE和那些属于其他层的SERVICE,并划分责任,以便将它们明确地区分开。
文献中所讨论的大多数SERVICE是纯技术的SERVICE,它们都属于基础设施层。领域层和应用层的SERVICE与这些基础设施层SERVICE进行协作。
很多领域或应用层SERVICE是在ENTITY和VALUE OBJECT的基础上建立起来的,它们的行为类似于将领域的一些潜在功能组织起来以执行某种任务的脚本。 ENTITY和VALUE OBJECT往往由于粒度过细而无法提供对领域层功能的便捷访问。我们在这里会遇到领域层与应用层之间很微妙的分界线。
例如,如果银行应用程序可以把我们的交易进行转换并导出到一个电子表格文件中,以便进行分析,那么这个导出操作就是应用层SERVICE。‚文件格式‛在银行领域中是没有意义的,它也不涉及业务规则。
粒度
SERVICE是将一个概念建模为SERVICE的表现力,但SERVICE还有其他有用的功能,它可以控制领域层中的接口的粒度,并且避免客户端与ENTITY和VALUE OBJECT耦合。
在大型系统中,中等粒度的、无状态的SERVICE更容易被复用,因为它们在简单的接口背后封装了重要的功能。此外,细粒度的对象可能导致分布式系统的消息传递的效率低下。
由于应用层负责对领域对象的行为进行协调,因此细粒度的领域对象可能会把领域层的知识泄漏到应用层中。 这产生的结果是应用层不得不处理复杂的、细致的交互,从而使得领域知识蔓延到应用层或用户界面代码当中,而领域层会丢失这些知识。 明智地引入领域层服务有助于在应用层和领域层之间保持一条明确的界限。
这种模式有利于保持接口的简单性,便于客户端控制并提供了多样化的功能。它提供了一种在大型或分布式系统中便于对组件进行打包的中等粒度的功能。而且,有时SERVICE是表示领域概念的最自然的方式。
对SERVICE的访问
与分离特定职责的设计决策相比,提供对SERVICE的访问机制的意义并不是十分重大。一个“操作”对象可能足以作为SERVICE接口的实现。 我们很容易编写一个简单的SINGLETON对象[Gamma et al.1995]来实现对SERVICE的访问。从编码惯例可以明显看出,这些对象只是SERVICE接口的提供机制,而不是有意义的领域对象。
只有当真正需要实现分布式系统或充分利用框架功能的情况下才应该使用复杂的架构。
模式:MODULE(也称为PACKAGE)
MODULE为人们提供了两种观察模型的方式
- 一是可以在MODULE中查看细节,而不会被整个模型淹没
- 二是观察MODULE之间的关系,而不考虑其内部细节。
领域层中的MODULE应该成为模型中有意义的部分,MODULE从更大的角度描述了领域。
每个人都会使用MODULE,但却很少有人把它们当做模型中的一个成熟的组成部分。 代码按照各种各样的类别进行分解,有时是按照技术架构来分割的,有时是按照开发人员的任务分工来分割的。 甚至那些从事大量重构工作的开发人员也倾向于使用项目早期形成的一些MODULE。
众所周知,MODULE之间应该是低耦合的,而在MODULE的内部则是高内聚的。 耦合和内聚的解释使得MODULE听上去像是一种技术指标,仿佛是根据关联和交互的分布情况来机械地判断它们。然而,MODULE并不仅仅是代码的划分,而且也是概念的划分。 一个人一次考虑的事情是有限的(因此才要低耦合)。不连贯的思想和“一锅粥”似的思想同样难于理解(因此才要高内聚)。
像领域驱动设计中的其他元素一样,MODULE是一种表达机制。MODULE的选择应该取决于被划分到模块中的对象的意义。
选择能够描述系统的MODULE,并使之包含一个内聚的概念集合。 这通常会实现MODULE之间的低耦合,但如果效果不理想,则应寻找一种更改模型的方式来消除概念之间的耦合,或者找到一个可作为MODULE基础的概念(这个概念先前可能被忽视了),基于这个概念组织的MODULE可以以一种有意义的方式将元素集中到一起。 找到一种低耦合的概念组织方式,从而可以相互独立地理解和分析这些概念。对模型进行精化,直到可以根据高层领域概念对模型进行划分,同时相应的代码也不会产生耦合。
MODULE的名称应该是UBIQUITOUS LANGUAGE中的术语。MODULE及其名称应反映出领域的深层知识。
敏捷的MODULE
MODULE需要与模型的其他部分一同演变。这意味着MODULE的重构必须与模型和代码一起进行。但这种重构通常不会发生。 更改MODULE可能需要大范围地更新代码。这些更改可能会对团队沟通起到破坏作用,甚至会妨碍开发工具(如源代码控制系统)的使用。 因此,MODULE结构和名称往往反映了模型的较早形式,而类则不是这样。
在MODULE选择的早期,有些错误是不可避免的,这些错误导致了高耦合,从而使MODULE很难进行重构。 而缺乏重构又会导致问题变得更加严重。克服这一问题的唯一方法是接受挑战,仔细地分析问题的要害所在,并据此重新组织MODULE。
一些开发工具和编程系统会使问题变得更加严重。无论在实现中采用哪种开发技术,我们要想尽一切办法来减少重构MODULE的工作量,并最大限度地减少与其他开发人员沟通时出现的混乱情况。
import 技术意味着把类和包混在一起(类依赖于包),但它除了表达前面一长串类的列表之外,还表达了在具体MODULE上建立一种依赖性的意图。 如果一个类确实依赖于另一个包中的某个类,而且本地MODULE对该MODULE并没有概念上的依赖关系,那么或许应该移动一个类,或者考虑重新组织MODULE。
通过基础设施打包时存在的隐患
技术框架对打包决策有着极大的影响,有些技术框架是有帮助的,有些则要坚决抵制。
这种框架设计是在尝试解决两个合理的问题。一个问题是关注点的逻辑划分:
- 一个对象负责数据库访问,
- 另外一个对象负责处理业务逻辑,等等。
这种划分方法使人们更容易(在技术层面上)理解每个层的功能,而且更容易切换各个层。这种设计的问题在于没有顾及应用程序的开发成本。 可以采用一些框架设计解决,即使别无选择,也值得牺牲一些分层的好处来换取更内聚的领域层。
精巧的技术打包方案会产生如下两个代价:
- 如果框架的分层惯例把实现概念对象的元素分得很零散,那么代码将无法再清楚地表示模型。
- 人的大脑把划分后的东西还原成原样的能力是有限的,如果框架把人的这种能力都耗尽了,那么领域开发人员就无法再把模型还原成有意义的部分了。
最好把事情变简单。要极度简化技术分层规则,要么这些规则对技术环境特别重要,要么这些规则真正有助于开发。例如,将复杂的数据持久化代码从对象的行为方面提取出来可以使重构变得更简单。
除非真正有必要将代码分布到不同的服务器上,否则就把实现单一概念对象的所有代码放在同一个模块中(如果不能放在同一个对象中的话)。
利用打包把领域层从其他代码中分离出来。否则,就尽可能让领域开发人员自由地决定领域对象的打包方式,以便支持他们的模型和设计选择。
领域模型中的每个概念都应该在实现元素中反映出来。ENTITY、VALUE OBJECT、它们之间的关联、领域SERVICE以及用于组织元素的MODULE都是实现与模型直接对应的地方。 实现中的对象、指针和检索机制必须直接、清楚地映射到模型元素。如果没有做到这一点,就要重写代码,或者回头修改模型,或者同时修改代码和模型。
建模范式
对象范式流行的原因
大部分人都比较容易理解面向对象设计的基本知识。尽管一些开发人员还没有完全领悟建模的奥妙,但即使是非专业人员也可以理解对象模型图。
然而,虽然对象建模的概念很简单,但它的丰富功能足以捕获重要的领域知识。而且它从一开始就获得了开发工具的支持,使得模型可以在软件中表达出来。
现在,对象范式已经发展很成熟并得到了广泛采用,这使得它具有明显的优势。
开发者社区和设计文化的成熟也同样重要。采用新范式的项目可能很难找到精通它的开发人员,也很难找到能够使用新范式创建有效模型的人员。
而对象范式则不同,大多数开发人员、项目经理和从事项目工作的其他专家都已经很了解它。
对象世界中的非对象
不管在项目中使用哪种主要的模型范式,领域中都会有一些部分更容易用某种其他范式来表达。
当领域中只有个别元素适合用其他范式时,开发人员可以接受一些蹩脚的对象,以使整个模型保持一致(或者,在另一种极端的情况下,如果大部分问题领域都更适合用其他范式来表达,那么可以整个改为使用那种范式,并选择一个不同的实现平台)。
但是,当领域的主要部分明显属于不同的范式时,明智的做法是用适合各个部分的范式对其建模,并使用混合工具集来进行实现。当领域的各个部分之间的互相依赖性较小时,可以把用另一种范式建立的子系统封装起来。
这就是将业务规则引擎或工作流引擎这样的非对象组件集成到对象系统中的动机。混合使用不同的范式使得开发人员能够用最适当的风格对特殊概念进行建模。
在混合范式中坚持使用MODEL-DRIVEN DESIGN
在面向对象的应用程序开发项目中,有时会混合使用一些其他的技术,规则引擎就是一个常见的例子。
逻辑范式已经得到了很好的发展并且功能强大,它是对象范式的很好补充,使其可以扬长避短。
重要的是在使用规则的同时要继续考虑模型。团队必须找到能够同时适用于两种实现范式的单一模型。 虽然这并非易事,但还是可以办到的,条件是规则引擎支持富有表达力的实现方式。如果不这样,数据和规则就会失去联系。 与领域模型中的概念规则相比,引擎中的规则更像是一些较小的程序。只有保持规则与对象之间紧密、清晰的关系,才能确保显示出这二者所表达的含义。
如果没有无缝的环境,就要完全靠开发人员提炼出一个由清晰的基本概念组成的模型,以便完全支撑整个设计。
将各个部分紧密结合在一起的最有效工具就是健壮的UBIQUITOUS LANGUAGE,它是构成整个异构模型的基础。 坚持在两种环境中使用一致的名称,坚持用UBIQUITOUS LANGUAGE讨论这些名称,将有助于消除两种环境之间的鸿沟。
虽然MODEL-DRIVEN DESIGN不一定是面向对象的,但它确实需要一种富有表达力的模型结构实现,无论是对象、规则还是工作流,都是如此。 如果可用工具无法提高表达力,就要重新考虑选择工具。缺乏表达力的实现将削弱各种范式的优势。
当将非对象元素混合到以面向对象为主的系统中时,需要遵循以下4条经验规则:
- 不要和实现范式对抗。
- 我们总是可以用别的方式来考虑领域。找到适合于范式的模型概念。
- 把通用语言作为依靠的基础。
- 即使工具之间没有严格联系时,语言使用上的高度一致性也能防止各个设计部分分裂。
- 不要一味依赖UML。
- 有时固定使用某种工具(如UML绘图工具)将导致人们通过歪曲模型来使它更容易画出来。
- 例如,UML确实有一些特性很适合表达约束,但它并不是在所有情况下都适用。有时使用其他风格的图形(可能适用于其他范式)或者简单的语言描述比牵强附会地适应某种对象视图更好。
- 保持怀疑态度。
- 工具是否真正有用武之地?不能因为存在一些规则,就必须使用规则引擎。规则也可以表示为对象,虽然可能不是特别优雅。多个范式会使问题变得非常复杂。
在决定使用混合范式之前,一定要确信主要范式中的各种可能性都已经尝试过了。尽管有些领域概念不是以明显的对象形式表现出来的,但它们通常可以用对象范式来建模。
领域对象的生命周期
主要的挑战有以下两类。
- 在整个生命周期中维护完整性。
- 防止模型陷入管理生命周期复杂性造成的困境当中。
将通过3种模式解决这些问题。
- 首先是AGGREGATE(聚合),它通过定义清晰的所属关系和边界,并避免混乱、错综复杂的对象关系网来实现模型的内聚。聚合模式对于维护生命周期各个阶段的完整性具有至关重要的作用。
- 接下来,我们将注意力转移到生命周期的开始阶段,使用FACTORY(工厂)来创建和重建复杂对象和AGGREGATE(聚合),从而封装它们的内部结构。
- 最后,在生命周期的中间和末尾使用REPOSITORY(存储库)来提供查找和检索持久化对象并封装庞大基础设施的手段。
使用AGGREGATE进行建模,并且在设计中结合使用FACTORY和REPOSITORY,这样我们就能够在模型对象的整个生命周期中,以有意义的单元、系统地操纵它们。 AGGREGATE可以划分出一个范围,这个范围内的模型元素在生命周期各个阶段都应该维护其固定规则。 FACTORY和REPOSITORY在AGGREGATE基础上进行操作,将特定生命周期转换的复杂性封装起来。
模式:AGGREGATE
在具有复杂关联的模型中,要想保证对象更改的一致性是很困难的。 不仅互不关联的对象需要遵守一些固定规则,而且紧密关联的各组对象也要遵守一些固定规则。 然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义地互相干扰,从而使系统不可用。
人们已经开发出很多模式(scheme)来定义模型中的所属关系。
- 首先,我们需要用一个抽象来封装模型中的引用。AGGREGATE就是一组相关对象的集合,我们把它作为数据修改的单元。
- 每个AGGREGATE都有一个根(root)和一个边界(boundary)。
- 边界定义了AGGREGATE的内部都有什么。
- 根则是AGGREGATE所包含的一个特定ENTITY。
- 对AGGREGATE而言,外部对象只可以引用根,而边界内部的对象之间则可以互相引用。
- 除根以外的其他ENTITY都有本地标识,但这些标识只在AGGREGATE内部才需要加以区别,因为外部对象除了根ENTITY之外看不到其他对象。
- 每个AGGREGATE都有一个根(root)和一个边界(boundary)。
- 固定规则(invariant)是指在数据变化时必须保持的一致性规则,其涉及AGGREGATE成员之间的内部关系。
- 而任何跨越AGGREGATE的规则将不要求每时每刻都保持最新状态。通过事件处理、批处理或其他更新机制,这些依赖会在一定的时间内得以解决。
- 但在每个事务完成时,AGGREGATE内部所应用的固定规则必须得到满足。
- 为了实现这个概念上的AGGREGATE,需要对所有事务应用一组规则。
- 只有AGGREGATE的根才能直接通过数据库查询获取。所有其他对象必须通过遍历关联来发现。
- AGGREGATE内部的对象可以保持对其他AGGREGATE根的引用。
- 删除操作必须一次删除AGGREGATE边界之内的所有对象。(利用垃圾收集机制,这很容易做到。由于除根以外的其他对象都没有外部引用,因此删除了根以后,其他对象均会被回收。)
- 当提交对AGGREGATE边界内部的任何对象的修改时,整个AGGREGATE的所有固定规则都必须被满足。
我们应该将ENTITY和VALUE OBJECT分门别类地聚集到AGGREGATE中,并定义每个AGGREGATE的边界。 在每个AGGREGATE中,选择一个ENTITY作为根,并通过根来控制对边界内其他对象的所有访问。 只允许外部对象保持对根的引用。对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。 由于根控制访问,因此不能绕过它来修改内部对象。 这种设计有利于确保AGGREGATE中的对象满足所有固定规则,也可以确保在任何状态变化时AGGREGATE作为一个整体满足固定规则。
AGGREGATE划分出一个范围,在这个范围内,生命周期的每个阶段都必须满足一些固定规则。
模式:FACTORY
当创建一个对象或创建整个AGGREGATE时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用FACTORY进行封装。
对象的功能主要体现在其复杂的内部配置以及关联方面。我们应该一直对对象进行提炼,直到所有与其意义或在交互中的角色无关的内容被完全剔除为止。 一个对象在它的生命周期中要承担大量职责。如果再让复杂对象负责自身的创建,那么职责过载将会导致问题。
对象的创建本身可以是一个主要操作,但被创建的对象并不适合承担复杂的装配操作。将这些职责混在一起可能产生难以理解的拙劣设计。 让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏被装配对象或AGGREGATE的封装,而且导致客户与被创建对象的实现之间产生过于紧密的耦合。
复杂的对象创建是领域层的职责,然而这项任务并不属于那些用于表示模型的对象。
FACTORY,它是一种负责创建其他对象的程序元素。
正如对象的接口应该封装对象的实现一样(从而使客户无需知道对象的工作机理就可以使用对象的功能),FACTORY封装了创建复杂对象或AGGREGATE所需的知识。 它提供了反映客户目标的接口,以及被创建对象的抽象视图。
因此:
应该将创建复杂对象的实例和AGGREGATE的职责转移给单独的对象,这个对象本身可能没有承担领域模型中的职责,但它仍是领域设计的一部分。 提供一个封装所有复杂装配操作的接口,而且这个接口不需要客户引用要被实例化的对象的具体类。在创建AGGREGATE时要把它作为一个整体,并确保它满足固定规则。
任何好的工厂都需满足以下两个基本需求:
- 每个创建方法都是原子的,而且要保证被创建对象或AGGREGATE的所有固定规则。
- FACTORY生成的对象要处于一致的状态。
- 在生成ENTITY时,这意味着创建满足所有固定规则的整个AGGREGATE,但在创建完成后可以向聚合添加可选元素。
- 在创建不变的VALUE OBJECT时,这意味着所有属性必须被初始化为正确的最终状态。
- 如果FACTORY通过其接口收到了一个创建对象的请求,而它又无法正确地创建出这个对象,那么它应该抛出一个异常,或者采用其他机制,以确保不会返回错误的值。
- FACTORY生成的对象要处于一致的状态。
- FACTORY应该被抽象为所需的类型,而不是所要创建的具体类。[Gamma et al.1995]中的高级FACTORY模式介绍了这一话题。
选择FACTORY及其应用位置
一般来说,FACTORY的作用是隐藏创建对象的细节,而且我们把FACTORY用在那些需要隐藏细节的地方。这些决定通常与AGGREGATE有关。
例如,如果需要向一个已存在的AGGREGATE添加元素,可以在AGGREGATE的根上创建一个FACTORY METHOD。 这样就可以把AGGREGATE的内部实现细节隐藏起来,使任何外部客户看不到这些细节,同时使根负责确保AGGREGATE在添加元素时的完整性。
一个FACTORY METHOD封装了AGGREGATE的扩展:
另一个示例是在一个对象上使用FACTORY METHOD,这个对象与生成另一个对象密切相关,但它并不拥有所生成的对象。 当一个对象的创建主要使用另一个对象的数据(或许还有规则)时,则可以在后者的对象上创建一个FACTORY METHOD,这样就不必将后者的信息提取到其他地方来创建前者。 这样做还有利于表达前者与后者之间的关系。
FACTORY METHOD生成一个ENTITY,但这个ENTITY并不属于FACTORY所在的AGGREGATE:
FACTORY与被构建对象之间是紧密耦合的,因此FACTORY应该只被关联到与被构建对象有着密切联系的对象上。 当有些细节需要隐藏(无论要隐藏的是具体实现还是构造的复杂性)而又找不到合适的地方来隐藏它们时,必须创建一个专用的FACTORY对象或SERVICE。 整个AGGREGATE通常由一个独立的FACTORY来创建,FACTORY负责把对根的引用传递出去,并确保创建出的AGGREGATE满足固定规则。 如果AGGREGATE内部的某个对象需要一个FACTORY,而这个FACTORY又不适合在AGGREGATE根上创建,那么应该构建一个独立的FACTORY。 但仍应遵守规则——把访问限制在AGGREGATE内部,并确保从AGGREGATE外部只能对被构建对象进行临时引用。
由一个独立的FACTORY来构建AGGREGATE:
有些情况下只需使用构造函数
在有些情况下直接使用构造函数确实是最佳选择。FACTORY实际上会使那些不具有多态性的简单对象复杂化。
在以下情况下最好使用简单的、公共的构造函数:
- 类(class)是一种类型(type)。它不是任何相关层次结构的一部分,而且也没有通过接口实现多态性。
- 客户关心的是实现,可能是将其作为选择STRATEGY的一种方式。
- 客户可以访问对象的所有属性,因此向客户公开的构造函数中没有嵌套的对象创建。
- 构造并不复杂。
- 公共构造函数必须遵守与FACTORY相同的规则:它必须是原子操作,而且要满足被创建对象的所有固定规则。
- 不要在构造函数中调用其他类的构造函数。构造函数应该保持绝对简单。复杂的装配,特别是AGGREGATE,需要使用FACTORY。使用FACTORY METHOD的门槛并不高。
接口的设计
当设计FACTORY的方法签名时,无论是独立的FACTORY还是FACTORY METHOD,都要记住以下两点:
- 每个操作都必须是原子的。
- 我们必须在与FACTORY的一次交互中把创建对象所需的所有信息传递给FACTORY。
- 同时必须确定当创建失败时将执行什么操作,比如某些固定规则没有被满足。可以抛出一个异常或仅仅返回null。为了保持一致,可以考虑采用编码标准来处理所有FACTORY的失败。
- Factory将与其参数发生耦合。
- 如果在选择输入参数时不小心,可能会产生错综复杂的依赖关系。耦合程度取决于对参数(argument)的处理。
- 如果只是简单地将参数插入到要构建的对象中,则依赖度是适中的。如果从参数中选出一部分在构造对象时使用,耦合将更紧密。
几个技巧:
- 最安全的参数是那些来自较低设计层的参数。
- 另一个好的参数选择是模型中与被构建对象密切相关的对象,这样不会增加新的依赖。
- 使用抽象类型的参数,而不是它们的具体类。FACTORY与被构建对象的具体类发生耦合,而无需与具体的参数发生耦合。
固定规则的相关逻辑应放置在哪里
FACTORY负责确保它所创建的对象或AGGREGATE满足所有固定规则,然而在把应用于一个对象的规则移到该对象外部之前应三思。
- FACTORY可以将固定规则的检查工作委派给被创建对象,而且这通常是最佳选择。
- 但FACTORY与被创建对象之间存在一种特殊关系。
- FACTORY已经知道被创建对象的内部结构,而且创建FACTORY的目的与被创建对象的实现有着密切的联系。
- 在某些情况下,把固定规则的相关逻辑放到FACTORY中是有好处的,这样可以让被创建对象的职责更明晰。对于AGGREGATE规则来说尤其如此(这些规则会约束很多对象)。
- 但固定规则的相关逻辑却特别不适合放到那些与其他领域对象关联的FACTORY METHOD中。
- 虽然原则上在每个操作结束时都应该应用固定规则,但通常对象所允许的转换可能永远也不会用到这些规则。在这种情况下,FACTORY是放置固定规则的合适地方,这样可以使FACTORY创建出的对象更简单。
- 可能ENTITY标识属性的赋值需要满足一条固定规则。但该标识在创建后可能一直保持不变。
- VALUE OBJECT则是完全不变的。
- 如果逻辑在对象的有效生命周期内永远也不被用到,那么对象就没有必要携带这个逻辑。
ENTITY FACTORY与VALUE OBJECT FACTORY
ENTITY FACTORY与VALUE OBJECT FACTORY有两个方面的不同。
- 由于VALUE OBJECT是不可变的,因此,FACTORY所生成的对象就是最终形式。因此FACTORY操作必须得到被创建对象的完整描述。
- 而ENTITY FACTORY则只需具有构造有效AGGREGATE所需的那些属性。对于固定规则不关心的细节,可以之后再添加。
当由程序分配标识符时,FACTORY是控制它的理想场所。尽管唯一跟踪ID实际上是由数据库“序列”或其他基础设施机制生成的,但FACTORY知道需要什么样的标识,以及将标识放到何处。
重建已存储的对象
用于重建对象的FACTORY与用于创建对象的FACTORY很类似,主要有以下两点不同。
- 用于重建对象的ENTITY FACTORY不分配新的跟踪ID。
- 如果重新分配ID,将丢失与先前对象的连续性。因此,在重建对象的FACTORY中,标识属性必须是输入参数的一部分。
- 当固定规则未被满足时,重建对象的FACTORY采用不同的方式进行处理。
- 当创建新对象时,如果未满足固定规则,FACTORY应该简单地拒绝创建对象,但在重建对象时则需要更灵活的响应。
- 如果对象已经在系统的某个地方存在(如在数据库中),那么不能忽略这个事实。但是,同样也不能任凭规则被破坏。必须通过某种策略来修复这种不一致的情况,这使得重建对象比创建新对象更困难。
总之,必须把创建实例的访问点标识出来,并显式地定义它们的范围。它们可能只是构造函数,但通常需要有一种更抽象或更复杂的实例创建机制。 为了满足这种需求,需要在设计中引入新的构造——FACTORY。FACTORY通常不表示模型的任何部分,但它们是领域设计的一部分,能够使对象更明确地表示出模型。
FACTORY封装了对象创建和重建时的生命周期转换。
模式:REPOSITORY
无论要用对象执行什么操作,都需要保持一个对它的引用。那么如何获得这个引用呢?
- 第一种方法是创建对象,因为创建操作将返回对新对象的引用。
- 第二种方法是遍历关联。我们以一个已知对象作为起点,并向它请求一个关联的对象。
- 第三种获取引用的方式,基于对象的属性,执行查询来找到对象;或者是找到对象的组成部分,然后重建它。
数据库搜索是全局可访问的,它使我们可以直接访问任何对象。由此,所有对象不需要相互联接起来,整个对象关系网就能够保持在可控的范围内。 是提供遍历还是依靠搜索,这成为一个设计决策,需要在搜索的解耦与关联的内聚之间做出权衡。
- 客户需要一种有效的方式来获取对已存在的领域对象的引用。如果基础设施提供了这方面的便利,那么开发人员可能会增加很多可遍历的关联,这会使模型变得非常混乱。
- 另一方面,开发人员可能使用查询从数据库中提取他们所需的数据,或是直接提取具体的对象,而不是通过AGGREGATE的根来得到这些对象。
- 这样就导致领域逻辑进入查询和客户代码中,而ENTITY和VALUE OBJECT则变成单纯的数据容器。
- 采用大多数处理数据库访问的技术复杂性很快就会使客户代码变得混乱,这将导致开发人员简化领域层,最终使模型变得无关紧要。
问题:
在所有持久化对象中,有一小部分必须通过基于对象属性的搜索来全局访问。当很难通过遍历方式来访问某些AGGREGATE根的时候,就需要使用这种访问方式。 它们通常是ENTITY,有时是具有复杂内部结构的VALUE OBJECT,还可能是枚举VALUE。而其他对象则不宜使用这种访问方式,因为这会混淆它们之间的重要区别。 随意的数据库查询会破坏领域对象的封装和AGGREGATE。技术基础设施和数据库访问机制的暴露会增加客户的复杂度,并妨碍模型驱动的设计。
代码也不再表达业务,而是对数据库检索技术进行操纵。REPOSITORY是一个简单的概念框架,它可用来封装这些解决方案,并将我们的注意力重新拉回到模型上。
REPOSITORY将某种类型的所有对象表示为一个概念集合(通常是模拟的)。它的行为类似于集合(collection),只是具有更复杂的查询功能。 在添加或删除相应类型的对象时,REPOSITORY的后台机制负责将对象添加到数据库中,或从数据库中删除对象。 这个定义将一组紧密相关的职责集中在一起,这些职责提供了对AGGREGATE根的整个生命周期的全程访问。
客户使用查询方法向REPOSITORY请求对象,这些查询方法根据客户所指定的条件(通常是特定属性的值)来挑选对象。 REPOSITORY检索被请求的对象,并封装数据库查询和元数据映射机制。REPOSITORY可以根据客户所要求的各种条件来挑选对象。 它们也可以返回汇总信息,如有多少个实例满足查询条件。REPOSITORY甚至能返回汇总计算,如所有匹配对象的某个数值属性的总和
REPOSITORY解除了客户的巨大负担,使客户只需与一个简单的、易于理解的接口进行对话,并根据模型向这个接口提出它的请求。 要实现所有这些功能需要大量复杂的技术基础设施,但接口很简单,而且在概念层次上与领域模型紧密联系在一起。
因此:
为每种需要全局访问的对象类型创建一个对象,这个对象相当于该类型的所有对象在内存中的一个集合的“替身”。 通过一个众所周知的全局接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。 提供根据具体条件来挑选对象的方法,并返回属性值满足查询条件的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。 只为那些确实需要直接访问的AGGREGATE根提供REPOSITORY。让客户始终聚焦于模型,而将所有对象的存储和访问操作交给REPOSITORY来完成。
REPOSITORY有很多优点,包括:
- 它们为客户提供了一个简单的模型,可用来获取持久化对象并管理它们的生命周期;
- 它们使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦;
- 它们体现了有关对象访问的设计决策;
- 可以很容易将它们替换为“哑实现”(dummy implementation),以便在测试中使用(通常使用内存中的集合)。
REPOSITORY的查询
所有REPOSITORY都为客户提供了根据某种条件来查询对象的方法,但如何设计这个接口却有很多选择。
- 最容易构建的REPOSITORY用硬编码的方式来实现一些具有特定参数的查询。
- 尽管大多数查询都返回一个对象或对象集合,但返回某些类型的汇总计算也符合REPOSITORY的概念,如对象数目,或模型需要对某个数值属性进行求和统计。
- 在一些需要执行大量查询的项目上,可以构建一个支持更灵活查询的REPOSITORY框架。
- 基于SPECIFICATION(规格)的查询是将REPOSITORY通用化的好办法。客户可以使用规格来描述(也就是指定)它需要什么,而不必关心如何获得结果。在这个过程中,可以创建一个对象来实际执行筛选操作。
- 即使一个REPOSITORY的设计采取了灵活的查询方式,也应该允许添加专门的硬编码查询。这些查询作为便捷的方法,可以封装常用查询或不返回对象(如返回的是选中对象的汇总计算)的查询。
- 不支持这些特殊查询方式的框架有可能会扭曲领域设计,或是干脆被开发人员弃之不用。
在简单REPOSITORY中进行的硬编码查询:
在一个复杂的REPOSITORY中,用一种灵活的、声明式的SPECIFICATION来表述一个搜索条件:
客户代码可以忽略REPOSITORY的实现,但开发人员不能忽略
持久化技术的封装可以使得客户变得十分简单,并且使客户与REPOSITORY的实现之间完全解耦。但像一般的封装一样,开发人员必须知道在封装背后都发生了什么事情。 在使用REPOSITORY时,不同的使用方式或工作方式可能会对性能产生极大的影响。
底层技术可能会限制我们的建模选择。 例如,关系数据库可能对复合对象结构的深度有实际的限制。同样,开发人员要获得REPOSITORY的使用及其查询实现之间的双向反馈。
REPOSITORY的实现注意事项
REPOSITORY概念在很多情况下都适用。可能的实现方法有很多,这里只能列出如下一些需要谨记的注意事项。
- 对类型进行抽象。
- REPOSITORY“含有”特定类型的所有实例,但这并不意味着每个类都需要有一个REPOSITORY。
- 类型可以是一个层次结构中的抽象超类(例如,TradeOrder可以是BuyOrder或SellOrder)。
- 类型可以是一个接口——接口的实现者并没有层次结构上的关联,也可以是一个具体类。
- 记住,由于数据库技术缺乏这样的多态性质,因此我们将面临很多约束。
- 充分利用与客户解耦的优点。
- 我们可以很容易地更改REPOSITORY的实现,但如果客户直接调用底层机制,我们就很难修改其实现。
- 也可以利用解耦来优化性能,因为这样就可以使用不同的查询技术,或在内存中缓存对象,可以随时自由地切换持久化策略。
- 通过提供一个易于操纵的、内存中的(in-memory)哑实现,还能够方便客户代码和领域对象的测试。
- 将事务的控制权留给客户。
- 尽管REPOSITORY会执行数据库的插入和删除操作,但它通常不会提交事务。
- 例如,保存数据后紧接着就提交似乎是很自然的事情,但想必只有客户才有上下文,从而能够正确地初始化和提交工作单元。
- 如果REPOSITORY不插手事务控制,那么事务管理就会简单得多。
- 尽管REPOSITORY会执行数据库的插入和删除操作,但它通常不会提交事务。
- 通常,项目团队会在基础设施层中添加框架,用来支持REPOSITORY的实现。
- REPOSITORY超类除了与较低层的基础设施组件进行协作以外,还可以实现一些基本查询,特别是要实现的灵活查询时。
- 遗憾的是,对于类似Java这样的类型系统,这种方法会使返回的对象只能是Object类型,而让客户将它们转换为REPOSITORY含有的类型。
- 当然,如果在Java中查询所返回的对象是集合时,客户不管怎样都要执行这样的转换。
- 有关实现REPOSITORY的更多指导和一些支持性技术模式(如QUERY OBJECT)可以在[Fowler 2002]一书中找到。
在框架内工作
在实现REPOSITORY这样的构造之前,需要认真思考所使用的基础设施,特别是架构框架。 这些框架可能提供了一些可用来轻松创建REPOSITORY的服务,但也可能会妨碍创建REPOSITORY的工作。 我们可能会发现架构框架已经定义了一种用来获取持久化对象的等效模式,也有可能定义了一种与REPOSITORY完全不同的模式。
例如,你的项目可能会使用J2EE。看看这个框架与MODEL-DRIVEN DESIGN的模式之间有哪些概念上近似的地方(记住,实体bean与ENTITY不是一回事),你可能会把实体bean和AGGREGATE根当作一对类似的概念。 在J2EE框架中,负责对这些对象进行访问的构造是EJB Home。但如果把EJB Home装饰成REPOSITORY的样子可能会导致其他问题。
一般来讲,在使用框架时要顺其自然。当框架无法切合时,要想办法在大方向上保持领域驱动设计的基本原理,而一些不符的细节则不必过分苛求。 寻求领域驱动设计的概念与框架中的概念之间的相似性。这里的假设是除了使用指定框架之外没有别的选择。很多J2EE项目根本不使用实体bean。 如果可以自由选择,那么应该选择与你所使用的设计风格相协调的框架或框架中的一些部分。
REPOSITORY与FACTORY的关系
FACTORY负责处理对象生命周期的开始,而REPOSITORY帮助管理生命周期的中间和结束。
REPOSITORY基于数据来创建对象,因此很多人认为REPOSITORY就是FACTORY,而从技术角度来看的确如此。 但我们最好还是从模型的角度来看待这一问题,重建一个已存储的对象并不是创建一个新的概念对象。
从领域驱动设计的角度来看,FACTORY和REPOSITORY具有完全不同的职责。 FACTORY负责制造新对象,而REPOSITORY负责查找已有对象。 REPOSITORY应该让客户感觉到那些对象就好像驻留在内存中一样。对象可能必须被重建(的确,可能会创建一个新实例),但它是同一个概念对象,仍旧处于生命周期的中间。
REPOSITORY也可以委托FACTORY来创建一个对象,这种方法(虽然实际很少这样做,但在理论上是可行的)可用于从头开始创建对象,此时就没有必要区分这两种看问题的角度了
REPOSITORY使用FACTORY来重建一个已有对象:
这种职责上的明确区分还有助于FACTORY摆脱所有持久化职责。FACTORY的工作是用数据来实例化一个可能很复杂的对象。 如果产品是一个新对象,那么客户将知道在创建完成之后应该把它添加到REPOSITORY中,由REPOSITORY来封装对象在数据库中的存储。
客户使用REPOSITORY来存储新对象:
另一种情况促使人们将FACTORY和REPOSITORY结合起来使用,这就是想要实现一种“查找或创建”功能,即客户描述它所需的对象,如果找不到这样的对象,则为客户新创建一个。 最好不要追求这种功能,它不会带来多少方便。 当将ENTITY和VALUE OBJECT区分开时,很多看上去有用的功能就不复存在了。需要VALUE OBJECT的客户可以直接请求FACTORY来创建一个。 通常,在领域中将新对象和原有对象区分开是很重要的,而将它们组合在一起的框架实际上只会使局面变得混乱。
为关系数据库设计对象
在以面向对象技术为主的软件系统中,最常用的非对象组件就是关系数据库。除了技术上的难点以外,不匹配可能对对象模型产生很大的影响。
有3种常见情况:
- 数据库是对象的主要存储库;
- 数据库是为另一个系统设计的;
- 数据库是为这个系统设计的,但它的任务不是用于存储对象。
如果数据库模式(database schema)是专门为对象存储而设计的,那么接受模型的一些限制是值得的,这样可以让映射变得简单一点。 如果在数据库模式设计上没有其他的要求,那么可以精心设计数据库结构,以便使得在更新数据时能更安全地保证聚合的完整性,并使数据更新变得更加高效。
从技术上来看,关系表的设计不必反映出领域模型。 映射工具已经非常完善了,足以消除二者之间的巨大差别。问题在于多个重叠的模型过于复杂了。 MODEL-DRIVEN DESIGN的很多关于避免将分析和设计模型分开的观点,也同样适用于这种不匹配问题。 这确实会牺牲一些对象模型的丰富性,而且有时必须在数据库设计中做出一些折中(如有些地方不能规范化)。
但如果不做这些牺牲就会冒另一种风险,那就是模型与实现之间失去了紧密的耦合。 这种方法并不要必须使用一种简单的、一个对象/一个表的映射。依靠映射工具的功能,可以实现一些聚合或对象的组合。 但至关重要的是:映射要保持透明,并易于理解——能够通过审查代码或阅读映射工具中的条目就搞明白。
当数据库被视作对象存储时,数据模型与对象模型的差别不应太大(不管映射工具有多么强大的功能)。 可以牺牲一些对象关系的丰富性,以保证它与关系模型的紧密关联。如果有助于简化对象映射的话,不妨牺牲某些正式的关系标准(如规范化)。
对象系统外部的过程不应该访问这样的对象存储。它们可能会破坏对象必须满足的固定规则。 此外,它们的访问将会锁定数据模型,这样使得在重构对象时很难修改模型。
另一方面,很多情况下数据是来自遗留系统或外部系统的,而这些系统从来没打算被用作对象的存储。在这种情况下,同一个系统中就会有两个领域模型共存。 或许与另一个系统中隐含的模型保持一致有一定的道理,也可能更好的方法是使这两个模型完全不同。
允许例外情况的另一个原因是性能。为了解决执行速度的问题,有时可能需要对设计做出一些非常规的修改。
但大多数情况下关系数据库是面向对象领域中的持久化存储形式,因此简单的对应关系才是最好的。 表中的一行应该包含一个对象,也可能还包含AGGREGATE中的一些附属项。表中的外键应该转换为对另一个ENTITY对象的引用。 有时我们不得不违背这种简单的对应关系,但不应该由此就全盘放弃简单映射的原则。
UBIQUITOUS LANGUAGE可能有助于将对象和关系组件联系起来,使之成为单一的模型。 对象中的元素的名称和关联应该严格地对应于关系表中相应的项。 尽管有些功能强大的映射工具使这看上去有些多此一举,但关系中的微小差别可能引发很多混乱。
对象世界中越来越盛行的重构实际上并没有对关系数据库设计造成多大的影响。 此外,一些严重的数据迁移问题也使人们不愿意对数据库进行频繁的修改。 这可能会阻碍对象模型的重构,但如果对象模型和数据库模型开始背离,那么很快就会失去透明性。
最后,有些原因使我们不得不使用与对象模型完全不同的数据库模式,即使数据库是专门为我们的系统创建的。 数据库也有可能被其他一些不对对象进行实例化的软件使用。即使当对象的行为快速变化或演变的时候,数据库可能并不需要修改。 让模型与数据库之间保持松散的关联是很有吸引力的。但这种结果往往是无意为之,原因是团队没有保持数据库与模型之间的同步。 如果有意将两个模型分开,那么它可能会产生更整洁的数据库模式,而不是一个为了与早前的对象模型保持一致而到处都是折中处理的拙劣的数据库模式。
通过重构来加深理解
我们的最终目的是开发出能够捕捉到领域深层含义的模型。以这种方式设计出来的软件不但更加贴近领域专家的思维方式,而且能更好地满足用户的需求。
要想成功地开发出实用的模型,需要注意以下3点。
- 复杂巧妙的领域模型是可以实现的,也是值得我们去花费力气实现的。
- 这样的模型离开不断的重构是很难开发出来的,重构需要领域专家和热爱学习领域知识的开发人员密切参与进来。
- 要实现并有效地运用模型,需要精通设计技巧。
重构的层次
- 有些重构能够极大地提高系统的可用性,它们要么源于对领域的新认知,要么能够通过代码清晰地表达出模型的含义。这些重构不能取代设计模式重构和代码细节重构,这两种重构应该持续进行。
- 但前者添加了另一种重构层次:
- 为实现更深层模型而进行的重构。在深入理解领域的基础上进行重构,通常需要实现一系列的代码细节重构,但这么做绝不仅仅是为了改进代码状态。
- 相反,代码细节重构是一组操作方便的修改单元,通过这些重构可以得到更深层次的模型。
- 其目标在于:
- 开发人员通过重构不仅能够了解代码实现的功能,还能明白个中原因,并把它们与领域专家的交流联系起来。
- 但前者添加了另一种重构层次:
深层模型深层模型
深层模型能够穿过领域表象,清楚地表达出领域专家们的主要关注点以及最相关的知识。
恰当反映领域的模型通常都具有功能多样、简单易用和解释力强的特性。这种模型的共同之处在于:它们提供了一种业务专家青睐的简单语言,尽管这种语言可能也是抽象的。
深层模型/柔性设计
在不断重构的过程中,设计本身也需要支持重构所带来的变化。设计自身的某些特性就可以使其更易于修改和使用。这些特性并不复杂,却很有挑战性。
如果每次对模型和代码所进行的修改都能反映出对领域的新理解,那么通过不断的重构就能给系统最需要修改的地方增添灵活性,并找到简单快捷的方式来实现普通的功能。
柔性设计除了便于修改,还有助于改进模型本身。MODEL-DRIVEN DESIGN需要以下两个方面的支持:
- 深层模型使设计更具表现力;
- 同时,当设计的灵活性可以让开发人员进行试验,而设计又能清晰地表达出领域含义时,那么这个设计实际上就能够将开发人员的深层理解反馈到整个模型发现的过程中。
这段反馈回路是很重要的,因为我们所寻求的模型并不仅仅只是一套好想法:它还应该是构建系统的基础。
发现过程
要想创建出确实能够解决当前问题的设计,首先必须拥有可捕捉到领域核心概念的模型。
由于模型和设计之间具有紧密的关系,因此如果代码难于重构,建模过程也会停滞不前。
使开发人员能够高效地扩展和修改代码,这一设计过程与模型的进一步精化是密不可分的。它通常需要更高级的设计技巧以及更严格的模型定义。
你需要富有创造力,不断地尝试,不断地发现问题才能找到合适的方法为你所发现的领域概念建模,但有时你也可以借用别人已建好的模式。 这些模式并不是现成的解决方案,但是它们可以帮助我们消化领域知识并缩小研究范围。
当我们有机会进行突破时,一定要懂得识别并抓住机会。
突破
这种突破不是某种技巧,而是一个事件。它的困难之处在于你需要判断发生了什么,然后再决定如何处理。
机遇
当突破带来更深层的模型时,通常会令人感到不安。与大部分重构相比,这种变化的回报更多,风险也更高。而且突破出现的时机可能很不合时宜。
尽管我们希望进展顺利,但往往事与愿违。过渡到真正的深层模型需要从根本上调整思路,并且对设计做大幅修改。在很多项目中,建模和设计工作最重要的进展都来自于突破。
关注根本
不要试图去制造突破,那只会使项目陷入困境。通常,只有在实现了许多适度的重构后才有可能出现突破。在大部分时间里,我们都在进行微小的改进,而在这种连续的改进中模型深层含义也会逐渐显现。
要为突破做好准备,应专注于知识消化过程,同时也要逐渐建立健壮的UBIQUITOUS LANGUAGE。寻找那些重要的领域概念,并在模型中清晰地表达出来。精化模型,使其更具柔性。提炼模型。利用这些更容易掌握的手段使模型变得更清晰,这通常会带来突破。
不要犹豫着不去做小的改进,这些改进即使脱离不开常规的概念框架,也可以逐渐加深我们对模型理解。不要因为好高骛远而使项目陷入困境。只要随时注意可能出现的机会就够了。
越来越多的新理解
通常,在经过一次真正的突破并获得了深层模型之后,所获得的新设计变得更加清晰简单,新的UBIQUITOUS LANGUAGE也会增进沟通,于是又促成了下一次建模突破。
将隐式概念转变为显式概念
深层建模的第一步就是要设法在模型中表达出领域的基本概念。随后,在不断消化知识和重构的过程中,实现模型的精化。 但是实际上这个过程是从我们识别出某个重要概念并且在模型和设计中把它显式地表达出来的那个时刻开始的。
若开发人员识别出设计中隐含的某个概念或是在讨论中受到启发而发现一个概念时,就会对领域模型和相应的代码进行许多转换,在模型中加入一个或多个对象或关系,从而将此概念显式地表达出来。
有时,这种从隐式概念到显式概念的转换可能是一次突破,使我们得到一个深层模型。 但更多的时候,突破不会马上到来,而需要我们在模型中显式表达出许多重要概念,并通过一系列重构不断地调整对象职责、改变它们与其他对象的关系、甚至多次修改对象名称,在这之后,突破才会姗姗而来。 最后,所有事情都变得清晰了。但是要实现上述过程,必须首先识别出以某种形式存在的隐含概念,无论这些概念有多么原始。
概念挖掘
开发人员必须能够敏锐地捕捉到隐含概念的蛛丝马迹,但有时他们必须主动寻找线索。 要挖掘出大部分的隐含概念,需要开发人员去倾听团队语言、仔细检查设计中的不足之处以及与专家观点相矛盾的地方、研究领域相关文献并且进行大量的实验。
倾听语言
倾听领域专家使用的语言。
- 有没有一些术语能够简洁地表达出复杂的概念?
- 他们有没有纠正过你的用词(也许是很委婉的提醒)?
- 当你使用某个特定词语时,他们脸上是否已经不再流露出迷惑的表情?
这些都暗示了某个概念也许可以改进模型。
这不同于原来的“名词即对象”概念。听到新单词只是个开头,然后我们还要进行对话、消化知识,这样才能挖掘出清晰实用的概念。 如果用户或领域专家使用了设计中没有的词汇,这就是个警告信号。 而当开发人员和领域专家都在使用设计中没有的词汇时,那就是一个倍加严重的警告信号了。
或者,应该把这种警告看成一次机会。UBIQUITOUS LANGUAGE是由遍布于对话、文档、模型图甚至代码中的词汇构成的。 如果出现了设计中没有的术语,就可以把它添加到通用语言中,这样也就有机会改进模型和设计了。
检查不足之处
你所需要的概念并不总是浮在表面上,也绝不仅仅是通过对话和文档就能让它显现出来。有些概念可能需要你自己去挖掘和创造。 要挖掘的地方就是设计中最不足的地方,也就是操作复杂且难于解释的地方。每当有新的需求时,似乎都会让这个地方变得更加复杂。
这个时候,你必须积极地让领域专家参与到讨论中来。 如果你足够幸运,这些专家可能会愿意一起思考各种想法,并通过模型来进行验证。 如果你没那么幸运,你和你的同事就不得不自己思索出不同的想法,让领域专家对这些想法进行判断,并注意观察专家的表情是认同还是反对。
思考矛盾之处
由于经验和需求的不同,不同的领域专家对同样的事情会有不同的看法。即使是同一个人提供的信息,仔细分析后也会发现逻辑上不一致的地方。 在挖掘程序需求的时候,我们会不断遇到这种令人烦恼的矛盾,但它们也为深层模型的实现提供了重要线索。 有些矛盾只是术语说法上的不一致,有些则是由于误解而产生的。但还有一种情况是专家们会给出相互矛盾的两种说法。
即使不去解决矛盾,我们也应该仔细思考对立的两种看法是如何同时应用于同一个外部现实的,这会给我们带来启示。
查阅书籍
阅读书籍并不能提供现成的解决方案,但可以为她提供一些全新的实验起点,以及在这个领域中探索过的人总结出来的经验。这样可以避免开发人员重复设计已有的概念。
尝试,再尝试
不断尝试和出错,并不是所有这些方向性的改变都毫无用处。每次改变都会把开发人员更深刻的理解添加到模型中。每次重构都使设计变得更灵活并且为那些可能需要修改的地方做好准备。
我们其实别无选择。只有不断尝试才能了解什么有效什么无效。企图避免设计上的失误将会导致开发出来的产品质量低劣,因为没有更多的经验可用来借鉴,同时也会比进行一系列快速实验更加费时。
如何为那些不太明显的概念建模
面向对象范式会引导我们去寻找和创造特定类型的概念。所有事物(即使是像“应计费用”这种非常抽象的概念)及其操作行为是大部分对象模型的主要部分。 它们就是面向对象设计入门书籍所讲到的“名词和动词”。但是,其他重要类别的概念也可以在模型中显式地表现出来。
显式的约束
约束是模型概念中非常重要的类别。它们通常是隐含的,将它们显式地表现出来可以极大地提高设计质量。
class Bucket {
private float capacity;
private float contents;
public void pourIn(float addedVolume) {
if (contents + addedVolume > capacity) {
contents = capacity;
} else {
contents = contents + addedVolume;
}
}
}
class Bucket {
private float capacity;
private float contents;
public void pourIn(float addedVolume) {
float volumePercent = contents + addedVolume;
contents = constrainedToCapacity(volumePercent);
}
private float constrainedToCapacity(float volumePlacedIn) {
if (volumePlacedIn > capacity) {
return capacity;
}
return volumePlacedIn;
}
}
这两个版本的代码都实施了约束,但是第二个版本与模型的关系更为明显(这也是MODEL-DRIVEN DESIGN的基本需求)。 这个规则十分简单,使用最初形式的代码也很容易理解,但如果要是执行的规则比较复杂的话,它们就会像所有隐式概念一样淹没掉被约束的对象或操作。
将约束条件提取到其自己的方法中,这样就可以通过方法名来表达约束的含义,从而在设计中显式地表现出这条约束。 现在这个约束条件就是 一个“有名有姓”的概念了,我们可以用它的名字来讨论它。 这种方式也为约束的扩展提供了空间。 比这更复杂的规则很容易就会产生比其调用者(在这里就是pourIn()方法)更长的方法。这样,调用者就可以简单一些,并且只专注于处理自己的任务,而约束条件则可以根据需要进行扩展。
这种独立方法为约束预留了一定的增加空间,但是在很多时候,约束条件是无法用单独的方法来轻松表达的。 或者,即使方法自身能够保持其简单性,但它可能会调用一些信息,但对于对象的主要职责而言,这些信息毫无用处。这种规则可能就不适合放到现有对象中。
下面是一些警告信号,表明约束的存在正在扰乱其“宿主对象”(Host Object)的设计。
- 计算约束所需的数据从定义上看并不属于这个对象。
- 相关规则在多个对象中出现,造成了代码重复或导致不属于同一族的对象之间产生了继承关系。
- 很多设计和需求讨论是围绕这些约束进行的,而在代码实现中,它们却隐藏在过程代码中。
如果约束的存在掩盖了对象的基本职责,或者如果约束在领域中非常突出但在模型中却不明显,那么就可以将其提取到一个显式的对象中,甚至可以把它建模为一个对象和关系的集合。 (The Object Constraint Language:Precise Modeling with UML[Warmer and Kleppe 1999]一书中提供了关于这个问题的半正式的深入解决方案。)
将过程建模为领域对象
我们都不希望过程变成模型的主要部分。对象是用来封装过程的,这样我们只需考虑对象的业务目的或意图就可以了。
在这里,我们讨论的是存在于领域中的过程,我们必须在模型中把这些过程表示出来。否则当这些过程显露出来时,往往会使对象设计变得笨拙。
- SERVICE是显式表达这种过程的一种方式,同时它还会将异常复杂的算法封装起来。
- 如果过程的执行有多种方式,那么我们也可以用另一种方法来处理它,那就是将算法本身或其中的关键部分放到一个单独的对象中。
- 这样,选择不同的过程就变成了选择不同的对象,每个对象都表示一种不同的STRATEGY。
过程是应该被显式表达出来,还是应该被隐藏起来呢?区分的方法很简单:
它是经常被领域专家提起呢,还是仅仅被当作计算机程序机制的一部分?
- 约束和过程是两大类模型概念,它们一旦被我们视为模型元素,就真的可以让我们的设计更为清晰。
- “规格”提供了用于表达特定类型的规则的精确方式,它把这些规则从条件逻辑中提取出来,并在模型中把它们显式地表示出来。
模式:SPECIFICATION
业务规则通常不适合作为ENTITY或VALUE OBJECT的职责,而且规则的变化和组合也会掩盖领域对象的基本含义。 但是将规则移出领域层的结果会更糟糕,因为这样一来,领域代码就不再表达模型了。
逻辑编程提供了一种概念,即“谓词”这种可分离、可组合的规则对象,但是要把这种概念用对象完全实现是很麻烦的。 同时,这种概念过于通用,在表达设计意图方面,它的针对性不如专门的设计那么好。
为特殊目的创建谓词形式的显式的VALUE OBJECT。SPECIFICATION就是一个谓词,可用来确定对象是否满足某些标准。
SPECIFICATION将规则保留在领域层。由于规则是一个完备的对象,所以这种设计能够更加清晰地反映模型。
SPECIFICATION的应用和实现
SPECIFICATION最有价值的地方在于它可以将看起来完全不同的应用功能统一起来。出于以下3个目的中的一个或多个,我们可能需要指定对象的状态。
- 验证对象,检查它是否能满足某些需求或者是否已经为实现某个目标做好了准备。
- 从集合中选择一个对象(如上述例子中的查询过期发票)。
- 指定在创建新对象时必须满足某种需求。
这3种用法(验证、选择和根据要求来创建)从概念层面上来讲是相同的。 如果没有诸如SPECIFICATION这样的模式,相同的规则可能会表现为不同的形式,甚至有可能是相互矛盾的形式。这样就会丧失概念上的统一性。 通过应用SPECIFICATION模式,我们可以使用一致的模型,尽管在实现时可能需要分开处理。
验证
规格的最简单用法是验证,这种用法也最能直观地展示出它的概念.
验证是对一个独立的对象进行测试,检查它是否满足某些标准,然后客户可能根据验证的结论来采取行动。
class DelinquentInvoiceSpecification extends InvoiceSpecification {
private Date currentDate;
// An instance is used and discarded on a single date
public DelinquentInvoiceSpecification(Date currentDate) {
this.currentDate = currentDate;
}
public boolean isSatisfiedBy(Invoice candidate) {
int gracePeriod = candidate.customer().getPaymentGracePeriod();
Date firmDeadline = DateUtility.addDaysToDate(candidate.dueDate(), gracePeriod);
return currentDate.after(firmDeadline);
}
}
public boolean accountIsDelinquent(Customer customer) {
Date today = new Date();
Specification delinquentSpec = new DelinquentInvoiceSpecification(today);
Iterator it = customer.getInvoices().iterator();
while (it.hasNext()) {
Invoice candidate = (Invoice) it.next();
if (delinquentSpec.isSatisfiedBy(candidate))
return true;
}
return false;
}
选择(或查询)
另一种常见需求是根据某些标准从对象集合中选择一个子集。SPECIFICATION概念同样可以在此应用,但是实现问题会有所不同。
内存筛选:
public Set selectSatisfying(InvoiceSpecification spec) {
Set results = new HashSet();
Iterator it = invoices.iterator();
while (it.hasNext()) {
Invoice candidate = (Invoice) it.next();
if (spec.isSatisfiedBy(candidate)) results.add(candidate);
}
return results;
}
Set delinquentInvoices = invoiceRepository.selectSatisfying(new DelinquentInvoiceSpecification(currentDate));
查询被封装在验证规则所在的类中。我们在Invoice Specification中添加了一个方法,该方法在Delinquent Invoice Specification子类中得以实现:
public String asSQL() {
return
" SELECT * FROM INVOICE, CUSTOMER" +
" WHERE INVOICE.CUST_ID = CUSTOMER.ID" +
" AND INVOICE.DUE_DATE + CUSTOMER.GRACE_PERIOD" +
" < " + SQLUtility.dateAsSQL(currentDate);
}
现在的设计有一些问题。最重要的问题是,表结构的细节本应该被隔离到一个映射层中(这个映射层把领域对象关联到关系表),现在却泄漏到了DOMAIN LAYER中。
public class InvoiceRepository {
public Set selectWhereGracePeriodPast(Date aDate) {
// this is a specialised query
String sql = whereGracePeriodPast_SQL(aDate);
ResultSet queryResultSet = SQLDatabaseInterface.instance.executeQuery(sql);
return buildInvoicesFromResultSet(queryResultSet);
}
public String whereGracePeriodPast_SQL(Date aDate) {
return " SELECT * FROM INVOICE, CUSTOMER" + " WHERE INVOICE.CUST_ID = CUSTOMER.ID"
+ " AND INVOICE.DUE_DATE + CUSTOMER.GRACE_PERIOD" + " < " + SQLUtility.dateAsSQL(aDate);
}
// this solution makes use of double dispatch
public Set selectSatisfying(InvoiceSpecification spec) {
return spec.satisfyingElementsFrom(this);
}
}
class DelinquentInvoiceSpecification extends InvoiceSpecification {
// base code for DelinquentInvoiceSpecification here
public Set selectSatisfyingFrom(InvoiceRepository repo) {
// delinquency rule defined as: "grace period past as of current date"
return repo.selectWhereGracePeriodPast(currentDate);
}
}
上面的讨论基本上没有涉及将SPECIFICATION与数据库结合时所面临的挑战,Mee和Hieatt在[Fowler 2002]中讨论了用规格设计REPOSITORY时遇到的一些技术问题。
根据要求来创建(生成)
为尚未创建的对象指定标准。但是,SPECIFICATION的实现则会大不相同。
这种SPECIFICATION与查询不同,它不用来过滤已存在对象;也与验证不同,并不用来测试已有对象。在这里,我们要创建或重新配置满足SPECIFICATION的全新对象或对象集合。
如果不使用SPECIFICATION,可以编写一个生成器,其中包含可创建所需对象的过程或指令集。这种代码隐式地定义了生成器的行为。
反过来,我们也可以使用描述性的SPECIFICATION来定义生成器的接口,这个接口就显式地约束了生成器产生的结果。这种方法具有以下几个优点。
- 生成器的实现与接口分离。SPECIFICATION声明了输出的需求,但没有定义如何得到输出结果。
- 接口把规则显式地表示出来,因此开发人员无需理解所有操作细节即可知晓生成器会产生什么结果。而如果生成器是采用过程化的方式定义的,那么要想预测它的行为,唯一的途径就是在不同的情况下运行或去研究每行代码。
- 接口更为灵活,或者说我们可以增强其灵活性,因为需求由客户给出,生成器唯一的职责就是实现SPECIFICATION中的要求。
- 最后一点也很重要。这种接口更加便于测试,因为接口显式地定义了生成器的输入,而这同时也可用来验证输出。也就是说,传入生成器接口的用于约束创建过程的同一个SPECIFICATION也可发挥其验证的作用(如果实现方式能够支持这一点的话),以保证被创建的对象是正确的。
根据要求来创建可以是从头创建全新对象,也可以是配置已有对象来满足SPECIFICATION。
通过可工作的原型来摆脱开发僵局
有的团队必须要等待另一个团队编写出代码后才可以继续工作。而这两个团队都要等到代码完全整合后才可以测试组件或从用户那里获取反馈。 这种僵局通常可以通过关键组件的模型驱动原型来缓解,即使原型并不满足所有需求也可以。 当实现与接口分离时,只要有可以工作的实现,项目工作就可以并行地开展下去。时机成熟的时候,可以用更为高效的实现来替代原型。同时,系统中的其他部分也能在开发期间与原型进行交互。
柔性设计
当具有复杂行为的软件缺乏良好的设计时,重构或元素的组合会变得很困难。
柔性设计是对深层建模的补充。一旦我们挖掘出隐式概念,并把它们显示地表达出来之后,就有了原料。通过迭代循环,我们可以把这些原料打造成有用的形式:
- 建立的模型能够简单而清晰地捕获主要关注点;
- 其设计可以让客户开发人员真正使用这个模型。
在设计和代码的开发过程中,我们将获得新的理解,并通过这些理解改善模型概念。 我们一次又一次回到迭代循环中,通过重构得到更深刻的理解。 但我们究竟要获得什么样的设计呢?在这个过程中应该进行哪些实验?
很多过度设计(overengineering)借着灵活性的名义而得到合理的外衣。但是,过多的抽象层和间接设计常常成为项目的绊脚石。
为了把创建的元素装配到复杂系统中,而且在装配之后仍然能够理解它们,必须坚持模型驱动的设计方法,与此同时还要坚持适当严格的设计风格。
开发人员扮演着两个角色,而设计必须要为这两个角色服务。同一个人可能会同时承担这两种角色,甚至在几分钟之内来回变换角色,但角色与代码之间的关系是不同的。
- 一个角色是客户开发人员,负责将领域对象组织成应用程序代码或其他领域层代码,以便发挥设计的功能。
- 柔性设计能够揭示深层次的底层模型,并把它潜在的部分明确地展现出来。
- 客户开发人员可以灵活地使用一个最小化的、松散耦合的概念集合,并用这些概念来表示领域中的众多场景。
- 设计元素非常自然地组合到一起,其结果也是健壮的,可以被清晰地刻画出来,而且也是可以预知的。
- 同样重要的是,设计也必须为那些修改代码的开发人员服务。
- 为了便于修改,设计必须易于理解,必须把客户开发人员正在使用的同一个底层模型表示出来。
- 我们必须按照领域深层模型的轮廓进行设计,以便大部分修改都可以灵活地完成。
- 代码的结果必须是完全清晰明了的,这样才容易预见到修改的影响。
早期的设计版本通常达不到柔性设计的要求。由于项目的时间期限和预算的缘故,很多设计一直就是僵化的。我也从未见过有哪个大型程序自始至终都是柔性的。 但是,当复杂性阻碍了项目的前进时,就需要仔细修改最关键、最复杂的地方,使之变成一个柔性设计,这样才能突破复杂性带给我们的限制,而不会陷入遗留代码维护的麻烦中。
一些有助于获得柔性设计的模式:
模式:INTENTION-REVEALING INTERFACES(释意接口)
如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。 当某个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。 如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰。
当我们把概念显式地建模为类或方法时,为了真正从中获取价值,必须为这些程序元素赋予一个能够反映出其概念的名字。类和方法的名称为开发人员之间的沟通创造了很好的机会,也能够改善系统的抽象。
Kent Beck曾经提出通过INTENTION-REVEALING SELECTOR(释意命名选择器)来选择方法的名称,使名称表达出其目的[Beck 1997]。 设计中的所有公共元素共同构成了接口,每个元素的名称都提供了揭示设计意图的机会。类型名称、方法名称和参数名称组合在一起,共同形成了一个INTENTION-REVEALING INTERFACE(释意接口)。
因此:
在命名类和操作时要描述它们的效果和目的,而不要表露它们是通过何种方式达到目的的。这样可以使客户开发人员不必去理解内部细节。 这些名称应该与UBIQUITOUS LANGUAGE保持一致,以便团队成员可以迅速推断出它们的意义。 在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它。
所有复杂的机制都应该封装到抽象接口的后面,接口只表明意图,而不表明方式。
在领域的公共接口中,
- 可以把关系和规则表述出来,但不要说明规则是如何实施的;
- 可以把事件和动作描述出来,但不要描述它们是如何执行的;
- 可以给出方程式,但不要给出解方程式的数学方法。
- 可以提出问题,但不要给出获取答案的方法。
整个子领域可以被划分到独立的模块中,并用一个表达了其用途的接口把它们封装起来。这种方法可以使我们把注意力集中在项目上,并控制大型系统的复杂性。
模式:SIDE-EFFECT-FREE FUNCTION(无副作用方法)
复杂的逻辑可以在SIDE-EFFECT-FREE FUNCTION中安全地执行
多个规则的相互作用或计算的组合所产生的结果是很难预测的。 开发人员在调用一个操作时,为了预测操作的结果,必须理解它的实现以及它所调用的其他方法的实现。如果开发人员不得不“揭开接口的面纱”,那么接口的抽象作用就受到了限制。 如果没有了可以安全地预见到结果的抽象,开发人员就必须限制“组合爆炸”,这就限制了系统行为的丰富性。
返回结果而不产生副作用的操作称为函数。
- 一个函数可以被多次调用,每次调用都返回相同的值。
- 一个函数可以调用其他函数,而不必担心这种嵌套的深度。
- 函数比那些有副作用的操作更易于测试。由于这些原因,使用函数可以降低风险。
VALUE OBJECT是不可变的,这意味着除了在创建期间调用的初始化程序之外,它们的所有操作都是函数。 像函数一样,VALUE OBJECT使用起来很安全,测试也很简单。
如果一个操作把逻辑或计算与状态改变混合在一起,那么我们就应该把这个操作重构为两个独立的操作。但从定义上来看,这种把副作用隔离到简单的命令方法中的做法仅适用于ENTITY。
在完成了修改和查询的分离之后,可以考虑再进行一次重构,把复杂计算的职责转移到VALUE OBJECT中。 通过派生出一个VALUE OBJECT(而不是改变现有状态),或者通过把职责完全转移到一个VALUE OBJECT中,往往可以完全消除副作用。
尽可能把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的、非常简单的操作中。 当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑移到VALUE OBJECT中,这样可以进一步控制副作用。
SIDE-EFFECT-FREE FUNCTION,特别是在不变的VALUE OBJECT中,允许我们安全地对多个操作进行组合。 当通过INTENTION-REVEALING INTERFACE把一个FUNCTION呈现出来的时候,开发人员就可以在无需理解其实现细节的情况下使用它。
public class Paint {
int red;
int yellow;
int blue;
Double volume;
public void mixIn(Paint other) {
volume = volume.plus(other.getVolume());
// color mix logic
// ending with set color values
}
}
public class PigmentColor {
int red;
int yellow;
int blue;
Double volume;
public void mixWith(PigmentColor other, double radio) {
// color mix logic
// ending with create new PigmentColor by new colors
}
}
public class Paint {
Double volume;
PigmentColor pigmentColor;
public void mixIn(Paint other) {
volume = volume.plus(other.getVolume());
double radio = other.getVolume() / volume;
pigmentColor = pigmentColor.mixWith(other.getPigmentColor(), radio);
}
}
新的Pigment Color类捕获了知识,并显式地把这些知识表达出来,而且它还提供了一个SIDE-EFFECT-FREE FUNCTION,这个函数的计算结果很容易理解,也很容易测试,因此可以安全地使用或与其他操作进行组合。由于它的安全性很高,因此复杂的调色逻辑真正被封装起来了。使用这个类的开发人员不必理解其实现。
模式:ASSERTION(断言)
改变系统状态的方法可以用ASSERTION来刻画。
把复杂的计算封装到SIDE-EFFECT-FREE FUNCTION中可以简化问题,但实体仍然会留有一些有副作用的命令,使用这些ENTITY的人必须了解使用这些命令的后果。 在这种情况下,使用ASSERTION(断言)可以把副作用明确地表示出来,使它们更易于处理。
如果操作的副作用仅仅是由它们的实现隐式定义的,那么在一个具有大量相互调用关系的系统中,起因和结果会变得一团糟。 理解程序的唯一方式就是沿着分支路径来跟踪程序的执行。封装完全失去了价值。跟踪具体的执行也使抽象失去了意义。
把操作的后置条件和类及AGGREGATE的固定规则表述清楚。 如果在你的编程语言中不能直接编写ASSERTION,那么就把它们编写成自动的单元测试。 还可以把它们写到文档或图中(如果符合项目开发风格的话)。
寻找在概念上内聚的模型,以便使开发人员更容易推断出预期的ASSERTION,从而加快学习过程并避免代码矛盾。
public void testMixingVolume(){
PigmentColor yellow = new PigmentColor(0,50,0);
PigmentColor blue = new PigmentColor(0,0,50);
StockPaint paint1 = new StockPaint(1.0,yellow);
StockPaint paint2 = new StockPaint(1.5,blue);
mix.mixIn(paint1);
mix.mixIn(paint2);
assertEquals(2.5,mix.getVolume(),0.01);
}
这个模型捕捉并传递了更多领域知识。固定规则和后置条件符合常识,这使得它们更易于维护和使用。
INTENTION-REVEALING INTERFACE清楚地表明了用途,SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们能够更准确地预测结果,因此封装和抽象更加安全。
可重组元素的下一个因素是有效的分解……
模式:CONCEPTUAL CONTOUR(概念轮廓)
如果把模型或设计的所有元素都放在一个整体的大结构中,那么它们的功能就会发生重复。外部接口无法给出客户可能关心的全部信息。由于不同的概念被混合在一起,它们的意义变得很难理解。
而另一方面,把类和方法分解开也可能是毫无意义的,这会使客户更复杂,迫使客户对象去理解各个细微部分是如何组合在一起的。 更糟的是,有的概念可能会完全丢失。铀原子的一半并不是铀。 而且,粒度的大小并不是唯一要考虑的问题,我们还要考虑粒度是在哪种场合下使用的。
把设计元素(操作、接口、类和AGGREGATE)分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。 在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层CONCEPTUAL CONTOUR。 使模型与领域中那些一致的方面(正是这些方面使得领域成为一个有用的知识体系)相匹配。
INTENTION-REVEALING INTERFACE使客户能够把对象表示为有意义的单元,而不仅仅是一些机制。 SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们可以安全地使用这些单元,并对它们进行复杂的组合。 CONCEPTUAL CONTOUR的出现使模型的各个部分变得更稳定,也使得这些单元更直观,更易于使用和组合。
然而,我们仍然会遇到“概念过载”(conceptual overload)的问题——当模型中的互相依赖过多时,我们就必须把大量问题放在一起考虑。
模式:STANDALONE CLASS(独立的类)
即使是在MODULE内部,设计也会随着依赖关系的增加而变得越来越难以理解。这加重了我们的思考负担,从而限制了开发人员能处理的设计复杂度。隐式概念比显式引用增加的负担更大。
我们应该对每个依赖关系提出质疑,直到证实它确实表示对象的基本概念为止。这个仔细检查依赖关系的过程从提取模型概念本身开始。 然后需要注意每个独立的关联和操作。仔细选择模型和设计能够大幅减少依赖关系——常常能减少到零。
低耦合是对象设计的一个基本要素。尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变得完全独立了,这就使得我们可以单独地研究和理解它。 每个这样的独立类都极大地减轻了因理解MODULE而带来的负担。
尽力把最复杂的计算提取到STANDALONE CLASS(独立的类)中,实现此目的的一种方法是从存在大量依赖的类中将VALUE OBJECT建模出来。
低耦合是减少概念过载的最基本办法。独立的类是低耦合的极致。 消除依赖性并不是说要武断地把模型中的一切都简化为基本类型,这样只会削弱模型的表达能力。
模式:CLOSURE OF OPERATION(闭合操作)
模式CLOSURE OF OPERATION(闭合操作)就是一种在减小依赖性的同时保持丰富接口的技术。
大部分引起我们兴趣的对象所产生的行为仅用基本类型是无法描述的。
在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。 如果实现者(implementer)的状态在计算中会被用到,那么实现者实际上就是操作的一个参数,因此参数和返回值应该与实现者有相同的类型。 这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他概念的任何依赖。
public Set lowPaidEmployees(Set employees) {
Set lowPaidEmployees = Sets.newHashSet();
Iterator it = employees.iterator();
while (it.hasNext()) {
Employee current = it.next();
if (current.getSalary() < 40000) {
lowPaidEmployees.add(current);
}
}
}
上段代码只是从集合中选择了一个子集。是否真的有必要使用Iterator这个额外的概念以及它所带来的所有机制上的复杂性呢?
public Set lowPaidEmployees(Set employees) {
return employees.strem().filter(e -> e.getSalary() < 40000).collect(Collectors.toList());
}
由于返回值与实现者的类型相匹配,因此它们可以像一系列过滤器一样被串接在一起。读写代码都变得很容易。它们并没有引入与选择子集无关的外来概念。
声明式设计
从模型属性的声明来生成可运行的程序是MODEL-DRIVEN DESIGN的理想目标,但在实践中这种方法也有自己的缺陷。
- 声明式语言并不足以表达一切所需的东西,它把软件束缚在一个由自动部分构成的框架之内,使软件很难扩展到这个框架之外。
- 代码生成技术破坏了迭代循环——它把生成的代码合并到手写的代码中,使得代码重新生成具有巨大的破坏作用。
声明式设计发挥的最大价值是用一个范围非常窄的框架来自动处理设计中某个特别单调且易出错的方面,如持久化和对象关系映射。 最好的声明式设计能够使开发人员不必去做那些单调乏味的工作,同时又完全不限制他们的设计自由。
领域特定语言
这种技术也许能在非常成熟的模型中发挥出最大的作用,在这种情况下,客户代码可能是由不同的团队编写的。 但一般情况下,这样的设置会产生有害的结果——团队被分成两部分,框架由那些技术水平较高的人来构建,而应用程序则由那些技术水平较差的人来构建了,但也并不是非得如此。
声明式设计风格
一旦你的设计中有了INTENTION-REVEALING INTERFACE、SIDE-EFFECT-FREE FUNCTION和ASSERTION,那么你就具备了使用声明式设计的条件。当我们有了可以组合在一起来表达意义的元素,并且使其作用具体化或明朗化,甚或是完全没有明显的副作用,我们就可以获得声明式设计的很多益处。
用声明式的风格来扩展SPECIFICATION
SPECIFICATION是由“谓词”(predicate)这个众所周知的形式化概念演变来的。谓词还有其他一些有用的特性,我们可以对这些特性进行有选择的利用。
使用逻辑运算对SPECIFICATION进行组合
当使用SPECIFICATION时,我们很容易就会遇到需要把它们组合起来使用的情况。SPECIFICATION是谓词的一个例子,而谓词可以用“AND”、“OR”和“NOT”等运算进行组合和修改。 这些逻辑运算都是谓词这个类别之下的闭合操作,因此SPECIFICATION组合也是CLOSURE OF OPERATION。
参考《SPECIFICATION模式》
另外可以配合逆波兰表达式等提升性能。
包容
包容特性并不是经常需要,而且实现起来也很难,但有时它确实能够解决很困难的问题。它还能够表达出一个SPECIFICATION的含义。
更严格的SPECIFICATION包容不太严格的SPECIFICATION。用更严格的SPECIFICATION来取代不严格的SPECIFICATION不会遗漏掉先前的任何需求。
如果把每个SPECIFICATION看成一个谓词,那么包容就等于逻辑蕴涵(logical implication)。使用传统的符号,A→B表示声明A蕴涵声明B,因此,如果A为真,则B也为真。
New Spec → Old Spec
或者在更复杂的情况中,
A AND B→A
A AND B AND C→A AND B
这样,如果Composite Specification能够把所有由“AND”连接起来的叶节点(leaf)SPECIFICATION收集到一起,那么我们要做的事情只是检查包容规格(subsuming SPECIFICATION)是否含有被包容规格的所有叶节点(而且它可能还包含更多的叶节点)——它的叶节点集合是另一个SPECIFICATION的叶节点集合的超集。
public boolean subsumes(Specification other){
if(other instanceof CompositeSpecification){
Collection otherLeaves = (CompositeSpecification)other.leafSpecifications();
Iterator it = otherLeaves.iterator();
while(it.hasNext()){
if(!leafSpecifications.contains(it.next())){
return false;
}
}
}else{
if(!leafSpecifications().contains(other)){
return false;
}
}
}
我们还可以增强这种交互,对仔细选择的参数化的叶节点SPECIFICATION进行比较或者进行其他一些复杂的比较。 遗憾的是,当把OR和NOT也包括进来时,这些证明会变得更复杂。在大多数情况下,最好避免出现这样的复杂性:
要么选择放弃一些运算符,要么不使用包容。如果这二者同时需要,那么要慎重考虑这样做的价值是否多过它所带来的麻烦。
切入问题的角度
把这些模式结合起来使用,并用于处理更大的设计。
分割子领域
我们无法一下子就能处理好整个设计,而需要一步一步地进行。
- 我们从系统的某些方面可以看出适合用哪种方法处理,那么就把它们提取出来加以处理。
- 如果模型的某个部分可以被看作是专门的数学,那么可以把这部分分离出来。
- 如果应用程序实施了某些用来限制状态改变的复杂规则,那么可以把这部分提取到一个单独的模型中,或者提取到一个允许声明规则的简单框架中。
- 在剩下的模型中,有的部分是用声明式的风格来编写的——这些可能是根据专门数学或验证框架编写的声明,或者是子领域所采用的任何形式。
随着这些步骤的进行,不仅新模型更整洁了,而且剩下的部分也更小、更清晰了。
尽可能利用已有的形式
我们不能把从头创建一个严密的概念框架当作一项日常的工作来做。在项目的生命周期中,我们有时会发现并精炼出这样一个框架。 但更常见的情况是,可以对你的领域或其他领域中那些建立已久的概念系统加以修改和利用,其中有些系统已经被精化和提炼达几个世纪之久。
例如,很多商业应用程序涉及会计学。会计学定义了一组成熟的ENTITY和规则,我们很容易对这些ENTITY和规则进行调整,得到一个深层的模型和柔性设计。 有很多这样的正式概念框架,比如数学框架。
应用“分析模式”
在《分析模式》一书中,Martin Fowler这样定义分析模式[Fowler 1997,p.8]: 分析模式是一种概念集合,用来表示业务建模中的常见结构。它可能只与一个领域有关,也可能跨越多个领域。
分析模式的最大作用是借鉴其他项目的经验,把那些项目中有关设计方向和实现结果的广泛讨论与当前模型的理解结合起来。 脱离具体的上下文来讨论模型思想不但难以落地,而且还会造成分析与设计严重脱节的风险,而这一点正是MODEL-DRIVEN DESIGN坚决反对的。
有时,我们甚至想象不到,程序的一些部分也能从领域模型获益。它们可能一开始很简单,并一步步机械地演变。它们看上去就像是复杂的应用程序代码,而不是领域逻辑。 分析模式在找到这些盲点方面特别有用。
分析模式是很有价值的知识
当你可以幸运地使用一种分析模式时,它一般并不会直接满足你的需求。但它为你的研究提供了有价值的线索,而且提供了明确抽象的词汇。它还可以指导我们的实现,从而省去很多麻烦。
我们应该把所有分析模式的知识融入到知识消化和重构的过程中,从而形成更深刻的理解,并促进开发。
但有一个误区是应该避免的。当使用众所周知的分析模式中的术语时,一定要注意,不管其表面形式的变化有多大,都不要改变它所表示的基本概念。这样做有两个原因,
- 一是模式中蕴含的基本概念将帮助我们避免问题,
- 二是(也是更重要的原因)使用被广泛理解或至少是被明确解释的术语可以增强UBIQUITOUS LANGUAGE。
如果在模型的自然演变过程中模型的定义也发生改变,那么就要修改模型名称了。
很多对象模型都有文献资料可查,其中有些对象模型专门用于某个行业中的某种应用,而有些则是通用模型。 大部分对象模型都有助于开阔思路,但只有为数不多的一些模型精辟地阐述了选择这些模式的原理和使用的结果,而这些才是分析模式的精华所在。 这些精化后的分析模式大部分都很有价值,有了它们,可以免去一次次的重复开发工作。 尽管我们不大可能归纳出一个包罗万象的分析模式类目,但针对具体行业的类目还是能够开发出来的。而且在一些跨越多个应用的领域中适用的模式可以被广泛共享。
这种对已组织好的知识的重复利用完全不同于通过框架或组件进行的代码重用,但是二者唯一的共同点是它们都提供了一种新思路的萌芽,而这种新思路先前可能并不十分明晰。 一个模型,甚至一个通用框架,都是一个完整的整体,而分析则相当于一个工具包,它被应用于模型的一些部分。
分析模式专注于一些最关键和最艰难的决策,并阐明了各种替代和选择方案。它们提前预测了一些后期结果,而如果单靠我们自己去发现这些结果,可能会付出高昂的代价。
将设计模式应用于模型
除了《设计模式》中介绍的模式以外,近年来还出现了其他很多技术设计模式。 有些模式反映了在一些领域中出现的深层概念。这些模式都有很大的利用价值。为了在领域驱动设计中充分利用这些模式,我们必须同时从两个角度看待它们:
从代码的角度来看它们是技术设计模式,从模型的角度来看它们就是概念模式。
模式:STRATEGY(也称为POLICY)
领域模型包含一些并非用于解决技术问题的过程,将它们包含进来是因为它们对处理问题领域具有实际的价值。 当必须从多个过程中进行选择时,选择的复杂性再加上多个过程本身的复杂性会使局面失去控制。
我们需要把过程中的易变部分提取到模型的一个单独的“策略”对象中。将规则与它所控制的行为区分开。 按照STRATEGY设计模式来实现规则或可替换的过程。策略对象的多个版本表示了完成过程的不同方式
通常,作为设计模式的STRATEGY侧重于替换不同算法的能力,而当其作为领域模式时,其侧重点则是表示概念的能力,这里的概念通常是指过程或策略规则。
带选项的SERVICE接口需要条件逻辑:
通过STRATEGY(或者POLICY)来确定选项(STRATEGY是作为参数传入的):
我们在领域层中使用技术设计模式时,必须认识到这样做的另外一种动机,也是它的另一层含义。当所使用的STRATEGY对应于某种实际的业务策略时,模式就不再仅仅是一种有用的实现技术了(但它在实现方面的价值并未改变)。
模式:COMPOSITE
当嵌套容器的关联性没有在模型中反映出来时,公共行为必然会在层次结构的每一层重复出现,而且嵌套也变得僵化(例如,容器通常不能包含同一层中的其他容器,而且嵌套的层数也是固定的)。 客户必须通过不同的接口来处理层次结构中的不同层,尽管这些层在概念上可能没有区别。通过层次结构来递归地收集信息也变得非常复杂。
当在领域中应用任何一种设计模式时,首先关注的问题应该是模式的意图是否确实适合领域概念。
- 以递归的方式遍历一些相互关联对象确实比较方便,但它们是否真的存在整体—部分层次结构?
- 你是否发现可以通过某种抽象方式把所有部分都归到同一概念类型中?
如果你确实发现了这种抽象方式,那么使用COMPOSITE可以令模型的这些部分变得更清晰,同时使你能够借助设计模式所提供的那些经过深思熟虑的设计及实现的考量。
定义一个把COMPOSITE的所有成员都包含在内的抽象类型。 在容器上实现那些查询信息的方法时,这些方法返回由容器内容所汇总的信息。而“叶”节点则基于它们自己的值来实现这些方法。 客户只需使用抽象类型,而无需区分“叶”和容器。
为什么没有介绍FLYWEIGHT
事实上,FLYWEIGHT虽然是设计模式的一个典型的例子,却并不适用于领域模型。
当一个VALUE OBJECT集合(其中的值对象数目有限)被多次使用的时候(如房屋规划中电源插座的例子),那么把它们实现为FLYWEIGHT可能是有意义的。这是一个适用于VALUE OBJECT(但不适用于ENTITY)的实现选择。 COMPOSITE模式与它的不同之处在于,组合模式的概念对象是由其他概念对象组成的。这使得组合模式既适用于模型,也适用于实现,这是领域模式的一个基本特征。
把设计模式用作领域模式的唯一要求是这些模式能够描述关于概念领域的一些事情,而不仅仅是作为解决技术问题的技术解决方案。
通过重构得到更深层的理解
通过重构得到更深层的理解是一个涉及很多方面的过程。有三件事情是必须要关注的:
- 以领域为本;
- 用一种不同的方式来看待事物;
- 始终坚持与领域专家对话。
在寻求理解领域的过程中,可以发现更广泛的重构机会。
开始重构
获得深层理解的重构可能出现在很多方面。一开始有可能是为了解决代码中的问题——一段复杂或笨拙的代码。 但开发人员并没有使用(代码重构所提供的)标准的代码转换,相反,他们认为问题的根源在于领域模型。或许是领域中缺少一个概念,或许是某个关系发生了错误。
与传统重构观点不同的是,即使在代码看上去很整洁的时候也可能需要重构,原因是模型的语言没有与领域专家保持一致,或者新需求不能被自然地添加到模型中。 重构的原因也可能来自学习:当开发人员通过学习获得了更深刻的理解,从而发现了一个得到更清晰或更有用的模型的机会。
如何找到问题的病灶往往是最难和最不确定的部分。 在这之后,开发人员就可以系统地找出新模型的元素。他们可以与同事和领域专家一起进行头脑风暴,也可以充分利用那些已经对知识做了系统性总结的分析模式或设计模式。
探索团队
不管问题的根源是什么,下一步都是要找到一种能够使模型表达变得更清楚和更自然的改进方案。这可能只需要做一些简单、明显的修改,只需几小时即可完成。 在这种情况下,所做的修改类似于传统重构。但寻找新模型可能需要更多时间,而且需要更多人参与。
修改的发起者会挑选几位开发人员一起工作,这些开发人员应该擅长思考该类问题,了解领域,或者掌握深厚的建模技巧。如果涉及一些难以捉摸的问题,他们还要请一位领域专家加入。 这个由4~5人组成的小组会到会议室或咖啡厅进行头脑风暴,时间为半小时至一个半小时。 在这个过程中,他们画一些UML草图,并试着用对象来走查场景。他们必须保证主题专家(subject matter expert)能够理解模型并认为模型有用。 当发现了一些令他们满意的新思路后,他们就回去编码,或者决定再多考虑几天,先回去做点别的事情。 几天之后,这个小组再次碰头,重复上面的过程。这时,他们已经对前几天的想法有了更深入的理解,因此更加自信了,并且得出了一些结论。 他们回到计算机前,开始对新设计进行编码。
要想保证这个过程的效率,需要注意几个关键事项。
- 自主决定。
- 可以随时组成一个小的团队来研究某个设计问题。这个团队只工作几天,然后就可以解散了。这种团队没有长期存在的必要,也不必有复杂的组织结构。
- 注意范围和休息。
- 在几天内召开两三次短会就应该能够产生一个值得尝试的设计。工作拖得太长并没什么好处。如果讨论毫无进展,可能是一次讨论的内容太多了。选一个较小的设计方面,集中讨论它。
- 练习使用UBIQUITOUS LANGUAGE。
- 让其他团队成员(特别是主题专家)参与头脑风暴会议是练习和精化UBIQUITOUS LANGUAGE的好机会。这样,原来的开发人员可以得到更完善的UBIQUITOUS LANGUAGE,并反映到编码中。
成熟的头脑风暴是灵活机动、不拘泥于形式的,而且具有令人难以置信的高效率。
借鉴先前的经验
我们没有必要总去做一些无谓的重复工作。 用于查找缺失概念或改进模型的头脑风暴过程具有巨大的作用,通过这个过程可以收集来自各个方面的想法,并把这些想法与已有知识结合起来。 随着知识消化的不断开展,就能找到当前问题的答案。
- 我们可以从书籍和领域自身的其他知识源获得思路。
- 尽管相关领域的人员可能还没有创建出适合运行软件的模型,但他们可能已经把概念很好地组织到了一起,并发现了一些有用的抽象。
- 把这些知识结合到知识消化过程中,可以更快速地得到更丰富的结果,而且这个结果也更为领域专家们所熟悉。
- 有时我们可以从分析模式中汲取他人的经验。
- 这些经验对于帮助我们读懂领域起到了一定的作用,但分析模式是专门针对软件开发的,因此应该直接根据我们自己在领域中实现软件的经验来利用这些模式。
- 分析模式可以提供精细的模型概念,并帮助我们避免很多错误。但它们并不是现成的“菜谱”。它们只是为知识消化过程提供了一些供给。
- 随着零散知识的归纳,必须同时处理模型关注点和设计关注点。
- 同样,这并不意味着总是需要从头开发一切。当设计模式既符合实现需求,又符合模型概念时,通常就可以在领域层中应用这些模式。
- 同样,当一种常见的形式体系(如算术逻辑或谓词逻辑)与领域的某个部分非常符合时,可以把这个部分提取出来,并根据它来修改形式系统的规则。
- 这可以产生非常简练且易于理解的模型。
针对开发人员的设计
软件不仅仅是为用户提供的,也是为开发人员提供的。开发人员必须把他们编写的代码与系统的其他部分集成到一起。 在迭代过程中,开发人员反复修改代码。开发人员应该通过重构得到更深层的理解,这样既能够实现柔性设计,也能够从这样一个设计中获益。
柔性设计能够清楚地表明它的意图。这样的设计使人们很容易看出代码的运行效果,因此也很容易预计修改代码的结果。 柔性设计主要通过减少依赖性和副作用来减轻人们的思考负担。 这样的设计是以深层次的领域模型为基础的,在模型中,只有那些对用户最重要的部分才具有较细的粒度。 在这样的模型中,那些经常需要修改的地方能够保持很高的灵活性,而其他地方则相对比较简单。
重构的时机
持续重构渐渐被认为是一种“最佳实践”,但大部分项目团队仍然对它抱有很大的戒心。 人们虽然看到了修改代码会有风险,还要花费开发时间,但却不容易看到维持一个拙劣设计也有风险,而且迁就这种设计也要付出代价。
想要重构的开发人员往往被要求证明其重构的合理性。虽然这看似合理,但这使得一个本来就很难进行的工作变得几乎不可能完成,而且会限制重构的进行(或者人们只能暗地里进行)。 软件开发并不是一个可以完全预料到后果的过程,人们无法准确地计算出某个修改会带来哪些好处,或者是不做某个修改会付出多大代价。
在探索领域的过程中、在培训开发人员的过程中,以及在开发人员与领域专家进行思想交流的过程中,必须始终坚持把“通过重构得到更深层理解”作为这些工作的一部分。 因此,当发生以下情况时,就应该进行重构了:
- 设计没有表达出团队对领域的最新理解;
- 重要的概念被隐藏在设计中了(而且你已经发现了把它们呈现出来的方法);
- 发现了一个能令某个重要的设计部分变得更灵活的机会。
我们虽然应该有这样一种积极的态度,但并不意味着可以随随便便做任何修改。在发布的前一天,就不要进行重构了。 不要引入一些只顾炫耀技术能力而没有解决领域核心问题的“柔性设计”。 无论一个“更深层的模型”看起来有多好,如果你不能说服领域专家们去使用它,那么就不要引入它。 万事都不是绝对的,但如果某个重构对我们有利,那么不妨在这个方向上大胆前进。
危机就是机遇
在达尔文创立进化论后的一个多世纪中,人们一直认为标准的进化模型就是物种随着时间缓慢地改变(在一定程度上这种改变是稳定的)。 突然之间,这个模型在20世纪70年代被“间断平衡”(punctuated equilibrium)模型取代了。 它对原有进化论进行了扩展,认为长期的缓慢变化或稳定变化会被相对来说很短的、爆发性的快速变化所打断。然后事物会进入一个新的平衡。
软件开发与物种进化之间的不同点是前者具有明确的方向(虽然在某些项目上可能并不明显),尽管如此软件开发仍遵循这种进化规律。
传统意义上的重构听起来是一个非常稳定的过程。但通过重构得到更深层理解往往不是这样的。 在对模型进行一段时间稳定的改进后,你可能突然有所顿悟,而这会改变模型中的一切。 这些突破不会每天都发生,然而很大一部分深层模型和柔性设计都来自这些突破。
这样的情况往往看起来不像是机遇,而更像危机。 例如,你突然发现模型中有一些明显的缺陷,在表达方面显示出一个很大的漏洞,或存在一些没有表达清楚的关键区域。或者有些描述是完全错误的。
这些都表明团队对模型的理解已经达到了一个新的水平。他们现在站在更高的层次上发现了原有模型的弱点。他们可以从这种角度构思一个更好的模型。
通过重构得到更深层理解是一个持续不断的过程。人们发现一些隐含的概念,并把它们明确地表示出来。有些设计部分变得更具有柔性,或许还采用了声明式的风格。 开发工作一下子到了突破的边缘,然后开发人员跨越这条界线,得到了一个更深层的模型,接下来又重新开始了稳步的改进过程。
战略设计
每个决策都必须对系统开发产生直接的影响,否则它就是无关的决策。战略设计原则必须指导设计决策,以便减少各个部分之间的互相依赖,在使设计意图更为清晰的同时而又不失去关键的互操作性和协同性。 战略设计原则必须把模型的重点放在捕获系统的概念核心,也就是系统的“远景”上。而且在完成这些目标的同时又不能为项目带来麻烦。
为了帮助实现这些目标,这一部分探索了3个大的主题:上下文、精炼和大型结构。
- 其中上下文是最不易引起注意的原则,但实际上它却是最根本的。无论大小,成功的模型必须在逻辑上一致,不能有互相矛盾或重叠的定义。
- 有时,企业系统会集成各种不同来源的子系统,或包含诸多完全不同的应用程序,以至于无法从同一个角度来看待领域。要把这些不同部分中隐含的模型统一起来可能是要求过高了。
- 通过为每个模型显式地定义一个BOUNDED CONTEXT,然后在必要的情况下定义它与其他上下文的关系,建模人员就可以避免模型变得缠杂不清。
- 通过精炼可以减少混乱,并且把注意力集中到正确的地方。
- 人们通常在领域的一些次要问题上花费了太多的精力。整体领域模型需要突出系统中最有价值和最特殊的那些方面,而且在构造领域模型时应该尽可能把注意力集中在这些部分上。
- 虽然一些支持组件也很关键,但绝不能把它们和领域核心一视同仁。把注意力集中到正确的地方不仅有助于把精力投入到关键部分上,而且还可以使系统不会偏离预期方向。
- 战略精炼可以使大的模型保持清晰。有了更清晰的视图后,CORE DOMAIN的设计就会发挥更大的作用。
- 大型结构是用来描述整个系统的。
- 在非常复杂的模型中,人们可能会“只见树木,不见森林”。精炼确实有帮助,它使人们能够把注意力集中到核心元素上,并把其他元素表示为支持作用,但如果不贯彻某个主旨来应用一些系统级的设计元素和模式的话,关系仍然可能非常混乱。
- 我将概要介绍几种大型结构方法,然后详细讨论其中一种模式——RESPONSIBILITY LAYER(职责层),通过这个示例来探索使用大型结构的含义。
- 我们所讨论的特殊结构只是一些例子,它们并不是大型结构的全部。当需要的时候,应该创造新的结构,抑或修改这些结构,但均需遵循演化顺序(EVOLVING ORDER)的过程来进行。
- 一些大型结构能够使设计保持一致性,从而加速开发,并提高集成度。
这3种原则各有各的用处,但结合起来使用将发挥更大的力量,遵守这些原则就可以创建出好的设计,即使是对一个非常庞大的没有人能够完全理解的系统也是如此。
- 大型结构能够保持各个不同部分之间的一致性,从而有助于这些部分的集成。
- 结构和精炼能够帮助我们理解各个部分之间的复杂关系,同时保持整体视图的清晰。
- BOUNDED CONTEXT使我们能够在不同的部分中进行工作,而不会破坏模型或是无意间导致模型的分裂。
把这些概念加进团队的UBIQUITOUS LANGUAGE中,可以帮助开发人员找出他们自己的解决方案。
保持模型的完整性
模型最基本的要求是它应该保持内部一致,术语总具有相同的意义,并且不包含互相矛盾的规则:虽然我们很少明确地考虑这些要求。 模型的内部一致性又叫做统一(unification),这种情况下,每个术语都不会有模棱两可的意义,也不会有规则冲突。除非模型在逻辑上是一致的,否则它就没有意义。 在理想世界中,我们可以得到涵盖整个企业领域的单一模型。这个模型将是统一的,没有任何相互矛盾或相互重叠的术语定义。每个有关领域的逻辑声明都是一致的。
大型系统开发并非如此理想。在整个企业系统中保持这种水平的统一是一件得不偿失的事情。 在系统的各个不同部分中开发多个模型是很有必要的,但我们必须慎重地选择系统的哪些部分可以分开,以及它们之间是什么关系。 我们需要用一些方法来保持模型关键部分的高度统一。所有这些都不会自行发生,而且光有良好的意愿也是没用的。它只有通过有意识的设计决策和建立特定过程才能实现。
大型系统领域模型的完全统一即不可行,也不划算。
有时人们会反对这一点。大多数人都看到了多个模型的代价:它们限制了集成,并且使沟通变得很麻烦。更重要的是,多个模型看上去似乎不够雅致。 有时,对多个模型的抵触会导致“极富雄心”的尝试——将一个大型项目中的所有软件统一到单一模型中。但请一定要考虑下面的风险。
- 一次尝试对遗留系统做过多的替换。
- 大项目可能会陷入困境,因为协调的开销太大,超出了这些项目的能力范围。
- 具有特殊需求的应用程序可能不得不使用无法充分满足需求的模型,而只能将这些无法满足的行为放到其他地方。
- 另一方面,试图用一个模型来满足所有人的需求可能会导致模型中包含过于复杂的选择,因而很难使用。
此外,除了技术上的因素以外,权力上的划分和管理级别的不同也可能要求把模型分开。而且不同模型的出现也可能是团队组织和开发过程导致的结果。 因此,即使完全的集成没有来自技术方面的阻力,项目也可能会面临多个模型。
既然无法维护一个涵盖整个企业的统一模型,那就不要再受到这种思路的限制。通过预先决定什么应该统一,并实际认识到什么不能统一,我们就能够创建一个清晰的、共同的视图。 确定了这些之后,就可以着手开始工作,以保证那些需要统一的部分保持一致,不需要统一的部分不会引起混乱或破坏模型。
我们需要用一种方式来标记出不同模型之间的边界和关系。我们需要有意识地选择一种策略,并一致地遵守它。
首先从描绘项目当前的范围开始。BOUNDED CONTEXT(限界上下文)定义了每个模型的应用范围,而CONTEXT MAP(上下文图)则给出了项目上下文以及它们之间关系的总体视图。 这些降低模糊性的技术能够使项目更好地进行,但仅仅有它们还是不够的。一旦确立了CONTEXT的边界之后,仍需要持续集成这种过程,它能够使模型保持统一。
其后,在这个稳定的基础之上,我们就可以开始实施那些在界定和关联CONTEXT方面更有效的策略了—从通过共享内核(SHARED KERNEL)来紧密关联上下文,到那些各行其道(SEPARATE WAYS)地进行松散耦合的模型。
模式:BOUNDED CONTEXT(有界上下文)
大型项目上会有多个模型共存,在很多情况下这没什么问题。不同的模型应用于不同的上下文中。
任何大型项目都会存在多个模型。而当基于不同模型的代码被组合到一起后,软件就会出现bug、变得不可靠和难以理解。团队成员之间的沟通变得混乱。 人们往往弄不清楚一个模型不应该在哪个上下文中使用。
明确地定义模型所应用的上下文。根据团队的组织、软件系统的各个部分的用法以及物理表现(代码和数据库模式等)来设置模型的边界。 在这些边界中严格保持模型的一致性,而不要受到边界之外问题的干扰和混淆。
BOUNDED CONTEXT明确地限定了模型的应用范围,以便让团队成员对什么应该保持一致以及上下文之间如何关联有一个明确和共同的理解。 在CONTEXT中,要保证模型在逻辑上统一,而不用考虑它是不是适用于边界之外的情况。 在其他CONTEXT中,会使用其他模型,这些模型具有不同的术语、概念、规则和UBIQUITOUS LANGUAGE的技术行话。 通过划定明确的边界,可以使模型保持纯粹,因而在它所适用的CONTEXT中更有效。同时,也避免了将注意力切换到其他CONTEXT时引起的混淆。 跨边界的集成必然需要进行一些转换,但我们可以清楚地分析这些转换。
BOUNDED CONTEXT不是MODULE
有时这两个概念易引起混淆,但它们是具有不同动机的不同模式。 确实,当两组对象组成两个不同模型时,人们几乎总是把它们放在不同的MODULE中。这样做的确提供了不同的命名空间(对不同的CONTEXT很重要)和一些划分方法。
但人们也会在一个模型中用MODULE来组织元素,它们不一定要表达划分CONTEXT的意图。 ODULE在BOUNDED CONTEXT内部创建的独立命名空间实际上使人们很难发现意外产生的模型分裂。
当然,边界只不过是一些特殊的位置。各个BOUNDED CONTEXT之间的关系需要我们仔细地处理。 CONTEXT MAP画出了上下文的范围,并给出了CONTEXT以及它们之间联系的总体视图,而几种模式定义了CONTEXT之间的各种关系的性质。 CONTINUOUS INTEGRATION的过程可以使模型在BOUNDED CONTEXT中保持统一。
当模型的统一性被破坏时,模型会是什么样子呢?我们又该如何识别概念上的不一致呢?
识别BOUNDED CONTEXT中的不一致
很多征兆都可能表明模型中出现了差异。
- 最明显的是已编码的接口不匹配。
- 对于更微妙的情况,一些意外行为也可能是一种信号。
- 采用了自动测试的CONTINUOUS INTEGRATION可以帮助捕捉到这类问题。
- 但语言上的混乱往往是一种早期的警告信号。
将不同模型的元素组合到一起可能会引发两类问题:重复的概念和假同源。
- 重复的概念是指两个模型元素(以及伴随的实现)实际上表示同一个概念。
- 每当这个概念的信息发生变化时,都必须更新两个地方。每次由于新知识导致一个对象被修改时,必须重新分析和修改另一个对象。
- 如果不进行实际的重新分析,结果就会出现同一概念的两个版本,它们遵守不同的规则,甚至有不同的数据。
- 更严重的是,团队成员必须学习做同一件事情的两种方法,以及保持这两种方法同步的各种方式。
- 假同源可能稍微少见一点,但它潜在的危害更大。它是指使用相同术语(或已实现的对象)的两个人认为他们是在谈论同一件事情,但实际上并不是这样。
- 当两个定义都与同一个领域方面相关,而只是在概念上稍有区别时,这种冲突更难以发现。
- 假同源会导致开发团队互相干扰对方的代码,也可能导致数据库中含有奇怪的矛盾,还会引起团队沟通的混淆。
当发现这些问题时,团队必须要做出相应的决定。
可能需要将模型重新整合为一体,并加强用来预防模型分裂的过程。分裂也有可能是由分组造成的,一些小组出于合理的原因,需要以一些不同的方式来开发模型,而且你可能也决定让他们独立开发。
模式:CONTINUOUS INTEGRATION(持续集成)
当很多人在同一个BOUNDED CONTEXT中工作时,模型很容易发生分裂。团队越大,问题就越大,但即使是3、4个人的团队也有可能会遇到严重的问题。 然而,如果将系统分解为更小的CONTEXT,最终又难以保持集成度和一致性。
极限编程(XP)在这样的环境中真正显示出自己的强大威力。很多XP实践都是针对在很多人频繁更改设计的情况下如何维护设计的一致性这个特定问题而出现的。 最纯粹的XP非常适合维护单一BOUNDED CONTEXT中的模型完整性。但是,无论是否使用XP,都很有必要采取CONTINUOUS INTEGRATION过程。
CONTINUOUS INTEGRATION是指把一个上下文中的所有工作足够频繁地合并到一起,并使它们保持一致,以便当模型发生分裂时,可以迅速发现并纠正问题。 像领域驱动设计中的其他方法一样,CONTINUOUS INTEGRATION也有两个级别的操作:
- 模型概念的集成;
- 实现的集成。
团队成员之间通过经常沟通来保证概念的集成。团队必须对不断变化的模型形成一个共同的理解。有很多方法可以帮助做到这一点,但最基本的方法是对UBIQUITOUS LANGUAGE多加锤炼。 同时,实际工件通过系统性的合并/构建/测试过程来集成,这样能够尽早暴露出模型的分裂问题。用来集成的过程有很多,大部分有效的过程都具备以下这些特征:
- 分步集成,采用可重现的合并/构建技术;
- 自动测试套件;
- 有一些规则,用来为那些尚未集成的改动设置一个相当小的生命期上限。
有效过程的另一面是概念集成,虽然它很少被正式地纳入进来。
- 在讨论模型和应用程序时要坚持使用UBIQUITOUS LANGUAGE。
建立一个把所有代码和其他实现工件频繁地合并到一起的过程,并通过自动化测试来快速查明模型的分裂问题。 严格坚持使用UBIQUITOUS LANGUAGE,以便在不同人的头脑中演变出不同的概念时,使所有人对模型都能达成一个共识。
最后,不要在持续集成中做一些不必要的工作。CONTINUOUS INTEGRATION只有在BOUNDED CONTEXT中才是重要的。相邻CONTEXT中的设计问题(包括转换)不必以同一个步调来处理。
CONTINUOUS INTEGRATION可以在任何单独的BOUNDED CONTEXT中使用,只要它的工作规模大到需要两个以上的人去完成就可以。 它可以维护单一模型的完整性。当多个BOUNDED CONTEXT共存时,我们必须要确定它们的关系,并设计任何必需的接口。
模式:CONTEXT MAP
只有一个BOUNDED CONTEXT并不能提供全局视图。其他模型的上下文可能仍不清楚而且还在不断变化。
其他团队中的人员并不是十分清楚CONTEXT的边界,他们会不知不觉地做出一些更改,从而使边界变得模糊或者使互连变得复杂。当不同的上下文必须互相连接时,它们可能会互相重叠。
识别在项目中起作用的每个模型,并定义其BOUNDED CONTEXT。这包括非面向对象子系统的隐含模型。为每个BOUNDED CONTEXT命名,并把名称添加到UBIQUITOUS LANGUAGE中。
描述模型之间的联系点,明确所有通信需要的转换,并突出任何共享的内容。
先将当前的情况描绘出来。以后再做改变。
在每个BOUNDED CONTEXT中,都将有一种一致的UBIQUITOUS LANGUAGE的“方言”。 我们需要把BOUNDED CONTEXT的名称添加到该方言中,这样只要通过明确CONTEXT就可以清楚地讨论任意设计部分的模型。
CONTEXT MAP无需拘泥于任何特定的文档格式。我发现类似本章的简图在可视化和沟通上下文图方面很有帮助。有些人可能喜欢使用较多的文本描述或别的图形表示。 在某些情况下,团队成员之间的讨论就足够了。需求不同,细节层次也不同。不管CONTEXT MAP采用什么形式,它必须在所有项目人员之间共享,并被他们理解。它必须为每个BOUNDED CONTEXT提供一个明确的名称,而且必须阐明联系点和它们的本质。
模型上下文总是存在的,但如果我们不注意的话,它们可能会发生重叠和变化。通过明确地定义BOUNDED CONTEXT和CONTEXT MAP,团队就可以掌控模型的统一过程,并把不同的模型连接起来。
测试CONTEXT的边界
对各个BOUNDED CONTEXT的联系点的测试特别重要。这些测试有助于解决转换时所存在的一些细微问题以及弥补边界沟通上存在的不足。 测试充当了有用的早期报警系统,特别是在我们必须信赖那些模型细节却又无法控制它们时,它能让我们感到放心。
CONTEXT MAP的组织和文档化
这里只有以下两个重点。
- BOUNDED CONTEXT应该有名称,以便可以讨论它们。这些名称应该被添加到团队的UBIQUITOUS LANGUAGE中。
- 每个人都应该知道边界在哪里,而且应该能够分辨出任何代码段的CONTEXT,或任何情况的CONTEXT。
将CONTEXT MAP融入到讨论中都是至关重要的,前提是CONTEXT的名称要添加到UBIQUITOUS LANGUAGE中。
不要说“George团队的内容改变了,因此我们也需要改变那些与其进行交互的内容”,而应该说:“Transport Network模型发生了改变,因此我们也需要修改Booking上下文的转换器
BOUNDED CONTEXT之间的关系
把模型连接到一起之后,就能够把整个企业笼括在内。这些模式有着双重目的,
- 一是为成功地组织开发工作设定目标,
- 二是为描述现有组织提供术语。
下面这些模式涵盖了一些最常见和最重要的情况,它们提供了一些很好的思路,沿着这些思路,我们就可以知道如何处理其他情况。
开发一个紧密集成产品的优秀团队可以部署一个大的、统一的模型。
- 如果团队需要为不同的用户群提供服务,或者团队的协调能力有限,可能就需要采用SHARED KERNEL(共享内核)或CUSTOMER/SUPPLIER(客户/供应商)关系。
- 有时仔细研究需求之后可能发现集成并不重要,而系统最好采用SEPARATE WAY(各行其道)模式。
- 当然,大多数项目都需要与遗留系统或外部系统进行一定程度的集成,这就需要使用OPEN HOST SERVICE(开放主机服务)或ANTICORRUPTION LAYER(防护层)。
模式:SHARED KERNEL(共享内核)
当功能集成受到局限,CONTINUOUS INTEGRATION的开销可能会变得非常高。尤其是当团队的技能水平或行政组织不能保持持续集成,或者只有一个庞大的、笨拙的团队时,更容易发生这种情况。 在这种情况下就要定义单独的BOUNDED CONTEXT,并组织多个团队。
当不同团队开发一些紧密相关的应用程序时,如果团队之间不进行协调,即使短时间内能够取得快速进展,但他们开发出的产品可能无法结合到一起。 最后可能不得不耗费大量精力在转换层上,并且频繁地进行改动,不如一开始就使用CONTINUOUS INTEGRATION那么省心省力,同时这也造成重复工作,并且无法实现公共的UBIQUITOUS LANGUAGE所带来的好处。
因此:
从领域模型中选出两个团队都同意共享的一个子集。当然,除了这个模型子集以外,还包括与该模型部分相关的代码子集,或数据库设计的子集。 这部分明确共享的内容具有特殊的地位,一个团队在没与另一个团队商量的情况下不应擅自更改它。
功能系统要经常进行集成,但集成的频率应该比团队中CONTINUOUS INTEGRATION的频率低一些。在进行这些集成的时候,两个团队都要运行测试。
SHARED KERNEL通常是CORE DOMAIN,或是一组GENERIC SUBDOMAIN(通用子领域),也可能二者兼有,它可以是两个团队都需要的任何一部分模型。 使用SHARED KERNEL的目的是减少重复(并不是消除重复,因为只有在一个BOUNDED CONTEXT中才能消除重复),并使两个子系统之间的集成变得相对容易一些。
当使用了不同的实现技术时,SHARED KERNEL是很难做到的。
模式:CUSTOMER/SUPPLIER DEVELOPMENT TEAM(客户/供应商)
我们常常会碰到这样的情况:一个子系统主要服务于另一个子系统;“下游”组件执行分析或其他功能,这些功能向“上游”组件反馈的信息非常少,所有依赖都是单向的。 两个子系统通常服务于完全不同的用户群,其执行的任务也不同,在这种情况下使用不同的模型会很有帮助。工具集可能也不相同,因此无法共享程序代码。
如果下游团队对变更具有否决权,或请求变更的程序太复杂,那么上游团队的开发自由度就会受到限制。 由于担心破坏下游系统,上游团队甚至会受到抑制。同时,由于上游团队掌握优先权,下游团队有时也会无能为力。
因此:
在两个团队之间建立一种明确的客户/供应商关系。在计划会议中,下游团队相当于上游团队的客户。 根据下游团队的需求来协商需要执行的任务并为这些任务做预算,以便每个人都知道双方的约定和进度。
两个团队共同开发自动化验收测试,用来验证预期的接口。把这些测试添加到上游团队的测试套件中,以便作为其持续集成的一部分来运行。 这些测试使上游团队在做出修改时不必担心对下游团队产生副作用。
这种模式有两个关键要素。
- 关系必须是客户与供应商的关系,其中客户的需求是至关重要的。
- 由于下游团队并不是唯一的客户,因此不同客户的要求必须通过协商来平衡,但这些要求都是非常重要的。
- 这种关系与那种经常出现的“穷亲威”关系相反,在后者的关系中,下游团队不得不乞求上游团队满足其需求。
- 必须有自动测试套件,使上游团队在修改代码时不必担心破坏下游团队的工作,并使下游团队能够专注于自己的工作,而不用总是密切关注上游团队的行动。
CUSTOMER/SUPPLIER TEAM涉及的团队如果能在同一个部门中工作,最后会形成共同的目标,这样成功机会将更大一些,如果两个团队分属不同的公司,但实际上也具有这些角色,同样也容易成功。 但是,当上游团队不愿意为下游团队提供服务时,情况就会完全不同……
模式:CONFORMIST(遵从者)
当两个开发团队具有上/下游关系时,如果上游团队没有动力来满足下游团队的需求,那么下游团队将无能为力。出于利他主义的考虑,上游开发人员可能会做出承诺,但他们可能不会履行承诺。 下游团队出于良好的意愿会相信这些承诺,从而根据一些永远不会实现的特性来制定计划。下游项目只能被搁置,直到团队最终学会利用现有条件自力更生为止。下游团队不会得到根据他们的需求而量身定做的接口。
在这种情况下,有3种可能的解决途径。
- 一种是完全放弃对上游的使用。做出这种选择时,应进行切实地评估,绝不要假定上游会满足下游的需求。有时我们会高估这种依赖性的价值,或是低估它的成本。如果下游团队决定切断这条链,他们将走上SEPARATE WAY(各行其道)的道路。
- 有时,使用上游软件具有非常大的价值,因此必须保持这种依赖性(或者是行政决策规定团队不能改变这种依赖性)。在这种情况下,还有两种途径可供选择,选择哪一种取决于上游设计的质量和风格。
- 如果上游的设计很难使用(可能是由于缺乏封装、使用了不恰当的抽象或者建模时使用了下游团队无法使用的范式),那么下游团队仍然需要开发自己的模型。
- 他们将担负起开发转换层的全部责任,这个层可能会非常复杂(参见ANTICORRUPTION LAYER)。
- 另一方面,如果上游设计的质量不是很差,而且风格也能兼容的话,那么最好不要再开发一个独立的模型。这种情况下可以使用CONFORMIST(跟随者)模式。
- 如果上游的设计很难使用(可能是由于缺乏封装、使用了不恰当的抽象或者建模时使用了下游团队无法使用的范式),那么下游团队仍然需要开发自己的模型。
因此:
通过严格遵从上游团队的模型,可以消除在BOUNDED CONTEXT之间进行转换的复杂性。 尽管这会限制下游设计人员的风格,而且可能不会得到理想的应用程序模型,但选择CONFORMITY模式可以极大地简化集成。此外,这样还可以与供应商团队共享UBIQUITOUS LANGUAGE。 供应商处于统治地位,因此最好使沟通变容易。他们从利他主义的角度出发,会与你分享信息。
这个决策会加深你对上游团队的依赖,同时你的应用也受限于上游模型的功能,充其量也只能做一些简单的增强而已。人们在主观上不愿意这样做,因此有时本应该这样选择时,却没有这样选择。
如果这些折中不可接受,而上游的依赖又必不可少,那么还可以选择第二种方法。通过创建一个ANTICORRUPTION LAYER来尽可能把自己隔离开,这是一种实现转换映射的积极方法。
CONFORMIST模式类似于SHARED KERNEL模式。在这两种模式中,都有一个重叠的区域——在这个重叠区域内模型是相同的,此外还有你的模型所扩展的部分,以及另一个模型对你没有影响的部分。 这两种模式之间的区别在于决策制定和开发过程不同。SHARED KERNEL是两个高度协调的团队之间的合作模式,而CONFORMIST模式则是应对与一个对合作不感兴趣的团队进行集成。
在两个BOUNDED CONTEXT之间集成时可以进行的各种合作,从高度合作的SHARED KERNEL模式或CUSTOMER/SUPPLIER DEVELOPER TEAM到单方面的CONFORMIST模式。
跟随并不总是坏事
当使用一个具有很大接口的现成组件时,一般应该遵循(CONFORM)该组件中隐含的模型。 组件和你自己的应用程序显然是不同的BOUNDED CONTEXT,因此根据团队组织和控制的不同,可能需要使用适配器来进行一点点格式转换,但模型一定要保持相同。 否则,就应该质疑使用该组件的价值。如果它确实能够提供价值,那说明它的设计中已经消化吸收了一些知识。 在该组件的应用范围内,它可能比你的理解要深入。你的模型大概会超出该组件的范围,而且这些超出部分将演化出你自己的概念。 但在两者连接的地方,你的模型将是一个CONFORMIT,遵从组件模型的领导。实际上,你将被带到一个更好的设计中。
当你与组件的接口很小时,那么共享一个统一模型就不那么重要了,而且转换也是个可行的选项。但是,当接口很大而且集成更加重要时,跟随通常是有意义的。
模式:ANTICORRUPTION LAYER(防腐层)
新系统几乎总是需要与遗留系统或其他系统进行集成,这些系统具有其自己的模型。当把参与集成的BOUNDED CONTEXT设计完善并且团队相互合作时,转换层可能很简单,甚至很优雅 但是,当边界那侧发生渗透时,转换层就要承担起更多的防护职责。
当正在构建的新系统与另一个系统的接口很大时,为了克服连接两个模型而带来的困难,新模型所表达的意图可能会被完全改变,最终导致它被修改得像是另一个系统的模型了(以一种特定的风格)。 遗留系统的模型通常很弱。即使对于那些模型开发得很好的例外情况,它们可能也不符合当前项目的需要。然而,集成遗留系统仍然具有很大的价值,而且有时还是绝对必要的。
我们需要在不同模型的关联部分之间建立转换机制,这样模型就不会被未经消化的外来模型元素所破坏。
因此:
创建一个隔离层,以便根据客户自己的领域模型来为客户提供相关功能。这个层通过另一个系统现有接口与其进行对话,而只需对那个系统作出很少的修改,甚至无需修改。 在内部,这个层在两个模型之间进行必要的双向转换。
这种连接两个系统的机制可能会使我们想到把数据从一个程序传输到另一个程序,或者从一个服务器传输到另一个服务器。 技术通信机制的使用这些细节问题不应与ANTICORRUPTION LAYER混淆,因为ANTICORRUPTION LAYER并不是向另一个系统发送消息的机制。 相反,它是在不同的模型和协议之间转换概念对象和操作的机制。
ANTICORRUPTION LAYER本身就可能是一个复杂的软件。接下来将概要描述在创建ANTICORRUPTION LAYER时需要考虑的一些事项。
设计ANTICORRUPTION LAYER的接口
ANTICORRUPTION LAYER的公共接口通常以一组SERVICE的形式出现,但偶尔也会采用ENTITY的形式。 构建一个全新的层来负责两个系统之间的语义转换为我们提供了一个机会,它使我们能够重新对另一个系统的行为进行抽象,并按照与我们的模型一致的方式把服务和信息提供给我们的系统。 在我们的模型中,把外部系统表示为一个单独的组件可能是没有意义的。最好是使用多个SERVICE(或偶尔使用ENTITY),其中每个SERVICE都使用我们的模型来履行一致的职责。
实现ANTICORRUPTION LAYER
对ANTICORRUPTION LAYER设计进行组织的一种方法是把它实现为FACADE、ADAPTER(这两种模式来自[Gamma et al.1995])和转换器的组合,外加两个系统之间进行对话所需的通信和传输机制。
我们常常需要与那些具有大而复杂、混乱的接口的系统进行集成。这不是概念模型差别的问题(概念模型差别是我们使用ANTICORRUPTION LAYER的动机),而是一个实现问题。 当我们尝试创建ANTICORRUPTION LAYER时,会遇到这个实现问题。 当从一个模型转换到另一个模型的时候(特别是当一个模型很混乱时),如果不能同时处理那些难于沟通的子系统接口,那么将很难完成。好在FACADE可以解决这个问题。
- FACADE是子系统的一个可供替换的接口,它简化了客户访问,并使子系统更易于使用。
- 由于我们非常清楚要使用另一个系统的哪些功能,因此可以创建FACADE来促进和简化对这些特性的访问,并把其他特性隐藏起来。
- FACADE并不改变底层系统的模型。它应该严格按照另一个系统的模型来编写。否则会产生严重的后果:
- 轻则导致转换职责蔓延到多个对象中,并加重FACADE的负担;
- 重则创建出另一个模型,这个模型既不属于另一个系统,也不属于你自己的BOUNDED CONTEXT。
- FACADE应该属于另一个系统的BOUNDED CONTEXT,它只是为了满足你的专门需要而呈现出的一个更友好的外观。
- ADAPTER是一个包装器,它允许客户使用另外一种协议,这种协议可以是行为实现者不理解的协议。
- 当客户向适配器发送一条消息时,ADAPTER把消息转换为一条在语义上等同的消息,并将其发送给“被适配者”(adaptee)。之后ADAPTER对响应消息进行转换,并将其发回。
- 在这里使用适配器(adapter)这个术语略微有点儿不严谨,因为[Gamma et al.1995]一书中强调的是使包装后的对象符合客户所期望的标准接口,而我们选择的是被适配的接口,而且被适配者甚至可能不是一个对象。
- 强调的是两个模型之间的转换,但我认为这与ADAPTER的意图是一致的。
- 当客户向适配器发送一条消息时,ADAPTER把消息转换为一条在语义上等同的消息,并将其发送给“被适配者”(adaptee)。之后ADAPTER对响应消息进行转换,并将其发回。
- 我们所定义的每种SERVICE都需要一个支持其接口的ADAPTER,这个适配器还需要知道怎样才能向其他系统及其FACADE发出相应的请求)。
- 剩下的要素就是转换器了。
- ADAPTER的工作是知道如何生成请求。概念对象或数据的实际转换是一种完全不同的复杂任务,我们可以让一个单独的对象来承担这项任务,这样可以使负责转换的对象和ADAPTER都更易于理解。
- 转换器可以是一个轻量级的对象,它可以在需要的时候被实例化。由于它只属于它所服务的ADAPTER,因此不需要有状态,也不需要是分布式的。
这些都是我用来创建ANTICORRUPTION LAYER的基本元素。此外,还有其他一些需要考虑的因素。
一般是由正在设计的系统(你的子系统)来发起一个动作。但在有些情况下,其他子系统可能需要向你的子系统提交某种请求,或是把某个事件通知给你的子系统。 ANTICORRUPTION LAYER可以是双向的,它可能使用具有对称转换的相同转换器来定义两个接口上的SERVICE(并使用各自的ADAPTER)。 尽管实现ANTICORRUPTION LAYER通常不需要对另一个子系统做任何修改,但为了使它能够调用ANTICORRUPTION LAYER的SERVICE,有时还是有必要修改的。
- 我们通常需要一些通信机制来连接两个子系统,而且它们可能位于不同的服务器上。
- 在这种情况下,必须决定在哪里放臵通信链接。如果无法访问另一个子系统,那么可能必须在FACADE和另一个子系统之间设臵通信链接。
- 但是,如果FACADE可以直接与另一个子系统集成到一起,那么在适配器和FACADE之间设臵通信链接也不失为一种好的选择,这是因为FACADE的协议比它所封装的内容要简单。
- 在有些情况下,整个ANTICORRUPTION LAYER可以与另一个子系统放在一起,这时可以在你的系统和构成ANTICORRUPTION LAYER接口的SERVICE之间设臵通信链接或分发机制。
- 这些都是需要根据实际情况做出的实现和部署决策。它们与ANTICORRUPTION LAYER的概念角色无关。
- 如果有权访问另一个子系统,你可能会发现对它进行少许的重构会使你的工作变得更容易。特别是应该为那些需要使用的功能编写更显式的接口,如果可能的话,首先从编写自动测试开始。
- 当需要进行广泛的集成时,转换的成本会直线上升。
- 这时需要对正在设计的系统的模型做出一些选择,使之尽量接近外部系统,以便使转换更加容易。做这些工作时要非常小心,不要破坏模型的完整性。
- 这是只有当转换的难度无法掌控时才选择进行的事情。
- 如果这种方法看起来是大部分重要问题的最自然的解决方案,那么可以考虑让你的子系统采用CONFORMIST模式,从而消除转换。
- 如果另一个子系统很简单或有一个很整洁的接口,可能就不需要FACADE了。
- 如果一个功能是两个系统的关系所需的,就可以把这个功能添加到ANTICORRUPTION LAYER中。此外我们还很容易想到两个特性,一是外部系统使用情况的审计跟踪,二是追踪逻辑,其用于调试对另一个接口的调用。
记住,ANTICORRUPTION LAYER是连接两个BOUNDED CONTEXT的一种方式。 我们常常需要使用别人创建的系统,然而我们并未完全理解这些系统,并且也无法控制它们。 但这并不是我们需要在两个子系统之间使用防护层的唯一情况。
- 如果你自己开发的两个子系统基于不同的模型,那么使用ANTICORRUPTION LAYER把它们连接起来也是有意义的。在这种情况下,你应该可以完全控制这两个子系统,而且通常可以使用一个简单的转换层。
- 但是,如果这两个BOUNDED CONTEXT采用了SEPARATE WAY模式,而仍然需要进行一定的功能集成,那么可以使用ANTICORRUPTION LAYER来减少它们之间的矛盾。
任何集成都是有开销的,无论这种集成是单一BOUNDED CONTEXT中的完全CONTINUOUS INTEGRATION,还是集成度较轻的SHARED KERNEL或CUSTOMER/SUPPLIER DEVELOPER TEAM,或是单方面的CONFORMIST模式和防御型的ANTICORRUPTION LAYER模式。 集成可能非常有价值,但它的代价也总是十分高昂的。我们应该确保在真正需要的地方进行集成。
模式:SEPARATE WAY(各行其道)
我们必须严格划定需求的范围。如果两组功能之间的关系并非必不可少,那么二者完全可以彼此独立。
集成总是代价高昂,而有时获益却很小。
因此:
声明一个与其他上下文毫无关联的BOUNDED CONTEXT,使开发人员能够在这个小范围内找到简单、专用的解决方案。
采用SEPARATE WAY(各行其道)模式需要预先决定一些选项。尽管持续重构最后可以撤销任何决策,但完全隔离开发的模型是很难合并的。如果最终仍然需要集成,那么转换层将是必要的,而且可能很复杂。 当然,不管怎样,这都是我们将要面对的问题。
模式:OPEN HOST SERVICE(公开主机服务)
一般而言,对于每个限界上下文,您都要给上下文外的每个组件定义一个必须集成的转换层。这里的集成是一次性的,这种为每个外部系统插入转换层的方法,能够以最小的代价避免破坏模型。 但是当您发现您的子系统需要大量集成时,就可能需要寻求另外一种更加灵活的方法了。
当子系统必须与大量的其他系统集成时,为每个系统定制一个转换器可能会让团队陷入困境。转换器越多,那么在做出改变时,需要担心的事情也越多。
因此:
定义一个协议,把您的子系统作为一组服务来使用。开放这个协议,让所有需要与您集成的人都能使用该协议。 除非当一个团队有特殊需要时,否则,应该改进和扩充协议来满足新的集成需求。而为那种特殊情况使用一次性转换器来扩充该协议,从而可以保持公用协议的简单性和一致性。
这种形式化的通信包含了一些共享的模型词汇,即服务接口的基础。 结果,其他的子系统都被关联到开放主机(Open Host)的模型中,并且其他的开发团队都被迫去学习主体团队使用的专门用语。 所以,在一些情况下,使用众所周知的公布语言(Published language)作为交换模型可以减少关联,而且也易于理解。
模式:PUBLISHED LANGUAGE(公布语言)
两个BOUNDED CONTEXT的模型之间进行转换需要一种共同语言。
与现有模型进行直接的转换可能不是一种好的解决方案。这些模型可能过于复杂或设计得较差。他们可能没有很好的文档化。如果把其中一个模型作为数据交换语言,它实质上就被固定住了,而无法满足新的开发需求。
把一个良好文档化的、能够表达出所需领域信息的共享语言作为公共的通信媒介,必要时在其他信息与该语言之间进行转换。
可扩展标记语言(XML)。这种新技术让数据交换变得更加容易。XML的一个非常有价值的特点是,通过文档类型定义(DTD)或者XML模式,XML允许对专门领域的语言进行正式定义,数据能被转换成该领域的语言。 为了给工厂制定出一个标准的DTD,现在已经开始形成了相应的行业团体。实际上,这些团体正在用语言定义的形式创建一种共享的领域模型。由此可以说,化学公式信息或者遗传代码信息可以在许多团体之间沟通交换。
”大象“的统一
承认多个互相冲突的领域模型实际上正是面对现实的做法,通过明确定义每个模型都适用的上下文,可以维护每个模型的完整性,并清楚地看到要在两个模型之间创建的任何特殊接口的含义。
盲人没法看到整个大象,但只要他们承认各自的理解是不完整的,他们的问题就能得到解决。
选择你的模型上下文策略
绘制出CONTEXT MAP来反映当前状况是很重要的,但是,一旦绘制好CONTEXT MAP之后,你很可能想要改变现状。 现在,你可以开始有意识的选择CONTEXT的边界和关系。
以下是一些指导规则
团队决策或更高层的决策
首先,团队必须对在哪里定义限界上下文以及它们之间有什么样的关系都要做出决策。这些决策必须由团队做出,或者至少要保证团队的每一个人都被通知到并且能够理解决策的全部意图。 事实上,像这样的决策通常要在自己的团队内达成一致的意见。有可能会在独立的团队作用和直接、充分的集成之间权衡,然后做出是否扩展或者分割限界上下文的决策。
在实践中,团队之间的地位关系通常决定了系统应该如何集成。由于团队的等级结构,可能做不到技术上的有利统一,管理层可能会要求做出不实用的合并。 您不可能总会获得您想要的东西,但至少可以估计出因此而需要承担的代价,并设法减轻这些开销,从一个实际的上下文映射开始分析,同时在选择转换时更要注重实效。
置身上下文中
当我们在开发一个软件项目时,主要是关注系统中我们团队正在改变的那些部分内容,其次才会关注那些需要与其通信的系统。
一个典型的例子是,打算把设计系统划分成一个或两个限界上下文,由主开发团队来负责开发,可能让另外的一两个上下文来担当辅助的角色。除此之外,剩下的就是这些上下文和外部系统之间的关系了。 这是一个简单的典型视图,对您可能碰到的情况作了大致性的描述。
我们只不过是在这个主要的上下文的一部分中工作,这在我们的上下文映射中能够反映出来。 如果我们意识到偏好的存在,并且在我们离开了图的适用范围时能意识到,那么就不会有什么问题了。
转换边界
在划定限界上下文的边界时,会有无数种变化情况和无数种选择。但通常是对下面各个方面进行权衡:
- 选择较大的限界上下文
- 当用一个统一模型来处理更多的用户任务时,它们之间的流动更加平稳。
- 一个内聚的模型比两个模型加上它们的映射更容易理解。
- 两个模型之间的转换可能很难(有时是不可能的)。
- 共享术语使团队之间的交流更清楚。
- 选择较小的限界上下文
- 开发人员之间的交流开销降低。
- 规模小的团队和代码库使持续集成更加容易。
- 更大的上下文可能要求更加通用的抽象模型,所需要的技巧很少有人能掌握。
- 不同的模型可以满足特别的需要,或者包括特殊用户群的行话以及通用语言的专门术语。
在两个不同限界上下文之间进行功能的深度集成是不切实际的。集成被限制在模型中那些已经严格按照另一种模型规定的部分进行,而且这种集成能够起到相当大的作用。 当两个系统之间存在小接口时进行深度集成才会有意义。
接受我们无法改变的事物:描述外部系统
最好从最容易的决策开始入手。一些子系统明显不属于在开发的系统的任何限界上下文。 例如,您不可能马上替换掉的旧系统和提供您需要服务的外部系统。您可以马上确定这些系统,然后准备把它们从您的设计当中分离出去。
这里要注意我们所作的假设。虽然对这些系统都建立起自己的限界上下文,想起来可能应该很方便,但是大多数外部系统是很难满足定义。 最重要的是,限界上下文是试图在某些边界内统一模型。您可能负责管理老式系统的维护工作,在这种情况下,您可以声明这种目的,要不然就协助原来的开发团队进行非正式的持续集成,但是不要想当然地认为对每一个系统都建立一个限界上下文。 检查一下,如果开发不能很好地被集成,就要特别小心了。在这样的系统的不同部分中,发现语义矛盾并不稀奇。
与外部系统的关系
在这里可以应用3个模式。
- 首先,考虑SEPARATE WAY。
- 是的,如果真的不需要集成,就不要把它们包括进来。但是让用户轻松地使用这两个系统就足够了吗?集成的代价不仅昂贵而且还分散了开发的注意力,因此要尽可能多地解除项目中的负担。
- 如果集成真的很重要,可以在两个极端之间选择:CONFORMIST或者ANTICORRUPTION LAYER。
- 作为CONFORMIST并不好玩。您的创造力和选择新功能的权利将受到限制。创建一个主要的新系统,按照遗留或者外部系统来设计未必管用(否则为什么要创建一个新系统?)。
- 可是,对旧系统进行扩充,当然它仍然还是主要系统,在这种情况下,采用原有模型可能比较合适。这样的例子包括经常用Excel编写的轻量级决策支持工具或者其他的一些简单工具。如果您的应用真的是现有系统的扩展,并且与那个系统的接口增大了,那么,上下文之间的转换工作比实现应用程序本身的功能将还要大。
- 即使您已经把自己放到另外那个系统的限界上下文中,仍然可以釆用一些优秀的设计。如果在另外那个系统中有一个可辨别的领域模型,那么,只要严格遵照这个老模型,使得该模型更加清楚,就可以改进您的实现。
- 如果决定釆用CONFORMIST模式来设计的话,就必须尽心尽力地使用老模式。这样,把自己限定在只对现有的模型进行扩充,而不是修改它。
- 如果设计的系统与另外那个系统的接口较小,或者那个系统设计得很糟糕,那么当实现它的功能要比扩充现有系统更加棘手时,就需要有自己的限界上下文,这意味着需要创建一个转换层,甚至ANTICORRUPTION LAYER。
设计中的系统
您的项目团队实际上开发的软件是在设计系统(system underdesign)。您可以在这个范围内确定限界上下文,并在每个上下文中应用持续集成,从而保持它们统一。 但是需要确定多少个限界上下文呢?它们彼此有什么样的关系呢?因为在这个范围内,我们有更多的自由开发和管理的能力,所以比起使用外部系统的情况来说,答案就变得更加不确定。
解决的方法应该非常简单:为整个在设计系统创建单个限界上下文。 例如,团队成员应少于10个人,而且他们开发的功能都紧密相关。
如果开发团队变大,持续集成可能会变得困难(尽管我曾经看到过在比较大的团队中持续集成)。您可能会考虑采用共享内核,把相对独立的功能集划分到不同的限界上下文中,每个上下文环境的开发人员都不超过10个人。 如果这两个上下文中的所有依赖关系都是单向的,那么您就可以采用顾客/供应商开发团队。
您可能会认识到由于两个开发小组的思路相差太大,以至于他们的建模努力经常会发生冲突。可能是由于他们的实际需要差别很大,也可能是因为不同的背景知识,再或者可能是由于项目中嵌入的管理结构,这些可能性都将会造成了冲突。如果不能改变造成冲突的原因,或者不想改变时,可以让模型选择隔离方式。 可以在需要集成的地方开发一个转换层,作为持续集成的单个点,由两个团队共同维护。同集成外部系统作对比,不管有没有另外一边的大力支持,防腐层通常必须要适应另外一边的那个系统。
一般而言,每个限界上下文对应一个开发团队。虽然一个开发团队可以维护多个限界上下文,但是很难(尽管不是不可能)让多个开发团队工作在一个限界上下文中。
用不同模型满足特殊需要
开发相同业务的不同小组经常会使用自己的专门术语,但它们的意思可能互不相同。这些行话可能很精确并且能够符合他们的需要。改变它们(例如,强加一种标准的、企业范围的术语)需要进行广泛训练和分析后才能够解决它们之间的差异问题。尽管那样,新术语可能还是不如原有术语表达的意思精确。
您可能会决定在不同的限界上下文中满足这些特别的需要,除了转换层要持续集成外,允许模型使用隔离方式。通用语言的不同方言会围绕这些模型和模型基于的行话演化。 如果两种方言有重叠,那么共享内核模式可以提供必要的特殊化处理,将转换代价减到最小。
如果不需要集成,或者集成相对有限,那么就继续使用惯用术语以免破坏模型。这种做法仍然存在相应的代价和危险。
- 缺少共享术语会减少交流
- 增加集成的费用
- 随着同一个业务活动和实体的不同模型的演化,会有一些重复开发工作。
但可能出现的最大危险是,它可能会变成反对变化的一场争论或者一次为所有不常见、有局限性模型作的一次辩护。 究竟需要对系统的这个部分作多少修改才能满足特殊需要呢?最重要的是,这个用户群里使用的特殊行话到底有多少价值呢?因此必须针对转换的风险权蘅是否采取更加独立的开发方式,注意合理地处理术语的变化。
有时会出现一种深度模型,能够统一这些不同的术语并且满足双方需要。只有在经历了大量的开发和知识消化后,深度模型才会在生命周期的后面出现。所以您不可能去设计一个深度模型,只能在它出现时,您才能抓住这个机会,改变策略,重新构造。
记住,如果集成的需求很大,那么转换的代价就会攀升。从对有复杂转换的对象进行很小的修改,到对共享内核的修改,尽管不需要完全统一,但团队之间的协调仍可以使转换变得更加容易。
部署
协调打包和复杂系统的部署是一项麻烦的任务,做起来总是比看起来复杂得多。选择限界上下文的策略对部署是有影响的。例如,如果顾客/供应商模式要重新部署开发系统,那么只有在他们互相协调好后,才能发布共同测试过的版本。代码和数据的迁移必须在这些合并中进行。在一个分布式系统里,我们可以在一个过程里保留上下文之间的转换层,这样就不会出现多种版本共存的现象了。
如果数据迁移很耗时,或者分布式系统不能即时更新,会导致两种版本的代码和数据共存,也就是说,即使是部署一个限界上下文里的组件也可能具有挑战性。
根据部署的环境和技术,有许多技术上需要考虑的事项。限界上下文的关系可以把问题指向关注的热点,并且转换接口都己经规划好了。
部署计划的可行性将反馈到上下文界限的绘制上来。当两个上下文由一个转换层连接时,其中一个环境可能会被更新,那么会有一个新的转换层提供相同的接口到另一个上下文。 不仅仅在开发阶段,而且在部署阶段,共享内核模式都会更加注重协调性。而隔离方式模式就会简单许多。
权衡
总结这些指导方针可以发现,有不少是关于统一模型或集成模型的策略。
概括地讲,
- 要把无缝集成的优势与附加的协调和通信代价进行权衡;
- 在选择更多独立行动与更平稳的通信之间进行权衡。
进行更大的统一需要控制有关子系统的设计。
当项目正在进行时
很多情况下,我们不是从头开发一个项目,而是会改进一个正在开发的项目。在这种情况下,第一步是根据当前的状况来定义BOUNDED CONTEXT。 这很关键。为了有效地定义上下文,CONTEXT MAP必须反映出团队的实际工作,而不是反映那个通过遵守以上描述的指导原则而得出的理想组织。
描述了当前真实的BOUNDED CONTEXT以及它们的关系以后,下一步就是围绕当前组织结构来加强团队的工作。在CONTEXT中加强CONTINOUS INTEGRATION。 把所有分散的转换代码重构到ANTICORRUPTION LAYER中。命名现有的BOUNDED CONTEXT,并确保它们处于项目的UBIQUITOUS LANGUAGE中。
现在考虑一下边界和关系自身的变化。在开发一个新项目时,我已经描述了一些适用的法则,而这些变化会很自然地由这些相同的法则来驱动,但是这些变化必须被分成很小的部分,并且被合理地选择,从而用最小的代价以及对项目造成的最小破坏来换取最大的好处。
转换
像建模和设计的其他方面,有关BOUNDED CONTEXT的决策并非不可改变的。在很多情况下,我们必须改变最初有关边界以及BOUNDED CONTEXT之间关系的决策,这是不可避免的。 一般而言,分割CONTEXT是很容易,但合并它们或改变它们之间的关系却很难。
讲述几个典型的变化,它们很麻烦但却很重要。这些转换通常太大以至于需要一次重构,甚至可能是一次项目的迭代。 由于这个原因,我把这些转换作为一连串便于管理的步骤。当然,您必须要使这些指导方针适合于特殊的环境和事件。
合并CONTEXT:SEPARATE WAY->SHARED KERNEL
合并BOUNDED CONTEXT的动机很多:翻译开销够高、重复现象很明显。合并很难,但什么时候做都不晚,只是需要一些耐心。
即使你的最终目标是完全合并一个采用CONTINUOS INTEGRATION的CONTEXT,也应该先过渡到SHARED KERNEL。
- 评估现状。
- 在开始统一两个CONTEXT之前,一定要确信它们确实需要统一。
- 建立合并过程。
- 你需要决定代码的共享方式以及模块应该采用哪种命名约定。
- SHARED KERNEL的代码至少每周要集成一次,而且它必须有一个测试套件。在开发任何共享代码之前,先把它设置好。(测试套件将是空的,因此很容易通过!)
- 选择某个小的子领域作为开始,它应该是两个CONTEXT中重复出现的子领域,但不是CORE DOMAIN的一部分。
- 第一次合并将建立转换过程,所以最好使用一些简单的和相对普遍或非关键的东西。检查已有的集成和转换。选择一些已转换的东西比从一个已证明的转换开始要好得多,另外这样会减少转换层的任务。
- 现在,有两个模型应用于相同的子领域。主要有3种合并的方法。
- 可以选择一个模型并且重构另外的那个上下文,并保持它们的一致性。可以从全局的方式来做这个决定,有系统地更换一个上下文的模型,并保持作为单元开发的模型的内聚性。
- 或者可以每次选择一个,最后选择两者最好的那个(但是注意不要以混乱告终)。
- 第三种选择是找出一种新模型,可能这个要比任意一个原始模型来得更优秀些,并且能够承担双方的职责。
- 从两个团队中共选出2~4位开发人员组成一个小组,有他们来为子领域开发一个共享的模型。
- 不管是如何得到该模型的,都需要有一个详细的设计。这包括了确定同义词和映射所有没有被转换的术语等一些艰巨的任务。这个联合小组概述模型的基本测试集。
- 来自两个团队的开发成员一起负责实现模型(或修改要共享的现有代码)、确定各种细节并使模型开始工作。如果这些开发人员在模型中遇到了问题,就从第3步开始重新组织团队,并进行必要的概念修订工作。
- 每个团队的开发人员都承担与新的SHARED KERNEL集成的任务。
- 清除那些不再需要的翻译。
现在,您已经拥有一个很小的共享内核,而且还有了一个适当的过程来维护它。在后面的项目迭代设计过程中,重复步骤3~步骤7。 当这些过程稳定下来并且团队有了信心以后,您就能够处理更复杂的子系统,并且能够同时处理多个子系统或者核心领域的子系统了。
注意,当在处理模型中更多的领域专用部分时,可能会碰到这样的情况,两个模型采用不同用户群的专用行话。除非已经发现了深层模型的突破,为您提供一种能够替代两种专用语的语言,否则推迟将这些专用术语合并到共享内核中,这是一个十分明智的做法。共享内核的一个优势是,您不仅可以拥有一些持续集成的优点,同时还能保留一些隔离方式的优点。
另一个选择:
如果两个模型之中有一个直接符合首选的条件,那么考虑不集成而直接采用这个模型。这并不是共享公共子域,而是通过重构应用程序使用那个符合条件的上下文模型,并增强该模型,有系统地把这些子域的全部职责都转移到那个上下文中。无需任何集成的费用,就已经消除了冗余。 最终可能会(但不是必要的)完全采用那个比较有利的限界上下文,并且达到与合并相同的效果。转换时(可能很长或者不确定),必须对隔离方式模式和共享内核模式进行权衡。
合并CONTEXT:SHARED KERNEL->CONTINUOUS INTEGRATION
如果你的KERNEL正在扩大,你可能会被完全统一两个BOUNDED CONTEXT的优点所吸引。但这并不只是一个解决模型差异的问题。你将改变团队的结构,而且最终会改变人们所使用的语言。
这个过程从人员和团队开始准备。
- 确保每个团队都已经建立了CONTINOUS INTEGRATION所需的所有过程(共享代码所有权、频繁集成等)。两个团队协商集成步骤,以便所有人都以同一步调工作。
- 团队成员在团队之间流动。这样可以形成一大批同时理解两个模型的人员,并且可以把两个团队的人员联系起来。
- 澄清每个模型的精髓。
- 现在,团队应该有了足够的信心把核心领域合并到SHARED KERNEL中。
- 这可能需要进行几次迭代,有时候在新的共享和尚未共享的部分之间还会需要暂时的转换层。一旦要合并核心领域时,转换越迅速越好。这个阶段不仅代价高而且风险大,转换时间尽可能短,
- 优先权比大多数最新的开发高,但是不要超出您能处理的范围。
- 随着SHARED KERNEL的增长,把集成频率提高到每天一次,最后实现CONTINOUS INTEGRATION。
- 当SHARED KERNEL逐渐把先前两个BOUNDED CONTEXT的所有内容都包括进来的时候,你会发现要么形成了一个大的团队,要么形成了两个较小的团队,这两个较小的团队共享一个CONTINOUS INTEGRATION的代码库,而且团队成员可以经常在两个团队之间来回流动。
要合并核心模型,有几种选择。可以坚持一个模型并修改另外的模型来与它保持一致,或者创建子域的新模型,然后修改两个上下文去使用它。
注意,如果需要修改两个模型来满足不同用户的需要,可能需要有处理原始模型的专门能力,这就要求开发出一个能替代两个原始模型的深层模型。开发深层的统一模型是非常困难的,但如果坚持要完全合并这两个上下文的话,就不再需要使用多种专用语言了。根据最终模型和代码集成的清晰程度,会获得相应的好处,注意,并不是因为您的能力满足了用户的特殊需要。
逐步淘汰遗留系统
讨论一个普遍的情况:最近,一个在商业上每天使用的旧系统添加了几个更先进的新系统,它们与旧系统之间通过防腐层通信。
首先要执行的步骤是确定测试策略。应该为新系统中的新功能编写自动的单元测试,但逐步淘汰遗留系统还有一些特殊的测试要求。一些组织会在某段时间内同时运行新旧两个系统。
在任何一次迭代中:
- 在一次迭代中,确定旧系统中的功能,并把它们都添加到合适的新系统中去。
- 确定防腐层需要添加的东西。
- 实现
- 有时需要几次迭代才能写出替代旧系统某个组件的功能,但仍以小的、迭代尺寸的单元来计划新功能,只在部署时再进行多次的迭代的部署。
- 部署
- 另一个关键的地方是对模型进行部署。
- 如果这些小的、逐步增加的变化能够大批进行,会给开发带来好处。
- 但通常需要组织成大型的版本,还必须培训用户使用新的软件,有时候还必须要顺利地与开发并行完成,而且还会有许多后勤的问题必须去解决。
- 另一个关键的地方是对模型进行部署。
- 找出ANTICORRUPTION LAYER中那些不必要的部分,并去掉它们;
- 考虑删除遗留系统中目前未被使用的模块,虽然这种做法未必实际。
- 但有意思的是,设计得越好的软件越容易被淘汰。而设计糟糕的软件却很难被除去。
- 所以可以这样做,忽略未被使用的部分,直到剩余部分已经被淘汰后,再停止使用。
不断重复这几个步骤。遗留系统应该越来越少地参与业务,最终,替换工作会看到希望的曙光并完全停止遗留系统。 同时,当结合增加或减少系统之间的相互依赖时,防腐层也同时随之收缩和膨胀。如果其他地方都相同的话,当然首先迁移的那些功能应该使形成的防腐层变得较小才对。但可能会受到其他因素的影响,在一些过渡阶段,您可能必须忍受一些危险的转换。
OPEN HOST SERVICE->PUBLISHED LANGUAGE
我们已经通过一系列特地的协议与其他系统进行了集成,但随着需要访问的系统逐渐增多,维护负担也不断增加,或者交互变得很难理解。我们需要通过PUBLISHED LANGUAGE来规范系统之间的关系。
- 如果有一种行业标准语言可用,则尽可能评估并使用它。
- 如果没有标准语言或预先公开发布的语言,则完善作为HOST的系统的CORE DOMAIN。
- 使用CORE DOMAIN作为交换语言的基础,尽可能使用像XML这样的标准交互范式。
- (至少)向所有参与协作的各方发布语言。
- 如果涉及新的系统架构,那么也要发布它。
- 为每个协作系统构建转换层。
- 切换。
现在,当加入更多协作系统时,对整个系统的破坏已经减至最小了。
记住,PUBLISHED LANGUAGE必须是稳定的,但当继续重构时,仍需要有改变HOST模型的自由。 因此,不要把交流的语言和HOST模型等同起来。把它们紧密联系在一起只能降低转换的开销,可以让HOST选择CONFORMIST模式,但是如果加强转换层更加有利于集成时,应该能够从同流者模式中摆脱出来。
项目领导会基于功能的集成需要和开发团队的关系来定义限界上下文。一旦限界上下文和上下文映射被明确定义后,就得保证它们在逻辑上的一致性。至少要提出相关的通信问题,并能够得到妥善地解决。
然而,不管是意识上的还是事实存在的模型上下文,有时候被误用来解决系统里的问题而非逻辑矛盾。 开发团队可能会发现,一个具有大的上下文的模型看起来可能太复杂,以至于不能从总体上获得更多的认识并对其进行彻底的分析。 出于选择或者意外,经常会把大型的上下文分成易于处理的小片段,这种片段容易丧失保持一致性的机会。 现在,仔细观察在一个很大的上下文中建立一个大型模型的决策是值得的,如果没有可能的结构或策略来维护这个大模型,而这个模型实际上是分割的,那么应该重新绘制和确定能维持的边界。 但是如果一个大型限界上下文能够满足必要的集成需要时,除了模型本身的复杂性,它看起来基本上是可行的,那么分解上下文可能并不是最好的选择。
在划分大的上下文之前,应该考虑是否还有其他的方法来处理大型模型。
精炼
如何才能专注于核心问题而不被大量的次要问题淹没呢? LAYERED ARCHITECTURE可以把领域概念从技术逻辑中(技术逻辑确保了计算机系统能够运转)分离出来,但在大型系统中,即使领域被分离出来,它的复杂性也可能仍然难以管理。
精炼是把一堆混杂在一起的组件分开的过程,以便通过某种形式从中提取出最重要的内容,而这种形式将使它更有价值,也更有用。模型就是知识的精炼。 通过每次重构所得到的更深层的理解,我们得以把关键的领域知识和优先级提取出来。
像很多化学蒸馏过程一样,精炼过程所分离出来的副产品(如GENERIC SUBDOMAIN和COHERENT MECHANISM)本身也很有价值,但精炼的主要动机是把最有价值的那部分提取出来,正是这个部分使我们的软件区别于其他软件并让整个软件的构建物有所值,这个部分就是CORE DOMAIN。
领域模型的战略精炼包括以下部分:
- 帮助所有团队成员掌握系统的总体设计以及各部分如何协调工作;
- 找到一个具有适度规模的核心模型并把它添加到通用语言中,从而促进沟通;
- 指导重构;
- 专注于模型中最有价值的那部分;
- 指导外包、现成组件的使用以及任务委派。
模式:CORE DOMAIN
在设计大型系统时,有非常多的组成部分——它们都很复杂而且对开发的成功也至关重要,但这导致真正的业务资产——领域模型最为精华的部分——被掩盖和忽略了。
一个严峻的现实是我们不可能对所有设计部分进行同等的精化,而是必须分出优先级。为了使领域模型成为有价值的资产,必须整齐地梳理出模型的真正核心,并完全根据这个核心来创建应用程序的功能。 但本来就稀缺的高水平开发人员往往会把工作重点放在技术基础设施上,或者只是去解决那些不需要专门领域知识就能理解的领域问题(这些问题都已经有了很好的定义)。
因此
对模型进行提炼。找到CORE DOMAIN并提供一种易于区分的方法把它与那些起辅助作用的模型和代码分开。最有价值和最专业的概念要轮廓分明。尽量压缩CORE DOMAIN。
让最有才能的人来开发CORE DOMAIN,并据此要求进行相应的招聘。在CORE DOMAIN中努力开发能够确保实现系统蓝图的深层模型和柔性设计。仔细判断任何其他部分的投入,看它是否能够支持这个提炼出来的CORE。
提炼CORE DOMAIN并不容易,但它确实会让一些决策变得容易。你需要投入大量的工作使你的CORE鲜明突出,而其他设计部分则只需依照常规做得实用即可。 如果某个设计部分需要保密以便保持竞争优势,那么它就是你的CORE DOMAIN。其他的部分则没有必要隐藏起来。 当必须在两个看起来都很有用的重构之间进行抉择时(由于时限的缘故),应该首选对CORE DOMAIN影响最大的那个重构。
选择核心
我们需要关注的是那些能够表示业务领域并解决业务问题的模型部分。
对CORE DOMAIN的选择取决于看问题的角度。
一个应用程序的CORE DOMAIN在另一个应用程序中可能只是通用的支持组件。尽管如此,仍然可以在一个项目中(而且通常在一个公司中)定义一个一致的CORE。 像其他设计部分一样,人们对CORE DOMAIN的认识也会随着迭代而发展。开始时,一些特定关系可能显得不重要。而最初被认为是核心的对象可能逐渐被证明只是起支持作用。
工作的分配
在项目团队中,技术能力最强的人员往往缺乏丰富的领域知识。这限制了他们的作用,并且更倾向于分派他们来开发一些支持组件,从而形成了一个恶性循环——知识的缺乏使他们远离了那些能够学到领域知识的工作。
打破这种恶性循环是很重要的,方法是建立一支由开发人员和一位或多位领域专家组成的联合团队,其中开发人员必须能力很强、能够长期稳定地工作并且对学习领域知识非常感兴趣,而领域专家则要掌握深厚的业务知识。 如果你认真对待领域设计,那么它就是一项有趣且充满技术挑战的工作。你肯定也会找到持这种观点的开发人员。
从外界聘请一些短期的专业人员来设计CORE DOMAIN的关键环节通常是行不通的,因为团队需要积累领域知识,而且短期人员会造成知识流失。相反,充当培训和指导角色的专家可能非常有价值,因为他们帮助团队建立领域设计技巧,并促进团队成员使用尚未掌握的高级设计原则。
出于类似的原因,购买CORE DOMAIN也是行不通的。
除了上述原因之外,还有一个更重要的原因需要引起我们的注意。自主开发的软件的最大价值来自于对CORE DOMAIN的完全控制。 一个设计良好的框架可能会提供满足你的专门使用需求的高水平抽象,它可以节省开发那些更通用部分的时间,并使你能够专注于CORE。 但是,如果它对你的约束超出了这个限度,可能有以下3种原因。
- 你正在失去一项重要的软件资产。此时应该让这些限制性的框架退出你的CORE DOMAIN。
- 框架所处理的部分并不是你所认为的核心。此时应该重新划定CORE DOMAIN的边界,把你的模型中真正的标志性部分识别出来。
- 你的CORE DOMAIN并没有特殊的需求。此时应该考虑采用一种风险更低的解决方案,如购买软件并与你的应用程序进行集成。
不管是哪种情况,创建与众不同的软件还是会回到原来的轨道上——需要一支稳定工作的团队,他们不断积累和消化专业知识,并将这些知识转化为一个丰富的模型。没有捷径,也没有魔法。
精炼的逐步提升
一份简单的DOMAIN VISION STATEMENT(领域愿景说明)只需很少的投入,它传达了基本概念以及它们的价值。HIGHLIGHTED CORE(突出核心)可以增进沟通,并指导决策制定,这也只需对设计进行很少的改动甚至无需改动。
更积极的精炼方法是通过重构和重新打包显式地分离出GENERIC SUBDOMAIN,然后单独进行处理。在使用COHESIVE MECHANISM的同时,也要保持设计的通用性、易懂性和柔性,这两个方面可以结合起来。只有除去了这些细枝末节,才能把CORE剥离出来。
重新打包出一个SEGREGATED CORE(分离的核心),可以使这个CORE清晰可见(即使在代码中也是如此),并且促进将来在CORE模型上的工作。
最富雄心的精炼是ABSTRACT CORE(抽象内核),它用纯粹的形式表示了最基本的概念和关系(因此,需要对模型进行全面的重新组织和重构)。
每种技术都需要我们连续不断地投入越来越多的工作,但刀磨得越薄,就会越锋利。领域模型的连续精炼将为我们创造一项资产,使项目进行得更快、更敏捷、更精确。
模式:GENERIC SUBDOMAIN(通用子领域)
首先,我们可以把模型中最普通的那些部分分离出去,它们就是GENERIC SUBDOMAIN(通用子领域)。GENERIC SUBDOMAIN与CORE DOMAIN形成鲜明的对比,使我们可以更清楚地理解它们各自的含义。
模型中有些部分除了增加复杂性以外并没有捕捉或传递任何专门的知识。任何外来因素都会使CORE DOMAIN愈发的难以分辨和理解。 模型中充斥着大量众所周知的一般原则,或者是专门的细节,这些细节并不是我们的主要关注点,而只是起到支持作用。 然而,无论它们是多么通用的元素,它们对实现系统功能和充分表达模型都是极为重要的。
因此:
识别出那些与项目意图无关的内聚子领域。把这些子领域的通用模型提取出来,并放到单独的MODULE中。任何专有的东西都不应放在这些模块中。 把它们分离出来以后,在继续开发的过程中,它们的优先级应低于CORE DOMAIN的优先级,并且不要分派核心开发人员来完成这些任务(因为他们很少能够从这些任务中获得领域知识)。 此外,还可以考虑为这些GENERIC SUBDOMAIN使用现成的解决方案或“公开发布的模型”(PUBLISHED MODEL)。
当开发这样的软件包时,有以下几种选择。
- 选择1:现成的解决方案,有时可以购买一个已实现好的解决方案,或使用开源代码。
- 优点
- 可以减少代码的开发。
- 维护负担转移到了外部。
- 代码已经在很多地方使用过,可能较为成熟,因此比自己开发的代码更可靠和完备。
- 缺点
- 在使用之前,仍需要花时间来评估和理解它。
- 就业内目前的质量控制水平而言,无法保证它的正确性和稳定性。
- 它可能设计得过于细致了(远远超出了你的目的),集成的工作量可能比开发一个最小化的内部实现更大。
- 外部元素的集成常常不顺利。它可能有一个与你的项目完全不同的BOUNDED CONTEXT。
- 即使不是这样,它也很难顺利地引用你的其他软件包中的ENTITY。
- 它可能会引入对平台、编译器版本的依赖等。
- 优点
- 选择2:公开发布的设计或模型
- 优点
- 比自己开发的模型更为成熟,并且反映了很多人的深层知识。
- 提供了随时可用的高质量文档。
- 缺点
- 可能不是很符合你的需要,或者设计得过于细致了(远远超出了你的需要)。
- 优点
- 选择3:把实现外包出去
- 优点
- 使核心团队可以脱身去处理CORE DOMAIN,那才是最需要知识和经验积累的部分。
- 开发工作的增加不会使团队规模无限扩大下去,同时又不会导致CORE DOMAIN知识的分散。
- 强制团队采用面向接口的设计,并且有助于保持子领域的通用性,因为规格已经被传递到外部。
- 缺点
- 仍需要核心团队花费一些时间,因为他们需要与外包人员商量接口、编码标准和其他重要方面。
- 当把代码移交回团队时,团队需要耗费大量精力来理解这些代码。(但是这个开销比理解专用子领域要小一些,因为通用子领域不需要理解专门的背景知识。)
- 代码质量或高或低,这取决于两个团队能力的高低。
- 优点
- 选择4:内部实现
- 优点
- 易于集成。
- 只开发自己需要的,不做多余的工作。
- 可以临时把工作分包出去。
- 缺点
- 需要承受后续的维护和培训负担。
- 很容易低估开发这些软件包所需的时间和成本。
- 优点
随着时间的推移,CORE模型的范围将会不断变窄,而越来越多的通用模型将作为框架被实现出来,或者至少被实现为公开发布的模型或分析模式。 但是现在,大部分模型仍然需要我们自己开发,但把它们与CORE DOMAIN模型区分开是很有价值的。
通用不等于可重用
重用确实会发生,但不一定总是代码重用。模型重用通常是更高级的重用,但是,虽然这样的模型概念可能适用于很多情况,我们也不必把它开发成“万能的”模型。我们只要把业务所需的那部分建模出来并实现即可。
尽管我们很少需要考虑设计的可重用性,但通用子领域的设计必须严格地限定在通用概念的范围之内。如果把行业专用的模型元素引入到通用子领域中,会产生两个后果。
- 第一,它会妨碍将来的开发。虽然现在我们只需要子领域模型的一小部分,但我们的需求会不断增加。如果把任何不属于子领域概念的部分引入到设计中,那么再想灵活地扩展系统就很难了,除非完全重建原来的部分并重新设计使用该部分的其他模块。
- 第二,也是更重要的,这些行业专用的概念要么属于CORE DOMAIN,要么属于它们自己的更专业的子领域,而且这些专业的模型比通用子领域更有价值。
项目风险管理
敏捷过程通常要求通过尽早解决最具风险的任务来管理风险。特别是XP过程,它要求迅速建立并运行一个端到端的系统。 这种初步的系统通常用来检验某种技术架构,而且人们会试图建立一个外围系统,用来处理一些支持性的GENERIC SUBDOMAIN,因为这些子领域通常更易于分析。但是要注意,这可能会不利于风险管理。
项目面临着两方面的风险,有些项目的技术风险更大,有些项目则是领域建模的风险更大一些。端到端的系统是实际系统中最困难部分的“雏形”——它控制风险的能力也仅限于此。 当使用这种雏形时,我们很容易低估领域建模的风险。这种风险包括未预料到存在复杂性、与业务专家的交流不够充分,或者开发人员的关键技能存在欠缺等。
因此,除非团队拥有精湛的技术并且对领域非常熟悉,否则第一个雏形系统应该以CORE DOMAIN的某个部分作为基础,不管它有多么简单。
相同的原则也适用于任何试图把高风险的任务放到前面处理的过程。CORE DOMAIN就是高风险的,因为它的难度往往会超出我们的预料,而且如果没有它,项目就不可能获得成功。
模式:DOMAIN VISION STATEMENT(领域愿景说明)
在项目开始时,模型通常并不存在,但是模型开发的需求是早就确定下来的重点。在后面的开发阶段,我们需要解释清楚系统的价值,但这并不需要深入地分析模型。 此外,领域模型的关键方面可能跨越多个BOUNDED CONTEXT,而且从定义上看,无法将这些彼此不同的模型组织起来表明其共同的关注点。
因此:
写一份CORE DOMAIN的简短描述(大约一页纸)以及它将会创造的价值,也就是“价值主张”。那些不能将你的领域模型与其他领域模型区分开的方面就不要写了。 展示出领域模型是如何实现和均衡各方利益的。这份描述要尽量精简。尽早把它写出来,随着新的理解随时修改它。
DOMAIN VISION STATEMENT可以用作一个指南,它帮助开发团队在精炼模型和代码的过程中保持统一的方向。团队中的非技术成员、管理层甚至是客户也都可以共享领域愿景说明(当然,包含专有信息的情况除外)。
左侧是DOMAIN VISION STATEMENT,右侧内容虽然很重要,但它不是DOMAIN VISION STATEMENT的一部分
航班预定系统
半导体工厂自动化系统
模式:HIGHLIGHTED CORE(突出核心)
DOMAIN VISION STATEMENT从宽泛的角度对CORE DOMAIN进行了说明,但它把什么是具体核心模型元素留给人们自己去解释和猜测。除非团队的沟通极其充分,否则单靠VISION STATEMENT是很难产生什么效果的。
尽管团队成员可能大体上知道核心领域是由什么构成的,但CORE DOMAIN中到底包含哪些元素,不同的人会有不同的理解,甚至同一个人在不同的时间也会有不同的理解。 如果我们总是要不断过滤模型以便识别出关键部分,那么就会分散本应该投入到设计上的精力,而且这还需要广泛的模型知识。因此,CORE DOMAIN必须要很容易被分辨出来。
对代码所做的重大结构性改动是识别CORE DOMAIN的理想方式,但这些改动往往无法在短期内完成。事实上,如果团队的认识还不够全面,这样的重大代码修改是很难进行的。
把模型的一个特别部分连同它的实现一起区分出来,这只是对模型的一种反映,而不必是模型自身的一部分。任何使人们易于了解CORE DOMAIN的技术都可以采用。这类解决方案有两种典型的代表性技术。
精炼文档
我经常会创建一个单独的文档来描述和解释CORE DOMAIN。这个文档可能很简单,只是最核心的概念对象的清单。它可能是一组描述这些对象的图,显示了它们最重要的关系。 它可能在抽象层次上或通过示例来描述基本的交互过程。它可能会使用UML类图或序列图、专用于领域的非标准的图、措辞严谨的文字解释或上述这些元素的组合。 精炼文档并不是完备的设计文档。它只是一个最简单的切入点,描述并解释了核心,并给出了更进一步研究这些核心部分的理由。 精炼文档为读者提供了一个总体视图,指出了各个部分是如何组合到一起的,并且指导读者到相应的代码部分寻找更多细节。
因此(作为HIGHLIGHTED CORE(突出核心)的一种形式):
编写一个非常简短的文档(3~7页,每页内容不必太多),用于描述CORE DOMAIN以及CORE元素之间的主要交互过程。
独立文档带来的所有常见风险也会在这里出现:
- 文档可能得不到维护;
- 文档可能没人阅读;
- 由于有多个信息来源,文档可能达不到简化复杂性的目的。
控制这些风险的最好方法是保持绝对的精简。剔除那些不重要的细节,只关注核心抽象以及它们的交互,这样文档的老化速度就会减慢,因为这个层次的模型通常更稳定。
精炼文档应该能够被团队中的非技术人员理解。把它当作一个共享的视图,描述每个人都应该知道的东西,而且可以把它作为团队所有成员研究模型和代码的一个起点。
标明CORE
这种技术并不仅限于纸面上的对象图。使用大量UML图的团队可以使用一个“原型”(Stereotype)来识别核心元素。把代码用作唯一模型存储库的团队可以使用注释(可以采用Java Doc这样的结构),或使用开发环境中的一些工具。使用哪种特定技术都没关系,只要使开发人员容易分辨出什么在核心领域内,什么在核心领域外就可以了。
因此(作为另一种形式的HIGHLIGHTED CORE):
把模型的主要存储库中的CORE DOMAIN标记出来,不用特意去阐明其角色。使开发人员很容易就知道什么在核心内,什么在核心外。
把精炼文档作为过程工具
如果精炼文档概括了CORE DOMAIN的核心元素,那么它就可以作为一个指示器——用以指示模型改变的重要程度。 当模型或代码的修改影响到精炼文档时,需要与团队其他成员一起协商。当对精炼文档做出修改时,需要立即通知所有团队成员,而且要把新版本的文档分发给他们。 CORE外部的修改或精炼文档外部的细节修改则无需协商或通知,可以直接把它们集成到系统中,其他成员在后续工作过程中自然会看到这些修改。 这样开发人员就拥有了XP所建议的完全的自治性。
尽管VISION STATEMENT和HIGHLIGHTED CORE可以起到通知和指导的作用,但它们本身并没有修改模型或代码。具体地划分GENERIC SUBDOMAIN可以除去一些非核心元素。 接下来的几个模式着眼于从结构上修改模型和设计本身,目的是使CORE DOMAIN更明显,更易于管理。
模式:COHESIVE MECHANISM(内聚机制)
计算有时会非常复杂,使设计开始变得膨胀。机械性的“如何做”大量增加,把概念性的“做什么”完全掩盖了。为解决问题提供算法的大量方法掩盖了那些用于表达问题的方法。
因此:
把概念上的COHESIVE MECHANISM(内聚机制)分离到一个单独的轻量级框架中。要特别注意公式或那些有完备文档的算法。 用一个INTENTION-REVEALING INTERFACE来暴露这个框架的功能。现在,领域中的其他元素就可以只专注于如何表达问题(做什么)了,而把解决方案的复杂细节(如何做)转移给了框架。
然后,这些被分离出来的机制承担起支持的任务,从而留下一个更小的、表达得更清楚的CORE DOMAIN,这个核心以更加声明式的方式通过接口来使用这些机制。
CORE DOMAIN或GENERIC SUBDOMAIN的模型描述的是事实、规则或问题。而COHESIVE MECHANISM则用来满足规则或者用来完成模型指定的计算。
COHESIVE MECHANISM的另一个例子是用一个框架来构造SPECIFICATION对象,并为这些对象所需的基本的比较和组合操作提供支持。利用这个框架, CORE DOMAIN和GENERIC SUBDOMAIN可以用SPECIFICATION模式中所描述的清晰的、易于理解的语言来声明它们的规格。这样,比较和组合等复杂操作可以留给框架去完成。
GENERIC SUBDOMAIN与COHESIVE MECHANISM的比较
GENERIC SUBDOMAIN与COHESIVE MECHANISM的动机是相同的——都是为CORE DOMAIN减负。
区别在于二者所承担的职责的性质不同。
- GENERIC SUBDOMAIN是以描述性的模型作为基础的,它用这个模型表示出团队会如何看待领域的某个方面。在这一点上它与CORE DOMAIN没什么区别,只是重要性和专门程度较低而已。
- COHESIVE MECHANISM并不表示领域,它的目的是解决描述性模型所提出来的一些复杂的计算问题。
有时MECHANISM是CORE DOMAIN一部分
我们几乎总是想要把MECHANISM从CORE DOMAIN中分离出去。 例外的情况是MECHANISM本身就是专有的并且是软件的一项核心价值。 有时,非常专用的算法就是这种情况。例如,如果一个非常高效的算法(用于计算日程安排)是运输物流应用程序中的标志性特性之一,那么该机制就可以被认为是概念核心的一部分。
通过精炼得到声明式风格
精炼的价值在于使你能够看到自己正在做什么,不让无关细节分散你的注意力,并通过不断削减得到核心。 如果领域中那些起到支持作用的部分提供了一种简练的语言,可用于表示CORE的概念和规则,同时又能够把计算或实施这些概念和规则的方式封装起来,那么CORE DOMAIN的重要部分就可以采用声明式设计。
COHESIVE MECHANISM用途最大的地方是它通过一个INTENTION-REVEALING INTERFACE来提供访问,并且具有概念上一致的ASSERTION和SIDE-EFFECT-FREE FUNCTION。 利用这些MECHANISM和柔性设计,CORE DOMAIN可以使用有意义的声明,而不必调用难懂的函数。但最不同寻常的回报来自于使CORE DOMAIN的一部分产生突破,得到一个深层模型, 而且这部分核心领域本身成为了一种语言,可以灵活且精确地表达出最重要的应用场景。
深层模型往往与相对应的柔性设计一起产生。柔性设计变得成熟的时候,就可以提供一组易于理解的元素,我们可以明确地把它们组合到一起来完成复杂的任务,或表达复杂的信息,就像单词组成句子一样。 此时,客户代码就可以采用声明式风格,而且更为精炼。
把GENERIC SUBDOMAIN提取出来可以减少混乱,而COHESIVE MECHANISM可以把复杂操作封装起来。这样可以得到一个更专注的模型,从而减少了那些对用户活动没什么价值的、分散注意力的方面。 但我们不太可能为领域模型中所有非CORE元素安排一个适当的去处。SEGREGATED CORE(分离的核心)采用直接的方法从结构上把CORE DOMAIN划分出来。
模式:SEGREGATED CORE(分离的核心)
模型中的元素可能有一部分属于CORE DOMAIN,而另一部分起支持作用。核心元素可能与一般元素紧密耦合在一起。CORE的概念内聚性可能不是很强,看上去也不明显。 这种混乱性和耦合关系抑制了CORE。设计人员如果无法清晰地看到最重要的关系,就会开发出脆弱的设计。
因此:
对模型进行重构,把核心概念从支持性元素(包括定义得不清楚的那些元素)中分离出来,并增强CORE的内聚性,同时减少它与其他代码的耦合。 把所有通用元素或支持性元素提取到其他对象中,并把这些对象放到其他的包中——即使这会把一些紧密耦合的元素分开。
这里基本上采用了与GENERIC SUBDOMAIN一样的原则,只是从另一个方向来考虑而已。那些在应用程序中非常关键的内聚子领域可以被识别出来,并分离到它们自己的内聚包中。 如何处理剩下那些未加区分的元素虽然也很重要,但其重要性略低。这些元素或多或少地可以保留在原先的位臵,也可以放到包含了重要类的包中。 最后,越来越多的剩余元素可以被提取到GENERIC SUBDOMAIN中。但就目前来看,使用哪种简单解决方案都可以,只需把注意力集中在SEGREGATED CORE(分离的核心)上即可。
通过重构得到SEGREGATED CORE的一般步骤如下所示。
- 识别出一个CORE子领域(可能是从精炼文档中得到的)。
- 把相关的类移到新的MODULE中,并根据与这些类有关的概念为模块命名。
- 对代码进行重构,把那些不直接表示概念的数据和功能分离出来。把分离出来的元素放到其他包的类(可以是新的类)中。
- 尽量把它们与概念上相关的任务放在一起,但不要为了追求完美而浪费太长时间。
- 把注意力放在提炼CORE子领域上,并且使CORE子领域对其他包的引用变得更明显且易于理解。
- 对新的SEGREGATED CORE MODULE进行重构,使其中的关系和交互变得更简单、表达得更清楚,并且最大限度地减少并澄清它与其他MODULE的关系(这将是一个持续进行的重构目标)。
- 对另一个CORE子领域重复这个过程,直到完成SEGREGATED CORE的工作。
创建SEGREGATED CORE的代价
有时候,把CORE分离出来会使得它与那些紧密耦合的非CORE类的关系变得更晦涩,甚至更复杂,但CORE DOMAIN更清晰了,而且更易于处理,因此获得的好处还是足以抵偿这种代价。
当然,另一个代价是分离CORE需要付出很大的工作量。我们必须认识到,在做出SEGREGATED CORE的决定时,有可能需要开发人员对整个系统做出修改。
当系统有一个很大的、非常重要的BOUNDED CONTEXT时,但模型的关键部分被大量支持性功能掩盖了,那么就需要创建SEGREGATED CORE了。
不断发展演变的团队决策
就像很多战略设计决策所要求的一样,创建SEGREGATED CORE需要整个团队一致行动。这一行动需要团队的一致决策,而且团队必须足够自律和协调才能执行这样的决策。 困难之处在于既要约束每个人使其都使用相同的CORE定义,又不能一成不变地去执行这个决策。 由于CORE DOMAIN也是不断演变的(像任何其他设计方面一样),在处理SEGREGATED CORE的过程中我们会不断积累经验,这将使我们对什么是核心什么是支持元素这些问题产生新的理解。 我们应该把这些理解反馈到设计中,从而得到更完善的CORE DOMAIN和SEGREGATED CORE MODULE的定义。
这意味着新的理解必须持续不断地在整个团队中共享,但个人(或编程对)不能单方面根据这些理解擅自采取行动。 无论团队采用了什么样的决策过程,团队一致通过也好,由领导者下命令决定也好,决策过程都必须具有足够的敏捷性,可以反复纠正。团队必须进行有效的沟通,以便使每个人都共享同一个CORE视图。
把货物运输模型的CORE分离出来:
货物运输调度软件
按照客户需求可靠地运输货物是这个项目的核心目标
完成SEGREGATED CORE之后留下的有意义的非CORE子领域MODULE
这种效果不是一次就能实现的,可能需要经过多次重构。于是,我们最后得到了一个SEGREGATED CORE包、一个GENERIC SUBDOMAIN和两个起支持作用的领域专用包。 在有了更深层的理解后,可能会为Customer创建一个GENERIC SUBDOMAIN,或者将Customer专用于运输。
识别有用的、有意义的MODULE是一项建模活动。开发人员和领域专家在战略精炼中进行协作,这种协作是知识消化过程的一部分。
模式:ABSTRACT CORE(抽象内核)
通常,即便是CORE DOMAIN模型也会包含太多的细节,以至于它很难表达出整体视图。
当不同MODULE的子领域之间有大量交互时,要么需要在MODULE之间创建很多引用,这在很大程度上抵消了划分模块的价值; 要么就必须间接地实现这些交互,而后者会使模型变得晦涩难懂。
因此:
把模型中最基本的概念识别出来,并分离到不同的类、抽象类或接口中。设计这个抽象模型,使之能够表达出重要组件之间的大部分交互。 把这个完整的抽象模型放到它自己的MODULE中,而专用的、详细的实现类则留在由子领域定义的MODULE中。
这里并不是寻找技术上的技巧。只有当领域中的基本概念能够用多态接口来表达时,这才是一种有价值的技术 在这种情况下,把这些分散注意力的细节分离出来可以使MODULE解耦,同时可以精炼出一个更小、更内聚的CORE DOMAIN。
现在,大部分专用的类都将引用ABSTRACT CORE MODULE,而不是其他专用的MODULE。ABSTRACT CORE(抽象核心)提供了主要概念及其交互的简化视图。
如果项目中同时使用了ABSTRACT CORE和精炼文档,而且精炼文档随着应用程序理解的加深而不断演变,那么抽象核心的最后结果看起来应该与精炼文档非常类似。 当然,ABSTRACT CORE是用代码编写的,因此更为严格和完整。
深层模型精炼
精炼并不仅限于从整体上把领域中的一些部分从CORE中分离出来。它也意味着对子领域(特别是CORE DOMAIN)进行精炼,通过持续重构得到更深层的理解,从而向深层模型和柔性设计推进。 精炼的目标是把模型设计得更明显,使我们可以用模型简单地把领域表示出来。深层模型把领域中最本质的方面精炼成一些简单的元素,使我们可以把这些元素组合起来解决应用程序中的重要问题。
尽管任何带来深层模型的突破都有价值,但只有CORE DOMAIN中的突破才能改变整个项目的轨道。
选择重构目标
如果你既不能全面解决问题,又不能“哪儿痛治哪儿”,那么该怎么办呢?
- 如果采用“哪儿痛治哪儿”这种重构策略,要观察一下根源问题是否涉及CORE DOMAIN或CORE与支持元素的关系。如果确实涉及,那么就要接受挑战,首先修复核心。
- 当可以自由选择重构的部分时,应首先集中精力把CORE DOMAIN更好地提取出来,完善对CORE的分离,并且把支持性的子领域提炼成通用子领域。
以上就是如何从重构中获取最大利益的方法。
大型结构
在一个大的系统中,如果因为缺少一种全局性的原则而使人们无法根据元素在模式(这些模式被应用于整个设计)中的角色来解释这些元素,那么开发人员就会陷入“只见树木,不见森林”的境地。
设计一种应用于整个系统的规则(或角色和关系)模式,使人们可以通过它在一定程度上了解各个部分在整体中所处的位臵(即使是在不知道各个部分的详细职责的情况下)。
这种结构可以被限制在一个BOUNDED CONTEXT中,但通常情况下它会跨越多个BOUNDED CONTEXT,并通过提供一种概念组织把项目涉及的所有团队和子系统紧密结合到一起。 好的结构可以帮助人们深入地理解模型,还能够对精炼起到补充作用。
大部分大型结构都无法用UML来表示,而且也不需要这样做。这些大型结构是用来勾画和解释模型及设计的,但在设计中并不出现,它们只是用来表达设计的另外一种方式。
当团队规模较小而且模型也不太复杂时,只需将模型分解为合理命名的MODULE,再进行一定程度的精炼,然后在开发人员之间进行非正式的协调,以上这些就足以使模型保持良好的组织结构了。
模式:EVOLVING ORDER(演化有序)
一个没有任何规则的随意设计会产生一些无法理解整体含义且很难维护的系统。但架构中早期的设计假设又会使项目变得束手束脚,而且会极大地限制应用程序中某些特定部分的开发人员/设计人员的能力。 很快,开发人员就会为适应结构而不得不在应用程序的开发上委曲求全,要么就是完全推翻架构而又回到没有协调的开发老路上来。
因此:
让这种概念上的大型结构随着应用程序一起演变,甚至可以变成一种完全不同的结构风格。不要依此过分限制详细的设计和模型决策,这些决策和模型决策必须在掌握了详细知识之后才能确定。
当发现一种大型结构可以明显使系统变得更清晰,而又没有对模型开发施加一些不自然的约束时,就应该采用这种结构。 使用不合适的结构还不如不使用它,因此最好不要为了追求设计的完整性而勉强去使用一种结构,而应该找到尽可能精简的方式解决所出现问题。要记住宁缺勿滥的原则。
模式:SYSTEM METAPHOR(系统隐喻)
隐喻思维在软件开发(特别是模型)中是很普遍的。但极限编程中的“隐喻”却具有另外一种含义,它用一种特殊的隐喻方式来使整个系统的开发井然有序。
软件设计往往非常抽象且难于掌握。开发人员和用户都需要一些切实可行的方式来理解系统,并共享系统的一个整体视图。
从某种程度上讲,隐喻对人们的思考方式有着深刻地影响,它已经渗透到每个设计中。系统有很多“层”,层与层之间依次叠放起来。系统还有“内核”,位于这些层的“中心”。但有时隐喻可以传达整个设计的中心主题,并能够在团队所有成员中形成共同理解。
在这种情况下,系统实际上就是由这个隐喻塑造的。开发人员所做的设计决策也将与系统隐喻保持一致。这种一致性使其他开发人员能够根据同一个隐喻来解释复杂系统中的多个部分。开发人员和专家在讨论时有一个比模型本身更具体的参考点。
因此:
当系统的一个具体类比正好符合团队成员对系统的想象,并且能够引导他们向着一个有用的方向进行思考时,就应该把这个类比用作一种大型结构。 围绕这个隐喻来组织设计,并把它吸收到UBIQUITOUS LANGUAGE中。SYSTEM METAPHOR应该既能促进系统的交流,又能指导系统的开发。 它可以增加系统不同部分之间的一致性,甚至可以跨越不同的BOUNDED CONTEXT。但所有隐喻都不是完全精确的,因此应不断检查隐喻是否过度或不恰当,当发现它起到妨碍作用时,要随时准备放弃它。
“幼稚隐喻”以及我们为什么不需要它
由于在大多数项目并不会自动出现有用的隐喻,因此XP社区中的一些人开始谈论“幼稚隐喻”(Naive Metaphor),他们所说的幼稚隐喻就是领域模型本身。
这个术语的一个问题在于,一个成熟的领域模型绝对不会是“幼稚的”。实际上,“工资处理就像一条装配线”这个隐喻与模型的实际情况相比要幼稚得多,因为模型是软件开发人员与领域专家进行了多次知识消化的迭代过程才得到的,它已经紧密结合到应用程序的实现中,并经过了实践的检验。
“幼稚隐喻”这个术语应该停止使用了。
SYSTEM METAPHOR并不适用于所有项目。从总体上讲,大型结构并不是必须要用的。在极限编程的12个实践中,SYSTEM METAPHOR的角色可以由UBIQUITOUS LANGUAGE来承担。 当项目中发现一种非常合适的SYSTEM METAPHOR或其他大型结构时,应该用它来补充UBIQUITOUS LANGUAGE。
模式:RESPONSIBILITY LAYER(职责分层)
从头至尾的讨论中,单独的对象被分配了一组相关的、范围较窄的职责。职责驱动的设计在更大的规模上也适用。
如果每个对象的职责都是人为分配的,将没有统一的指导原则和一致性,也无法把领域作为一个整体来处理。为了保持大模型的一致,有必要在职责分配上实施一定的结构化控制。
自发的分层,这些包描述了什么事情
分层模式有一种变体最适合按职责来分层,我们把这种变体称为RELAXED LAYERED SYSTEM(松散分层系统)[Buschmann et al.1996,p.45],如果采用这种分层模式,某一层中的组件可以访问任何比它低的层,而不限于只能访问直接与它相邻的下一层。
因此:
注意观察模型中的概念依赖性,以及领域中不同部分的变化频率和变化的原因。如果在领域中发现了自然的层次结构,就把它们转换为宽泛的抽象职责。 这些职责应该描述系统的高层目的和设计。对模型进行重构,使得每个领域对象、AGGREGATE和MODULE的职责都清晰地位于一个职责层当中。
如果所采用的结构强制性地要求我们做出很多别扭的设计选择,那么就要遵循EVOLVING ORDER(演变的顺序),在项目进行过程中评估这种结构,并修改甚至放弃它。
选择适当的层
一些指导方针,无论是刚开始选择一种结构,还是对已有结构进行转换,这些指导方针都适用。
当对层进行删除、合并、拆分和重新定义等操作时,应寻找并保留以下一些有用的特征。
- 场景描述。
- 层应该能够表达出领域的基本现实或优先级。选择一种大比例结构与其说是一种技术决策,不如说是一种业务建模决策。层应该显示出业务的优先级。
- 概念依赖性。
- “较高”层概念的意义应该依赖“较低”层,而低层概念的意义应该独立于较高的层。
- CONCEPTUAL CONTOUR。
- 如果不同层的对象必须具有不同的变化频率或原因,那么层应该能够容许它们之间的变化。
在为每个新模型定义层时不一定总要从头开始。在一系列相关领域中,有些层是固定的。
例如,在那些利用大型固定资产进行运作的企业(如工厂或货运)中,物流软件通常可以被组织为“潜能”层(上面例子中的“能力”层的另外一个名称)和“作业”层。
- 潜能层。
- 我们能够做什么?潜能层不关心我们打算做什么,而关心能够做什么。
- 企业的资源(包括人力资源)以及这些资源的组织方式是潜能层的核心。与供应商签订的合同也明确界定了企业的潜能。
- 这个层几乎存在于任何业务领域中,但在那些相对来说依靠大型固定资产来支持业务运作的企业中(如运输和制造业)尤其突出。
- 潜能也包括临时性的资产,但主要依赖临时资产来运作的企业可能会强调临时资产的层(这个层在例子中被称为“Capability”)。
- 作业层。
- 我们正在做什么?我们利用这些潜能做了什么事情?像潜能层一样,这个层也应该反映出现实状况,而不是我们设想的状况。
- 我们希望在这个层中看到自己的工作和活动:我们正在销售什么,而不是能够销售什么。
- 通常来说,作业层对象可以引用潜能层对象,它甚至可以由潜能层对象组成,但潜能层对象不应该引用作业层对象。
在这类领域很多(也许是大部分)现有的系统中,这两个层可以涵盖一切对象(尽管可能会有某种完全不同的和更清晰的分解结构)。它们可以跟踪当前状况和正在执行的作业计划,以及问题报告或相关文档。 但跟踪往往是不够的。当项目要为用户提供指导或帮助或者要自动制定一些决策时,就需要有另外一组职责,这些职责可以被组织到作业层之上的决策支持层中。
- 决策支持层。
- 应该采取什么行动或制定什么策略?这个层是用来作出分析和制定决策的。
- 它根据来自较低层(如潜能层或作业层)的信息进行分析。决策支持软件可以利用历史信息来主动寻找适用于当前和未来作业的机会。
决策支持系统对其他层(如作业层或潜能层)有概念上的依赖性,因为决策并不是凭空制定的。 很多项目都利用数据仓库技术来实现决策支持。在这样的项目中,决策支持层实际上变成了一个独特的BOUNDED CONTEXT,并且与作业软件具有一种CUSTOMER/SUPPLIER关系。 在其他项目中,决策支持层被更深地集成到系统中,就像前面的扩展示例讲到的那样。 分层结构的一个内在的优点是较低的层可以独立于较高的层存在。这样有利于在较老的作业系统上分阶段引入新功能或开发高层次的增强功能。
另一种情形是软件实施了详细的业务规则或法律需求,这些规则或需求可以形成一个RESPONSIBILITY LAYER。
- 策略层。
- 规则和目标是什么?规则和目标主要是被动的,但它们约束着其他层的行为。这些交互的设计是一个微妙的问题。
- 有时策略会作为一个参数传给较低层的方法。有时会使用STRATEGY模式。
- 策略层与决策支持层能够进行很好的协作,决策支持层提供了用于搜索策略层所设定的目标的方式,这些目标又受到策略层所设定的规则的约束。
策略层可以和其他层使用同一种语言来编写,但它们有时是使用规则引擎来实现的。这并不是说一定要把它们放到一个单独的BOUNDED CONTEXT中。
实际上,通过在两种不同的实现技术中严格使用同一个模型,可以减小在这两种实现技术之间进行协调的难度。 当规则与它们所应用的对象是基于不同模型编写的时候,要么复杂度会大大增加,要么对象会变得十分笨拙而难以管理。
工厂自动化系统中的概念依赖性和切合点:
很多企业并不是依靠工厂和设备能力来运营的。
举两个例子,在金融服务或保险业中,潜能在很大程度上是由当前的运营状况决定的。 一家保险公司在考虑签保单承担理赔责任时,要根据当前业务的多样性来判断是否有能力承担它所带来的风险。 潜能层有可能会被合并到作业层中,这样就会演变出一种不同的分层结构。
这些情况下经常出现的一个层是对客户所做出的承诺。
- 承诺层。
- 我们承诺了什么?
- 这个层具有策略层的性质,因为它表述了一些指导未来运营的目标;但它也有作业层的性质,因为承诺是作为后续业务活动的一部分而出现和变化的。
潜能层和承诺层并不是互相排斥的。
在有的领域中(如一家提供很多定制运输服务的运输公司),这两个层都很重要,因此可以同时使用它们。与这些领域密切相关的其他层也会用到。 我们需要对分层结构进行调整和实验,但一定要使分层系统保持简单,如果层数超过4或5,就比较难处理了。层数过多将无法有效地描述领域,而且本来要使用大比例结构解决的复杂性问题又会以一种新的方式出现。 我们必须对大比例结构进行严格的精简。
投资银行系统中的概念依赖性和切合点:
虽然这5个层对很多企业系统都适用,但并不是所有领域的主要概念都涵盖在这5个层中。 有些情况下,在设计中生硬地套用这种形式反而会起反作用,而使用一组更自然的RESPONSIBILITY LAYER会更有效。如果一个领域与上述讨论毫无关系,所有的分层可能都必须从头开始。 最后,我们必须根据直觉选择一个起点,然后通过EVOLVING ORDER来改进它。
模式:KNOWLEDGE LEVEL(知识级别)
“KNOWLEDGE LEVEL是”一组描述了另一组对象应该有哪些行为的对象。
当我们需要让用户对模型的一部分有所控制,而模型又必须满足更大的一组规则时,可以利用KNOWLEDGE LEVEL(知识级别)来处理这种情况。 它可以使软件具有可配臵的行为,其中实体中的角色和关系必须在安装时(甚至在运行时)进行修改。
在《分析模式》[Fowler 1996,pp.24–27]一书中,知识级别这种模式是讨论在组织内部对责任进行建模的时候提到的,后来在会计系统的过账规则中也用到了这种模式。 虽然有几章内容涉及此模式,但并没有为它单独开一章,因为它与书中所讨论的大部分模式都不相同。KNOWLEDGE LEVEL并不像其他分析模式那样对领域进行建模,而是用来构造模型的。
如果在一个应用程序中,ENTITY的角色和它们之间的关系在不同的情况下有很大变化,那么复杂性会显著增加。 在这种情况下,无论是一般的模型还是高度定制的模型,都无法满足用户的需求。
为了兼顾各种不同的情形,对象需要引用其他的类型,或者需要具备一些在不同情况下包括不同使用方式的属性。 具有相同数据和行为的类可能会大量增加,而这些类的唯一作用只是为了满足不同的组装规则。
在我们的模型中嵌入了另一个模型,而它的作用只是描述我们的模型。KNOWLEDGE LEVEL分离了模型的这个自我定义的方面,并清楚地显示了它的限制。
KNOWLEDGE LEVEL是REFLECTION(反射)模式在领域层中的一种应用,很多软件架构和技术基础设施中都使用了它,[Buschmann et al.1996])中给出了详尽介绍。 REFLECTION模式能够使软件具有―自我感知的特性,并使所选中的结构和行为可以接受调整和修改,从而满足变化需要。 这是通过将软件分为两个层来实现的,一个层是―基础级别(base level),它承担应用程序的操作职责;另一个是―元级别(meta level),它表示有关软件结构和行为方面的知识。
值得注意的是,我们并没有把这种模式叫做知识―层‖(layer)。虽然REFLECTION与分层很类似,但反射却包含双向依赖关系。
KNOWLEDGE LEVEL具有两个很有用的特性。
- 首先,它关注的是应用领域,这一点与人们所熟悉的REFLECTION模式的应用正好相反。
- 其次,它并不追求完全的通用性。
正如一个SPECIFICATION可能比通用的断言更有用一样,专门为一组对象和它们的关系定制的一个约束集可能比一个通用的框架更有用。KNOWLEDGE LEVEL显得更简单,而且可以传达设计者的特别意图。
因此:
创建一组不同的对象,用它们来描述和约束基本模型的结构和行为。把这些对象分为两个“级别”,一个是非常具体的级别,另一个级别则提供了一些可供用户或超级用户定制的规则和知识。
如果得到合理的运用,KNOWLEDGE LEVEL能够解决一些其他方式很难解决的问题。如果系统中某些部分的定制非常关键,而要是不提供定制能力就会破坏掉整个设计,这时就可以利用知识级别来解决这一问题。
乍看上去,KNOWLEDGE LEVEL像是RESPONSIBILITY LAYER(特别是策略层)的一个特例,但它并不是。首先,两个级别之间的依赖性是双向的,而在层次结构中,较低的层不依赖于较高的层。
实际上,RESPONSIBILITY LAYER可以与其他大部分的大比例结构共存,它提供了另一种用来组织模型的维度。
模式:PLUGGABLE COMPONENT FRAMEWORK( 可插入式组件框架 )
在深入理解和反复精炼基础上得到的成熟模型中,会出现很多机会。通常只有在同一个领域中实现了多个应用程序之后,才有机会使用PLUGGABLE COMPONENT FRAMEWORK(可插入式组件框架)。
当很多应用程序需要进行互操作时,如果所有应用程序都基于相同的一些抽象,但它们是独立设计的,那么在多个BOUNDED CONTEXT之间的转换会限制它们的集成。 各个团队之间如果不能紧密地协作,就无法形成一个SHARED KERNEL。重复和分裂将会增加开发和安装的成本,而且互操作会变得很难实现。
因此:
从接口和交互中提炼出一个ABSTRACT CORE,并创建一个框架,这个框架要允许这些接口的各种不同实现被自由替换。 同样,无论是什么应用程序,只要它严格地通过ABSTRACT CORE的接口进行操作,那么就可以允许它使用这些组件。
高层抽象被识别出来,并在整个系统范围内共享,而特化(specialization)发生在MODULE中。应用程序的中央hub是SHARED KERNEL内部的ABSTRACT CORE。 但封装的组件接口可以把多个BOUNDED CONTEXT封装到其中,这样,当很多组件来自多个不同地方时,或者当组件中封装了用于集成的已有软件时,可以很方便地使用这种结构。
在某些情况下,还有一种选择是使用一种PUBLISHED LANGUAGE来编写hub的插入接口。
LUGGABLE COMPONENT FRAMEWORK也有几个缺点。
- 一个缺点是它是一种非常难以使用的模式。它需要高精度的接口设计和一个非常深入的模型,以便把一些必要的行为捕获到ABSTRACT CORE中。
- 另一个很大的缺点是它只为应用程序提供了有限的选择。如果一个应用程序需要对CORE DOMAIN使用一种非常不同的方法,那么可插入式组件框架将起到妨碍作用。
- 开发人员可以对模型进行特殊修改,但如果不更改所有不同组件的协议,就无法修改ABSTRACT CORE。这样一来,CORE的持续精化过程(也是通过重构得到更深层理解的过程)在某种程度上会陷入僵局。
PLUGGABLE COMPONENT FRAMEWORK不适合作为项目的第一个大比例结构,也不适合作为第二个。最成功的例子都是在完全开发出了多个专门应用之后才采用这种结构的。
结构应该有一种什么样的约束
我们可以为每种不同的情况设计不同的事件机制,也可以让特殊层中的对象在交互时遵守一种一致的模式。结构越严格,一致性就越高,设计也越容易理解。如果结构适当的话,规则将推动开发人员得出好的设计。不同的部分之间会更协调。
另一方面,约束也会限制开发人员所需的灵活性。在异构系统中,特别是当系统使用了不同的实现技术时,可能无法跨越不同的BOUNDED CONTEXT来使用非常特殊的通信路径。
因此一定要克制,不要滥用框架和死板地实现大比例结构。大比例结构的最重要的贡献在于它具有概念上的一致性,并帮助我们更深入地理解领域。每条结构规则都应该使开发变得更容易实现。
通过重构得到更适当的结构
团队要想坚持EVOLVING ORDER原则,必须在项目的整个生命周期中大胆地反复思考大比例结构。 团队不应该一成不变地使用早期构思出来的那个结构,因为那时所有人对领域或需求的理解都不够完善。
遗憾的是,这种演变意味着最终的结构不会在项目一开始就被发现,而且我们必须在开发过程中进行重构,以便得到最终的结构。 这可能很难实现,而且需要高昂的代价,但这样做是非常必要的。有一些通用的方法可以帮助控制成本并最大化收益。
最小化
控制成本的一个关键是保持一种简单、轻量级的结构。不要试图使结构面面俱到。只需解决最主要的问题即可,其他问题可以留到后面一个一个地解决。
开始最好选择一种松散的结构,如SYSTEM METAPHOR或几个RESPONSIBILITY LAYER。不管怎样,一种最小化的松散结构可以起到轻量级的指导作用,它有助于避免混乱。
沟通和自律
整个团队在新的开发和重构中必须遵守结构。要做到这一点,整个团队必须理解这种结构。必须把术语和关系纳入到UBIQUITOUS LANGUAGE中。
大比例结构为项目提供了一个术语表,它概要地描述了整个系统,并且使不同人员能够做出一致的决策。但由于大多数大比例结构只是松散的概念指导,因此团队必须要自觉地遵守它。
如果很多人不遵守结构,它慢慢就会失去作用。这时,结构与模型和实现的各个部分之间的关系无法总是在代码中明确地反映出来,而且功能测试也不再依赖结构了。此外,结构往往是抽象的,因此很难保证在一个大的团队(或多个团队)中一致地应用它。
在大多数团队中,仅仅通过沟通是不足以保证在系统中采用一致的大比例结构的。至关重要的一点是要把它合并到项目的通用语言中,并让每个人都严格地使用UBIQUITOUS LANGUAGE。
通过重构得到柔性设计
对模型反复进行合理的转换也有相同效果。不断增加的知识被合并到模型中,更改的要点已经被识别出来,并且更改也变得更灵活,同时模型中一些稳定的部分也得到了简化。 这样,底层领域的更显著的CONCEPTUAL CONTOUR(概念轮廓)就会在模型结构中浮现出来。
通过精炼可以减轻负担
对模型施加的另一项关键工作是持续精炼。这可以从各个方面减小修改结构的难度。首先,从CORE DOMAIN中去掉一些机制、GENERIC SUBDOMAIN和其他支持结构,需要重构的内容就少多了。
如果可能的话,应该把这些支持元素简单地定义成符合大比例结构的形式。例如,在一个RESPONSIBILITY LAYER系统中,可以把GENERIC SUBDOMAIN定义成只适合放到某个特定层中。 当使用了PLUGGABLE COMPONENT FRAMEWORK的时候,可以把GENERIC SUBDOMAIN定义成完全由某个组件拥有,也可以定义成一个SHARED KERNEL,供一组相关组件使用。 这些支持元素可能需要进行重构,以便找到它们在结构中的适当位臵,但它们的移动与CORE DOMAIN是独立的,而且移动也限制在很小的范围内,因此更容易实现。 最后,它们都是次要元素,因此它们的精化不会影响大局。
通过精炼和重构得到更深层理解的原理甚至也适用于大比例结构本身。例如,最初可以根据对领域的初步理解来选择分层结构,然后逐步用更深层次的抽象(这些抽象表达了系统的基本职责)来代替它们。 这种极高的清晰度使人们能够透彻地理解领域,这也是我们的目标。它也是一种使系统的整体控制变得更容易、更安全的手段。
领域驱动设计的综合运用
把大型结构与BOUNDED CONTEXT结合起来使用
战略设计的3个基本原则(上下文、精炼和大型结构)并不是可以互相代替的,而是互为补充,并且以多种方式进行互动。 例如,一种大型结构可以存在于一个BOUNDED CONTEXT中,也可以跨越多个BOUNDED CONTEXT存在,并用于组织CONTEXT MAP。
在单一的BOUNDED CONTEXT内部构造一个模型
在不同BOUNDED CONTEXT的组件关系上所使用的结构
允许一些组件跨越多个层的结构
在一个CONTEXT中和整个CONTEXT MAP(作为一个整体)中使用同一种结构
将大型结构与精炼结合起来使用
大型结构和精炼的概念也是互为补充的。大型结构可以帮助解释CORE DOMAIN内部的关系以及GENERIC SUBDOMAIN之间的关系。
通过分层把CORE DOMAIN的MODULE(用粗框显示)和GENERIC SUBDOMAIN分得更清楚
同时,大型结构本身可能也是CORE DOMAIN的一个重要部分。 例如,把潜能层、作业层、策略层和决策支持层区分开,能够提炼出对软件所要解决的业务问题的基本理解。当项目被划分为多个BOUNDED CONTEXT时,这种理解尤其有用,这样CORE DOMAIN的模型对象就不会具有过多的含义。
首先评估
当对一个项目进行战略设计时,首先需要清晰地评估现状。
- 画出CONTEXT MAP。你能画出一个一致的图吗?有没有一些模棱两可的情况?
- 注意项目上的语言使用。有没有UBIQUITOUS LANGUAGE?这种语言是否足够丰富,以便帮助开发?
- 理解重点所在。CORE DOMAIN被识别出来了吗?有没有DOMAIN VISION STATEMENT?你能写一个吗?
- 项目所采用的技术是遵循MODEL-DRIVEN DESIGN,还是与之相悖?
- 团队开发人员是否具备必要的技能?
- 开发人员是否了解领域知识?他们对领域是否感兴趣?
当然,我们不会发现完美的答案。我们现在对项目的了解永远不如将来的了解深入。但这些问题为我们提供了一个可靠的起点。 当知道了这些问题的初步答案后,我们就会明白什么是最迫切需要解决的。 随着时间的推进,我们可以得出更精炼的答案,特别是CONTEXT MAP、DOMAIN VISION STATEMENT,以及其他创建出来的工件,这些答案都反映出了变化的情况和新的理解。
由谁制定策略
传统上,架构是在应用程序开发开始之前建立的,并且在这种组织中,负责建立架构的团队比应用开发团队拥有更大的权力。但我们并不一定得遵循这种传统的方式,因为它并不总是十分有效。
从应用程序开发自动得出的结构
一个非常善于沟通、懂得自律的团队在没有核心领导的情况下照样能够很好地工作,他们能够遵循EVOLVING ORDER来达成一组共同遵守的原则,这样就能够有机地形成一种秩序,而不用靠命令来约束。
这是极限编程团队的典型模式。 从理论上讲,任何一对儿编程人员都可以根据自己的理解来完全自发地创建一种结构。通常,让团队中的一个人(或几个人)来承担大型结构的一些监管职责有利于保持结构统一。 如果这位承担监管职责的非正式的领导人也是一位负责具体工作的开发人员(仲裁者和协调员),而不是决策的唯一制定者,那么这种方法将特别有效。 在我见过的极限编程团队中,这样的策略设计领导者可能会自动出现,而且通常在教练中产生。不管这个自动出现的领导人是谁,他仍然是开发团队的成员之一。 由此可见,开发团队必须至少有几位具有这样才干的人,由他们来制定一些运用到整个项目中的设计决策。
当多个团队使用同一种大型结构时,密切相关的团队可以开始非正式的协作。 在这种情况下,对这种大型结构,每个应用程序团队仍会产生各自的想法,而其中一些具体选择会由一个非正式的委员会来讨论,这个委员会由各个团队的代表组成。 在评估了这些选择对设计的影响之后,委员会决定是采用它、修改它,还是放弃它。团队在这种松散的合作关系下一起前进。 这种安排要想发挥作用,需要保证:团队数目相对较少,各个团队之间能够一致地保持彼此协调,他们的设计能力大致相同,而且他们的结构需求基本一致,可以通过同一种大型结构来满足。
以客户为中心的架构团队
当几个团队共用同一种策略时,确实需要集中制定一些决策。架构师如果脱离实际开发工作,就可能会设计出失败的模型,但这是完全可以避免的。 架构团队可以把自己放在与应用开发团队平等的位臵上,帮助他们协调大型结构、BOUNDED CONTEXT边界和其他一些跨团队的技术问题。 为了在这个过程中发挥作用,架构团队必须把思考的重点放在应用程序的开发上。
在组织结构图中,这样的团队看起来与传统的架构团队没什么分别,但实际上二者在每一项活动中都存在不同。 架构团队的成员是真正的开发协作者,他们与开发人员一起发现模式,与各个团队一起通过反复实验进行精炼,并亲自动手参与开发工作。
这种场景我曾经见到过几次,项目最终会由一位架构师来领导,下面列出的大部分工作都会由他来完成。
制定战略设计决策的6个要点
决策必须传达到整个团队
显然,如果不能确保团队中的所有人都知道策略并去遵守它,那么策略也就失去了作用。这个要求引导人们以架构团队(具有正式的“权威”)为中心组织到一起,以便在整个项目中应用一致的规则。 然而具有讽刺意味的是,那些脱离实际开发工作的架构师往往会被人们忽略或躲开。 如果架构师没有实践经验,又试图把他们自己的规则强加于实际的应用程序,那么他们所设计出来的模式就会不切实际,这时开发人员除了忽略他们之外别无选择。
在一个沟通良好的项目中,应用开发团队所产生的策略设计实际上会更有效地传播到每个人。这样策略将会实际发挥作用,而且具有权威性,因为它是通过集体智慧制定的决策。
无论开发什么系统,都不要用管理层所授予的权力来强制地推行战略决策,而应该更多地关注开发人员与策略之间的实际关系。
决策过程必须收集反馈意见
无论是建立组织原则、大型结构还是那些微妙的精炼,都需要真正理解项目的需求和领域概念。那些唯一具有这方面深层次知识的人就是应用程序开发团队的成员。 这解释了为什么架构团队所创建的应用架构很少对项目产生帮助,尽管我们必须承认很多架构师都非常有才能。
与技术基础设施和架构不同,战略设计虽然影响到所有的开发工作,但是它本身并不需要编写很多代码。战略设计真正需要的是应用开发团队的参与。 经验丰富的架构师可以听取来自各个团队的想法,并促进总体解决方案的开发。
我曾经与一个技术架构团队合作过,这个团队把成员轮流派到使用其架构的各个应用开发团队中。 这种流动性使架构团队亲身体验到了开发人员所面临的挑战,同时也把如何应用框架的知识传播给了开发人员。战略设计同样需要这种紧密的反馈循环。
计划必须允许演变
有效的软件开发是一个高度动态的过程。如果最高层的决策已经固定下来,那么当团队需要对变更做出响应时,选择就会更少。 遵循EVOLVING ORDER这一原则,可以避免出现这个问题,因为它强调的是根据理解的不断加深来调整大型结构。
当很多设计决策过早地固定下来时,开发团队可能会束手束脚,失去解决问题的灵活性。 因此,虽然那些为了协调项目而制定的原则可能很有价值,但原则必须能够随着项目开发生命周期的进行而完善和变化,而且不能过分限制应用程序开发人员的能力,因为开发工作本来就已经很难了。
有了积极的反馈之后,当构建应用程序的过程中遇到障碍或是出现了意想不到的机会时,创新就自然而然地涌现出来了。
架构团队不必把所有最好、最聪明的人员都吸收进来
架构层次的设计确实需要技术精湛的人员,而这样的人员总是供不应求。项目经理往往会把那些最有技术天分的开发人员调到架构团队和基础设施团队中,因为他们想要充分利用这些高级设计人员的技能。 在项目经理看来,开发人员都希望提高自己的影响力,或是攻克那些“更有趣”的问题。而且,加入精英团队本身也会赢得威望。
这样往往会把那些技术能力较差的人留下来构建应用程序。但要想开发出优秀的应用程序,是需要设计技巧的,因此这样安排注定会造成项目失败。 即使战略团队建立了一个很好的战略设计,应用程序开发团队也没有能力把它实现出来。
相反,架构团队几乎从来不会把那些缺乏设计技巧但精通领域知识的开发人员吸纳进来。 战略设计并不是一项纯粹的技术任务,把那些精通深层次领域知识的开发人员排除在外只会使架构师的工作更难进行。而且同样也需要领域专家的参与。
所有应用程序团队都应该有一些技术能力很强的设计人员,而且任何从事战略设计的团队也都必须具有领域知识,这两者都是非常重要的。 聘用更多高级设计人员是很有必要的,而且使架构团队偶尔从事一下开发工作也会很有帮助。我相信有很多有用的方法,但任何有效的战略团队必须要与一个有效的应用程序团队通力合作。
战略设计需要遵守简约和谦逊的原则
任何设计工作都必须精炼而简约,而战略设计尤为需要简约。即使是一个非常小的设计失误也有可能会变成可怕的隐患。 把架构团队单分出来时要格外慎重,因为他们将更少感知他们为应用程序开发团队所设臵的障碍。 同时,架构师对其主要职责的过度关注会使他们迷失方向。我就曾多次看到过这种情况,甚至我自己也犯过这种错误。 有了一个好的想法后,又会引出另一个想法,想法太多最后就会得到一个过度设计的架构,这种体系结构反而起到了负面作用。
相反,我们必须严格地约束自己,从而使设计出来的组织原则和核心模型精简到只包含那些能够显著提高设计清晰度的内容。 事实上,几乎任何事物都会对其他某个事物构成障碍,因此每个元素都必须是确实值得存在的。我们需要有一个谦逊的态度,才能认识到我们自己认为的最佳思路可能会妨碍其它人。
对象的职责要专一,而开发人员应该是多面手
良好的对象设计的关键是为每个对象分配一个明确且专一的职责,并且把对象之间的互相依赖减至最小。人们有时会试图让团队中的交流像软件中的交互那样整齐。 其实在一个优秀的项目中,会有很多人参与其他人的事情。开发人员有时也处理框架,而架构师有时也会编写应用程序代码。 所有人员都可以互相交流。这看似混乱但却行之有效。因此,应该让对象职责专一,而让开发人员成为多面手。
把战略设计与其他设计区分开,是为了帮助澄清所涉及的工作,但必须指出:这两种设计活动并不意味着有两种人员。 虽然基于深层模型创建柔性设计是一种高级设计活动,但细节问题也至关重要,因此战略设计工作必须由接触编码工作的人来完成。 战略设计源自应用设计,然而战略设计需要一个总体的开发活动视图,这个视图可能跨越多个团队。人们总喜欢想出各种办法把工作分得很细,以使得设计专家不必了解业务,而领域专家也不用知道技术。 确实,一个人能学的知识是有限的,但过于专业化也会削弱领域驱动设计的力量。
技术框架同样如此
技术框架提供了基础设施层,从而使应用程序不必自己去实现基础服务,而且技术框架还能帮助把领域与其他关注点隔离开,因此它能够极大地加速应用程序(包括领域层)的开发。 但技术框架也是有风险的,那就是它会影响领域模型实现的表达能力,并妨碍领域模型的自由改变。甚至当框架设计人员并没有特意去干涉领域层或应用层的时候,情况同样如此。
用于克服战略设计缺点的原则同样适用于技术架构。遵守演变、简约等原则并且让应用程序开发团队参与进来,就能够得到一组持续精化的服务和规则,这些服务和规则能够真正有助于应用程序的开发,而不会妨碍开发。 如果架构不按照这种方式来做,那么它们要么会抑制应用程序开发的创造力,要么会被人们绕过去,从而导致应用程序为了能够把开发进行下去而根本不使用架构。
不要编写“傻瓜式”的框架
在划分团队时,如果认为一些开发人员不够聪明,无法胜任设计工作,而让他们去做开发工作,那么这种态度可能会导致失败,因为他们低估了应用程序开发的难度。 如果这些人在设计方面不够聪明,就不应该让他们来开发软件。如果他们足够聪明,那么这种隔离只会造成障碍,使他们得不到所需的工具。
这种态度还会损害团队之间的关系。我就曾经在这样傲慢自大的团队中感到疲惫不堪,于是我每次谈话都得向开发人员道歉,我自己也因为有这样自大的同事而感到难堪(我恐怕永远也无法改变这样的团队)。
注意,把无关的技术细节封装起来与我所反对的这种“傻瓜式”的预打包完全不同。框架可以为开发人员提供有力的抽象和工具,使他们不用去做那么多苦差事。 有用的封装和“傻瓜式”的预打包之间的区别很难用一种通用的方式描述出来,但只要问问框架设计人员他们对将要使用工具/框架/组件的那些人有什么期望,就可以看出区别。 如果设计人员对框架的用户非常尊重,那么他们的工作方向可能就是正确的。
注意总体规划
Alexander和他的同事倡议由社区成员共同制定一组原则,并在“聚少成多地成长”的每次行动中都应用这些原则,这样就会形成一种“有机秩序”,并且能够根据环境变化作出调整。
以上总结来源于《领域驱动设计,软件核心复杂性应对之道》