战略设计- DDD
随着系统的增长,它会变得越来越复杂,当我们无法通过分析对象来理解系统的时候,就需要掌握一些操纵和理解大模型的技术了。本文将介绍一些原则。遵循这些原则,就可以对非常复杂的领域进行建模。大部分这样的决策都需要由团队来制定,甚至需要多个团队共同协商制定。这些决策往往是把设计和策略综合到一起的结果。
最负雄心的企业系统意欲实现一个涵盖所有业务、紧密集成的系统。然而在几乎所有这种规模的组织中,整体业务模型太大也太复杂了,因此难以管理,甚至很难把它作为一个整理来理解。我们必须在概念和实现上把系统分解成较小的部分。但问题在于,如何保证实现这种模块化的同时,不失去集成所具备的好处;从而使系统的不同部分能够进行互操作,以便协调各种业务操作。如果设计一个把所有概念都涵盖进来的单一领域模型,它将会非常笨拙,而且将会出现大量难以察觉的重复和矛盾。而如果用临时拼凑的接口把一组小的、各自不同的子系统集成到一起,又不具备解决企业级问题的能力,并且在每个集成点上都有可能出现不一致。通过采用系统的、不断演变的设计策略,就可以避免这两种极端问题。
即使在这种规模的系统中采用领域驱动设计方法,也不要脱离实现去开发模型。每个决策都必须对系统开发产生直接的影响,否则它就是无关的决策。战略设计原则必须知道设计决策,以便减少各个部分之间的相互依赖,在使设计意图更为清晰的同时而又不失去关键的互操作性和协同性。战略设计原则必须把模型的重点放在捕获系统的概念核心,也就是系统的“远景”上。而且在完成这些目标的同时又不能为项目带来麻烦。为了帮助实现这些目标,这一部分讨论三个大的主体:上下文、精炼和大型结构。
其中上下文是最不易引起注意的原则,但实际上它却是最根本的。无论大小,成功的模型必须在逻辑上一致,不能有相互矛盾或重叠的定义。有时,企业系统会集成各种不同来源的子系统,或包含诸多完全不同的应用程序,以至于无法从同一个角度来看待领域。要把这些不同部分中隐含的模型统一起来可能是要求过高了。通过为每个模型显式地定义一个 Bounded Context ,然后在必要的情况下定义它与其他上下文的关系,建模人寰就可以避免模型变得缠杂不清。
通过精炼可以减少混乱,并且把注意力集中到正确的地方。人们通常在领域的一些次要问题上花费了太多的精力。整体领域模型需要突出系统中最有价值和最特殊的那些地方,而且在构造领域模型时应该尽可能把注意力集中在这些部分上。虽然一些支持组件也很关键,但绝不能把他们和领域核心一视同仁。把注意力集中到正确的地方不仅有助于把精力投入到关键部分上,而且还可以使系统不会偏离预期方向。战略精炼可以使大的模型保持清晰。有了更清晰的视图后,Core Domain 的设计就会发挥更大的作用。
大型结构是用来描述整个系统的。在非常复杂的模型中,人们可能会“只见树木,不见森林”。精炼确实有帮助,它使人们能够把注意力集中到核心元素上,并把其他元素表示为支持作用,但如果不贯彻某个主旨来应用一些系统级的设计元素和模式的话,关系仍然可能非常混乱。
一、保持模型的完整性
模型最基本的要求是它应该保持内部一致,术语总是具有相同的意义,并且不包含互相矛盾的规则:虽然我们很少明确地考虑这些要求。模型的内部一致性又叫统一(unification),这种情况下,每个术语都不会有模棱两可的意义,也不会有规则冲突。除非模型在逻辑上是一致的,否则它就没有意义。在理想世界中,我们可以得到涵盖整个企业领域的单一模型。这个模型将是统一的,没有任何互相矛盾或重叠的术语定义。每个有关领域的逻辑声明是一致的。
但大型系统开发并非如此理想。在整个企业系统中保持这种水平的统一是一件得不偿失的事情。在系统的各个不同部分中开发多个模型是很有必要的,但我们必须慎重地选择系统的哪些部分可以分开,以及它们之间是什么关系。我们需要用一些方法来保持模型关键部分的高度统一。所有这些都不会自行发生,而且光有良好的意愿也是没用的。它只有通过有意识的设计决策和建立特定过程才能实现。大型系统领域模型的完全统一既不可行,也不划算。
既然无法维护一个涵盖整个企业的统一模型,那就不要再受到这种思路的限制。通过预先决定什么应该统一,并实际认识到什么不能统一,我们就能够创建一个清晰、共同的视图。确定了这些之后,就可以着手开始工作,以保证那些需要统一的部分保持一致,不需要统一的部分不会引起混乱或坏模型。
我们需要用一种方式来标记出不同模型之间的边界和关系。我们需要有意识地选择一种策略,并一致地遵守它。
本文将介绍一些用于识别、沟通和选择模型边界及关系的技术。首先讨论从描绘项目当前的范围开始。Bounded Context(限界上下文)定义了每个模型的应用范围,而 Context Map(上下文图)则给出了项目上下文以及它们之间关系的总体视图。这些降低模糊性的技术能够使项目更好地进行,但仅仅有它们还是不够的。一旦确立了Context 的边界之后,仍需要持续集成这种过程,它能够使模型保持统一。
其后,在这个稳定的基础之上,我们就可以开始实施那些在界定和关联 Context 方面更有效的策略了-从通过共享内核(Shared Kernel)来紧密关联上下文,到哪些各行其道(Separate Ways)地进行松散耦合的模型。
1、模式:Bounded Context
任何大型项目都会存在多个模型。而当基于不同模型的代码被组合到一起后,软件就会出现bug,变得不可靠和难以理解。团队成员之间的沟通变得混乱。人们往往弄不清一个模型不应该在哪个上下文中使用。
模型混乱的问题最终会在代码不能正常运行时暴露出来,但问题的根源却在于团队的组织方式和成员的交流方法。因此,为了澄清模型的上下文,我们既要注意项目,也要注意它的最终产品(代码、数据库模式等)。
一个模型只在一个上下文中使用。这个上下文可以是代码的一个特定部分,也可以是某个特定团队的工作。模型上下文是为了保证该模型中的术语具有特定意义而必须要应用的一组条件。
为了解决多个模型的问题,我们需要明确地定义模型的范围——模型的范围是软件系统中一个有界的部分,这部分只应用一个模型,并尽可能使其保持统一。团队组织中必须一致遵守这个定义。
因此:
明确地定义模型所应用的上下文。根据团队的组织、软件系统的各个部分的用法以及物理表现(代码和数据库模式等)来设置模型的边界。在这些边界中严格保持模型的一致性,而不要受到边界之外问题的干扰和混淆。
当然,边界只不过是一些特殊的位置。各个 Bounded Context 之间的关系需要我们仔细地处理。
识别 Bounded Context 中的不一致
很多征兆都可能表明模型中出现了异常。最明显的是已编码的接口不匹配。对于更微妙的情况,一些意外行为也可能是一种信号。采用了自动测试的 Continuous Integration 可以帮助捕捉到这类问题。但语言上的混乱往往是一种早期的警告信号。
将不同模型的元素组合到一起可能会引发两类问题:重复的概念和假同源。重复的概念是指两个模型元素(以及伴随的实现)实际上表示同一概念。每当这个概念的信息发生变化时,都必须更新两个地方。每次由于新知识导致一个对象被修改时,必须重新分析和修改另一个对象。如果不进行实际的重新分析,结果就会出现同一概念的两个版本,它们遵守不同的规则,甚至有不同的数据。更严重的是,团队成员必须学习做同一件事情的两种方法,以及保持这两种方法同步的各种方式。
假同源可能稍微少见一点,但它潜在的危害更大。它是指使用相同术语(或已实现的对象)的两个人认为他们是在讨论同一件事情,实际上并不是这样。
2、模式:Continuous Integration(持续集成)
定义完一个 Bounded Context 后,必须让它保持合理。
当很多人在同一个 Bounded Context 中工作时,模型很容易发生分裂。团队越大,问题就越大,但即使是3、4个人的团队也有可能会遇到严重的问题。然而,如果将系统分解为更小的 Context ,最终又难以保持集成度和一致性。
有时开发人员没有完全理解其他人所创建的对象或交互的意图,就对它进行了修改,使其失去了原来的作用。有时他们没有意识到他们正在开发的概念已经在模型的另一个部分实现了,从而导致了这些概念和行为(不正确的)重复。有时他们意识到了这些概念有其他的表示,但却因为担心破坏现有功能而不敢去改动它们,于是他们继续重复的开发这些概念和功能。
开发统一的系统(无论规模大小)需要维持很高的沟通水平,而这一点常常很难做到。我们需要运用各种方法来增进沟通并减小复杂性。还需要一些安全防护措施,以避免过于谨慎的行为(例如,开发人员由于担心破坏现有代码而重复开发一些功能)。
极限编程(XP)在这样的环境中真正显示出自己的强大威力。很多XP实践都是针对在很多人频繁更改设计的情况下如何维护设计的一致性这个特定问题而出现的。最纯粹的XP非常适合维护单一 Bounded Context 中的模型完整性。但是,无论是否使用XP,都很有必要采取 Continuous Integration 过程。
Continuous Integration 是指把一个上下文的中所有工作足够频繁地合并到一起,并使它们保持一致,以便当模型发生分裂时,可以迅速发现并纠正问题。像领域驱动设计中的其他方法一样,Continuous Integration 也有两个级别的操作:(1)模型概念的集成;(2)实现的集成。
团队成员之间通过经常沟通来保证概念的集成。团队必须对不断变化的模型形成一个共同的理解。有很多方法可以帮助做到这一点,但是最基本的方法是对 Ubiquitous Language 多加锤炼。同时,实际工件通过系统的合并/构建/测试过程来继承,这样能够尽早暴露出模型的分裂问题。用来集成的过程有很多,大部分有效的过程都具备以下这些特征:
(1)分步集成,采用可重现的合并/构建技术;
(2)自动测试套件;
(3)有一些规则,用来为那些尚未集成的改动设置一个相当小的生命期上限。
有效过程的另一面是概念集成,虽然它很少被正式地纳入进来。
(4)在讨论模型和应用程序时要坚持使用 Ubiquitous Language 。
在 Model-Driven Design 中,概念集成为实现集成铺平了道路,而实现集成验证了模型的有效性和一致性,并暴露出模型的分裂问题。
因此:
建立一个把所有代码和其他实现工件频繁地合并到一起的过程,并通过自动化测试来快速查明模型的分裂问题。严格坚持使用 Ubiquitous Language ,以便在不同人的头脑中演变出不同的概念时,使所有人对模型都能达成一个共识。
最后,不要在持续集成中做一些不必要的工作。Continuous Integration 只有在 Bounded Context 中才是重要的。相邻Context中的设计问题(包括转换)不必以同一个步调来处理。
3、模式:Context Map
只有一个Bounded Context 并不能提供全局试图。其他模型的上下文可能仍不清楚而且还在不断变化。
其他团队中的人员并不是十分清楚Context的边界,他们会不知不觉地做出一些更改,从而使边界变得模糊或使互连变得复杂。当不同的上下文必须互相连接时,它们可能会互相重叠。
Bounded Context之间的代码重用是很危险的,应该避免。功能和数据的集成必须要通过转换去实现。通过定义不同上下文之间的关系,并在项目中创建一个所有模型上下文的全局试图,可以减少混乱。
因此:
识别在项目中起作用的每个模型,并定义其Bounded Context。这包括非面向对象子系统的隐含模型。为每个Bounded Context 命名,并把名称添加到 Ubiquitous Language 中。
描述模型之间的联系点,明确所有通信需要的转换,并突出任何共享的内容。
先将当前的情况描绘出来。以后再做改变。
3.1 测试Context的边界
对各个Bounded Context 的联系点的测试特别重要。这些测试有助于解决转换时所存在的一些细微问题以及弥补边界沟通上存在的不足。测试充当了有用的早期报警系统,特别是在我们必须信赖那些模型细节却又无法控制它们时,它能让我们感到放心。
3.2 Context Map 的组织和文档化
这里有以下两个重点:
(1)Bounded Context 应该有名称,以便可以讨论它们。这些名称应该被添加到团队的 Ubiquitous Language 中。
(2)每个人都应该知道边界在哪里,而且应该能够分辨出任何代码段的 Context ,或任何情况的 Context。
有很多方式可以满足第二项需求,这取决于团队的文化。一旦定义了 Bounded Context,那么把不同上下文的代码隔离到不同的 Module 中就再自然不过了,但这样就产生了一个问题——如何跟踪哪个Module属于哪个Context。我们可以用命名规范来表明这一点,或者使用其他简单且不会产生混淆的机制。
4、Bounded Context 之间的关系
下面介绍的这些模式涵盖了将两个模型关联起来的众多策略。把模型连接到一起之后,就能够把整个企业笼括在内。这些模式有着双中=重目的,一是为成功地组织开发工作设定目标,二是为描述现有组织提供术语。
要想得到一个明确的 Context Map,需要重新组织一些关系。在这种情况或任何需要考虑重组的情况下,这些模式提供了应对各种不同情况的选择。这些模式的主要区别包括你对另一个模型的控制程度、两个团队之间合作水平和合作类型,以及特性和数据的集成程度。
下面这些模式涵盖了一些常见和重要的情况,它们提供了一些很好的思路,沿着这些思路,我们就可以知道如何处理其他情况。开发一个紧密集成产品的优秀团队可以部署一个大的、统一的模型。如果团队需要为不同的用户群提供服务,或者团队的协调能力有限,可能就需要采用 Shared Kernel(共享内核)或 Customer/Supplier(客户/供应商)关系。有时仔细研究需求之后可能发现集成并不重要,而系统最好采用 Separate Way(各行其道)模式。当然,大多数项目都需要与遗留系统或外部系统进行一定程度的集成。这就需要使用 Open Host Service(开放主机服务)或 Anticorruption Layer(防护层)。
5、模式:Shared Kernel
当功能集成受到局限,Continuous Integration 的开销可能会变得非常高。尤其是当团队的技能水平或行政组织不能保持持续集成,或者只有一个庞大的、笨拙的团队时,更容易发生这种情况。在这种情况下就要定义单独的 Bounded Context,并组织多个团队。
当不同团队开发一些紧密相关的应用程序时,如果团队之间不进行协调,即使短时间内能够取得快速进展,但他们开发出的产品可能无法结合到一起。最后可能不得不耗费大量精力在转换层上,并且频繁地进行改动,不如一开始就使用 Continuous Integration 那么省心省力,同时这也造成重复工作,并且无法实现公共的 Ubiquitous Language 所带来的好处。
因此:
从领域模型中选出两个团队都同意共享的一个子集。当然,除了这个模型子集以外,还包括与该模型部分相关的代码子集,或数据库设计的子集。这部分明确共享的内容具有特殊的地位,一个团队在没与另一个团队商量的情况下不应擅自更改它。
功能系统要经常进行集成,但集成的频率应该比团队中 Continuous Integration 的频率低一些。在进行这些集成的时候,两个团队都要运行测试。
Shared Kernel 通常是 Core Domain ,或是一组 Generic SubDomain(通用子领域),也可能二者兼有,它可以是两个团队都需要的任何一部分模型。使用 Shared Kernel 的目的是减少重复(并不是消除重复,因为只有在一个 Bounded Context 中才能消除重复),并使两个子系统之间的集成变得相对容易一些。
6、模式:Customer/Supplier Development Team
我们常常会碰到这样的情况:一个子系统主要服务于另一个子系统;“下游”组件执行分析或其他功能,这些功能向“上游”组件反馈的信息非常少,所有依赖都是单向的。两个子系统通常服务于完全不同的用户群,其执行的任务也不同,在这种情况下使用不同的模型会很有帮助。工具集可能也不相同,因此无法共享程序代码。
上游和下游子系统很自然地分隔到两个 Bounded Context 中。如果两个组件需要不同的技能或不同的工具集来实现时,更需要把它们隔离到不同的上下文中。转换很容易,因为只需要进行单向转换。但两个团队的行政组织关系可能会引起问题。
因此:
在两个团队之间建立一种明确的客户/供应商关系。在计划会议中,下游团队相当于上游团队的客户。根据下游团队的需求来协商需要执行的任务并为这些任务做预算,以便每个人都知道双方的约定和进度。
两个团队共同开发自动化验收测试,用来验证预期的接口。把这些测试添加到上游团队的测试套件中,以便作为其持续集成的一部分来运行。这些测试使上游团队在做出修改时不必担心对下游团队产生副作用。
这种模式有两个关键要素:
(1)关系必须是客户与供应商的关系,其中客户的需求是至关重要的。由于下游团队并不是唯一的客户,因此不同客户的要求必须通过协商来平衡,但这些要求都是非常重要的。这种关系与那种经常出现的“穷亲戚”关系相反,在后者的关系中,下游团队不得不乞求上游团队满足其需求。
(2)必须有自动化测试套件,使上游团队在修改代码时不必担心破坏下游团队的工作,并使下游团队能够专注于自己的工作,而不用总是密切关注上游团队的行动。
7、模式:Conformist(跟随者)
当两个开发团队具有上/下游关系时,如果上游团队没有动力来满足下游团队的需求,那么下游团队将无能为力。
在这种情况,有三种可能的解决途径。一种是完全放弃对上游的使用。做出这种选择时,应进行切实地评估,绝不要假定上游会满足下游的需求。有时我们会高估这种依赖性的价值,或是低估它的成本。如果下游团队决定切段这条链,他们将走上 Separate Way(各行其道)的道路。
有时,使用上游软件具有非常大的价值,因此必须保持这种依赖性(或者行政决策规定团队不能改变这种依赖性)。在这种情况,还有两种途径可供选择,选择哪一种取决于上游设计的质量和风格。如果上游的设计很难使用,那么下游团队仍然需要开发自己的模型。他们将担负起开发转换层的全部责任,这个层kennel会非常复杂,后面讲介绍 Anticorruption Layer。
另一方面,如果上游设计的质量不是很差,而且风格也能兼容的话,那么最好不要再开发一个独立的模型。这种情况下可以使用 Conformist 模式。
通过严格遵从上游团队的模型,可以消除在 Bounded Context 之间进行转换的复杂性。尽管这会限制下游设计人员的风格,而且可能不会得到理想的应用程序模型,但选择 Conformist 模式可以极大地简化集成。此外,这样还可以与供应商团队共享 Ubiquitous Language 。供应商处于统治地位,因此最好使沟通变容易。他们从利他主义的角度出发,会与你分享信息。
8、模式:Anticorruption Layer(防护层)
新系统几乎总是需要与遗留系统或其他系统进行集成,这些系统具有自己的模型。当把参与集成的 Bounded Context 设计完善并且团队相互合作时,转换层可能很简单,甚至很优雅。但是,当边界那侧发生渗透时,转换层就要承担起更多的防护职责。
当正在构建的新系统与另一个系统的接口很大时,为了克服连接两个模型而带来的困难,新模型所表达的意图可能会被完全改变,最终导致它被修改得像是另一个系统的模型了(以一种特定的风格)。遗留系统的模型通常很弱。即使对于那些模型开发得很好的例外情况,它们可能也不符合当前项目的需要。然而,集成遗留系统仍然具有很大的价值,而且有时还是绝对必要的。
正确答案是不要全盘封杀与其他系统的集成。
创建一个隔离层,以便根据客户自己的领域模型来为客户提供相关功能。这个层通过另一个系统现有接口与其进行对话,而只需对那个系统作出很少的修改,甚至无需修改。在内部,这个层在两个模型之间进行必要的双向转换。
这种连接两个系统的机制可能会使我们想到把数据从一个程序传输到另一个程序,或者从一个服务传输到另一个服务。我们很快就会讨论技术通信机制的使用。但这些细节问题不应与 Anticorruption Layer 混淆,因为 Anticorruption Layer 并不是向另一个系统发送消息的机制。相反,它是在不同的模型和协议之间转换概念对象和操作的机制。
Anticorruption Layer 本身就可能是一个复杂的软件。接下来将概要描述在创建 Anticorruption Layer 时需要考虑的一些事项。
8.1、设计 Anticorruption Layer 的接口
Anticorruption Layer 的公共接口通常以一组 Service 的形式出现,但偶尔也会采用 Entity 的形式。构建一个全新的层来负责两个系统之间的语义转换为我们提供了一个机会,它使我们能够重新对另一个系统的行为进行抽象,并按照与我们的模型一致的方式把服务和信息提供给我们。在我们的模型中,把外部系统表示为一个单独的组件可能是没有意义的。最好是使用多个 Service (或偶尔使用 Entity),其中每个 Service 都使用我们的模型来履行一致的职责。
8.2、实现 Anticorruption Layer
对 Anticorruption Layer 设计进行组织的一种方法是把它实现为 Facade、Adapter 和转换器的组合,外加两个系统之间进行对话所需的通信和传输机制。
我们常常需要与那些具有大而复杂、混乱的接口的系统进行集成。这不是概念模型差别的问题(概念模型差别是我们使用 Anticorruption Layer 的动机),而是一个实现问题。当我们尝试创建 Anticorruption Layer 时,会遇到这个实现问题。当从一个模型转换到另一个模型的时候(特别是当一个模型很混乱时),如果不能同时处理那些难于沟通的子系统接口,那么将很难完成。好在 Facade 可以解决这个问题。
Facade 是子系统的一个可供替换的接口,它简化了客户访问,并使子系统更易于使用。由于我们非常清楚要使用另一个系统的哪些功能,因此可以创建 Facade 来促进和简化对这些特性的访问,并把其他特性隐藏起来。Facade 并不改变底层系统的模型。它应该严格按照另一个系统的模型来编写。否则会产生严重的后果:轻则导致转换职责蔓延到多个对象中,并加重 Facade 的负担;重则创建出另一个模型,这个模型既不属于另一个系统,也不属于你自己的 Bounded Context 。Facade 应该属于另一个系统的Bounded Context ,它只是为了满足你的专门需要而呈现出的一个更好的外观。
Adapter 是一个 包装器,它允许客户使用另外一个协议,这种协议可以是行为实现者不理解的协议。当客户向适配器发送一条消息时,Adapter 把消息转换为一条在语义上等同的消息,并将其发送给“被适配者”(adaptee)。
我们所定义的每种Service都需要一个支持其接口的Adapter,这个适配器还需要知道怎样才能向其他系统及其Facade发出其相应的请求。
剩下的要素就是转换器了。Adapter 的工作是知道如何生成请求。概念对象或数据的实际转换是一种完全不同的复杂任务,我们可以让一个单独的对象来承担这项任务,这样可以使负责转换的对象和 Adapter 都更易于理解。转换器可以是一个轻量级的对象,它可以在需要的时候被实例化。由于它只属于它所服务的 Adapter ,因此不需要有状态,也不需要是分布式的。
这些都是用来创建 Anticorruption Layer 的基本元素。此外,还有其他一些需要考虑的因素。
(1)如图所示,一般是由正在设计的系统(你的子系统)来发起一个动作。但在有些情况下,其他子系统可能需要向你的子系统提交某种请求,或是把某个事物通知给你的子系统。Anticorruption Layer 可以是双向的,它可能使用具有对称转换的相同转换器来定义两个接口的Service(并使用各自的Adapter)。尽管实现 Anticorruption Layer 通常不需要对另一个子系统做任何修改,但为了使它能够调用 Anticorruption Layer 的 Service,有时还是有必要修改的。
(2)我们通常需要一些通信机制来连接两个子系统,而且它们可能位于不同的服务器上。在这种情况下,必须决定在哪里放置通信链接。如果无法访问另一个子系统,那么可能必须在Facade和另一个子系统之间设置通信链接。但是,如果Facade可以直接与另一个子系统集成到一起,那么在适配器和Facade之间设置通信链接也不失为一种好的选择,这是因为Facade的协议比它所封装的内容要简单。在有些情况下,整个 Anticorruption Layer可以与另一个子系统放在一起,这时可以在你的系统和构成 Anticorruption Layer接口的Service之间设置通信链接或分发机制。这些都是需要根据实际情况做出的实现和部署决策。它们与Anticorruption Layer的概念角色无关。
(3)如果有权访问另一个子系统,你可能会发现对它进行少许的重构会使你的工作变得更加容易。特别是应该为那些需要使用的功能编写更显式的接口,如果可能的话,首先从编写自动化测试开始。
(4)当需要进行广泛的集成时,转换的成本会直线上升。这时需要对正在设计的系统的模型做出一些选择,使之尽量接近外部系统,以便使转换更加容易。做这些工作时要非常小心,不要破坏模型的完整性。这是只有当转换的难度无法掌控时才选择进行的事情。如果这种方法看起来是大部分重要问题的最自然的解决方案,那么可以考虑让你的子系统采用 Conformist 模式,从而消除转换。
(5)如果另一个子系统很简单或有一个很整洁的接口,可能就不需要Facade了。
(6)如果一个功能是两个系统的关系所需的,就可以把这个功能添加到 Anticorruption Layer 中。此外我们还很容易想到两个特性,一是外部系统使用情况的审计跟踪,二是追踪逻辑,其用于调试对另一个接口的调用。
9、模式:Separate Way
集成总是代价高昂,而有时获益却很小。
在很多情况下,集成不会提供明显的收益。如果两个功能部分并不需要互相调用对方的功能,或者这两个部分所使用的对象并不需要进行交互,或者在它们操作期间不共享数据,那么集成可能就是没有必要的(尽管可以通过一个转换层进行集成)。仅仅因为特性在用例中相关,并不一定意味着它们必须集成到一起。
因此:
声明一个与其他上下文毫无关联的 Bounded Context ,使开发人员能够在这个小范围内找到简单、专用的解决方案。
特性仍然可以被组织到中间件或UI层中,但它们将没有共享的逻辑,而且应该把通过转换层进行的数据传输减至最小,最好是没有数据传输。
采用 Separate Way 模式需要预先决定一些选项。尽管持续重构最后可以撤销任何决策,但完全隔离开发的模型是很难合并的。如果最终仍然需要集成,那么转换层将是必要的,而且可能很复杂。当然,不管怎样,这都是我们将要面对的问题。
10、模式:Open Host Service
当一个子系统必须与大量其他系统进行集成时,为每个集成都定制一个转换层可能会减慢团队的工作速度。需要维护的东西会越来越多,而且进行修改的时候担心的事情也会越来越多。
团队可能正在反复做着同样的事情。如果一个子系统有着某种内聚性,那么或许可以把它描述为一组Service,这组Service满足了其他子系统的公共需求。
要想设计出一个足够干净的协议,使之能够被多个团队理解和使用,是一件十分困难的事情,因此只有当子系统的资源可以被描述为一组内聚的Service并且必须进行很多集成的时候,才值得这样做。在这些情况下,它能够把维护模式和持续开发区别开。
因此:
定义一个协议,把你的子系统作为一组Service供其他系统访问。开放这个协议,以便所有需要与你的子系统集成的都可以使用它。当有新的集成需求时,就增强并扩展这个协议,但个别团队的特殊需求除外。满足这种特殊需求的方法是使用一次性的转换器来扩充协议,以便使公共协议简单并内聚。
这种通信形式暗含一些共享的模型词汇,它们是Service接口的基础。这样,其他子系统就变成了与Open Host(开放主机)的模型相连接,而其他团队则必须学习Host团队所使用的专用术语。在一些情况下,使用一个众所周知的 Published Language(公开发布的语言)作为交换模型可以减少耦合并简化理解。
11、模式:Published Language
两个 Bounded Context 之间的模型转换需要一种公共的语言。
与现有领域模型进行直接的转换可能不是一种好的解决方案。这些模型可能过于复杂或设计得较差。它们可能没有被很好地文档化。如果把其中一个模型作为数据交换语言,它实质上就被固定住了,而无法满足新的开发需求。
Open Host Service 使用一个标准化的协议来支持多方集成。它使用一个领域模型来在各系统间进行交换,尽管这些系统的内部可能并不使用该模型。这里我们可以进一步——发布这种语言,或找到一种已经公开的语言。
因此:
把一个良好文档化的、能够表达出所需领域信息的共享语言作为公共的通信媒介,必要时在其他信息与该语言之间进行转换。
12、转换
1、 合并 Context:Separate Way -> Shared Kernel
2、合并 Context:Shared Kernel -> Continuous Integration
3、逐步淘汰遗留系统
4、Open Host Service -> Published Language
2、精炼
如何才能专注于核心问题而不被大量的次要问题淹没呢?Layered Architecture 可以把领域概念从技术逻辑中(技术逻辑确保了计算机系统能够运转)分离出来,但在大型系统中,即使领域被分离出来,它的复杂性也可能仍然难以管理。
精炼是把一堆混杂在一起的组件分开的过程,以便通过某种形式从中提取出最重要的内容,而这种形式将是=使它更有价值,也更有用。模型就是知识的精炼。通过每次重构所得到的更深层的理解,我们得以把关键的领域知识和优先级提取出来。现在,让我们回过头来从战略角度看一下精炼,本节将介绍对模型进行粗线条划分的方式,并把领域模型作为一个整体进行精炼。
像很多化学蒸馏过程一样,精炼过程所分离出来的副产品(如 Generic SubDomain 和 Coherent Mechanism)本身也很有价值,但精炼的主要动机是把最有价值的那部分提取出来,正是这个部分使我们的软件区别于其他软件并让整个软件的构建物有所值,这个部分就是 Core DoMain。
领域模型的战略精炼包括以下部分:
(1)帮助所有团队成员掌握系统的总体设计以及各部分如何协调工作;
(2)找到一个具有适度规模的核心模型并把它添加到通用语言中,从而促进沟通;
(3)指导重构;
(4)专注于模型中最有价值的那部分;
(5)知道外部、x现成组件的使用以及任务委派。
下面将展示对 Core DoMain 进行战略精炼的系统性方法,解释如何在团队中有效地统一认识,并提供一种用于讨论工作的语言。
1、模式:Core DoMain
在设计大型系统时,有非常多的组成部分——它们都很复杂而且对开发的成功也至关重要,但这导致真正的业务资产——领域模型最为精华的部分——被掩盖和忽略了。
一个严峻的现实是我们不可能对所有设计部分进行同等的精化,而是必须分出优先级。为了使领域模型成为有价值的资产,必须整齐地梳理出模型的真正核心,并完全根据这个核心来创建应用程序的功能。但本来就稀缺的高水平开发人员往往会把工作重点放在技术基础设施上,或者只是去解决那些不需要专门领域知识就能理解的领域问题(这些问题都已经有了很好的定义)。
因此:
对模型进行提炼。找到 Core DoMain 并提供一种易于区分的方法并把它与那些起辅助作用的模型和代码分开。最有价值和最专业的概念要轮廓分明。尽量压缩 Core DoMain。
让最有才能的人来开发 Core DoMain ,并据此要求进行相应的招聘。在 Core DoMain 中努力开发能够确保实现系统蓝图的深层模型和柔性设计。仔细判断任何其他部分的投入,看它是否能够支持这个提炼出来的 Core。
选择核心
我们需要关注的是那些能够表示业务领域并解决业务问题的模型部分。
对 Core DoMain 的选择取决于看问题的角度。一个应用程序的 Core DoMain 在另一个应用程序中可能只是通用的支持组件。尽管如此,仍然可以在一个项目中(而且通常在一个公司中)定义一个一致的Core。
下面几节将给出制定这些决策的指导。
2、精炼的逐步提升
下面将介绍各种精炼技术,它们在使用顺序上基本没什么要求,但对设计的改动却大不相同。
一份简单的 DoMain Vision Statement(领域愿景说明)只需很少的投入,它传达了基本概念以及它们的价值。HighLight Core(突出核心)可以增进沟通,并指导决策制定,这也只需对设计进行很少的改动甚至无需改动。
更积极的精炼方法是通过重构和重新打包显式地分离出 Generic SubDoMain,然后单独进行处理。在使用 Cohesive Mechanism(内聚机制)的同时,也要保持设计的通用性、易懂性和柔性,这两个方面可以结合。只有除去了这些细枝末节,才能把 Core 剥离出来。
重新打包出一个 Segregated Core(分离的核心),可以使这个 Core 更加清晰可见(即使在代码中也是如此),并且促进将来在 Core 模型上的工作。
最富雄心的精炼是 Abstract Core(抽象内核),它用最纯粹的形式表示了最基本的概念和关系(因此,需要对模型进行全面的重新组织和重构)。
每种技术都需要我们连续不断地投入越来越多的工作,但刀磨得越薄,就会越锋利。领域模型的持续精炼将为我们创造一项资产,使项目进行得更快、更敏捷、更精确。
首先,我们可以把模型中最普通的那部分分离出去,它们就是 Generic SubDoMain(通用子领域)。Generic SubDoMain 和 Core DoMain 形成鲜明的对比,使我们可以更清楚地理解它们各自的含义。
3、模式:Generic SubDoMain(通用子领域)
模型中有些部分除了增加复杂性以外并没有捕捉或传递任何专门的知识。任何外来因素都会使 Core DoMain 愈发的难以分辨和理解。模型中充斥着大量众所周知的一般原则,或者是专门的细节,这些细节并不是我们的主要关注点,而只是起到了支持作用。然而,无论它们是多么通用的元素,它们对实现子系统功能和充分表达模型都是极为重要的。
因此:
识别出那些与项目意图无关的内聚子领域。把这些子领域的通用模型提取出来,并放到单独的 Module 中。任何专有的东西都不应该在这些模块中。
把它们分离出来以后,在继续开发的过程中,它们的优先级应低于 Core DoMain 的优先级,并且不要分派核心开发人员来完成这些任务(因为他们很少能够从这些任务中获得领域知识)。此外,还可以考虑为这些 Generic SubDoMain 使用现成的解决方案或“公开发布的模型”(Published Model)。
通用不等于可重用
虽然一直在强调这些子领域的通用性,但并没有提代码的可重用性。现成的解决方案可能适用于某种特殊情况,也可能不适用,但假设你要自己实现代码(内部实现或外包出去),那么不要特别关注代码的可重用性。因为那样做会违反精炼的基本动机——我们应该尽可能把大部分精力投入到 Core DoMain 工作中,而只有在必要的时候才在支持性的 Generic SubDoMain 中投入工作。
重用确实会发生,但不一定总是代码重用。模型重用通常是更高级的重用,例如,当使用公开发布的设计或模型的时候就是如此。如果必须创建自己的模型,那么它在以后的相关项目中可能很有价值。但是,虽然这样的模型概念可能适用于很多情况,我们也不必把它开发成“万能的”模型。我们只要把业务所需的那部分建模出来并实现即可。
尽管我们很少需要考虑设计的可重用性,但通用子领域的设计必须严格地限定在通用概念的范围之内。如果把行业专用的模型元素引入到通用子领域中,会产生两个后果。第一,它会妨碍将来的开发。虽然现在我们只需要子领域的一小部分,但我们的需求会不断增加。如果把任何不属于子领域概念的部分引入到设计中,那么再想灵活地扩展系统就很难了,除非完全重建原来的部分并重新设计使用该部分的其他模块。
第二,也是更重要的,这些行业专用的概念要么属于 Core DoMain,要么术语它们自己的更专业的子领域,而且这些专业的模型比通用子领域更有价值。
项目风险管理
敏捷过程通常要求通过尽早解决最具风险的任务来管理风险。特别是XP过程,它要求迅速建立并运行一个端到端的系统。这种初步的系统通常用来检验某种技术架构,而且人们会试图建立一个外围系统,用来处理一些支持性的 Generic SubDomain,因为这些子领域更易于分析。但是要注意,这可能会不利于风险管理。
项目面临着两方面的风险,有些项目的技术风险更大,有些项目则是领域建模的风险更大一些。端到端的系统是实际系统中最困难部分的“雏形”——它控制风险的能力也仅限于此。当使用这种雏形时,我们很容易低估领域建模的风险。这种风险包括未预料到存在复杂性、与业务专家的沟通不够充分,或者开发人员的关键技能存在欠缺等。
因此,除非团队拥有精湛的技术并且对领域非常熟悉,否则第一个雏形系统应该以 Core DoMain 的某个部分作为基础,不管它有多简单。
相同的原则也适用于任何试图把高风险的任务放到前面处理的过程。Core DoMain 就是高风险的,因为它的难度往往会超出我们的预料,而且如果没有它,项目就不可能获得成功。
本章介绍的大多数精炼模式都展示了如何修改模型和代码,以便提炼出 Core DoMain 。但是,接下来的两个模式 DoMain Vision Statement 和 HighLight Core 将展示如何用最少的投入通过补充文档来增进沟通、提高人们对核心的认识并使之把开发工作集中到 Core 上来。
4、模式:DoMain Vision Statement
在项目开始时,模型通常并不存在,但是模型开发的需求是早就确定下来的重点。在后面的开发阶段,我们需要解释清楚系统的价值,但这并不需要深入地分析模型。此外,领域模型的关键方面可能跨越多个 Bounded Context,而且从定义上看,无法将这些彼此不同的模型组织起来表明其共同的关注点。
DoMain Vision Statement 关注的重点是领域模型的本质以及如何为企业带来价值的。在项目开发的所有阶段,管理层和技术人员都可以直接使用领域愿景说明来指导资源分配、建模选择和团队成员的培训。如果领域模型为多个群体提供服务,那么此文档还能够显示出他们的利益是如何均衡的。
因此:
写一份 Core DoMain 的简单描述以及它将来会创造的价值,也就是“价值主张”。那些不能将你的领域模型与其他领域模型区分开的方面就不要写了。展示出领域模型是如何实现和均衡各方利益的。这份描述要尽量精简。尽早把它写出来,随着新的理解时修改它。
5、模式:HighLighted Core
DoMain Vision Statement 从宽泛的角度对 Core DoMain 进行了说明,但它把什么是具体核心模型元素留给人们自己去解释和猜测。除非团队的沟通极其充分,否则单靠 DoMain Vision Statement 是很难产生什么效果的。
尽管团队成员可能大体上知道核心领域是由什么构成的,但 Core DoMain 中到底包含哪些元素,不同的人会有不同的理解,甚至同一个人在不同的时间也会有不同的理解。如果我们总是要不断过滤模型以便识出关键部分,那么就会分散本应该投入到设计上的经理,而且还需要广泛的模型知识。因此,Core DoMain 必须要很容易被分辨出来。
对代码所做的重大结构性改动是识别 Core DoMain 的理想方式,但是这些改动往往无法在短期内完成。事实上,如果团队的认识还不够全面,这样的重大代码修改是很难进行的。
因此:
把模型的一个特别部分连同它的实现一起区分出来,这只是对模型的一种反映,而不必是模型自身的一部分。任何使人们易于了解 Core DoMain 的技术都可以采用。这类解决方案有两种典型的代表性技术。
(1)精炼文档
创建一个单独的文档来描述和解释 Core DoMain。这个文档可能很简单,只是最核心的概念对象的清单。它可能是一组描述这些对象的图,显示了它们最重要的关系。它可能在抽象层次上或通过示例来描述基本的交互过程。它可能会使用 UML 类图或序列图、专用于领域的非标准的图、措辞严谨的文字解释或上述这些元素的组合。精炼文档并不是完备的设计文档。它只是一个最简单的切入点,描述并解释了核心,并给出了更进一步研究这些核心部分的理由。精炼文档为读者提供了一个总体视图,指出了各个部分是如何组合到一起的,并且知道读者到相应的代码部分寻找更多的细节。
因此(作为 HighLight Core (突出核心)的一种形式):
编写一个非常简单的文档(3~7页,每页内容不必太多),用于描述 Core DoMain 以及 Core 元素之间的主要交互过程。
独立文档会带来一些常见的风险:
(1)文档可能得不到维护;
(2)文档可能没人阅读;
(3)由于有多个信息来源,文档可能达不到简化复杂性的目的。
控制这些风险的最好方法是保持绝对的精简。剔除那些不重要的细节,只关注核心抽象以及它们的交互,这样文档的老化速度就会减慢,因为这个层次的模型通常更稳定。
精炼文档应该能够被团队中的非技术人员理解。把它当作一个共享的视图,描述每个人都应该知道的东西,而且可以把它作为团队所有成员研究模型和代码的一个起点。
(2)标明 Core
作为另一种 HighLight Core 的形式:
把模型的主要存储库中的 Core DoMain 标记出来,不用特意去阐明其角色。使开发人员很容易就知道什么在核心内,什么在核心外。
尽管 Vision Statement 和 HighLighted Core 可以起到通知和指导的作用,但它们本身并没有修改模型或代码。具体地划分 Generic SubDoMain 可以除去一些非核心元素。接下来的几个模式着眼于从结构上修改模型和设计本身,目的是使 Core DoMain 更明显,更易于管理。
6、模式:Cohesive Mechanism (内聚机制)
封装机制是面向对象设计的一个基本原则。把复杂的算法隐藏到方法中,再为方法起一个一看就知道其用途的名字,这样就把“做什么”和“如何做”分开了。这种技术使设计更易于理解和使用。然而它也有一些先天的局限性。
计算有时会非常复杂,使设计开始变得膨胀。机械性的“如何做”大量增加,把概念性的“做什么”完全掩盖了。为解决问题提供算法的大量方法掩盖了那些勇于表达问题的方法。
因此:
把概念上的 Cohesive Mechanism 分离到一个单独的轻量级框架中。要特别注意公式或那些有完备文档的算法。用一个 Intention-Revealing Interface 来暴露这个框架的功能。现在,领域中的其他元素就可以只专注于如何表达问题(做什么)了,而把解决方案的复杂细节(如何做)转移给了框架。
然后,这些被分离出来的机制承担起支持的任务,从而留下一个更小的、表达得更清楚的 Core DoMain ,这个核心以更加声明式的方式通过接口来使用这些机制。
把标准的算法或公式识别出来以后,可以把一部分设计的复杂性转移到一系列已经经过深入研究的概念中。在这种方法的引导下,我们可以放心地实现一个解决方案,而且只需进行很少的尝试和试错。我们可以依靠其他一些了解这种算法或至少能够查到相关资料的开发人员。这个好处类似于从公开发布的 Generic SubDoMain 模型获得的好处,但找到完备的算法或公式的机会比利用通用子领域的机会更大一些,因为这种水平的计算机科学意境有了较深入的研究。
但是,我们仍常常需要创建新的算法。创建的算法应该主要用于计算,避免在算法中混杂用于表达问题的领域模型。二者的职责应该分离。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 并不表示领域,它的目的是解决描述性模型所提出来的一些复杂的计算问题。
7、通过精炼得到声明式风格
精炼的价值在于使你能够看到自己正在做什么,不让无关细节分散你的注意力,并通过不断削减得到核心。如果领域中那些起到支持作用的部分提供了一种简练的语言,可用于表示 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 划分出来。
8、模式:Segregated Core(分离的核心)
模型中的元素可能有一部分元素属于 Core DoMain ,而另一部分起支持作用。核心元素可能与一般元素紧密耦合在一起。Core 的概念内聚性可能不是很强,看上去也不明显。这种混乱性和耦合关系抑制了 Core 。设计人员如果无法清晰地看到最重要的关系,就会开发出脆弱的设计。
通过把 Generic SubDoMain 提取出来,可以从领域中清除一些干扰性的细节,使 Core 变得更清楚。但识别和澄清所有这些子领域是很困难的工作,而且有些工作看起来并不值得去做。同时,最重要的 Core DoMain 仍然与剩下的那些元素纠缠在一起。
因此:
对模型进行重构,把核心概念从支持性元素(包括定义得不清楚的那些元素)中分离出来,并增强 Core 的内聚性,同时减少它与其他代码的耦合。把所有通用元素或支持性元素提取到其他对象中,并把这些对象放到其他的包中——即使这会把一些紧密耦合的元素分开。
这里基本上采用了与 Generic SubDoMain 一样的原则,只是从另一个方向来考虑而已。那些在应用程序中非常关键的内聚子领域可以被识别出来,并分离到它自己的内聚包中。如何处理剩下的那些未加区分的元素虽然也很重要,但其重要性略低。这些元素或多或少地可以保留在原先的位置,也可以放到包含了重要类的包中。最后,越来越多的剩余元素可以被提取到 Generic SubDoMain 中。但就目前来看,使用哪种简单解决方案都可以,只需把注意力集中在 Segregated Core 上即可。
通过重构得到 Segregated Core 的一般步骤如下:
(1)识别出一个 Core 子领域(可能是从精炼文档中得到的)。
(2)把相关的类移动到新的 Module 中,并根据与这些类有关的概念为模块命名。
(3)对代码进行重构,把那些不直接表示概念的数据和功能分离出来。把分离出来的元素放到其他包的类(可能是新的类)中。尽量把它们与概念上相关的任务放在一起,但不要为了追求完美而浪费太长时间。把注意力放在提炼 Core 子领域上,并且使 Core 子领域对其他包的引用变得更明显且易于理解。
(4)对新的 Segregated Core Module 进行重构,使其中的关系和交互变得更简单、表达得更清楚,并且最大限度地减少并澄清它与其他 Module 的关系(这将是一个持续进行的重构目标)。
(5)对另一个 Core 子领域重复这个过程,直到完成 Segregated Core 的工作。
创建 Segregated Core 的代价
有时候,把Core分离出来会使得它与那些紧密耦合的非 Core 类的关系变得更晦涩,甚至更复杂,但 Core DoMain 更清晰了,而且更易于处理,因此获得的好处还是足以抵偿这种代价。
Segregated Core 使我们能够提高 Core DoMain 的内聚性。我们可以使用很多有意义的方式来分解模型,有时在创建 Segregated Core 时,可以把一个内聚性很好的 Module 拆分开,通过牺牲这种内聚性来换取 Core DoMain 的内聚性。这样做是值得的,因为企业软件的最大价值来自于模型中企业的那些特有方面。
9、模式:Abstract Core(抽象核心)
通常。即便是 Core DoMain 模型也会包含太多的细节,以至于它很难表达出整体视图。
我们处理大模型的方法通常是把它分解成足够小的子领域,以便能够掌握它们并把它们放到一些独立的 Module 中。这种简化式的打包风格通常是行之有效的,能够使一个复杂的模型变得易于管理。但有时创建独立的 Module 反而会使子领域之间的交互变得晦涩难懂,甚至变得更复杂。
当不同 Module 的子领域之间有大量交互时,要么需要在 Module 之间创建很多引用,这在很大程度上抵消了划分模块的价值;要么就必须间接地实现这些交互,而后者会使模型变得晦涩难懂。
我们不妨考虑采用横向切割而不是纵向切割的方式。多态性(polymorphism)允许我们忽略抽象类型实例的很多细节变化。如果 Module 之间的大部分交互都可以在多态接口这个层次上表达出来,那么就可以把这些类型重构到一个特定的 Core Module 中。
这里并不是寻找技术上的技巧。只有当领域中的基本概念能够用多态接口来表达时,这才是一种有价值的技术。在这种情况下,把这些分散注意力的细节分离出来可以使 Module 解耦,同时可以精炼出一个更小、更内聚的 Core DoMain。
因此:
把模型中最基本的概念识别出来,并分离到不同的类、抽象类或接口中。设计这个抽象模型,使之能够表达出重要组件之间的大部分交互。把这个完整的抽象模型放到它自己的 Module 中,而专用的、详细的实现类则留在由子领域定义的 Module 中。
现在,大部分专用的类都将引用 Abstract Core Module ,而不是其他专用的 Module。Abstract Core 提供了主要概念及其交互的简化视图。
提取 Abstract Core 并不是一个机械的过程。例如,如果把 Module 之间频繁引用的所有类都自动移动到一个单独的 Module 中,那么结果可能是一团糟,而且毫无意义。对 Abstract Core 进行建模需要深入理解关键概念以及它们在系统的主要交互中扮演的角色。换言之,它是通过重构得到更深层理解的,而且它通常需要大量的重新设计。
10、深层模型精炼
精炼并不仅限于从整体上把领域中的一部分从 Core 中分离出来。它也意味着对子领域(特别是 Core DoMain)进行精炼,通过持续重构得到更深层的理解,从而向深层模型和柔性设计推进。精炼的目标是把模型设计得更明显,使我们可以用模型简单地把领域表示出来。深层模型把领域中最本质的方面精炼成一些简单的元素,使我们可以把这些元素组合起来解决应用程序中的重要问题。
尽管任何带来深层模型的突破都有价值,但只有 Core DoMain 中的突破才能改变整个项目的轨道。
3、大型结构
精炼可以帮助我们把注意力集中于 Core DoMain,并将子领域分离出来,让它们承担支持性的职责。但我们仍然需要理解这些支持性元素,以及它们与 Core DoMain 的关系,还有它们互相之间的关系。理想的情况是,整个 Core DoMain 非常清楚和易于理解,因此不再需要额外的指导,但我们并不总能处于这样好的境况中。
无论项目的规模如何,人们总需要有各自的分工,来负责系统的不同部分。如果没有任何协调机制或规则,那么相同问题的各种不同风格和截然不同的解决方案就会混杂在一起,使人们很难理解各个部分是如何组织在一起的,也不可能看到整个系统的统一视图。从设计的一个部分学到的东西并不适用于这个设计的其他部分,因此项目最后的结果是开发人员成为各自 Module 的专家,一旦脱离了他们自己的小圈子就无法互相帮助。在这种情况下, Continuous Integration(持续集成)根本无法实现,而 Bounded Context 也使项目变得支离破碎。
在一个大的系统中,如果因为缺少一种全局性的原则而使人们无法根据元素在模式(这些模式被应用于整个设计)中的角色来解释这些元素,那么开发人员就会陷入“只见树木,不见森林”的境地。
我们需要理解各个部分在整体中的角色,而不必深究细节。
“大型结构”是一种语言,人们可以用它来从大局上讨论和理解系统。它用一组高级概念或规则(或两者兼有)来为整个系统的设计监理一种模式。这种组织原则既能指导设计,又能帮助理解设计。另外,它还能够协调不同人员的工作,因为它提供了共享的整体视图,让人们知道各个部分在整体中的角色。
设计一种应用于整个系统的规则(或角色和关系)模式,使人们可以通过它在一定程度上了解各个部分在整体中所处的位置(即使是在不知道个部分的详细职责的情况下)。
这种结构可以被限制在一个 Bounded Context 中,但通常情况下它会跨越多个 Bounded Context,并通过提供一种概念组织把项目涉及的所有团队和子系统紧密结合到一起。好的结构可以帮助人们深入地理解模型,还能够对精炼起到补充作用。
1、模式 Evolving Order(有序演化)
一个没有任何规则的随意设计会产生一些无法理解整体含义且很难维护的系统。但架构中早期的设计假设又会使项目变得束手束脚,而且会极大地限制应用程序中某些特定部分的开发人员/设计人员的能力。很快,开发人员就会为适应结构而不得不在应用程序的开发上委曲求全,要么就是完全推翻架构而又回到没有协调的开发老路上来。
问题并不在于指导规则本身应不应该存在,而在于这些规则的严格性和来源。如果这些用于控制设计的规则确实符合开发环境,那么它们不但不会阻碍开发,而且还会推动开发在健康的方向上前进,并且保持开发的一致性。
因此:
让这种概念上的大型结构随着应用程序一起演变,甚至可以变成一种完全不同的结构风格。不要依次过分限制详细的设计和模型决策,这些决策和模型决策必须掌握了详细知识之后才能确定。
有时个别部分具有一些很自然且有用的组织和表示方式,但这些方式并不适用于整体,因此施加全局规则会使这些部分的设计不够理想。在选择大型结构时,应该侧重于整体模型的管理,而不是优化个别部分的结构。因此,在“结构统一”和“用最自然的方式表示个别组件”之间需要做出一些折中选择。
与 Context Map 不同的是,大型结构是可选的。当使用某种结构可以节省成本并带来益处时,并且发现了一种适当的结构,就应该使用它。实际上,如果一个系统简单到把它分解为 Module 就足以理解它,那么就不必使用这种结构了。
2、模式:System Metaphor(系统隐喻)
软件设计往往非常抽象且难于掌握。开发人员和用户都需要一些切实可行的方式来理解系统,并共享系统的一个整体试图。
从某种程度上讲,隐喻对人们的思考方式有着深刻地影响,它已经渗透到每个设计中。系统有很多“层”,层与层之间依次叠放起来。系统还有“内核”,位于这些层的“中心”。但有时隐喻可以传达整个设计的中心主题,并能够在团队所有成员中形成共同理解。
在这种情况下,系统实际上就是由这个隐喻塑造的。开发人员所做的设计决策也将与系统隐喻保持一致。这种一致性使其他开发人员能够根据同一个隐喻来解释复杂系统中的多个部分。开发人员和专家在讨论时有一个比模型本身更具体的参考点。
System Metaphor(系统隐喻)是一种松散的、易于理解的大型结构,它与对象范式是协调的。由于系统隐喻只是对领域的一种类比,因此不同模型可以用近似的方式来与它关联,这使得人们能够在多个 Bounded Context 中使用系统隐喻,从而有助于协调各个 Bounded Context 之间的工作。
因此:
当系统的一个具体类比正好符合团队成员对系统的想象,并且能够引导他们向着一个有用的方向进行思考时,就应该把这个类比用作一种大型结构。围绕这个隐喻来组织设计,并把它吸收到 Ubiquitous Language 中。System Metaphor 应该既能促进系统的交流,又能指导系统的开发。它可以增加系统不同部分之间的一致性,甚至可以跨越不同的 Bounded Context 。但所有隐喻都不是完全精确的,因此应不断检查隐喻是否过度或不恰当,当发现它起到防癌作用时,要随时准备放弃它。
3、模式:Responsibility Layer(职责层)
前面的讨论中,单独的对象被分配一组相关的、范围较窄的职责。职责驱动的设计在更大的规模上也适用。
如果每个对象的职责都是人为分配的,将没有统一的指导原则和一致性,也无法把领域作为一个整体来处理。为了保持大模型的一致,有必要在职责分配上实施一定的结构化控制。
当对领域有了深入的理解后,大的规模会变得清晰起来。一些领域具有自然的层次结构。某些概念和活动处在其他元素形成的一个大背景下,而那些元素会因不同原因且以不同频率独立发生变化。如何才能充分利用这种自然结构,使它变得更清晰和用用呢?这种自然的层次结构使我们很容易想到把领域分层,这是最成功的架构设计模式之一。
所谓的层,就是对系统进行划分,每个层的元素都知道或能够使用在它“下面”的那些层的服务,但却不知道它“上面”的层,而且与它上面的层保持独立。
因此:
注意观察模型中的概念依赖性,以及领域中不同部分的变化频率和变化的原因。如果在领域中发现了自然的层次机构,就把它们转换为宽泛的抽象职责。这些职责应该描述系统的高层目的和设计。对模型进行重构,使得每个领域对象、Aggregate 和 Module 的职责都清晰地位于一个职责层当中。
选择适当的层
要想找到一种适当的 Responsibility Layer 或大比例结构,需要理解问题领域并反复进行实验。如果遵循 Evolving Order ,那么最初的起点并不是十分重要,尽管差劲的选择确实会加大工作量。结构可能最后演变的面目全非。因此,下面将给出一些知道==指导方针,无论是刚开始选择一种结构,还是对已有结构进行转换,这些指导方针都适用。
1、当对层进行删除、合并、拆分和重新定义等操作时,应寻找并保留以下一些有用的特征。
a、场景描述。层应该能够表达出领域的基本现实或优先级。选择一种大比例结构与其说是一种技术决策,不如说是一种业务建模决策。层应该显示出业务的优先级。
b、概念依赖性。“较高”层概念的意义应该依赖“较低”层,而低层概念的意义应该独立于较高的层。
c、Conceptual Contour(概念轮廓)。如果不同层的对象具有不同的变化频率或原因,那么层应该能够容许它们之间的变化。
2、在为每个新模型定义层时不一定总要从头开始。在一系列相关领域中,有些层是固定的。
例如,在那些利用大型固定资产进行运作的企业(如工厂或货运)中,物流软件通常可以被组织为“潜能”层和“作业”层。
a、潜能层。我们能够做什么?潜能层不关心我们打算做什么,而关心能够做什么。
b、作业层。我们正在做什么?我们利用这些潜能做了什么事情?像潜能层一样,这个层也应该反映出现实状况,而不是我们设想的状况。我们希望在这个层中看到自己的工作和活动:我们正在销售什么,而不是能够销售什么。
在这类领域很多(也许是大部分)现有的系统中,这两个层可以涵盖一切对象(尽管可能会有某种完全不同的和更清晰的分解结构)。它们可以跟踪当前状况和正在执行的作业计划,以及问题报告或相关文档。但跟踪往往是不够的。当项目要为用户提供指导或帮助或自动制定一些决策时,就需要有另外一组职责,这些职责可以被组织到作业层之上的决策支持层中。
c、决策支持层。应该采取什么行动或制定什么策略?这个层是用来作出分析和制定决策的。
决策支持支持系统对其他层(如作业层或潜能层)有概念上的依赖性,因为决策并不是凭空制定的。很多项目都利用数据仓库技术来实现决策支持。在这样的项目中,决策支持层实际上变成了一个独特的 Bounded Context,并且与作业软件具有一种 Customer/Supplier 关系。在其他项目中,决策支持层被更深地集成到系统中。分层结构的一个内在优点是较低的层可以独立于较高的层存在。这样有利于在较老的作业系统上分阶段引入新功能或开发高层次的增强功能。
另一种情形是软件实施了详细的业务规则或法律需求,这些规则或需求可以形成一个 Responsibility Layer。
d、策略层。规则和目标是什么?规则和目标主要是被动的,但它们约束着其他层的行为。这些交互的设计是一个微妙的问题。有时策略会作为一个参数传递给较低层的方法。有时会使用 Strategy 模式。策略层与决策支持层能够进行很好的协作,决策支持层提供了用于搜索决策层所设定的目标的方式,这些目标又受到策略层所设定的规则的约束。
很多企业并不是依靠工厂和设备能力来运营的。举个例子,在金融服务和保险业中,潜能在很大程度上是由当前的运营状况决定的。一家保险公司在考虑签保单承担理赔责任时,要根据当前业务的多样性来判断是否有能力承担它所带来的风险。潜能层有可能被合并到作业层中,这样就会演变出一种不同的分层结构。
这种情况下经常出现的一个层是对客户所做出的承诺。
e、承诺层。我们承诺了什么?这个层具有策略层的性质,因为它表述了一些指导未来运营的目标;但它也有作业层的性质,因为承诺是作为后续业务活动的一部分而出现和变化的。
一定要使分层系统保持简单,如果层数超过4或5,就比较难处理了。层数过多将无法有效地描述领域,而且本来要使用大比例结构解决的复杂性问题又会以一种新的方式出现。我们必须对大比例结构进行严格的精简。
虽然这5个层对很多企业系统都适用,但并不是所有领域的主要概念都涵盖在这5个层中。有些情况下,在设计中生硬地套用这种形式反而会起反作用,而使用一组更自然的 Responsibility Layer 会更有效。如果一个领域与上述讨论毫无关系,所有的层可能都必须从头开始。最后,我们必须根据直觉选择一个起点,然后通过 Evolving Order 来改进它。
4、模式:Knowledge Level(知识级别)
当我们需要让用户对模型的一部分有所控制,而模型又必须满足更大的一组规则时,可以利用 Knowledge Level 来处理这种情况。它可以使软件具有可配置的行为,其中实体中的角色和关系必须在安装时(甚至在运行时)进行修改。
如果在一个应用程序中,Entity 的角色和它们之间的关系在不同情况下有很大变化,那么复杂性会显著增加。在这种情况下,无论是一般的模型还是高度定制的模型,都无法满足用户的需求。为了兼顾各种不同的清醒,对象需要引用其他的类型,或者需要具备一些在不同情况下包括不同使用方式的属性。具有相同数据和行为的类可能会大量增加,而这些类的唯一作用只是为了满足不同的组装规则。
在我们的模型中嵌入另一个模型,而它的作用只是描述我们的模型。Knowledge Level 分离了模型的这个自我定义的方面,并清楚地显示了它的限制。
Knowledge Level 是 Reflection(反射)模式在领域层中的一种应用,很多软件架构和技术基础设施中都是用了它。Reflection 模式能够使软件具有“自我感知”特性,并使所选中的结构和行为可以接受调整和修改,从而满足变化需求。这是通过将软件分为两个层来实现的,一个层是“基础级别”(base level),它承担应用程序的操作职责;另一个是“元级别”(meta level),它表示有关软件结构和行为方面的知识。
值的注意的是,我们并没有把这种模式叫做知识“层”(layer)。虽然 Reflection 与分层很类似,但反射却包含双向依赖关系。
Knowledge Level 具有两个很有用的特性。首先,它关注的是应用领域,这一点与人们所熟悉的 Reflection 模式的应用正好相反。其次,它并不追求完全的通用性。正如一个 Specification 可能比通用的断言更有用一样,专门为一组对象和它们的关系定制的一个约束集可能比一个通用的框架更有用。Knowledge Level 显得更简单,而且可以传达设计者的特别意图。
因此:
创建一组不同的对象,用它们来描述和约束基本模型的结构和行为。把这些对象分为两个“级别”,一个是非常具体的级别,另一个级别则提供了一些可供用户或超级用户定制的规则和知识。
5、模式:Pluggable Component Framework(可插入式组件框架)
在深入理解和反复精炼基础上得到的成熟模型中,会出现很多机会。通常只有在同一个领域中实现了多个应用程序之后,才有机会使用 Pluggable Component Framework。
当很多应用程序需要进行互操作时,如果所有应用程序都基于相同的一些抽象,但它们是独立设计的,那么在多个 Bounded Context 之间的转换会限制它们的集成。各个团队之间如果不能紧密地协作,就无法形成一个 Shared Kernel。重复和分裂将会增加开发和安装的成本,而且互操作会变得很难实现。
一些成功的项目将它们的设计分解为组件,每个组件负责提供某些类别的功能。通常所有组件都能插入到一个中央hub上,这个hub支持组件所需的所有协议,并且知道如何与它们所提供的接口进行对话。还有其他一些组件连在一起的可行模式。对这些接口以及用于连接它们hub的设计必须要协调,而组件内部的设计则可以更独立一些。
有几个广泛使用的技术框架支持这种模式,但这只是次要问题。一种技术框架只有在能够解决某类重要技术问题的时候才有必要使用,如在分布式系统或不同应用程序中共享一个组件时。可插入式组件框架的基本模式是职责的概念组织,它很容易在单个的程序中使用。
因此:
从接口和交互中提炼出一个 Abstract Core,并创建一个框架,这个框架要允许这些接口的各种不同实现被自由替换。同样,无论是什么应用程序,只要它严格地通过 Abstract Core 的接口进行操作,那么就可以允许它使用这些组件。
高层抽象被识别出来,并在整个系统范围内共享,而特化(specialization)发生在 Module 中。应用程序的中央hub是 Shared Kernel 内部的 Abstract Core。但封装的组件接口可以把多个 Bounded Context 封装到其中,这样,当很多组件来自多个不同地方时,或者当组件中封装了用于集成的已用软件时,可以很方便地使用这种结构。
这并不是说不同组件一定要使用不同的模型。只要团队采用了 Continuous Integrate ,或者为一组密切相关的组件定义了另一个 Shared Kernel,那么就可以在同一个 Context 中开发多个组件。在 Pluggable Component Framework 这种大比例结构中,所有这些策略很容易共存。在某些情况下,还有一种选择是使用 Published Language 来编写hub的插入接口。
Pluggable Component Framework 也有几个缺点。一个缺点是它是一种非常难以使用的模式。它需要高精度的接口设计和一个非常深入的模型,以便把一些必要的行为捕获到 Abstract Core 中。另一个很大的缺点是它只为应用程序提供了有限的选择。如果一个应用程序需要对 Core Domain 使用一种非常不同的方法,那么可插入式组件框架将起到妨碍作用。开发人员可以对模型进行特殊修改,但如果不更改所有不同组件的歇息,就无法修改 Abstract Core。这样一来,Core 的持续精化过程(也是通过重构得到更深层理解的过程)在某种程度上会陷入僵局。
6、结构应该有一种什么样的约束
我们不要滥用框架和死板地实现大比例结构。大比例结构的最重要的贡献在于它具有概念上的一致性,并帮助我们更深入地理解领域。每条结构规则都应该时开发变得更容易实现。
7、通过重构得到更适当的结构
在当今这个时代,软件开发行业正在努力摆脱过多的预先设计,因此一些人会把大比例结构看作是倒退回了过去那段使用瀑布架构的令人痛苦的年代。但实际上,只有深入地理解领域和问题才能发现一种非常有用的结构,而获得这种深刻的理解的有效方式就是迭代开发过程。
团队要想坚持 Evolving Order 原则,必须在项目的整个生命周期中大胆地反复思考大比例结构。团队不应该一成不变地使用早期构思出来的那个结构,因为那时所有人对领域或需求的的理解都不完善。
遗憾的是,这种演变意味着最终的结构不会在项目一开始就被发现,而且我们必须在开发过程中进行重构,以便得到最终的结构。这可能很难实现,而且需要高昂的代价,但这样做是非常必要的。有一些通用的方法可以帮助控制成本并最大化收益。
(1)最小化
控制成本的一个关键是保持一种简单、轻量级的结构。不要试图使结构面面俱到。只需解决最主要的问题即可,其他问题可以留到后面一个一个地解决。
开始最好选择一种松散的结构,如 System Metaphor 或几个 Responsibility Layer。不管怎样,一种最小化的松散结构可以起到轻量级的指导作用,它有助于避免混乱。
(2)沟通和自律
整个团队在新的开发和重构中必须遵守结构。要做到这一点,整个团队必须理解这种结构。必须要术语和关系纳入到 Ubiquitous Language 中。
在大多数团队中,仅仅通过沟通是不足以保证在系统中采用一致的大比例结构。至关重要的一点是要把它合并到项目的通用语言中,并让每个人都严格地使用 Ubiquitous Language。
(3)通过重构得到柔性设计
(4)通过精炼可以减轻负担
对模型施加的另一项关键工作是持续精炼。这可以从各方面减小修改结构的难度。首先,从 Core DoMain 中去掉一些机制、Generic SubDoMain 和其他支持结构,需要重构的内容就少多了。
如果可能的话,应该把这些支持元素简单地定义成符合大比例结构的形式。例如,在一个 Responsibility Layer 系统中,可以把 Generic SubDomain 定义成只适合放到某个特定层中。当使用了 Pluggable Component Framework 的时候,可以把 Generic SubDomain 定义成完全由某个组件拥有,也可以定义成一个 Shared Kernel,供一组相关组件使用。这些支持元素可能需要进行重构,以便找到它们在结构中的适当位置,但它们的移动与 Core DoMain 是独立的,而且移动也限制在很小的范围内,因此更容易实现。最后,它们都是次要元素,因此它们的精化不会影响大局。
通过精炼和重构得到更深层理解的原理甚至也适用于大比例结构本身。例如,最初可以根据对领域的初步理解来选择分层结构,然后逐步用更深层次的抽象(这些抽象表达了系统的基本职责)来代替它们。这种极高的清晰度使人们能够透彻地理解领域,这也是我们的目标。它也是一种使系统的整体控制变得更容易、更安全的手段。