二、保持模型的完整性
2、模式:Continuous Integration
定义一个Bounded Context后,必须让它保持合理。
当很多人在同一个Bounded Context中工作时,模型很容易发生分裂。团队越大,问题就越大,但即使是3、4个人的团队也有可能会遇到严重的问题。然而,如果将系统分解为更小的Context,最终又难以保持集成度和一致性。
有时开发人员没有完全理解其他人所创建的对象或交互的意图,就对它进行了修改,使其失去了原来的作用。有时他们没有意识到他们正在开发的概念已经在模型的另一个部分中实现了,从而导致了这些概念和行为(不正确的)重复。有时他们意识到了这些概念有其他的表示,但却因为担心破坏现有功能而不敢去动它们,于是他们继续重复开发这些概念和功能。
开发统一的系统需要维护很高的沟通水平,而这一点常常很难做到。我们需要运用各种方法来增进沟通并减小复杂性。还需要一些安全防护措施,以避免过于谨慎的行为。
Continuous Integration是指把一个上下文中的所有工作足够频繁地合并到一起,并使它们保持一致,以便当模型发生分裂时,可以迅速发现并纠正问题。像领域驱动设计中的其他方法一样,Continuous Integration也有两个级别的操作:(1)模型概念的集成;(2)实现的集成。
团队间成员通过经常沟通来保证概念的集成。团队必须对不断变化的模型形成一个共同的理解。有很多方法可以帮助做到这一点,但最基本的方法是对Ubiquitous Language多加锤炼。同时,实际工件通过系统性的合并/构建/测试过程来集成,这样能够尽早暴露出模型的分裂问题。用来集成的的过程有很多,大部分有效的过程都具备以下这些特征:
- 分步集成,采用可重现的合并/构建技术;
- 自动测试套件;
- 有一些规则,用来为那些尚未集成的改动设置一个相当小的生命周期上限。有效过程的另一面是概念集成,虽然它很少被正式地纳入进来。
- 在讨论模型和应用程序时要坚持使用Ubiquitous Language
在Model-Driven Design中,概念集成为实现集成铺平了道路,而实现集成验证了模型的有效性和一致性,并暴露出模型的分裂问题。
因此:
建立一个把所有代码和其他实现工件频繁地合并到一起的过程,并通过自动化测试来快速查明模型的分裂问题。严格坚持使用Ubiquitous Language,以便在不同人的头脑中演变出不同的概念时,使所有人对模型都能达成一个共识。
最后,不要在持续集成中做一些不必要的工作。Continuous Integration只有在Bounded Context中才是重要的。相邻Context中的设计问题不必以同一个步调来处理。
微软的持续集成解决方案可参考文章 CI/CD 码农的流水线(Azure DevOps Server 2019 使用)_azuredevopsserver 2019 挑炼-CSDN博客
3、模式:Context Map
只有一个Bounded Context并不能提供全局视图。其他模型的上下文可能仍不清楚而且在不断变化。
其他团队中的人员并不是十分清楚Context的边界,他们会不知不觉地做出一些更改,从而使边界变得模糊或者使互连变得复杂。当不同的上下文必须互相连接时,它们可能会互相重叠。
Bounded Context之间的代码重用是很危险的,应该避免。功能和数据的集成必须要通过转换去实现。通过定义不同上下文之间的关系,并在项目中创建一个所有模型上下文的全局视图,可以减少混乱。
Context Map位于项目管理和软件设计的重叠部分。按照常规,人们往往按团队组织的轮廓来划定边界。紧密协作的人会很自然地共享一个模型上下文。不同团队的人使用不同的上下文。对于软件模型与设计的持续概念细分,项目经理和团队成员需要一个清晰的视图。
因此:
识别在项目中起作用的每个模型,并定义其Bounded Context。这包括非面向对象子系统的隐含模型。为每个Bounded Context命名,并把名称添加到Ubiquitous Language中。
描述模型之间的联系点,明确所有通信需要的转换,并突出任何共享的内容。
先将当前的情况描述出来。以后再做改变。
在每个Bounded Context中,都将有一种一致的Ubiquitous Language的“方言”。我们需要把Bounded Context的名称添加到该方言中,这样只要通过明确Context就可清楚地讨论任意设计部分的模型。
Context Map无需拘泥于任何特定的文档格式。我们发现类似简图在可视化和沟通上下文方面很有帮助。不管Context Map采用什么形式,它必须在所有项目人员之间共享,并被他们理解。它必须为每个Bounded 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不能像其他设计部分那样自由更改。在做决定时需要与另一个团队协商。共享内核中必须集成自动测试套件,因为修改共享内核时,必须要通过两个团队的所有测试。通常,团队先修改各自修改各自的共享内核副本,然后每隔一段时间与另一个团队的修改进行集成。例如,在每天进行Continuous Integration的团队中,可以每周进行一次内核的合并。不管代码集成是怎么安排的,两个团队越早讨论修改,效果就会越好。
6、模式:Customer/Supplier Development Team
我们经常会碰到这样的情况:一个子系统主要服务于另一个子系统;“下游”组件执行分析或其他功能,这些功能向“上游”组件反馈的信息非常少,所有依赖都是单向的。两个子系统通常服务于完全不同的用户群,其执行的任务也不同,在这种情况下使用不同的模型会很有帮助。工具集可能 也不相同,因此无法共享程序代码。
上游和下游子系统很自然地分隔到两个Bounded Context中。如果两个组件需要不同的技能或者不同的工具集来实现时,更需要把它们隔离到不同的上下文中。转换很容易,因为只需要进行单向转换。但两个团队的行政组织关系可能会引起问题。
如果下游团队对变更具有否决权,或请求变更的程序太复杂,那么上游团队的开发自由度就会受到限制。由于担心破坏下游系统,上游团队甚至会受到抑制。同时,由于上游团队掌握优先权,下游团队有时也会无能为力。
下游团队依赖上游团队,但上游团队却不负责下游团队的产品交付。要琢磨拿什么来影响对方团队,是人性呢,还是时间压力,抑或其他诸如此类的,这需要耗费大量额外的精力。因此,正式规定团队之间的关系会使所有人工作起来更容易。这样,就可以对开发过程进行组织,均衡地处理两个用户群的需求,并根据下游所需的特性来安排工作。
因此:
在两个团队之间建立一种明确的客户/供应商关系。在计划会议中,下游团队相当于上游团队的客户。根据下游团队的需要来协商需要执行的任务并为这些任务做预算,以便每个人都知道双方的约定和进度。
两个团队共同开发自动化验收测试,用来验证预期的接口。把这些测试添加到上游团队的测试套件中,以便作为其持续集成的一部分来运行。这些测试使上游团队在做出修改时不必担心对下游团队产生副作用。
在迭代期间,下游团队成员应该像传统的客户一样随时回答上游团队的提问,并帮助解决问题。
自动化验收测试是这种客户关系的一个重要部分。
Customer/Supplier Team涉及的团队如果能在同一个部门中工作,最后会形成共同的目标,这样成功的机会更大一些,如果两个团队分属不同的公司,但实际上也具有这些角色,同样也容易成功。但是,当上游团队不愿意为下游团队提供服务时,情况就会完全不同。
7、模式:Conformist
当两个具有上游/下游关系的团队不归同一个管理者时,Customer/Supplier Team这样的合作就不会奏效。勉强应用这种模式会给下游团队带来麻烦。
当两个开发团队具有上/下游关系时,如果上游团队没有动力来满足下游团队的需求,那么下游团队将无能为力。出于利他主义的考虑,上游开发人员可能会做出承诺,但他们可能不会履行承诺。下游团队出于良好的意愿会相信这些承诺,从而根据一些永远不会实现的特性来制定计划。下游项目只能被搁置,直到团队最终学会利用现有条件自力更生为止。下游团队不会得到根据他们的需求而量身定做的接口。
有时,使用上游软件具有非常大的价值,因此必须保持这种依赖性。在这种情况下,如果上游设计的质量不是很差,而且风格也能兼容的话,那么最好不要再开发一个独立的模型。这种情况下可以使用Conformist(跟随者)模式
因此:
通过严格遵从上游团队的模型,可以消除在Bounded Context之间进行转换的复杂性。尽管这会限制下游设计人员的风格,而且可能不会得到理想的应用程序模型,但选择Conformist模式可以极大的简化集成。此外,这样还可以与供应商团队共享Ubiquitous Language。供应商处于统治地位,因此最好使沟通变容易。他们从利他主义的角度出发,会与你分享信息。
这个决策使加深你对上游团队的依赖,同时你的应用也受限于上游模型的功能,充其量也只是能做一些简单的增强而已。人们在主观上不愿意这样做,因此有时本应该这样选择时,却没有这样选择。
如果这些择中不可接受,而上游的依赖又必不可少,那么还可以选择第二种方法。通过创建一个AntiCorruption Layer来尽可能把自己隔离开,这是一种实现转换映射的积极方法。
8、模式:AntiCorruption Layer
新系统几乎总是需要与遗留系统或其他系统进行集成,这些系统具有其自己的模型。当把参与集成的Bounded Context设计完善并且团队相互合作时,转换层可能很简单,甚至很优雅。但是,当边界那侧发生渗透时,转换层就要承担起更多的防护职责。
当正在构建的新系统与另一个系统的接口很大时,为了克服连接两个模型而带来的困难,新模型所表达的意图可能会被完全改变,最终导致它被修改得像是另一个系统的模型了。遗留系统的模型通常很弱。即使对于那些模型开发得很好的例外情况,它们可能也不符合当前项目的需要。然而,集成遗留系统仍然具有很大的价值,而且有时还是绝对必要的。
正确答案是不要全盘封杀与其他系统的集成。与现有系统集成是一种有价值的重用形式。在大型项目中,一个子系统通常必须与其他独立开发的子系统连接。这些子系统将从不同角度反映问题领域。当基于不同模型的系统被组合到一起时,为了使新系统符合另一个系统的语义,新系统自己的模型可能会被破坏。即使另一个系统设计得很好,它也不会与客户基于同一个模型。而且其他系统往往并不是设计得很好。
因此:
创建一个隔离层,以便根据客户自己的领域模型来为客户提供相关功能。这个层通过另一个系统现有接口与其进行对话,而只需对那个系统作出很少的修改,甚至无需修改。在内部,这个层在两个模型之间进行必要的双向转换。
这种连接两个系统的机制可能会使我们想把数据从一个程序传输到另一个程序,或者从一个服务器传输到另一个服务器。AntiCorruption Layer本身可能 是一个复杂的软件。需要考虑
- 设计AntiCorruption Layer的接口
- 实现AntiCorruption Layer
- 任何集成都是有开销的,但集成可能非常有价值
9、模式:Separate Way
我们必须严格划定需求的范围。如果两组功能之间的关系并非必不可少,那么二者完全可以彼此独立。
集成总是代价高昂,而有时获益却很少。
在很多情况下,若集成不能提供明显的收益。那么集成可能就是没有必要。
因此:
声明一个与其他上下文毫无关联的Bounded Context,使开发人员能够在这个小范围内找到简单、专用的解决方案。
特性仍然可以被组织到中间件或UI层中,但它们将没有共享的逻辑,而且应该把通过转换层进行的数据传输减至最小,最是没有数据传输。
采用Separate Way(各行其道)模式需要预先决定一些选项。尽管持续重构最后可以撤销任何决策,但完全隔离开发的模型是很难合并的。如果最终仍然需要集成,那么转换层将是必要的,而且可能很复杂。当然,不管怎样,这都是我们要面对的问题。
10、模式:Open Host Service
一般来说,在Bounded Context中工作时,我们会为Context外部的每个需要集成的组件定义一个转换层。当集成是一次性的,这种为每个外部系统插入转换层的方法可以以最小的代价避免破坏模型。但当子系统要与很多系统集成时,可能就需要更灵活的方法了。
当一个子系统必须与大量其他系统进行集成时,为每个集成都定制一个转换层可能会减慢团队的工作速度。需要维护的东西会越来越多,而且进行修改的时候担心的事情也会越来越多。
团队可能正在反复做着同样的事情。如果有一个子系统有某种内聚性,那么或许可以把它描述为一组Service,这组Service满足了其他子系统的公共需求。
要想设计出一个干净的协议,使之能够被多个团队理解和使用,是一件十分困难的事情,因此只有当子系统的资源可以被描述为一组内聚的Service并且必须进行很多集成的时候,才值得这样做。在这些情况下,它能够把维护模式和持续开发区别开。
因此:
定义一个协议,把你的子系统作为一组Service供其他系统访问。开放这个协议,以便所有需要与你的子系统集成的人都可以使用它。当有新的集成需求时,就增强并扩展这个协议,但个别团队的特殊需求除外。满足这种特殊需求的方法是使用一次性转换器来扩充协议,以便使共享协议简单且内聚。
11、模式:Published Language
两个Bounded Context之间的转换需要一种公共语言。
当两个领域模型必须共存而且必须交换信息时,转换过程本身就可能很复杂,而且很文档化和理解。如果正在构建一个新系统,我们一般会认为新模型是好的,因此只考虑把其他模型转换成新模型就可以了。但有时我们的工作是增强一系列旧系统并尝试集成它们。这时要在众多模型中选择一个比较不烂的模型,也就是说“两害相权取其轻”。
与现有领域模型进行直接的转换可能不是一种好的解决方案。这些模型可能过于复杂或设计得较差。它们可能没有被很好地文档化。如果把其中一个模型作为数据交换语言,它实质上就被固定住了,而无法满足新的开发需求。
因此:
把一个良好文档化的、能够表达出所需领域信息的共享语言作为公共通信媒介,必要时在其他信息与该语言之间进行转换。
这种语言不必从头创建。如:用XML和Json格式传输信息已经非常流行。
三、参考文档
DOMAIN-DRIVERN DESIGN
TACKLING COMPLEXITY IN THE HEART OF SOFTWARE
领域驱动设计
软件核心复杂性应对之道
【美】Eric Evans 著 赵俐 盛海艳 刘霞 等 译