8. 实现基于事件的异步协作方式
前面讲了一些与请求/响应模式相关的技术。那么基于事件的异步通信呢?
8.1 技术选择
主要有两个部分需要考虑:微服务发布事件机制和消费者接收事件机制。
方法一:使用消息代理
传统上来说,像RabbitMQ这样的消息代理能够处理上述两个方面的问题。生产者(producer)使用API向代理发布事件,代理也可以向消费者提供订阅服务,并且在时间发生时通知消费者。
不过需要注意的是,消息代理仅仅是中间件世界中的一个小部分而是。有一个原则需要谨记:尽量让中间件保持简单,而把业务逻辑放在自己的服务中。
方法二:使用HTTP来传播事件
ATOM是一个符合REST规范的协议,可以通过它提供资源聚合(feed)的发布服务,而且有很多现成的客户端可以用来消费该聚合。
如果你已经有了一个好的,具有弹性的消息代理的话,就用它来处理事件的订阅和发布吧。
8.2 异步架构的复杂性
事件驱动的系统看起来耦合度非常低,而且伸缩性很好。但是这种编程风格也会带来一定的复杂性,这种复杂性并不仅仅包括对消息的发布订阅操作。
当我们的系统因为代码中bug,工作者崩溃时,我们需要有灾难故障转移(catastrophic failover)。
而且,我们最好设置一个作业最大重试次数。但是我们还需要有一种方式来查看甚至重发这些有问题的消息。所以最后实现了一个消息医院(或者叫死信队列),所有失败的消息都会被发送到这里。我们还可以创建一个界面来显示这些消息,如果需要的话还可以触发一个重试。
事件驱动架构和异步编程会带来一定的复杂性,所以通常需要谨慎的选用。你需要确保各个流程有很好的监控机制,并考虑使用关联ID,这种机制可以帮助你对跨进程的请求进行跟踪。
9.服务即状态
前面已经提到过,服务应该根据限界上下文进行划分。我们的客户端微服务应该拥有与这个上下文中行为相关的所有逻辑。
把关键领域的生命周期显示建模出来非常有用。我们不但可以在唯一的一个地方处理状态冲突(比如,尝试更新已经被移除的用户),而且可以在这些状态变化的基础上封装一些行为。
10.响应式扩展
响应式扩展(Reactive extensions, Rx)提供了一种机制,在此之上,你可以把多个调用结果组装起来并在此基础上执行操作。
很多Rx实现都在分布式系统中找到了归宿。因为调用的细节被屏蔽了,所以事情也更容易处理。我们可以简单的对下游服务调用的结果进行观察,而不需要关心它是阻塞的还是非阻塞的,唯一需要做的就是等待结果并作出响应。其漂亮之处在于,我们可以把多个不同的调用组合起来,这样就可以更容易对下游服务的并发调用做处理。
11.微服务世界中的DRY和代码重用的危险
DRY, Don’t Repeat Yourself。
使用DRY可以得到重用性比较好的代码。把重复代码抽取出来,然后就可以在多个地方进行调用。比如说可以创建一个随处可用的共享库。但是这个方法在微服务的架构中可能是危险的。
我们想要避免微服务和消费者之间的过度耦合,否则对微服务任何小的改动都会引起消费方的改动。而共享代码就有可能导致这种耦合。
跨服务共用代码很有可能会引入耦合。但是使用像日志库这样的公共代码就没什么问题,因为它们对外是不可见的。
推荐的做法:在微服务内部不要违反DRY,但在跨服务的情况下可以适当违反DRY。服务之间引入大量的耦合会比重复代码带来更糟糕的问题。
客户端库
很多团队坚持在最开始的时候为服务开发一个客户端库。原因在于,这样不仅能简化对服务的使用,还能避免不同消费者之间存在重复的与服务交互的代码。
但是如果开发服务端API和客户端API的是同一批人,那么服务端的逻辑很有可能泄露到客户端中。潜入客户端库的逻辑越多,内聚性就越差,然后你必须在修复一个服务端问题的同时,也需对多个客户端进行修改。
如果你想要使用客户端库,一定要保证其中只包含处理底层传输协议的代码,比如服务发现和故障处理等。千万不要把与目标服务相关的逻辑放到客户端库中。想清楚你是否要坚持使用客户端库,或者你是否允许别人使用不同的技术栈来对底层API进行调用。最后,确保由客户端来负责何时进行客户端库的升级,这样才能保证每个服务可以独立于其他服务进行发布。
12.按引用访问
如何传递领域实体的相关信息是一个值得讨论的话题。
很重要的一个想法,微服务应该包含核心领域实体(比如客户)全生命周期的相关操作。在这种设计下,如果想要做任何与客户相关的改动,就必须向客户服务发起请求。它遵守了一个原则,即客户服务应该是关于客户信息的唯一可靠来源。
想象这样一个场景,你从客户服务获取了一个客户资源,那么就能看到该资源在你发起请求那一刻的样子。但是有可能在你发送了请求之后,其他人对该资源进行了修改,所以你所持有的其实是该客户资源曾经的样子。你持有这个资源的时间越久,其内容失效的可能性就越高。当然,避免不必要的数据请求可以让系统更高效。
有时候使用本地副本没什么问题,但在其他场景下你需要知道该副本是否已经失效。所以,当你持有一个本地副本时,请确保同时持有一个指向原始资源的引用,这样在你需要的时候就可以对本地副本进行更新。
当然在使用引用时也需要做一些取舍。如果总是从客户服务去查询给定客户的相关信息,那么客户服务的负载就会过大。如果在获取资源的同时,可以得到资源的有效性时限(即该资源再什么时间之前是有效的)信息的话,就可以进行相应的缓存,从而减小服务的负载。
另一个问题是,有些服务可能不需要知道整个客户资源,所以坚持进行查询这种方式会引入潜在的耦合。
原则上讲,应该在不确定数据是否能保持有效的情况下,谨慎的进行处理。
13.版本管理
13.1 尽可能推迟破坏性修改
首先,减小破坏性修改影响的最好办法就是尽量不要做这样的修改。比如在很多的集成技术中,你可以通过选择正确的技术来做到这一点。比如数据库集成很容易引入破坏性的修改;而REST就好很多,因为内部的修改不太容易引起外部服务接口的变化。
另一个延迟破坏性修改的关键是鼓励客户端的正确行为,避免过早的将客户端和服务端紧密的绑定起来。
客户端尽可能灵活的消费服务响应这一点符合Postel法则(也叫做鲁棒性原则),该法则认为,系统中的每个模块都应该“宽进严出”,即对自己发送的东西要严格,对接收的东西则要宽容。
13.2 及早发现破坏性修改
这里强烈建议使用消费者驱动的契约来及早定位这些问题。
如果你支持多种不同的客户端库,那么最好针对最新的服务对所有的客户端运行测试。一旦发现,你可能会对某个消费者造成破坏,那么可以选择要么尽量避免破坏性修改,要么接受它,并跟维护这些服务的人员好好聊一聊。
13.3 使用语义化的版本管理
如果一个客户端能够仅仅通过查看服务的版本号,就知道它能否与之进行集成,那就太好了。
语义化版本管理就是一种能够支持这种方式的规格说明。
语义化版本管理的每一个版本号都遵循这样的格式:MAJOR.MINOR.PATCH。其中MAJOR的改变意味着其中包含向后不兼容的修改;MINOR的改变意味着有新功能的增加,但应该是向后兼容的;最后,PATCH的改变代表对已有功能的缺陷修复。
13.4 不同的接口共存
我们可以在同一个服务上使新接口和老接口同时存在。所以,在发布一个破坏性修改时,可以部署一个同时包含新老接口的版本。
这可以帮助我们尽快发布新版本的微服务,其中包含了新的接口,同时也给了消费者时间做迁移。一旦消费者不再访问老的接口,就可以删除掉该接口及相关的代码。
这其实就是一个扩展/收缩的实例,它允许我们对破坏性修改进行平滑的过度。首先扩张服务的能力,对新老两种服务都进行支持。然后等到老的消费者都采用了新的方式,再通过收缩API去掉就的功能。
13.5 同时使用多个版本的服务
另一种经常被提起的版本管理的方法是,同时运行不同版本的服务,然后把老用户路由到老版本的服务,而新用户可以看到新版本的服务。
14.用户界面
最重要的其实是,考虑该界面是否能够很好的支持服务之间的集成。毕竟用户界面是连接各个微服务的工具,而只有把各个微服务集成起来才能真正的为客户创造价值。
14.1 走向数字化
我们不应该对网页端和移动端区别对待,相反应该对数字化策略做全局考虑,即如何让客户更好的使用我们的服务。
通过把服务的功能进行不同的组合,可以为桌面应用程序、移动端设备、可穿戴设备的客户提供不同的体验。
14.2 约束
在用户与系统之间,需要考虑不同的交互形式中存在的一些约束。比如在桌面Web应用中,需要考虑与用户浏览器及屏幕解析度相关的约束。
14.3 API组合
假设我们的服务彼此之间已经通过XML或者JSON通信了,那么可以让用户界面直接与这个API进行交互。
图4-7:使用多个API来表示用户界面
这种方式有一些问题。首先,很难为不同的设备定制不同的响应。另一个问题是,谁来创建用户界面?维护服务的人往往不是服务的使用者。
使用API入口(gateway)可以很好的缓解这一问题,在这种模式下多个底层的调用会被聚合成为一个调用,当然它也有一定的局限性。
14.4 UI片段的组合
相比UI主动访问所有的API,然后再将状态同步到UI控件,另一种选择是让服务直接暴露出一部分UI,然后只需要简单的把这些片段组合在一起就可以创建出整体UI。
14.5 为前端服务的后端
对与前端交互比较频繁的界面及需要给不同设备提供不同内容的界面来说,一个常见的解决方案是,使用服务端的聚合接口或API入口。该入口可以对多个后端调用进行编排,并为不同的设备提供定制化的内容。
图4-9 使用单块入口来处理与UI之间的交互
这样做会得到一个聚合所有服务的巨大层。由于所有的东西都被放在了一起,也就失去了不同用户界面之间的隔离性,从而限制了独立于彼此进行发布的能力。
还有一种模式,保证一个后端服务只为一个应用或者用户界面服务。
这种模式有时也叫做BFF(Backends For Frontends,为前端服务的后端)。
API认证和授权层可以处在BFF和UI之间。
与任何一种聚合层类似,使用这种方法的风险在于包含不该包含的逻辑。业务逻辑应该处在服务中,而不应该泄露到这一层。这些BFF应该仅仅包含与实现某种特定的用户体验相关的逻辑。
14.6 一种混合方式
前面提到的那些选择各自都有其使用的范围。一个组织会选择基于片段组装的方式来构建网站,但对于移动端应用来说,BFF可能是更好的方式。关键是要保持底层服务能力的内聚性。
15. 小结
前面了解了很多不同的集成选择,也谈了什么样的选择能够最大程度的保证微服务之间的低耦合:
- 无论如何,避免数据库集成
- 理解REST和RPC之间的取舍,但总是使用REST作为请求/响应模式的起点
- 相比编排,优先选择协同
- 避免破坏性修改、理解Postel法则、使用容错性读取器
- 将用户界面视为一个组合层