@levinzhang
2017-08-09T06:09:27.000000Z
字数 10528
阅读 1283
by
对于长时间运行并且业务跨越单个微服务边界的场景,本文探讨了如何基于事件模型来实现服务。
假设我们想要设计一个微服务架构实现一些相对复杂的“端对端”用例,例如,基于网络的零售订单履行。显然,这会涉及到多个微服务。这样的话,我们就需要管理跨越服务边界的业务过程。一旦涉及到解耦微服务,这种跨服务流的场景就会带来很多挑战。在本文中,我们会介绍名为“事件命令转换(event command transformation)”的模式,并提供技术手段来解决跨微服务进行流程编码所带来的复杂性,在这个过程中不会引入任何的中央控制器。
首先,我们需要提供一个初始化的微服务环境,并定义微服务的边界和范围。我们的目标是把不同服务之间的耦合最小化,并让它们保持独立部署。这样做是因为我们想最大化团队的自主权;对于每一个微服务,都应该有一个跨功能的团队来负责。由于这对我们尤其重要,我们决定采用一种更为粗粒度的方法,围绕业务功能构建一些自包含的服务。因此,就形成了以下的微服务:
网上商店本身可能是由更多微服务构成的,例如:搜索、目录等等。因为我们专注于订单履行,我们只对网上商店中跟客户下订单相关的服务感兴趣:
这个服务将最终触发订单履行服务的启动。
我们必须考虑整个订单履行服务的一个重要特征:这是一个长周期运行的流(long running flow),其中包含了要执行的操作。关于“长周期运行”,我们指的是它可能需要运行几分钟、几小时,甚至几个星期,直到订单处理过程结束。
请考虑下面的这个例子:在支付过程中,如果发生信用卡被拒的情况,顾客有一周的时间来提供新的付款明细。这意味着订单也许要等上一个星期。这种长周期运行的行为会给实现方式带来新的需求,我们会在本文中详细讨论这种情况。
在本文中不会讨论各种通信模式的利弊,在我们的服务之间,将会采用以事件为中心的通信模式来阐述我们的主题。
事件协作(event collaboration)理念的核心是,如果微服务的内部发生了业务相关的事情,它们将会发布事件。其他的服务可能会订阅这个事件,并对其产生一定的响应,比如将相关的信息按照最适合其目的的方式来进行存储。这样,在以后的某个时间点,进行订阅的微服务就能使用该信息执行自己的服务,而无需调用其他的服务。由此,通过事件协作,服务之间的高度解耦就水到渠成了。另外,在微服务架构中,我们总是希望能够达到数据管理的去中心化,借助这种方式实现起来会更加容易和自然。
领域驱动设计(Domain Driven Design)很好地理解了这个概念,随着微服务的流行和分布式系统中的交互成为“新常态(new normal)”,这种范式的发展变得越来越迅速。
需要注意的是,事件协作可以通过异步消息的方式来实现,也可以通过其他的方式来实现。比如,微服务可以发布基于REST的feed,其中包含它们的事件,这些feed可以被其他的服务定期消费。
我们的订单履行从Order Placed事件开始。在我们的订单履行服务中,第一件必须要发生的事情就是顾客付款。支付服务成功完成之后,就会发布Payment Received事件,然后我们要从库存中发货(Goods Fetched)并交付给顾客(Goods Shipped)。这样的话,就形成了一个清晰的事件流,至少在“乐观”的场景下如此。我们可以很容易地创建一个事件链,如图1所示。
图1:在链中,每个微服务都在监听前一个服务
在支持了事件协作的基本理念之后,这些事件链提供了一个次优的方式来实现整个业务流程的端到端逻辑。我们看到了它降低耦合的崇高目标,但是这些方案可能会适得其反,反而会增加耦合。让我们深入探讨一下。
根据定义,事件会通知我们发生了相关的事实,其他服务可能会对此感兴趣。但是,接收到事件后,我们需要有个服务来应对事件,在使用事件时,我们将其视为具备命令的语义。这样的结果就是产生了不必要的更紧密的耦合。
在我们的样例中,支付服务监听结账服务的Order Placed事件。支付服务必须要知道结账的相关信息,但是如果能够不这样做的话会更好,原因如下:
因此,我们推荐采用事件命令转换(event command transformation)模式。确保负责进行业务决策(这里就是所需的支付)的组件将事件(Order Placed)转换为命令(Retrieve Payment)。这个命令可以发送给接收服务(支付),服务不需要知道客户端的情况,, 也不会产生上述的缺点。
需要注意,对于长周期运行的流,“生成命令(issuing command)”并不一定必须采用面向请求/答复(request/reply)的协议,也可以通过其他的方式来实现。微服务按照类似的方式监听异步命令消息,就像监听事件一样。另外,当事件订阅者将事件转换为内部命令时,也会发生事件命令转换。在这个过程中,会有一个参与者负责决定“接下来需要发生什么事情”,我们建议由它来进行这种转换。
但是,在我们的样例中,这个参与者是谁呢?结账服务(checkout service)应该发出Retrieve Payment命令吗?并非如此。让我们再考虑一下上面的场景变化。所有这些都表明我们需要一个单独的微服务来处理订单履行的某些端到端的逻辑。
该服务执行事件命令转换。它把Order Placed转换为Retrieve Payment。它会针对非VIP客户自主地执行该逻辑。它可能需要先咨询另一个确定客户是否为VIP的微服务,这个微服务封装了VIP客户的构成规则。与之前所描述的纯粹事件协作相比,这样的端到端服务能够在很大程度上提高解耦水平。
但是,引入端到端的服务有可能会形成一种“神一般(God-like)”的服务,这种服务涵盖了大多数的核心业务逻辑,并将功能委托给“贫血的(anemic)”的(CRUD)服务,我们该如何避免这种状况呢?这种做法会损耗掉事件协同(Choreography)的很多收益,很多的作者都不建议采用God服务,比如Sam Newman在Building Microservices中就持这样观点。另外,编排(orchestration)原则被视为实现松耦合的障碍,这是一个采用编排规则的命令服务吗?
对于看重团队职责和团队自治的组织来说,避免God服务和中心化控制器确实是一个大问题。在高度去中心化的组织中,具有端到端职责的Order服务并不意味我们就要干扰其他的团队(比如支付团队)的职责,而是恰恰相反!订单具有端到端的职责,这样“支付”就变成了一个黑盒。我们只负责要求它履行其职责(Retrieve Payment)并等待它完成:Payment Received。
考虑到前面提到的业务需求,一旦信用卡被拒,顾客有一周时间用以提供新的付款明细。我们能试着在Order服务中实现这样的逻辑,但是只有在支付服务提供的命令是非常细粒度的时候才可以这么做。如果支付团队认真对待自己的业务功能和相关的责任,那么就算这比单纯的信用卡扣款花费更多的时间,也要由他们来承担相关的职责。支付团队可以提供一些粗粒度的、可能长周期运行的功能,而不是仅仅是大量细粒度,甚至像CRUD这样的功能,从而避免出现God服务这种趋势。这种理念如下面的图2所示。
图2:端到端服务是去中心化治理的,其职责是分布式的
在一个高度去中心化的组织里,端到端的订单服务要尽可能精简,因为在其端到端的处理流程中,大部分内容都会是自治的,有其他专门的服务功能来进行管理。上面的支付服务就是一个这样的例子:支付团队需要实现支付相关的所有事情。
在讨论业务流程实现时,有一个重要的考虑因素和一个常见的误解:这并不意味着我们需要将整个流程设计为一个整体,通过一个中心化的orchestrator来执行,就像以前在SOA和BPM中所作的那样。流程的所有者和所需的流程逻辑可以是分布式的。它能实现到什么程度主要依赖于组织结构,这种组织结构也会反映到服务的设计中(参见康威定律)。如果按照这种方式的话,我们最终不会形成中心化、单体的控制器。
如果你现在觉得划分端到端的流程逻辑会增加系统的复杂度,那么你的判断也许是正确的。在第一次引入微服务架构时,也存在类似的权衡:单体方式通常更容易,但是随着系统的增长,它会达到一个极限并且也无法再由一个团队来进行处理。流逻辑面临的情况与之类似。
总结一下我们到目前为止所讨论的内容:对于微服务架构来说,协同是基本的模式。我们建议遵循这个模式作为一个重要的经验法则。但是,当涉及业务流程时,不要创建纯粹的事件链,而是实施去中心化的流逻辑,采用事件命令转换模式。负责决定行动的微服务也应该负责把事件转换为命令。
(在本文中,协同对应的英文单词为Choreography,编排对应的英文单词为Orchestration,Choreography本意是舞蹈中的编舞,而Orchestration本意是管弦乐中的编曲。根据维基百科,服务协同指的是一种服务组合的方式,多个服务参与者之间的交互协议要在全局视角来定义,“舞者按照全局的场景来跳舞,没有单点控制”,而在服务编排中,逻辑是通过单个参与者的视角来定义的,被称之为orchestrator。本质上讲,服务协同和服务编排是一个硬币的两个面。服务协同可以通过一个projection过程抽取为服务编排,而已有的服务编排可以组合为协同。关于二者的关联,可以参见该论文。——译者注)
现在,我们看一下长周期运行的流的逻辑实现。长周期运行的流需要将其状态保存起来,因为我们可能等待的时间是不确定的。状态处理并不是什么新鲜的事情,数据库就是为此而生的。所以,一种较为简单的方式就是将订单状态以实体的形式进行存储,如下面的代码片段1所示:
public class OrderStatus {
boolean paymentReceived = false;
boolean goodsFetched = false;
boolean goodsShipped = false;
}
代码片段1:简化订单状态,作为某个实体的一部分
我们还可以采用最喜欢的Actor框架,在这篇文章中,我们讨论了基本的可选方案。这些工作都有一定的作用,但是在为长期运行的行为实现状态时,我们很快就会面临额外的需求:我们该如何等待七天的时间?该如何实现错误处理和重试功能?该如何评估订单的处理周期?在什么情况下订单会因为没有支付而自动取消?如果在处理流水线上,在某个地方总是会积压订单,我们该如何更改流程?
这会导致大量的编码,最终会形成一个只能在本地使用的框架。负责相关项目的团队会抱怨他们付出的巨大努力都被埋没了。所以我们要看一种不同的方式:采用已有的框架。在本文中,我们使用来自Camunda的开源引擎阐述代码样例。现在,我们看一下代码片段2:
engine.getRepositoryService().createDeployment()
.addModelInstance(Bpmn.createExecutableProcess("order")
.startEvent()
.serviceTask().name("Retrieve payment").camundaClass(RetrievePaymentAdapter.class)
.serviceTask().name("Fetch goods").camundaClass(FetchGoodsAdapter.class)
.serviceTask().name("Ship goods").camundaClass(ShipGoodsAdapter.class)
.endEvent().camundaExecutionListenerClass("end", GoodsShippedAdapter.class)
.done()
).deploy();
代码片段2:使用代码来描述订单流程,以Java为例
引擎现在运行流实例,跟踪它们的状态并将它们以持久化的方式进行存储,从而缓解灾害或长时间等待的影响。缺失的适配器逻辑也很容易编码实现,如代码片段3所示:
public class RetrievePaymentAdapter implements JavaDelegate {
public void execute(ExecutionContext ctx) throws Exception {
// Prepare payload for the outgoing command
publishCommand("RetrievePayment", payload);
addEventSubscription("PaymentReceived", ctx);
}
}
代码片段3:额外的逻辑可以通过适配器编码实现,以Java为例
这样的引擎也能处理更复杂的需求。以下的流能够捕获信用卡付款时发生的所有错误。这个流以一种可选择的方式来运行,要求用户更新其支付细节。我们并不知道客户何时会完成这件事情,甚至不知道他们是否会这样做,我们只是等待他们传入进来的消息(从技术上来讲,很可能会来自UI或其他的微服务)。但是,我们只会等待七天,然后就自动结束这个流并发布一个Payment Failed事件,请参照代码片段4:
Bpmn.createExecutableProcess("payment")
.startEvent()
.serviceTask().id("charge").name("Charge credit card").camundaClass(ChargeCreditCardAdapter.class)
.boundaryEvent().error()
.serviceTask().name("Ask customer to update credit card").camundaClass(AskCustomerAdapter.class)
.receiveTask().id("wait").name("Wait for new credit card data").message("CreditCardUpdated")
.boundaryEvent().timerWithDuration("PT7D") // time out after 7 days
.endEvent().camundaExecutionListenerClass("end", PaymentFailedAdapter.class)
.moveToActivity("wait").connectTo("charge") // retry with new credit card data
.moveToActivity("charge")
.endEvent().camundaExecutionListenerClass("end", PaymentCompletedAdapter.class)
.done();
代码片段4:流逻辑允许在一周的时间范围内更新信用卡数据
我们将在本文后续的内容中介绍一些其他有趣的方面,比如:将这样的流进行可视化。现在,总结一下,我们可以利用这样的状态机来处理状态,并围绕着状态转换定义强大的流。
这样的状态机是一个简单的库,可以嵌入到微服务中。在本文提供的源码示例中,可以看到一个如何启动Camunda引擎的示例,它会作为使用Java所实现的微服务的一部分,它也可以通过Spring Boot或类似的框架来实现。
我们需要强调以下内容:实现长周期流的每个微服务必须解决流和状态机相关的需求。那么,每个微服务都应该采用像Camunda这样的引擎吗?这要由负责该微服务的团队来决定,但是不一定所有的团队都采用这样的决策。在微服务架构中,我们通常会发现与技术选型无关的去中心化治理。某个团队尽可以采用不同的框架,甚至可以对他们的流进行硬编码。他们还可以采用相同框架的不同版本。在我们引入工作流引擎的时候,不一定要涉及中心化的组件。我们明确倡导在微服务环境中不要采用不必要的企业架构标准。
可嵌入不一定意味着我们要自己运行引擎,在微服务多语种的情况下更是如此,因为编程语言并不一定直接适用。那么,我们可以以独立的方式来部署引擎,并与之进行远程通信。这可以通过REST来完成,但是更高效的方式也正在出现。在这里,比较重要的一点是,引擎的职责是与拥有微服务的团队联系在一起的,它并不是一个中心化的引擎(参见图3)。
图3:团队决定其流逻辑是否需要按照去中心化的方式使用并嵌入引擎
在倡导的去中心化架构中,我们有多个工作流引擎,其中的每一个只能看到整个流的一部分。这对正确的流程监控提出了新需求,这个问题目前还未得到解决。但是根据产品的不同,可能会有变通方案,你也可以在微服务的整体范围中采用已有的监控工具(例如Elastic stack)。它会引入一个人工交易id或跟踪id,我们在链中的每个服务调用时都会传递这个id。我们计划写篇博文专门讨论这个话题,因为在更复杂的微服务协作运行环境中,这是尤其重要的。
“我热爱代码,我热爱DSL,图形化UI却糟糕透顶”——在跟开发人员交谈时,我们经常会听到这样的说法。这是可以理解的,因为图形化模式通常会阻碍开发人员的工作方式,这被称之为“死于属性面板(death-by-properties-panel)”。这些模型可能还会隐藏幕后的复杂配置。但是编程人员的这种厌恶无法掩盖一个重要的事实:在运维过程中,图形化表述是极其便利的,因为我们不必深入代码以了解目前的状态或异常状况。在与业务利益相关方、需求工程师、运维人员或其他开发人员讨论模型时,我们可以采用图形化的方式。在简短的workshp会议上,当我们讨论并建模完一个流程(按照图形化的方式)后,会听到这样的评论:“我终于弄明白这么多年我们都做了些什么!”可视化也有助于对流进行变更,因为我们知道它当前是怎样实现的(请不要忘记,流是正在运行的代码),我们也可以很容易地指出哪里需要变更。
有了工作流引擎,我们就可以获得流的图形化表示。但是,我们通常会注意到这里忽略了一个很重要的方面:流不仅可以按照图形化的方式来定义,也可以按照代码或上述简单DSL的方式来定义。我们上面所给出的样例可以按照图形化的方式自动布局和监控,如下图所示。我们知道很多项目使用图形化的模式,因为这样更易于理解。如果是包含并行路径的复杂流,图形化的这一优势会更加突出,以代码的方式难以理解,但是以图形化的方式就一目了然了。图形化模式通常会直接存储为BPMN 2.0标准,但是我们了解到有些项目采用DSL编码也非常成功。
(点击图片放大)
图4:图形化的力量:从业务用户到开发人员,再到运维人员
在构建端到端监控方案时,对于我们代码所展示的图形化流,采用像bpmn.io这样的轻量级JavaScript框架就能很容易地进行可视化。我们只需要通过API从不同的引擎读取流程模型及其当前的状态,并展现所有人工交易id的运行实例。
在监控中,所显示的流粒度应该能够反映我们之前所讨论的事件协作,它们所对应的事件对域专家是有意义的。这样的话,这些流程对于项目的各种参与者都是可读的。流实际上应该被视为域逻辑的一部分,并以DDD所推动的通用语言(ubiquitous language)为中心。那么“我们到底该何时付款呢?”,这个问题就非常简单了,不管是业务用户,还是开发人员,甚至运维人员,都能很轻松地回答这个问题。
众所周知,细节是魔鬼。只要离开单个微服务的舒适区,就没有了原子性的事务,我们需要经历延迟和“最终一致性”,必须要和可能不可靠的合作者进行远程通信。于是,开发人员必须处理大量的故障,还要处理无法通过原子性事务实现的业务交易。
对于这些用例,在工作流引擎中有很多强大的工具,在引入BPMN工具后更是如此。在图5中,我们给出了一个例子,这次利用了图形化的格式。我们捕获到商品缺货的错误,并触发一个所谓的补偿机制(compensation)。引擎的补偿机制知道过去成功执行了哪些操作,然后执行预先定义好的补偿活动,在本例中,也就是退款Refund payment。我们可以采用这个功能,它很好地实现了所谓的Saga模式。
(点击图片放大)
图5:在出现商品缺货时,已支付的款项会进行退款
请注意,逻辑仍然存在于服务(可能非常精简)中,也就是Order Service中,整个流的其他部分将由对其负责的团队维护。无需任何中心化控制器,流逻辑是分布式的。
在现有的工具中,能够为长时间运行的服务提供流逻辑功能的工具通常被称为工作流或BPM引擎。但是,在“过去的SOA时代”,围绕业务流程管理(Business Process Management,简称BPM)出现过一些错误,尤其是在开发人员中留下了坏名声。他们认为得到的是一个僵化的、单体的、对开发人员不友好且昂贵的工具,这个工具强制他们遵循一些模型驱动的、私有的、零编码的方式。有些BPM厂商所提供的平台的确在微服务领域中无法使用。但是,需要指出的是,有些轻量级的开源引擎提供了易于使用的、嵌入式的状态机,就像前面看到的那样。我们可以使用这些工具来处理流,而不必要重新发明轮子,这能够节省你的时间,我们都知道时间是非常昂贵的。
消除误解的一个重要方面是认真对待术语。我们在这里展示的流不一定是“业务流程”,如果你“只是”想要一组互相协作的微服务来形成业务交易,这些流可能也不是“工作流”,因为它通常会涉及到人类的一些手动工作。这就是为什么我们常常只谈“流”,这对于不同的用例和不同的利益相关者都适用。
在这里展示的用例不仅仅是纯粹的理论。为了使概念具体化和易于解释,我们开发了由多个微服务组成的订单履行示例系统,源码可以在GitHub上找到。
微服务和事件驱动架构配合得很好。事件协同能够实现去中心化的数据管理,这通常会减少耦合,能够很好地支持本文所关注的长周期“后台”运行的进程。
支持长周期业务流程的大多数端到端的流逻辑应该分布于整个微服务中。 每个微服务根据自身的业务功能,实现它所负责那部分流功能。我们建议将事件转换为命令,这种转换需要在负责业务决策的服务中进行,在这种服务中需要一定的输入,进而做出决策。负责剩余端到端逻辑的服务要尽可能精简,但是按照我们的想法最好有一个事件链,而不是多个不透明且紧耦合的事件链。
为了实现流程,我们可以利用现有的轻量级状态机和工作流引擎。这些引擎能够嵌入微服务中,避免了中心化的工具或治理。我们可以把它们视为帮助开发人员的库。作为额外的奖励,我们还能得到流的图形化展示,这种展现形式在整个项目中都会给我们提供帮助。也许在你所在的公司里,需要克服某些关于工作流或BPM的常见误解,但是请相信我们,这是很值得的。
Bernd Rücker帮助过很多客户依照长周期流实现业务逻辑,例如,帮助快速成长的初创企业Zalando创建订单履行流程,实现在世界范围内出售服装,还在一些大型的通信企业中实现SIM卡的供应流程。在这个过程中,他为各种开源项目做出了贡献,还写了两本书。他也是Camunda的联合创始人。目前,他在思考如何在下一代架构中实施流。
Martin Schimak研究长周期流已经有15年之久,涉及能源交易、风洞(wind tunnel)组织以及电信企业的合同管理等不同领域。作为一个程序员,他比较热衷可读的API和可测试的规范,并且在GitHub上做了很多的贡献。在领域设计方面,因为其优秀的工作被誉为“解码器(decoder)”,他是领域驱动设计以及BPMN、DMN、CMMN的领军人物。他还是德语软件杂志OBJEKTspektrum的联合主编。
查看英文原文:Know the Flow! Microservices and Event Choreographies