在单体应用中,一个组件调用其它组组件时,是通过语言级的方法或者函数调用,而一个基于微服务的应用是运行于多个服务器上的分布式系统,每个服务实例是一个典型的进程。所以,如下图显示的,服务必须通过内部进程交互机制(IPC)进行交互。
在为一个服务选择IPC的时候,首先考虑一下这些服务是如何交互的是很有用处的。有多种client/server的交互风格,它们可以通过两个维度分类,第一种维度是交互是一对一,还是一对多的:
下表显示出各种交互风格:
有如下几种一对一的交互形式:
请求/响应:客户端发送一个请求给一个服务,并且等待响应结果,客户端期望结果能快速的返回,在一个基于线程的应用中,发送请求的线程甚至可能在等待的时候被阻塞。
有如下几种一对多的交互形式:
每个服务一般都使用这几种交互风格的组合风格。对于一些服务来说,单一的IPC机制就足够了,而其它的服务可能需要组合使用若干种IPC机制。下面的图给出当客户请求行程时,在一个打车应用可能出现的一些交互。
这些服务使用了通知,请求/响应,发布/订阅的交互方式。比如说,一个乘客的智能手机向行程管理服务发送了一个上车请求,行程管理服务通过请求/响应方式向乘客服务确认乘客的账户是否是活跃账户,行程管理服务于是创建一个行程订单,并且用发布/订阅方式通知其它的服务,包括一个分发服务,用以定位空闲的司机。
服务的API是服务与它所有的客户端之间的一种契约,不管选用何种IPC机制,使用一些接口定义语言(IDL),对于精确定义服务API是很重要的,甚至已经有一些关于使用API-first Approach来定义服务的好的讨论。
开发一个微服务从书写接口定义以及与客户端开发人员一起review这些接口定义开始,在不断的对这些API定义进行迭代,最终才算是实现了一个微服务。这种基于前端的设计方式,增加了构建出符合客户端需求的机会。
在文章的后面你会看到,API定义的特性依赖与你使用的IPC机制,如果你使用消息机制,API就会涉及到消息通道和消息类型;如果你选用HTTP方式,那么API就会包含一些URL和请求/响应的格式,之后我们会详细的介绍IDL。
一个服务的API会随着时间而经常变化。在单体应用中,通常是很直接的修改API,再更新所有的调用之处,但在基于微服务的应用中,情况要困难得多,甚至你API的所有消费者是同一个应用中的其它服务。你通常不能强迫所有的客户端步调一致的升级它们的服务。而且你可能会大量的开发服务的新版本,于是新旧版本的服务会同时运行,制定一个处理这种问题的战略原则显得很重要。
如何处理一个API的变化,取决于这种变化的多少。有的变化很少,可以向后兼容之前的版本,比如,你可能只是在请求或者响应格式中增加一些属性。设计出具有鲁棒原则的客户端和服务是有意义的,那些使用更旧的API的客户端应该能够继续和新版本的服务工作得很好,服务会给请求中没有的属性提供默认值,客户端会忽略那些响应中额外的属性。使用IPC机制和消息格式是重要的,让你能轻易的演进API。
有时候,你不得不对API做一些主要的、不兼容的改动。既然不能强制客户端立刻升级,那这个服务必须能够支持旧版本的API一定时期。如果你用的是基于HTTP的机制,如REST,一个好的办法是在API的URL中嵌入版本号。每个服务实例应该可以同时处理不同版本的API请求,或者是部署不同的服务实例来处理不同的API版本。
在之前关于API网关的文章中曾经提到,在分布式系统中,总会存在部分失败的风险,既然客户端和服务是分开的进程,一个服务可能不能对一个客户端请求及时的返回结果,服务也可能因为错误或者是维护停止了,亦或是因为过载而对请求响应缓慢。
比如说,如上篇文章中提到的那个产品详页的场景,试想一下如果那个推荐服务失去响应了,客户端的一个本地实现就可能在无限的等待响应中被阻塞了,这不仅会带来劣质的用户体验,而且在很多应用中,这会消耗宝贵的资源,如一个线程,最终运行时环境会线程耗尽,变成无法响应,正如下图所示。
为了避免这种问题,把你的服务设计成能处理部分失败是很有必要的。
Netfix给我们提出了一个可以遵循的好办法,其中处理部分失败的原则包括:
Netfix Hystrix是这些模式的一种开源实现,如果你正在使用JVM,你肯定会考虑使用Hystrix的,如果你运行的是一个非JVM的环境,同样需要考虑使用一个类似的库。
有许多IPC技术可供选择,如同步的请求/响应机制,这里面有基于HTTP方式的REST和Thrift,另外有基于消息的异步通信机制,如AMQP和STOMP。其中消息的格式也是多种多样的,有一些是人可读的,比如JSON和XML,有些是二进制格式的(这种更高效),如Avro和缓存协议。稍后我们介绍同步的IPC机制,但在这之前,先讨论异步的IPC机制。
异步(基于消息的通信)
当使用消息时,进程间通过异步的交换消息来通信。客户端通过向服务发送消息来发送请求,如果期望服务返回应答,那么它发送回一个独立的消息给客户端。由于通信是异步的,客户端不会阻塞在等待返回结果上,客户端应该是基于不会立刻收到返回结果的假设来实现。
消息包含消息头(如发送者这样的元数据)和消息体,各种消息在通道上交换,任意数量的生产者都能往通道上发送消息,同样,任意数量的消费者也能从这个通道接收消息。有两种类型的通道:点对点通道和发布/订阅通道。点对点的通道只给连接到这个通道上的众多消费者中的一个发送消息,服务使用这种通道往往是采用前面提到的一对一的交互风格。发布/订阅这种通道,是给连接到它之上的所有消费者发送消息,这种通道往往被一对多风格的服务采用。
下图描述的是,在打车应用中,发布/订阅的通道是如何使用的
行程管理服务向发布/订阅通道发送一个行程创建的消息,以此告诉那些对此感兴趣的服务(比如说分发器服务),一个新行程创建了。分发器服务找到一个可用的司机,将一个需要提名司机的消息写入发布/订阅通道,这样其它的服务就能得到这个通知。
有许多消息系统可供选择,你应该选择那些能支持多种开发语言的。一些消息系统支持AMQP和STOMP这些标准协议,其它的系统是一些专有而且文档化的协议。现在有不少开源的消息系统,其中包括RabbitMQ,Apache Kafka,Apache ActiveMQ和NSQ。总体上看,他们都支持消息格式和通道,都是可靠的、高性能的和可扩展的,但它们在消息模型细节方面有着巨大的差异。
使用消息有诸多优点:
当然,消息机制也有缺点:
现在我们已经讨论完了基于消息的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成熟度模型,包含如下一些级别:
使用基于HTTP的协议的好处有:
使用HTTP也有缺点:
开发者社区最近发现了接口定义语言对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。
一个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机制,异步的消息机制和同步的请求/响应机制。在这一系列文章的下一篇文章中,我们会研究在微服务架构中的服务发现问题。
英文原文:https://www.Nginx.com/blog/building-microservices-inter-process-communication/
原文:https://mp.weixin.qq.com/s/0DxXxtMEWKnxZVg_xcbwvA
作者:朱晓厮的博客
来源:微信公众号