领域驱动设计

2019/12/27 软件学

模式关系图

汇总图

汇总图

运用领域模型

每个软件程序是为了执行用户的某项活动,或是满足用户的某种需求。这些用户应用软件的问题区域就是软件的领域。

领域模型并非某种特殊的图,而是这种图所要传达的思想。它绝不单单是领域专家头脑中的知识,而是对这类知识严格的组织且有选择的抽象。

模型在领域驱动设计中的作用

  • 模型和设计的核心互相影响。
    • 正是模型与实现之间的紧密联系才使模型变得有用,并确保我们在模型中所进行的分析能够转化为最终产品(即一个可运行的程序)。
    • 模型与实现之间的这种紧密结合在维护和后续开发期间也会很有用,因为我们可以基于对模型的理解来解释代码。
  • 模型是团队所有成员使用的通用语言的中枢。
    • 由于模型与实现之间的关联,开发人员可以使用该语言来讨论程序。他们可以在无需翻译的情况下与领域专家进行沟通。
    • 而且,由于该语言是基于模型的,因此我们可借助自然语言对模型本身进行精化。
  • 模型是浓缩的知识。
    • 模型是团队一致认同的领域知识的组织方式和重要元素的区分方式。透过我们如何选择术语、分解概念以及将概念联系起来,模型记录了我们看待领域的方式。
    • 当开发人员和领域专家在将信息组织为模型时,这一共同的语言(模型)能够促使他们高效地协作。
    • 模型与实现之间的紧密结合使来自软件早期版本的经验可以作为反馈应用到建模过程中。

软件的核心

软件的核心是其为用户解决领域相关的问题的能力。

消化知识

有效建模的要素

  • 模型和实现的绑定。
    • 最初的原型虽然简陋,但它在模型与实现之间建立了早期链接,而且在所有后续的迭代中我们一直在维护该链接。
  • 建立了一种基于模型的语言。
    • 最初,工程师们不得不向我解释基本的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及其演变来选择那些需要保持更新并与项目活动紧密交互的文档。
完全依赖可执行代码的情况

尽管代码可能会产生误导,但它仍然比其他文档更基础。要想利用当前的标准技术使代码所传达的消息与它的行为和意图保持一致,需要纪律和思考设计的特定方式。 要有效地交流,代码必须基于在编写需求时所使用的同一种语言,也就是开发人员之间、开发人员与领域专家之间进行讨论时所使用的语言。

解释性模型

模型在讲授有关领域知识的时候也是一种非常有价值的教学手段。 驱动设计的模型是领域的一种视图,它也有助于学习其他视图,这些视图可能仅仅用作教学工具或是交流一般的领域知识。 在这些时候,人们可以使用传达与软件设计无关的其他模型的图片或语言。

绑定模型和实现

模式:MODEL-DRIVEN DESIGN(模型驱动设计)

无论是什么原因,软件的设计如果缺乏概念,那么软件充其量不过是一种机械化的产品——只实现有用的功能却无法解释操作的原因。

为什么

  • 如果整个程序设计或者其核心部分没有与领域模型相对应
    • 那么这个模型就是没有价值的,软件的正确性也值得怀疑。
    • 同时,模型和设计功能之间过于复杂的对应关系也是难于理解的,在实际项目中,当设计改变时也无法维护这种关系。
    • 若分析与和设计之间产生严重分歧,那么在分析和设计活动中所获得的知识就无法彼此共享。

结果

  • 软件系统各个部分的设计应该忠实地反映领域模型,以便体现出这二者之间的明确对应关系。
    • 我们应该反复检查并修改模型,以便软件可以更加自然地实现模型;即使想让模型反映出更深层次的领域概念时也应如此。
    • 我们需要的模型不但应该满足这两种需求,还应该能够支持健壮的UBIQUITOUS LANGUAGE(通用语言)。
  • 从模型中获取用于程序设计和基本职责分配的术语。让程序代码成为模型的表达,代码的改变可能会是模型的改变。而其影响势必要波及接下来相应的项目活动。
  • 完全依赖模型的实现通常需要支持建模范式的软件开发工具和语言,比如面向对象的编程。

软件系统的每一部分只能对应一个模型。单一模型能够减少出错的概率,因为程序设计直接来源于经过仔细考虑而创建的模型。程序设计,甚至是代码本身,都与模型密不可分。

建模范式和工具支持

为了使MODEL-DRIVEN DESIGN发挥作用,一定要在可控范围内严格保证模型与设计之间的一致性。 要实现这种严格的一致性,必须要运用由软件工具支持的建模范式,它可以在程序中直接创建模型中的对应概念。

建模范式和工具支持

揭示主旨:为什么模型对用户至关重要

如果程序设计基于一个能够反映出用户和领域专家所关心的基本问题的模型,那么与其他设计方式相比,这种设计可以将其主旨更明确地展示给用户。 让用户了解模型,将使他们有更多机会挖掘软件的潜能,也能使软件的行为合乎情理、前后一致。

模式:HANDS-ON MODELER(亲身实践的建模者)

为什么

  • 如果编写代码的人员认为自己没必要对模型负责,或者不知道如何让模型为应用程序服务,那么这个模型就和程序没有任何关联。如果开发人员没有意识到改变代码就意味着改变模型,那么他们对程序的重构不但不会增强模型的作用,反而还会削弱它的效果。
  • 同样,如果建模人员不参与到程序实现的过程中,那么对程序实现的约束就没有切身的感受,即使有,也会很快忘记。MODEL-DRIVEN DESIGN的两个基本要素(即模型要支持有效的实现并抽象出关键的领域知识)已经失去了一个,最终模型将变得不再实用。
  • 最后一点,如果分工阻断了设计人员与开发人员之间的协作,使他们无法转达实现MODEL-DRIVEN DESIGN的种种细节,那么经验丰富的设计人员则不能将自己的知识和技术传递给开发人员。

结果

  • 任何参与建模的技术人员,不管在项目中的主要职责是什么,都必须花时间了解代码。任何负责修改代码的人员则必须学会用代码来表达模型。
  • 每一个开发人员都必须不同程度地参与模型讨论并且与领域专家保持联系。
  • 参与不同工作的人都必须有意识地通过UBIQUITOUS LANGUAGE与接触代码的人及时交换关于模型的想法。

模型驱动设计的构造块

模式以及这些模式彼此关联的方式

模型中各个元素的实际设计和实现相对系统化。将领域设计与软件系统中的其他关注点分离会使设计与模型之间的关系非常清晰。 根据不同的特征来定义模型元素则会使元素的意义更加鲜明。对每个元素使用已验证的模式有助于创建出更易于实现的模型。

分离领域

模式:LAYERED ARCHITECTURE(分层架构)

LAYERED ARCHITECTURE

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。 除了标识问题之外,实体往往通过协调其关联对象的操作来完成自己的职责。

ENTITY建模

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所包含的属性应该形成一个概念整体。

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确实有一些特性很适合表达约束,但它并不是在所有情况下都适用。有时使用其他风格的图形(可能适用于其他范式)或者简单的语言描述比牵强附会地适应某种对象视图更好。
  • 保持怀疑态度。
    • 工具是否真正有用武之地?不能因为存在一些规则,就必须使用规则引擎。规则也可以表示为对象,虽然可能不是特别优雅。多个范式会使问题变得非常复杂。

在决定使用混合范式之前,一定要确信主要范式中的各种可能性都已经尝试过了。尽管有些领域概念不是以明显的对象形式表现出来的,但它们通常可以用对象范式来建模。

领域对象的生命周期

领域对象的生命周期

主要的挑战有以下两类。

  1. 在整个生命周期中维护完整性。
  2. 防止模型陷入管理生命周期复杂性造成的困境当中。

将通过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之外看不到其他对象。
  • 固定规则(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的基本交互

正如对象的接口应该封装对象的实现一样(从而使客户无需知道对象的工作机理就可以使用对象的功能),FACTORY封装了创建复杂对象或AGGREGATE所需的知识。 它提供了反映客户目标的接口,以及被创建对象的抽象视图。

因此:

应该将创建复杂对象的实例和AGGREGATE的职责转移给单独的对象,这个对象本身可能没有承担领域模型中的职责,但它仍是领域设计的一部分。 提供一个封装所有复杂装配操作的接口,而且这个接口不需要客户引用要被实例化的对象的具体类。在创建AGGREGATE时要把它作为一个整体,并确保它满足固定规则。

任何好的工厂都需满足以下两个基本需求:

  1. 每个创建方法都是原子的,而且要保证被创建对象或AGGREGATE的所有固定规则。
    • FACTORY生成的对象要处于一致的状态。
      • 在生成ENTITY时,这意味着创建满足所有固定规则的整个AGGREGATE,但在创建完成后可以向聚合添加可选元素。
      • 在创建不变的VALUE OBJECT时,这意味着所有属性必须被初始化为正确的最终状态。
    • 如果FACTORY通过其接口收到了一个创建对象的请求,而它又无法正确地创建出这个对象,那么它应该抛出一个异常,或者采用其他机制,以确保不会返回错误的值。
  2. FACTORY应该被抽象为所需的类型,而不是所要创建的具体类。[Gamma et al.1995]中的高级FACTORY模式介绍了这一话题。
选择FACTORY及其应用位置

一般来说,FACTORY的作用是隐藏创建对象的细节,而且我们把FACTORY用在那些需要隐藏细节的地方。这些决定通常与AGGREGATE有关。

例如,如果需要向一个已存在的AGGREGATE添加元素,可以在AGGREGATE的根上创建一个FACTORY METHOD。 这样就可以把AGGREGATE的内部实现细节隐藏起来,使任何外部客户看不到这些细节,同时使根负责确保AGGREGATE在添加元素时的完整性。

一个FACTORY METHOD封装了AGGREGATE的扩展:

一个FACTORY METHOD封装了AGGREGATE的扩展

另一个示例是在一个对象上使用FACTORY METHOD,这个对象与生成另一个对象密切相关,但它并不拥有所生成的对象。 当一个对象的创建主要使用另一个对象的数据(或许还有规则)时,则可以在后者的对象上创建一个FACTORY METHOD,这样就不必将后者的信息提取到其他地方来创建前者。 这样做还有利于表达前者与后者之间的关系。

FACTORY METHOD生成一个ENTITY,但这个ENTITY并不属于FACTORY所在的AGGREGATE:

FACTORY METHOD生成一个ENTITY,但这个ENTITY并不属于FACTORY所在的AGGREGATE

FACTORY与被构建对象之间是紧密耦合的,因此FACTORY应该只被关联到与被构建对象有着密切联系的对象上。 当有些细节需要隐藏(无论要隐藏的是具体实现还是构造的复杂性)而又找不到合适的地方来隐藏它们时,必须创建一个专用的FACTORY对象或SERVICE。 整个AGGREGATE通常由一个独立的FACTORY来创建,FACTORY负责把对根的引用传递出去,并确保创建出的AGGREGATE满足固定规则。 如果AGGREGATE内部的某个对象需要一个FACTORY,而这个FACTORY又不适合在AGGREGATE根上创建,那么应该构建一个独立的FACTORY。 但仍应遵守规则——把访问限制在AGGREGATE内部,并确保从AGGREGATE外部只能对被构建对象进行临时引用。

由一个独立的FACTORY来构建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很类似,主要有以下两点不同。

  1. 用于重建对象的ENTITY FACTORY不分配新的跟踪ID。
    • 如果重新分配ID,将丢失与先前对象的连续性。因此,在重建对象的FACTORY中,标识属性必须是输入参数的一部分。
  2. 当固定规则未被满足时,重建对象的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为客户执行一个搜索

REPOSITORY解除了客户的巨大负担,使客户只需与一个简单的、易于理解的接口进行对话,并根据模型向这个接口提出它的请求。 要实现所有这些功能需要大量复杂的技术基础设施,但接口很简单,而且在概念层次上与领域模型紧密联系在一起。

因此:

为每种需要全局访问的对象类型创建一个对象,这个对象相当于该类型的所有对象在内存中的一个集合的“替身”。 通过一个众所周知的全局接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。 提供根据具体条件来挑选对象的方法,并返回属性值满足查询条件的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。 只为那些确实需要直接访问的AGGREGATE根提供REPOSITORY。让客户始终聚焦于模型,而将所有对象的存储和访问操作交给REPOSITORY来完成。

REPOSITORY有很多优点,包括:

  • 它们为客户提供了一个简单的模型,可用来获取持久化对象并管理它们的生命周期;
  • 它们使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦;
  • 它们体现了有关对象访问的设计决策;
  • 可以很容易将它们替换为“哑实现”(dummy implementation),以便在测试中使用(通常使用内存中的集合)。
REPOSITORY的查询

所有REPOSITORY都为客户提供了根据某种条件来查询对象的方法,但如何设计这个接口却有很多选择。

  • 最容易构建的REPOSITORY用硬编码的方式来实现一些具有特定参数的查询。
  • 尽管大多数查询都返回一个对象或对象集合,但返回某些类型的汇总计算也符合REPOSITORY的概念,如对象数目,或模型需要对某个数值属性进行求和统计。
  • 在一些需要执行大量查询的项目上,可以构建一个支持更灵活查询的REPOSITORY框架。
    • 基于SPECIFICATION(规格)的查询是将REPOSITORY通用化的好办法。客户可以使用规格来描述(也就是指定)它需要什么,而不必关心如何获得结果。在这个过程中,可以创建一个对象来实际执行筛选操作。
  • 即使一个REPOSITORY的设计采取了灵活的查询方式,也应该允许添加专门的硬编码查询。这些查询作为便捷的方法,可以封装常用查询或不返回对象(如返回的是选中对象的汇总计算)的查询。
    • 不支持这些特殊查询方式的框架有可能会扭曲领域设计,或是干脆被开发人员弃之不用。

在简单REPOSITORY中进行的硬编码查询:

在简单REPOSITORY中进行的硬编码查询

在一个复杂的REPOSITORY中,用一种灵活的、声明式的SPECIFICATION来表述一个搜索条件:

在一个复杂的REPOSITORY中,用一种灵活的、声明式的SPECIFICATION来表述一个搜索条件

客户代码可以忽略REPOSITORY的实现,但开发人员不能忽略

持久化技术的封装可以使得客户变得十分简单,并且使客户与REPOSITORY的实现之间完全解耦。但像一般的封装一样,开发人员必须知道在封装背后都发生了什么事情。 在使用REPOSITORY时,不同的使用方式或工作方式可能会对性能产生极大的影响。

底层技术可能会限制我们的建模选择。 例如,关系数据库可能对复合对象结构的深度有实际的限制。同样,开发人员要获得REPOSITORY的使用及其查询实现之间的双向反馈。

REPOSITORY的实现注意事项

REPOSITORY概念在很多情况下都适用。可能的实现方法有很多,这里只能列出如下一些需要谨记的注意事项。

  • 对类型进行抽象。
    • REPOSITORY“含有”特定类型的所有实例,但这并不意味着每个类都需要有一个REPOSITORY。
    • 类型可以是一个层次结构中的抽象超类(例如,TradeOrder可以是BuyOrder或SellOrder)。
    • 类型可以是一个接口——接口的实现者并没有层次结构上的关联,也可以是一个具体类。
    • 记住,由于数据库技术缺乏这样的多态性质,因此我们将面临很多约束。
  • 充分利用与客户解耦的优点。
    • 我们可以很容易地更改REPOSITORY的实现,但如果客户直接调用底层机制,我们就很难修改其实现。
    • 也可以利用解耦来优化性能,因为这样就可以使用不同的查询技术,或在内存中缓存对象,可以随时自由地切换持久化策略。
    • 通过提供一个易于操纵的、内存中的(in-memory)哑实现,还能够方便客户代码和领域对象的测试。
  • 将事务的控制权留给客户。
    • 尽管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来重建一个已有对象:

REPOSITORY使用FACTORY来重建一个已有对象

这种职责上的明确区分还有助于FACTORY摆脱所有持久化职责。FACTORY的工作是用数据来实例化一个可能很复杂的对象。 如果产品是一个新对象,那么客户将知道在创建完成之后应该把它添加到REPOSITORY中,由REPOSITORY来封装对象在数据库中的存储。

客户使用REPOSITORY来存储新对象:

客户使用REPOSITORY来存储新对象

另一种情况促使人们将FACTORY和REPOSITORY结合起来使用,这就是想要实现一种“查找或创建”功能,即客户描述它所需的对象,如果找不到这样的对象,则为客户新创建一个。 最好不要追求这种功能,它不会带来多少方便。 当将ENTITY和VALUE OBJECT区分开时,很多看上去有用的功能就不复存在了。需要VALUE OBJECT的客户可以直接请求FACTORY来创建一个。 通常,在领域中将新对象和原有对象区分开是很重要的,而将它们组合在一起的框架实际上只会使局面变得混乱。

为关系数据库设计对象

在以面向对象技术为主的软件系统中,最常用的非对象组件就是关系数据库。除了技术上的难点以外,不匹配可能对对象模型产生很大的影响。

有3种常见情况:

  1. 数据库是对象的主要存储库;
  2. 数据库是为另一个系统设计的;
  3. 数据库是为这个系统设计的,但它的任务不是用于存储对象。

如果数据库模式(database schema)是专门为对象存储而设计的,那么接受模型的一些限制是值得的,这样可以让映射变得简单一点。 如果在数据库模式设计上没有其他的要求,那么可以精心设计数据库结构,以便使得在更新数据时能更安全地保证聚合的完整性,并使数据更新变得更加高效。

从技术上来看,关系表的设计不必反映出领域模型。 映射工具已经非常完善了,足以消除二者之间的巨大差别。问题在于多个重叠的模型过于复杂了。 MODEL-DRIVEN DESIGN的很多关于避免将分析和设计模型分开的观点,也同样适用于这种不匹配问题。 这确实会牺牲一些对象模型的丰富性,而且有时必须在数据库设计中做出一些折中(如有些地方不能规范化)。

但如果不做这些牺牲就会冒另一种风险,那就是模型与实现之间失去了紧密的耦合。 这种方法并不要必须使用一种简单的、一个对象/一个表的映射。依靠映射工具的功能,可以实现一些聚合或对象的组合。 但至关重要的是:映射要保持透明,并易于理解——能够通过审查代码或阅读映射工具中的条目就搞明白。

当数据库被视作对象存储时,数据模型与对象模型的差别不应太大(不管映射工具有多么强大的功能)。 可以牺牲一些对象关系的丰富性,以保证它与关系模型的紧密关联。如果有助于简化对象映射的话,不妨牺牲某些正式的关系标准(如规范化)。

对象系统外部的过程不应该访问这样的对象存储。它们可能会破坏对象必须满足的固定规则。 此外,它们的访问将会锁定数据模型,这样使得在重构对象时很难修改模型。


另一方面,很多情况下数据是来自遗留系统或外部系统的,而这些系统从来没打算被用作对象的存储。在这种情况下,同一个系统中就会有两个领域模型共存。 或许与另一个系统中隐含的模型保持一致有一定的道理,也可能更好的方法是使这两个模型完全不同。

允许例外情况的另一个原因是性能。为了解决执行速度的问题,有时可能需要对设计做出一些非常规的修改。

但大多数情况下关系数据库是面向对象领域中的持久化存储形式,因此简单的对应关系才是最好的。 表中的一行应该包含一个对象,也可能还包含AGGREGATE中的一些附属项。表中的外键应该转换为对另一个ENTITY对象的引用。 有时我们不得不违背这种简单的对应关系,但不应该由此就全盘放弃简单映射的原则。

UBIQUITOUS LANGUAGE可能有助于将对象和关系组件联系起来,使之成为单一的模型。 对象中的元素的名称和关联应该严格地对应于关系表中相应的项。 尽管有些功能强大的映射工具使这看上去有些多此一举,但关系中的微小差别可能引发很多混乱。

对象世界中越来越盛行的重构实际上并没有对关系数据库设计造成多大的影响。 此外,一些严重的数据迁移问题也使人们不愿意对数据库进行频繁的修改。 这可能会阻碍对象模型的重构,但如果对象模型和数据库模型开始背离,那么很快就会失去透明性。


最后,有些原因使我们不得不使用与对象模型完全不同的数据库模式,即使数据库是专门为我们的系统创建的。 数据库也有可能被其他一些不对对象进行实例化的软件使用。即使当对象的行为快速变化或演变的时候,数据库可能并不需要修改。 让模型与数据库之间保持松散的关联是很有吸引力的。但这种结果往往是无意为之,原因是团队没有保持数据库与模型之间的同步。 如果有意将两个模型分开,那么它可能会产生更整洁的数据库模式,而不是一个为了与早前的对象模型保持一致而到处都是折中处理的拙劣的数据库模式。

使用语言,一个扩展的示例

通过重构来加深理解

使用语言,一个扩展的示例

战略设计

以上总结来源于《领域驱动设计,软件核心复杂性应对之道》

Search

    Post Directory