在单体应用中,一个组件调用其它组组件时,是通过语言级的方法或者函数调用,而一个基于微服务的应用是运行于多个服务器上的分布式系统,每个服务实例是一个典型的进程。所以,如下图显示的,服务必须通过内部进程交互机制(IPC)进行交互。
交互风格
在为一个服务选择IPC的时候,首先考虑一下这些服务是如何交互的是很有用处的。有多种client/server的交互风格,它们可以通过两个维度分类,第一种维度是交互是一对一,还是一对多的:
- 一对一:每个客户端的请求只被一个服务实例处理
- 一对多:每个客户端请求被多个服务实例处理
- 第二种维度是交互是同步的还是异步的:
- 同步:客户端期望从服务得到及时的返回,并且甚至可以因此阻塞片刻
- 异步:客户端不会在等待返回结果的时候阻塞,返回结果也没必要立刻被发送出来
下表显示出各种交互风格:
一对一 | 一对多 | |
---|---|---|
同步 | 请求/响应 | |
异步 | 通知 | 发布/订阅 |
请求/异步响应 | 发布/异步响应 |
有如下几种一对一的交互形式:
请求/响应:客户端发送一个请求给一个服务,并且等待响应结果,客户端期望结果能快速的返回,在一个基于线程的应用中,发送请求的线程甚至可能在等待的时候被阻塞。
- 通知(一种单向请求):客户端发送一个请求到服务,但不期望有响应发送回来。
- 请求/异步响应:客户端往服务发送请求,响应结果异步的返回。客户端不会在等待的时候阻塞,而且客户端是基于响应在一段时间之后才返回的假设来设计的。
有如下几种一对多的交互形式:
- 发布/订阅:客户端发布消息,消息被零或者多个感兴趣的服务消费
- 发布/异步响应:客户端发布一个请求消息,等待固定的一段时间,以获得从感兴趣的服务返回的响应结果
每个服务一般都使用这几种交互风格的组合风格。对于一些服务来说,单一的IPC机制就足够了,而其它的服务可能需要组合使用若干种IPC机制。下面的图给出当客户请求行程时,在一个打车应用可能出现的一些交互。
这些服务使用了通知,请求/响应,发布/订阅的交互方式。比如说,一个乘客的智能手机向行程管理服务发送了一个上车请求,行程管理服务通过请求/响应方式向乘客服务确认乘客的账户是否是活跃账户,行程管理服务于是创建一个行程订单,并且用发布/订阅方式通知其它的服务,包括一个分发服务,用以定位空闲的司机。
定义API
服务的API是服务与它所有的客户端之间的一种契约,不管选用何种IPC机制,使用一些接口定义语言(IDL),对于精确定义服务API是很重要的,甚至已经有一些关于使用API-first approach来定义服务的好的讨论。
开发一个微服务从书写接口定义以及与客户端开发人员一起review这些接口定义开始,在不断的对这些API定义进行迭代,最终才算是实现了一个微服务。这种基于前端的设计方式,增加了构建出符合客户端需求的机会。
在文章的后面你会看到,API定义的特性依赖与你使用的IPC机制,如果你使用消息机制,API就会涉及到消息通道和消息类型;如果你选用HTTP方式,那么API就会包含一些URL和请求/响应的格式,之后我们会详细的介绍IDL。
API的演进
一个服务的API会随着时间而经常变化。在单体应用中,通常是很直接的修改API,再更新所有的调用之处,但在基于微服务的应用中,情况要困难得多,甚至你API的所有消费者是同一个应用中的其它服务。你通常不能强迫所有的客户端步调一致的升级它们的服务。而且你可能会大量的开发服务的新版本,于是新旧版本的服务会同时运行,制定一个处理这种问题的战略原则显得很重要。
如何处理一个API的变化,取决于这种变化的多少。有的变化很少,可以向后兼容之前的版本,比如,你可能只是在请求或者响应格式中增加一些属性。设计出具有鲁棒原则的客户端和服务是有意义的,那些使用更旧的API的客户端应该能够继续和新版本的服务工作得很好,服务会给请求中没有的属性提供默认值,客户端会忽略那些响应中额外的属性。使用IPC机制和消息格式是重要的,让你能轻易的演进API。
有时候,你不得不对API做一些主要的、不兼容的改动。既然不能强制客户端立刻升级,那这个服务必须能够支持旧版本的API一定时期。如果你用的是基于HTTP的机制,如REST,一个好的办法是在API的URL中嵌入版本号。每个服务实例应该可以同时处理不同版本的API请求,或者是部署不同的服务实例来处理不同的API版本。
处理部分失败
在之前关于API网关的文章中曾经提到,在分布式系统中,总会存在部分失败的风险,既然客户端和服务是分开的进程,一个服务可能不能对一个客户端请求及时的返回结果,服务也可能因为错误或者是维护停止了,亦或是因为过载而对请求响应缓慢。
在公众号后端架构师后台回复“架构整洁”,获取一份惊喜礼包。
比如说,如上篇文章中提到的那个产品详页的场景,试想一下如果那个推荐服务失去响应了,客户端的一个本地实现就可能在无限的等待响应中被阻塞了,这不仅会带来劣质的用户体验,而且在很多应用中,这会消耗宝贵的资源,如一个线程,最终运行时环境会线程耗尽,变成无法响应,正如下图所示。
为了避免这种问题,把你的服务设计成能处理部分失败是很有必要的。
Netfix给我们提出了一个可以遵循的好办法,其中处理部分失败的原则包括:
- 网络超时:永远不要无限的阻塞,总是在等待响应中使用超时,使用超时来确保资源不会被无限绑定。
- 限制未解决的请求数量:对一个客户端持有的对一个服务没有完成的请求,应该设定上限值,这个上限一旦达到,发送更多的请求就会是无意义的,而且这些新的请求需要立刻返回为失败。
- 回路中断器模式:跟踪成功请求和失败请求的数量,如果错误率超过了一个事先配置的阈值就开启回路中断器,让进一步的尝试立刻失败。如果大量的请求正处在失败中,那就预示服务不可用,而且发送请求也是无意义的。经过超时周期之后,客户端应该再进行尝试发送请求,如果请求成功,就关闭回路中短器。
- 提供回滚机制:一个请求失败时,执行回滚逻辑,比如说返回缓存的数据或者是默认值,也或者诸如一个关于推荐商品的空集合。
Netfix Hystrix是这些模式的一种开源实现,如果你正在使用JVM,你肯定会考虑使用Hystrix的,如果你运行的是一个非JVM的环境,同样需要考虑使用一个类似的库。
IPC技术
有许多IPC技术可供选择,如同步的请求/响应机制,这里面有基于HTTP方式的REST和Thrift,另外有基于消息的异步通信机制,如AMQP和STOMP。其中消息的格式也是多种多样的,有一些是人可读的,比如JSON和XML,有些是二进制格式的(这种更高效),如Avro和缓存协议。稍后我们介绍同步的IPC机制,但在这之前,先讨论异步的IPC机制。
异步(基于消息的通信)
当使用消息时,进程间通过异步的交换消息来通信。客户端通过向服务发送消息来发送请求,如果期望服务返回应答,那么它发送回一个独立的消息给客户端。由于通信是异步的,客户端不会阻塞在等待返回结果上,客户端应该是基于不会立刻收到返回结果的假设来实现。
消息包含消息头(如发送者这样的元数据)和消息体,各种消息在通道上交换,任意数量的生产者都能往通道上发送消息,同样,任意数量的消费者也能从这个通道接收消息。有两种类型的通道:点对点通道和发布/订阅通道。点对点的通道只给连接到这个通道上的众多消费者中的一个发送消息,服务使用这种通道往往是采用前面提到的一对一的交互风格。发布/订阅这种通道,是给连接到它之上的所有消费者发送消息,这种通道往往被一对多风格的服务采用。
下图描述的是,在打车应用中,发布/订阅的通道是如何使用的
行程管理服务向发布/订阅通道发送一个行程创建的消息,以此告诉那些对此感兴趣的服务(比如说分发器服务),一个新行程创建了。分发器服务找到一个可用的司机,将一个需要提名司机的消息写入发布/订阅通道,这样其它的服务就能得到这个通知。
有许多消息系统可供选择,你应该选择那些能支持多种开发语言的。一些消息系统支持AMQP和STOMP这些标准协议,其它的系统是一些专有而且文档化的协议。现在有不少开源的消息系统,其中包括RabbitMQ,Apache Kafka,Apache ActiveMQ和NSQ。总体上看,他们都支持消息格式和通道,都是可靠的、高性能的和可扩展的,但它们在消息模型细节方面有着巨大的差异。
使用消息有诸多优点:
- 把客户端从服务中解耦出来:客户端只需要简单的往正确的通道里发消息,它完全不用感知服务实例,它不需要通过发现机制来定位服务实例所在的位置。
- 消息缓冲:在使用HTTP这种同步的请求/响应协议时,客户端和服务都必须在交换数据的时候保持可用。与此相反,消息代理会将写到通道里面的消息队列化,直到消费者能够处理这些消息。这意味着,比如,对订单的消息进行简单的队列化之后,即使是订单填写系统响应缓慢或者不可用,一个在线商店仍然可以接收到来自客户的订单。
- 灵活的客户——服务交互:消息机制支持之前提到的所有交互风格。
- 显式的进程间通信:基于RPC的机制能够让调用远端的服务看起来如同调用本地服务,但由于存在物理规则和部分失败的可能,这些机制都有较大不同。消息机制让这些不同之处变得很显式,这样程序员不用陷于安全失误当中。
当然,消息机制也有缺点:
- 额外的操作复杂性:消息系统是另外一个系统,必须安装,配置和操作,消息代理必须高可用,要不然整个个系统的可靠性将受到影响。
- 实现基于请求/响应的交互比较复杂:请求/响应风格的交互要求一些实现上的工作,每个请求消息必须包含一个应答通道ID和关联ID,服务将相关ID包含在响应的消息中,并发送到响应通道,客户端就通过这个相关ID来将响应和请求匹配起来。使用IPC机制来直接支持请求/响应通常简单一些。
现在我们已经讨论完了基于消息的IPC,接下来探讨一下基于请求/响应的IPC
同步的请求/响应IPC
在同步的、基于请求/响应的IPC机制中,客户端向服务发送一个请求,服务处理这个请求,并将响应发回。在许多客户端的实现中,发送请求的线程会在等待响应的时候阻塞。
而另一些客户端的实现,可能使用异步的、事件驱动的方式,请求相关的代码会被封装在Futrues或者Rx Observables这样的库中。和前面介绍的消息机制不同,在这种IPC里客户端是假设响应会及时返回。有很多协议可供选择,其中有两种很流行:REST和Thrift。我们先来看看REST
REST
目前,使用RESTful风格来开发API是很流行的做法,REST是使用HTTP的IPC机制,REST的一个关键概念是资源,资源代表一个业务对象,比如说一个客户,一个产品,或者是一些业务对象的集合。REST使用HTTP的方法来操作资源,通过URL来引用资源。比如,GET请求会返回一个资源的信息,返回结果用XML文档或者JSON对象来表示,POST请求创建一个资源,PUT请求是更新一个资源。REST的创建者Roy Fielding的描述如下:
“REST提供一个架构约束的集合,当被整体应用时,强调组件交互的扩展性、接口的普遍性,组件的独立部署,减少交互延时的中间组件,增强的安全性以及对遗留系统的封装。”
下图展示了打车应用中使用REST的一个场景。
乘客的智能手机向行程管理服务发送创建行程的请求,这个时候一个POST请求发送到服务端,请求创建一个/trips资源,行程管理服务随后发送一个GET请求到乘客管理服务,来获取乘客的信息,在确认了这个乘客是一个授权过可以创建行程的用户之后,行程管理服务正式的创建出行程,并且返回一个201结果给智能手机。
很多开发者都声称他们的HTTP API都是RESTful的,但如Fielding在他的这篇博客里描述的,其实他们不一定都是。Leonard Richardson给出了一个很有用的REST成熟度模型,包含如下一些级别:
- 级别0:客户端通过发送基于HTTP的POST请求到唯一的URL服务端,每个请求指定要执行的动作,动作的对象(比如业务对象),以及其它任何参数。
- 级别1:支持资源的概念,为了在一个资源上执行动作,客户端需要在POST请求中指定执行的动作和所有的参数。
- 级别2:API使用HTTP的动词来执行动作:GET用来获取,POST用来创建,PUT用来修改。请求要求参数和请求体,如果有,还需要指定动作的参数,这样服务就可以利用页面系统的一些基础设施,如缓存GET请求。
- 级别3:这个级别的API是基于HATEOAS(超文本应用状态引擎)原则的,基本思想是在GET请求返回的代表资源的响应中,需要包含一些链接,这些链接对应与可对这个资源执行的动作。举个例子,订单的GET请求的返回结果中会包含操作的链接,其中有取消订单的操作链接,客户端可以从结果中找到这个链接,使用它取消订单。
- HATEOAS的优势在于不再需要将URL硬编码到客户端的代码里面去了,另一个好处是由于资源的返回结果中已经包含允许的操作的链接,客户端不用去猜测当前状态下能对资源做哪些操作了。
使用基于HTTP的协议的好处有:
- HTTP对与大家来说简单而熟悉。
- 可以用一些有Postman这种插件的浏览器来测试API,也可以用curl这种命令行工具来测试(返回结果是用JSON或者其它类型的文本格式)
- 直接支持请求/响应风格的通信
- HTTP是防火墙友好的
- 不需要有中间代理,这让系统的架构得到简化
使用HTTP也有缺点:
- 只支持请求/响应的交互风格,这使得在使用HTTP来发送通知的时候,服务端必须总是发送HTTP响应回来。
- 因为客户端和服务端直接通信(中间没有缓冲消息),他们在交换信息期间必须同时处于运行状态。
- 客户端必须知道每个服务实例的地址(比如URL),正如在上一篇文章中描述的,在现代应用中,这倒不是个重要的问题,一般客户端都需要使用服务发现机制来定位服务实例的位置。
开发者社区最近发现了接口定义语言对RESTful API的新价值,这方面有一些选择,包括RAML和Swagger。一些诸如Swagger的IDL允许定义出请求和响应消息的格式,其它一些诸如RAML的IDL则要求使用独立的规范,如JSON schema。在描述API的同时,IDL一般也有工具来给接口定义生成客户端桩和服务端骨架。
Thrift
Apache Thrift是REST的一种有趣的替代方案,它是开发跨语言RPC客户端和服务端的框架,Thrift提供C语言风格的IDL来定义你的API,使用Thrift编译器生成客户桩和服务骨架,编译器能够生成各种语言的代码,包括C ,Java,Python,PHP,Ruby,Erlang和Node.js。
在公众号Linux中文社区后台回复“命令行”,获取一份惊喜礼包。
一个Thrift接口包含一个或多个服务,定义服务与定义Java接口类似,是一些强输入方法的集合,Thrift方法可以定义城返回一个值(也可能是void的),或者定义成单向方法。返回一个值的方法都会实现请求/响应的交互风格。客户端等待请求,并且有可能抛出异常。单向方法其实是符合通知风格的交互,服务端不会发送响应。
Thrift支持多钟消息格式:JSON,二进制,紧凑的二进制。二进制格式通常比JSON更高效一些,因为解析它更快。对于紧凑二进制格式,如它的名字一样,它是节省空间的消息。而JSON,当然是对人和浏览器友好的一种格式。在Thrift中,也可以自己选择传输协议,其中包括原始TCP和HTTP。TCP一般比HTTP更高效一些,当然,HTTP是对防火墙、浏览器和人友好的。
消息格式
前面已经讨论过HTTP和Thrift,现在介绍消息格式的问题。如果使用消息系统或者REST,需要确定消息格式。其它一些如Thrift这种IPC机制只支持有限的集中消息格式,或许就一种而已。在任何一种情况中,使用跨语言的消息格式是很重要的。甚至你现在只是用一种语言来实现你的微服务,很可能你将来会使用其它的语言。
有两种主要的消息格式:文本和二进制码。基于文本的格式有JSON,XML这些。它们的优点在于是人可读的,而且是自描述的。在JSON中,对象的属性被表示成名称-值对的集合。类似的,在XML中,属性被表示成名字元素和值。这可以让消息消费者能够找到感兴趣的值,同时忽略其它的。而且,对格式的小量改动可以容易的兼顾到后向兼容性。
XML文档的结构是在XML schema文件中定义的,渐渐的社区的开发者意识到JSON也需要类似的机制,其中一个解决办法是使用JSON schema,以独立方式存在或者是如Swagger这种IDL的一部分。
基于消息的格式的一个缺点是比较繁琐,尤其是XML。因为消息是自描述的,除了包含属性的值之外,消息里还包含属性的名称。另外一个劣势是,解析消息文本需要开销。基于这些,你可能更想使用二进制码格式
有几种二进制格式可供选择。当用Thrift RPC,你可以选择二进制的Thrift。如果使用消息格式,比较流行的选择是Protocol Buffers和Apache Avro。这两种格式都提供输入的IDL来定义消息结构。不同之处在于,Protocol Buffers使用标签域,而Avro,它的消费者在翻译消息前,需要提前知道消息的schema。这篇博客完美解释了Thrift, Protocol Buffers和Avro的异同之处。
总结
微服务必须使用一种进程间通信机制,当设计你的服务如何通信时,需要考虑各种问题:服务如何交互,如何为每个服务设计API,如何演进API,以及如何处理部分失败问题。有两种微服务可用的IPC机制,异步的消息机制和同步的请求/响应机制。在这一系列文章的下一篇文章中,我们会研究在微服务架构中的服务发现问题。