本文主要是介绍《实现领域驱动设计》 (美)弗农著 13章 集成限界上下文,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
只供参考,喜欢请支持正版图书
集成基础知识
有多种直接的方式可以完成限界上下文之间的集成。
其中一种便是在一个限界上下文中暴露应用程序编程接口(API),然后在另 一个限界上下文中通过远程过程调用(RPC)的方式访问该API。此时的API可以 通过SOAP协议暴露给其他限界上下文,也可以直接在HTTP中使用XML (这种方 式与REST不同)。事实上,我们有多种方式创建这样的远程API,SOAP只是其中最流行的方式之一,因为它支持过程调用的风格,这是我们程序员能够非常容易理 解的。
另一种直接的集成方式便是使用消息机制。在消息机制中,每一个需要交互 的系统都使用消息队列或者发布-订阅[Gamma et al.]机制。当然,我们同样可以将 消息看作是某种形式的API,但是更好的方式是将消息机制看成是一种服务接口。 有大量的技术都支持通过消息来集成限界上下文,详情请参考[Hohpe & Woolf]。
第三种集成限界上下文的方式是使用RESTful HTTP。有人认为REST也是 某种形式的RPC,其实并非如此。诚然,REST和RPC的确存在相似之处,比如它 们都是从一个系统向另一个系统发出请求的。但是,在REST的请求中并不包含过 程调用中的参数。在架构(4)中我们已经讲到,REST用于交换和更改资源,这些 资源通过URI的方式进行定位,每种资源的URI都是唯一的。在每种资源上,我 们可以执行不同种类的操作。RESTful HTTP提供了一些方法,比如GET、PUT和 DELETE。虽然从表面上看,这些方法只支持CRUD操作,但是如果多思考一下你 便知道,我们可以通过这4个方法的意图对不同的操作进行分类。比如,GET方法 可以包含不同类型的查询操作,而PUT方法则用于聚合(10)上的命令操作。
当然,以上并不意味着只存在三种集成限界上下文的方法。你还可以通过共 享文件和数据库的方式进行集成,但是此时我只能说,你也太不与时俱进了。
举个例了•,SaaSOvation公司需要在不同的限界上下文中交换数据。他们可 以使用REST资源的方式,也可以在不同的服务系统之间发送含有事件(8)的消息。REST资源的其中一种表现形式称为通知(notification),而基于事件的消息 也婼通过Notification对象的形式发送给订阅方的。换句话讲,在两种情况下都由 Notification持有事件,rftf这两者将被格式化成单一的结构。此时,为通知和事件创 建的自定义的媒体类型规范将含有以下契约:
• 类型:Notification 格式:JSON
• notificationld:长整型唯一标识
• typeName: Notification的类型名,比如com.saasovation.agilcpm.domain.model.product.backlogltem.BacklogltemCommitted
• version: Notification的版本,整型数
• occurredOn: Nolificalion包含的事件所发生的FI期/时间
• event: JSON格式的事件数据,请参考具体的喂件类型
对typcName使川全类名(包含包名)使得订阅方能够精确地区分不同的 Notification类型。紧跟着该Notification规范的应该是不同类型的事件类型规范。 比如,对于BaddogltemCommitted事件,它的类型规范如下:
• 事件类型:com.saasovation.agilepm.domain.model.product.backlogltem. BacklogltemCommitted
• even丨Version:事件的版本号,以整型数表示,与Notification的version相同
• occurredOn:事件发生的日期/B才间,与Notification的occurcdOn相同
• backlog丨temld: Backlogltemld,以字符串表示
• committedToSprint丨d: Spring丨d,以字符串表示
• tenantld: Tenant丨d,包含了字符串形式的id属性
• 事件细节:请参考具体的事件类型
当然,我们还可以为每一种事件类型提供事件细节。有丫 Notification和所有 的事件类型,我们便可以安全地使用一个NotificationReader来读取某个通知,如 以下测试所示:
在上例中,对于每个序列化的Notification对象,NotificationReader都为其提 供了类型安全的访问方式以读取不同的数据成分。
下一个测试展示了如何从Notification的事件细节中读取各个特殊的数据成 分。我们可以通过类似于XPath的式,或者以点(.)分开的方式来读取各个属 性值,另外还可以使用以逗号分开的属性名的方式进行读取。每一个属性都可 以用String类型表示,而对于那些原始类型,我们可以直接使用实际类型(比如 int、long、boolean和double等):
在上例中,TestableNavigableDomainEvent持有了一个TestableDomainEvent, 这使得我们可以测试对那些深度属性的导航访问。不同的属性都通过类似于XPath 语法的方式进行读取,同时我们还测试了以不同的类型来读取每个属性值。
由于Notification和其所包含的事件实例总会携带一个版本号,此时我们便可 以通过版本号来读取特定于某个版本的特殊属性。当然,此时,我们依然可以将 Notification当作最老的版本(即版本1)予以接收。
因此,如果我们仔细地设计每一种事件类型,那么对于多数消费方来说便不 会出现不兼容的问题。在事件发生变化时,他们并不需要做相应的修改,或者重新 编译。当然,作为服务方来说,我们依然需要考虑到版本的兼容性,在做修改时应 该顾及到消费方。虽然有时这是难于做到的,但是在大多数情况下,这是可能的。
这种方式的另外一个好处在于,事件中可以不只是包含原始类型或者字符串, 而是可以包含更复杂的值对象(6)。比如,对于上例中的Backlogltemld、Sprintld 和Tenantld来说,我们可以通过以下方式进行访问:
这些值对象在数据结构中被冻结了,这意味着此时的事件不仅是不变的,并 且从长远来看都是固定的。如果事件中包含了新版本的值对象,这并不会妨碍消 费方访问那些既有Notification实例中的老版本的值对象。值得指出的是,对于版 本经常改变的事件来说,使用协议缓存将更加简单,而NotificationReader将变得 笨重复杂。
以上,我只是提供了一种反序列化方式,这种方式不需要到处部署事件类型 和其他依赖组件。对有些人来说,这是一种优雅的方式;而另外有些人却认为这 是一种危险的、笨拙的方式。更多的时候,人们使用的是部署接口与类的方式。这 里,我提供的是一条鲜有人走的道路。
无论是哪种方式——部署接口 /类,还是定义媒体类型契约——对于项目的某 些阶段来说,它们有可能是适用的。比如,在项目开始时,我们可以使用部署接口和通过REST资源集成限界上下文类的方式,供是在产品环境下,使用低耦合的自定义媒体类型契约则更好。当然, 在实践中,这不见得对每个团队都适用。有些团队在一幵始便认定了其中一种方 式,之后就不改了。
出于简单性考虑,在本章余下的例子中,我们都将使用NotificatimiReader。当 然,至于是否要使用自定义的媒体类型契约和NotificationReader,选择权在你自 己。
通过REST资源集成限界上下文
当一个限界上下文以URI的方式提供了大量的REST资源时,我们便可称其为开放主机服务(3):
为系统所提供的服务定义一套协议。开放该协议以使其他需要集成的系统能够使 用。在有新的集成需求时,对协议进行改进和扩展。[Evans]
我们完全可以把HTTP方法——GET、PUT、POST和DELETE——以及它们 所操作的资源看作是开放的服务。此时,HTTP和REST便组成了交互系统之间的 开放协议;而几乎取之不竭的URI又使得这些协议能够处理新的集成需求。因此, 这是一种功能强大的集成限界上下文的方式。
虽然如此,由于在请求服务时,REST服务的提供方都必须直接参与,因此使 用这种方式的客户端并不是完全自治的。如果提供REST服务的限界上下文不可 用,那么客户端限界上下文也无法完成集成操作。
当然,也不是完全没有办法,我们至少可以通过某些手段来减少REST对自治 性的阻碍。即便REST是你唯一的集成方式,你依然可以通过定时器或者消息机制 来营造一种暂时的解耦。此时,你的系统可以在定时器触发时,或者事件抵达时, 与远程系统交互。如果远程系统不可用,那么定时器所获得的服务数据便可以作 为替补;或者在使用消息时,我们可以向消息提供方回复否定应答,以使其重新发 布消息。诚然,这种方式将增加你团队的负担,但这是你所要付出的代价。
当SaaSOvation公司的开发团队决定将 身份与访问上下文的功能提供给客户方时, 他们选择了RESTful HTTP.他们认为这种方式的好处在于不用向客服方暴露自身领域
模型的结构和行为细节。他们需要通过REST资源的方式向外提供Tenant的身份与访问相关 的数据信息。
在身份与访问上下文中,他们通过HTTP的GET方法向外提供用户和用户群的身份标识, 以及与他们相关的角色信息。比如.如果客户方想知道某个租户下的某个用户是否扮演了某 个角色.它可以通过以下UR丨向身份与访问上下文发送GET请求:
/tenants/{tenantId}/users/{username}/inRole/{role}
如果该用户的确扮演了role这个角色,那么在返回中将包含HTTP的状态码200.即表示成 功,否则将返回表示无内容的状态码204。这是一种简单的RESTful HTTP设计。
接下来,让我们看看这是如何实现的,以及客户方是如何以符合通用语言(1)的方式来 消费这些资源的。
实现REST资源
当身份与访问上下文的团队考虑着如何向集成方提供开放主机服务时,他们认为可以简 单地以REST链接资源的方式将领域模型暴露出去。这意味着客户方可以通过HTTP GET的方 式获得一个租户资源,然后访问其中的用户、用户群和角色等信息。这是一种好的方式吗? 在一开始看来,这似乎是一种很自然的方式。毕竟.此时客户方被赋予了最大的灵活性,他 们知道领域模型的各个方面。对于一个用户是否扮演某个角色的问题,他们可以在自己的限 界上下文中做出判断。
那么.对于这种设计方式.我们可以采用哪种DDD的上下文映射模式呢?事实上,这并 不是一种开放主机服务.而更像是共享内核或者遵奉者(3).这使得消费方和身份与访问上 下文中的领域模型紧密地耦合起来。我们应该尽量地避免这种情况,因为它违背了DDD的根 本目标。
令人欣慰的是,团队成员们获得了一些好的建议,从而避免了将领域模型直接暴露给客 户方。他们学到了如何从集成方所需用例(或者用户故事)的角度来看待问题。这是符合开 放主机服务的部分定义的:"在有新的集成需求时,对协议进行改进和扩展。”这意味着他 们只需要提供集成方当前之所需,而这种需求便是通过用例所驱动出来的。
SaaSOvation公司的团队采取了这个建议,他们意识到,集成方真正关心的只 是一个用户是否扮演某个角色。向集成方隐藏领域模型的细节也增加了团队的生 产力,并且可以增加那些依赖方限界上下文的可维护性。此时,User的REST资源应 该包含:
在六边形(4)或端口与适配器架构中,UserResource类是RESTful HTTP端口 的适配器,该端口通过JAX-RS实现。此时,消费方可以通过以下方式发出请求:
GET /tenants/{tenantId}/users/{username}/inRole/{role}
该适配器会把功能委派给AccessService,这是一个应用服务(14),它位于六 边形内部,并向外提供API。作为领域模型的直接客户,AccessService负责管理用例和事务。该用例包括查找一个User是否存在,如果存在,再判断该User是否扮演 了某个指定的角色:
应用服务将分别找到User和Role聚合。当Role的查询方法islnRoleO被调用 时,我们传入了一个GroupMemberService。GroupMemberService并不是一个应用 服务,而是一个领域服务(7),它帮助Role执行一些与领域相关的检查和查询,因 为这些职责并不属于Role本身。
UserResource中的Response包含了一个User及其所扮演的角色名,此时团队成 员使用了一个自定义的媒体类型:
如果一个User的确扮演了某个指定的角色,那么UserResource适配器将在 HTTP应答中包含以下JSON数据:
当消费方在获取到该REST资源时,他们将把这些资源翻译成本地限界上下文 中的领域对象。
使用防腐层实现REST客户端
对于客户方来说,虽然身份与访问上下文所提供的JSON展现数据非常有用,但 是当我们考虑到DDD的目标时,客户方的限界上下文是不会原封不动地消费这些 JSON数据的。在前面的章节中我们已经讲到,如果消费方是协作上下文,该上下文 的开发团队对原生的用户和角色信息并不会感兴趣,他们关心的是更加特定于自身 领域的角色。此时,单纯地使用User和Role领域对象对他们来说已经不再适用。
那么,要使User-Role形式的数据能够服务于协作上下文,我们又应该怎么做 呢?让我看看图13.1所示的上下文映射图,其中的UserResource适配器已经在前一 节中讲到了。从图中可以看出,我们需要为协作上下文创建一些特定的接口和类,即 Col laboratorSer vice、Userln Role Adapter 和 CollaboratorTranslator。同时还有 HttpClient,这是通过JAX-RS实现中的ClientRequest和ClientResponse来提供的。
这里的CollaboratorService、UserlnRoleAdaptor和CollaboratorTranslator便组成了一个防腐层(3),该防腐层是协作上下文和身份与访问上下文交互的方式,同时 它还负责将User-Role形式的数据翻译成Collaborator值对象。
以下是CollaboratorService,它组成了防腐层的基本操作:
对于CollaboratorService的客户端来说,它根本看不到对远程系统的访问,以 及是如何将远程系统的发布语言翻译成本地对象的。在本例中,我们的确使用到 了独立接口[Fowler,P of EAA],因为该接口的实现是技术性的,并且不应该位于领域层中。
CollaboratorService中的所有工厂(11)方法都是相似的。它们都用 于创建抽象类Collaborator的某个子类。当然,前提是一个以anldentity 标定的User的确位于aTenant下,并且扮演了以下角色类型的其中一 种:Author、Creator、Moderator、Owner和Participant。让我们看看authorFrom()工
厂方法的实现:
请注意,这里的TranslatingCollaboratorService位于基础设施层的某个模块(9) 中。虽然我们将独立接口CollaboratorService当作领域模型的一部分,并将它放置 在了六边形的内部,但是它的实现却是技术性的,并且被放置在了六边形架构的外 部,即端口和适配器所在的位置。
作为技术实现的一部分,在防腐层中通常会有一个特定的适配器[Gamma et al.]和翻译器。再回头看看图13.1,你将看到其中的适配器UserlnRoleAdapter和翻译器CollaboratorTranslator,这个特定的UserInRoleAdapter负责与远程系统的交 互以请求所需的User-Role资源:
如果GET请求得到了成功(状态码200)的应答,表明该UserhiRdeAdapter获 取到了相应的User-Role资源。之后,CollaboratorTranslator负责将该资源翻译成 Collaborator的子类对象:
该CollaboratorTranslator的toCollaboratorFromRepresentation方法接受两个 参数:User-Role资源的文本展现(String类型)和Collaborator的某个子类的Class类型。首先,Representation Reader—和先前的Notification Reader相似-----负责读取JSON数据展现中的4个属性。此时,我们可以非常自信地这么做,因为 SaaSOvatimi公司自定义的媒体类型在生产方系统和消费方系统之间形成了一种绑 定契约。在CollaboratorTranslator获取到了所需的String类型属性时,它将用这些属 性来创建Collaborator值对象,在本例中即为Author:
如果要将Collaborator值对象实例和身份与访问上下文保持同步,我们并不需 要做额外的工作。因为Collaborator是不变的,我们不能对其进行修改,而只能完全 替代。下面的例子演示了应用服务如何获取到一个Author,然后将其交给Forum来 幵始一个新的Discussion:
通过消息集成限界上下文
在使用消息进行集成时,任何一个系统都可以获得更高层次的自治性。只要 消息基础设施工作正常,即使其中一个交互系统不可用,消息依然可以得到发送和 投递。
在DDD中,增强系统自治性的一种方式便是使用领域事件。当一个系统中发 生一些显著性的事情时,它将为此发布领域事件。在每个系统中,都存在着多个甚 至大量的事件,在创建这些事件时,我们需要考虑到事件的唯一性以便对每个事 件进行记录。当事件发生吋,系统将通过消息机制将这些事件发送到对事件感兴 趣的相关方。当然,以上只是对于事件的高层总览。如果你错过了本书前面章节对 事f1•细节的探讨,那么请参考架构⑷、领域事件(8)和聚合no)等章节。
从Scmm的产品负责人和团队成员处得到持续通知
在敏捷项目管理上下文中,对于每个订阅的租户来说,系统都需要为其维护一 组Scrum的产品负责人和团队成员。在任何时候,产品负责人都可以创建新的产 品,然后添加团队成员。那么,这个Scrum项目管理系统如何知道什么样的人扮演 着什么样的角色呢?答案是,它不会单干的。
事实上,敏捷项目管理上下文将通过身份与访问上下文来管理不同的角色,这 是一种fl然的选择,也是合适的选择。在身份与访问上下文中,每一个订阅的租户 都会创建2个Role实例:ScrumProductOwner和ScrumTeamMember。每一个需要扮演某种角色的User都会被指派给相应的Role。在该限界上下文的应用服务中,我 们通过以下方式来实现:
非常好!但是,敏捷项目管理上下文又如何知道是谁扮演了 ScrumTeamMember或者ScrumProductOwner呢?答案是:当Role中的assignUser()
方法执行完毕时,它将发布一个事件:
在上例中,ExchangeListener的默认构造函数得到了正确的调用, exchangcNamc()方法返回的是身份与访问上下文在发布事件时所使用的交换器的 名字,而listensToEvents所返回的数组中只包含了 UserAssignedToRole事件的全 类名。请注意,发布方和订阅方都应该使用事件的全类名,其中包含了事件所在的 模块名和事件本身的类名。这样,如果不同的限界上下文使用了相同的事件类名, 我们依然可以进行区分。
最后,真正包含大量行为的是filteredDispatch方法。正如该方法的 名字所暗示的,在调用应用层API之前,它将对通知进行过滤。在本例中, 它将过滤掉那些不包含 ScrumProductOwner和ScrumTeamMember角色的 UserAssignedToRole 事件。另一方面,如果 UserAssignedToRole 的确包含了以上 两种角色,那么filteredDispatch()方法将从通知中提取出UserAssignedToRole事 件的细节信息,并将操作进一步分发给应用层的TeamService。TeamService中的 enableProductOwner()方法和enableTeamMember()方法都以一个命令对象为参 数,分别是EnableProductOwnerCommand和EnableTeamMemberCommand。
乍看起来,每个UserAssignedToRole事件似乎都会导致新成员的创建。但是, 每个User都可能被指派成任何一个Role,之后还有可能解除指派,再重新指派。因 此,可能发生的情况是:在所接收的通知中,一个User所表示的成员已经存在了。 对此,以下是TeamService的解决方法:
你能处理这样的职责吗?
以上方法看来不错,并且也足够简单。我们有了 ProductOwner和TeamMember 聚合类型,它们都包含有外部限界上下文中User类的一些信息。但是,你有没有意 识到,此时的聚合承担了多少职责?
回想,在协作上下文中,要包含来自User的信息,开发团队使用的是一些不 变的值对象(请参考“使用防腐层实现REST客户端”)。正因为这些值对象是不变 的,幵发团队根本不用担心对共享信息的史新。当然,这也是一个问题,即在共享信 息更新之后,协作上下文中的相应对象将得不到更新。因此,敏捷项目管理幵发团 队选择了另一条路。
然而,要保持对聚合的实时更新是存在诸多挑战的。为什么?难道不是监听对 User实例的修改,然后相应地修改ProductOwner和TeamlVlcmbcr吗?当然是,并且我们也必须这么做。但是,我们所使用的消息基础设施将给我们带来一些挑战。
比如,在身份与访问上下文中,如果一个管理者错误地将Joe Johnson所扮演的 ScrumTeamMember角色解除了,情况会怎么样?当然,我们会收到一个事件通知, 然后调用TeamScrvice将loe Johnson所对应的TcamMcmbcr转为失活状态。等一等, 几秒钟之后,该管理者意识到了错误,她真正应该被操作的User是joe jones,而不 是Joe Johnson。因此,她立即将ScrumTcaniMember角色再次指派给Joe Johnson, 然后解除Joe Jones所扮演的ScnimTeamMember角色。之后,敏捷项目管理上下文将 接收到相应的通知,万事大吉。也或者,万事真的就大吉了吗?
对于这个用例来说,我们做出了错误的假设,即假设通知的接收顺序和 它们在身份与访问上下文中的产生顺序相同。但是,事实却不总是如此。对于 Joe Johnson来说,如果我们先接收到了UserAssignedToRole事件,再接收到 UserUnassignedToRole事件,情况又会如何呢?在所有事件处理完后,Joe Johnson 所对应的TeamMember将依然处于失活状态。此吋,有人可能需要向敏捷项目管理 上下文的数据库中打些补丁,或者管理者需要玩弄一些小技巧将Joe Johnson重新 激活。这种情况是有可能发生的,并且比我们所想象的发生频率更高。那么,我们 应该如何避免这种情况呢?
让我们仔细看看传给Team Service API的命令对象,比如EnablcTcam MemberCommand和DisableTeamMembeiCommand。这两个命令对象都需要 提供一个Data对象,即occurredOn属性。事实上,所有的命令对象都是如此设 计的。我们将使用该occurredOn属性来确保ProductOwner和TeamMember是 以正确的时间顺序来处理命令操作的。对于前面的UserAssignedToRole先于 UserUnassignedToRole被接收的情况,我们看看如何处理:
请注意,当我们调用TeamMember的disable()命令方法时,我们需要传入命令 对象中的occurredOn属性。TeamMember将使用该属性来确保命令的正确执行:
以上聚合行为是由一个抽象基类提供的,即Member。其中的disable()和 enable〇方法都通过一个changeTracker来决定是否应该执行命令操作,此时的 asOfDate参数即为所传入的occurredOn属性值。值对象MemberChangeTracker维护了最近一次操作的相关信息:
这里,我们检壺E-mail地址是否发生了改变。如果没有,那么我们将不予跟 踪。而如果的确跟踪了,那么真正包含有E-mail修改信息的事件将被忽略掉。
McmbcrChangeTracker还使得Member的子类的命令操作是幂等的,即如果同 一份通知被消息基础设施投递了多次,那么多余的通知将被忽略掉。
当然,我们也可以认为引人MemberChangeTracker是聚合设计的一个错误, 因为它与Scrum所使用的通用语言毫无关系。这是事实,但是,我们并没有把 MembcrChangeTracker暴露到聚合边界之外。这只是一个实现上的细节而已,客 户端根本意识不到这个MembcrChangeTrackcr的存在,它唯一需要提供的只是 occinredOn厲性值。另外,这也正是Pat Hclland在描述如何处理分布式系统之间 的合作者关系时所采用的实现细节。特别地,请参考[Hdland]的第5节,“Activities: Coping with Messy Messages"
现在,让我们网到对新职责的处理……
对于在不冋限界上下文间维护复制性信息来说,虽然以上只是一个非常基本 的例子,但是其中的职责分离却并非琐碎之事,至少在使用消息机制时是这样的。 因为此时我们需要考虑到消息无序抵达的情况和多次投递的情况、另外,在身份 与访问上下文中的听有操作都只会对Member的部分厲性产生影响。意识到这一点, 我们便可以总结出以下事件:
• PersonContactInformationChanged
• PersonNameChanged
• UserAssignedToRole
• UserUnassignedFromRole 还有另外的一些事件也是重要的:
• UserEnablementChanged
• TenantActivated
• TenantDeactivated
以上事实所强调的是:在有可能的情况下,我们应该最小化不同限界上下文之 N的信息复制,甚至彻底消除。当然,要完全避免信息复制是不可能的。服务层协议 (SLA)扦不能保证每次对远程数据的获取都能成功。这也是为什么SaaSOvation 的团队需要在本地上下文中维护User的名字和E-mail等信息。然而,对于那些位于我们自己职责之下的外部信息来说,信息量越少,我们的工作也越简单。这也是集 成限界h下文的“最小化信息”原则。
当然,对于Tenant和User的唯一标识,我们是无法避免重复的,因为它们是必 要的。这也是集成限界上下文的首要方法之一。另外,共享唯一标识是安全的,因 为它们不会改变。我们甚至可以通过禁用聚合和软删除的方式来保证那些被引用 的对象从不消失,比如Tenant、User、ProudctOwner和TeamMember便使用了这样
的方法。
以上提醒并不表示领域事件就不应该包含信息属性。需要肯定的是,领域事 件必须包含足够的信息以通知消费方完成相应的操作。此外,消费方的限界上下文 可以使用事件数据来执行计算操作或者得出其他状态,即此时消费方并不用维护 事件数据本身,也不用保持与远端系统的同步。
长时处理过程,以及避免职责
如果我们将前一节所描述的看成是一个负责任的成年人,那么本节将带你回 归到青年时代。你知道,成年人需要承担各种各样的职责。父母需要购买汽车,然 后为汽车购买保险,再掏钱给汽车加油,最后还得花钱维修。作为年轻人来说,我 们只是使用父母的汽车就行了,而不用担心花销。你想让一个青少年掏钱给父母买 车、加油、维修然后购买保险,几乎是不可能的。他们只是让:父母承担所有的责任,自己却逍遥自在去了。
在本节中,我们将讲到长时处理过程(4),对于在不同限界上下文之间复制信 息时所要求的职责来说,我们将予以拒绝。我们将使那些记录数据的系统自行处 理自己的信息。
在上下文映射图(3)中,我们展示了一个“创建产品”的用例:
前提条件:协作功能可用(附加功能需要购买)。
1.用户提供Product的描述信息。
2.用户希望为该Product创建一个Discussion。
3.用户发出创建Product的请求。
4.系统创建一个Product,连同Forum和Discussion。
你可能会注意到,先前关于Discussion (第3章)的通用语言在这里被进 一步改善了。敏捷项目管理团队认为应该区分开两种类型的Discussion,于是有了ProductDiscussion和BacklogltemDiscussion (在本节中,我们只关注于 PmductDiscussion)。这两个值对象都具有相同的状态和行为,但是这样的区分 有助千类型的安全性,从而避免了开发者将错误的Discussion添加到Product和 Backlogltem中。而在实际应用中,它们却是一样的。这两个值对象都维护了自身的 可见性信息,同时还包含协作上下文中Discussion聚合的唯一标识。
需要指出的是,虽然敏捷项目管理上下文中的值对象Discussion和协作上下文中 的聚合Discussion拥有相同的名字,但是这并不是一个错误。因此,我们并没有将 值对象Discussion重新命名为ProductDiscussion以示区分。
从上下文映射的角度来 看,维持Discussion值对象原有的名字是完全可以的,因为限界上下文已经对这两 个对象进行了区分。在敏捷项目管理上下文中创建两个类型不同的值对象完全是出 于本地模型的考虑。
让我们首先来看看创建Product的应用服务:
事实上,有两种方式都可以创建一个新的Product。第一种方式不需要 创建Discussion,这里未予显示。上例所示的是第二种方式,它需要在创建 Product时,连同创建一个ProductDiscussion。两个内部法,newProductWith() 和requestDiscussionIfAvailable(),在这里也未予以显示。后者用于检查CollabOvation的附加功能是否可用。如果可用,那么它将返回REQUESTED;否则 返回ADD_ON_NOT_ENABLED。方法newProductWith()将调Product的构造函数,该构造函数如下所示:
客户端需要传入一个Discussion Availability参数,该参数拥有以下状态 值:ADD_ON_NOT_ENABLED、NOT_REQUESTED和REQUESTED。另夕卜, 状态值READY表示完成状态。对于在前两种状态下所创建的ProductDiscussion 对象,它将维护一份原有状态的属性,也即此时所创建的产品并没有与之关联的 讨论。对于第三种状态REQUESTED,所创建的ProductDiscussion对象将拥有PENDING_SETUP状态。以下是ProductDiscussion的工厂方法,该方法被Product 的构造函数所使用:
长时处理过程的状态机和超时跟踪器
在长时处理过程(4)中,我们讲到了“跟踪器”的概念,现在,通过采 用相似的做法,我们可以使以上处理过程更加成熟。SaaSOvaticm的开发者 们创建了一个可重用的跟踪器概念:TimeConstrainedProcessTracker。该 TimeConstrainedProcessTracker将监视那些指定完成时间已经过期了的处理过 程;另外,对于那些在过期之前可以任意重试的处理过程,它也将进行监视。这种 设计使得我们可以定期地对长时处理过程进行重试,或者在不进行重试(或在达 到重试上限之后)的情况下彻底地超时。
需要指出的是,跟踪器并不是核心域的一部分,而是属于技术子域的,该技术 子域可以被SaaSOvation公司的所有项目所重用。这意味着,在有些情况下,在对跟 踪器进行持久化或者修改的时候,我们不用严格地遵循聚合原则。另外,跟踪器也 是相对独立的,并且与长时处理过程存在着一对一的关系,因此,并发冲突的可能 性并不大。如果的确发生了并发冲突,那么我们依然可以依赖于消息重发来解决问 题。在消息投递过程中产生的任何异常都会导致监听器做出否定应答,进而使得 RabbitMQ重发消息。另外,我们并不期待对处理过程进行大量的重试。
Product维护了长时处理过程的当前状态,当重试间隔抵达,或者处理过程彻 底超时时,跟踪器将发布以下事件:
虽然我们可能会对这样的结果表示满意,但是对于协作上下文当前的设计来 说,依然存在一些小问题。一个基本的问题是:此时协作上下文中的操作并不是幂 等的。以下是这种设计的一些瑕疵以及我们应该如何应对:
•由于我们釆用了可靠的消息机制,并且它有可能重复投递同一条消息,一旦 消息被发送到交换器中,该消息必然会被监听器所监听到。如果在创建协 作对象时发生了延迟,进而导致了消息的重发,那么这将导致多次发送同 一个CreateExclusiveDiscussion命令对象的情况。这样的结果是:协作上下 文将多次仓1j建相同的Forum和Discussion。当然,这并不会导致对象实例的 重复存在,因为Forum和Discussion的属性已经具有唯一性约束了。因此, 这种多次创建对象的错误将是良性的。但是,从错误日志来看,这却有可能 使人认为这样的错误是系统中的bug所致。问题在于,在已经有超时处理的 情况下,我们是否应该禁用周期性重试?
•虽然禁用敏捷项目管理上下文中的消息重试是一个不错的解决方案,但是这 里的底线是:将协作上下文中的操作变成幂等操作。我们知道,RabbitMQ有 可能多次发送同一条命令消息,因此,如果我们将协作上下文中的操作变成 幂等的,那么我们就可以避免多次创建Forum和Discussion的情况。
•敏捷项目管理上下文在发送CreateExclusiveDiscussion命令时,是有可能 失败的。如果发生失败的情况,那么我们需要保证对命令的重发直到成 功为止。否则,协作上下文将不能成功地创建Forum和Discussion对象。 我们可以通过多种方式来保证对命令的重发。如果消息发送失败,我们 可从filteredDispatchQ方法中抛出一个异常,这将引发一个否定应答。之 后,RabbitMQ将重新发送ProductCreated或者ProductDiscussionRequested事件通知,而ProductDiscussionRequestedListener将再次监听到该事件。另 一种方式则是简单地重复发送过程直到成功为止,比如可以使用盖帽指数 后退算法。如果RabbitMQ不可用,那么我们有可能在很长一段时间里都无 法成功地对消息进行重发。因此,将否定应答和消息重发结合起来使用可 能是最好的方式。毕竟,如果发生彻底超时的情况,系统将发送一封E-mail 以请求人为干预。
设计一个更复杂的长时处理过程
我们可能还希望创建一个更复杂的长时处理过程。在需要多步完成的情况 下,我们最好是釆用一个状态机。要满足这样的需求,我们可以创建一个Process。 以下是Process接口的定义:
以下是Process接口提供的主要操作:
• allowableDuration:在Process可以超时的情况下,该方法返回总 的持续时间或者重试之间的持续时间。
• canTimeout():如果Process可以超时,那么该方法将返回true。
• timeConstrainedProcessTrackerO:女口果Process可以超时,该方 法将返回一个新建的并且唯一TimeConstrainedProcessTracker。
• totalAllowableDuration ():返回process所允许的总持续时间,该方法返回的是allowableDuration()与 totalRetriesPermitted的乘积
• totalRetriesPermitted():在process允许超时和重试的的情况下, 该方法将返回重试的总数目。
Process的实现类可以通过TimeConstrainedProcessTracker来监控超时或者重试。在我们创建了一个Process之后,我们便可以从中获取到一个唯一的跟踪器。在 以下测试中,我们展示了这两个类是如何协同工作的,这和Product及其跟踪器的工 作方式相似:
以上测试中所创建的Process必须在5秒(5000L毫秒)中之内完成。只有在 confirml()和confirm2()方法都被调用之后,该Process才能被标记为完成。在 Process内部,它知道这两个状态都必须得到确认:
当消息机制或你的系统不可用时
在幵发复杂软件系统时,没有哪种单一的方式可以成为万能良方。每一种方式 都有不足之处,其中的一些我们已经讨论过了。对于消息系统来说,其中一个问题 是:在一段时间之内,它有可能是不可用的。这可能并不是一种多发的情况,但是如 果发生,那么有儿点是我们需要注意的。
在消息机制不可用时,通知的发布方将不能通过该消息机制发布事件。这种情 况将被发布客户端所检测到,此时的客户端可以退一步,减少消息的发送量,等到 消息系统吋用时再进行正常发送。在这个过程中,如果其中一次发送成功,那么我 们便可以认为消息系统已经再次可用了。但是直到那个时候,请确保消息的发送频 率小丁•正常情况。我们可以每隔3()秒或者丨分钟重试一次。请注意,如果你的系统 使用了事件存储,那么你的事件在成功发送之前都将一直位于消息队列中,当消息 系统重新可用时,我们可以立即对这些消息进行发送。
对于消息监听器来说,在消息机制不可用时,它将接收不到新的事件通知。当 消息系统重新可用时,你的监听器会被自动地重新激活吗,也或许你需要重新进 行订阅?如果此时的消息消费方不能Q动恢复,那么你需要确保重新注册该消费 泞。否则,你将发现你的限界上下文不再接收所依赖限界上下文发出的通知,这是 你需要避免的。
当然,问题并不总是出自消息机制。考虑以下场景:在一段时间之内,你的限界 上下文变得不可用。当它再次可用吋,此时的消息系统中已经收集到了大量的未投 递的消息。然后,你的限界上下文重新注册消息的消费方,那么要接收并处理完所 有未被处理的消息将消耗大量的时间。对于这种情况来说,你将没有什么好做的。 当然,你以增加更多的节点(集群),此时即便其中一个节点不可用,整个系统依 然是可用的。此外,有些时候你根本无法避免停机的情况。比如,当你对系统代码 的修改需要更新数据库,而你并不能直接向数据库中打补丁时,你便需要一些系统 停机时间了。在这种情况下,你的消息处理机制便只能使劲追赶了。
只供参考,喜欢请支持正版图书
这篇关于《实现领域驱动设计》 (美)弗农著 13章 集成限界上下文的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!