这是我们关于使用微服务架构构建应用程序系列的第三篇文章。 第一篇文章 介绍了 微服务架构模式 ,将其与 单体架构模式 进行了比较,并讨论了使用微服务的优缺点。 第二篇文章 描述了应用程序的客户端如何通过称为 API 网关的 中介与微服务通信。在本文中,我们将了解系统中的服务如何相互通信。
介绍
在单体应用程序中,组件通过语言级方法或函数调用相互调用。相反,基于微服务的应用程序是在多台机器上运行的分布式系统。每个服务实例通常是一个进程。因此,如下图所示,服务必须使用进程间通信 (IPC) 机制进行交互。
稍后我们将研究具体的 IPC 技术,但首先让我们探讨各种设计问题。
互动风格
在为服务选择 IPC 机制时,首先考虑服务如何交互是很有用的。有多种客户端⇔服务交互方式。它们可以按两个维度进行分类。第一个维度是交互是一对一还是一对多:
- 一对一——每个客户端请求都由一个服务实例处理。
- 一对多——每个请求由多个服务实例处理。
第二个维度是交互是同步还是异步:
- 同步——客户端期望服务及时响应,甚至可能在等待时阻塞。
- 异步——客户端在等待响应时不会阻塞,并且响应(如果有)不一定会立即发送。
下表显示了各种交互方式。
一对一 | 一对多 | |
同步 | 请求/响应 | — |
异步 | 通知 | 发布/订阅 |
请求/异步响应 | 发布/异步响应 |
有以下几种一对一交互:
- 请求/响应——客户端向服务发出请求并等待响应。客户希望响应及时到达。在基于线程的应用程序中,发出请求的线程甚至可能在等待时阻塞。
- 通知(也称为单向请求)——客户端向服务发送请求,但不会收到或发送任何回复。
- 请求/异步响应——客户端向服务发送请求,服务异步回复。客户端在等待时不会阻塞,并且在设计时假设响应可能暂时不会到达。
有以下几种一对多交互:
- 发布/订阅——客户端发布一条通知消息,该消息由零个或多个感兴趣的服务使用。
- 发布/异步响应——客户端发布请求消息,然后等待一定时间以等待感兴趣服务的响应。
每个服务通常使用这些交互方式的组合。对于某些服务,单个 IPC 机制就足够了。其他服务可能需要使用 IPC 机制的组合。下图显示了当用户请求出行时,出租车叫车应用程序中的服务可能如何交互。
这些服务结合使用了通知、请求/响应和发布/订阅。例如,乘客的智能手机向旅行管理服务发送通知以请求接载。旅行管理服务通过使用请求/响应调用乘客服务来验证乘客的帐户是否有效。 Trip Management 服务然后创建行程并使用发布/订阅来通知其他服务,包括 Dispatcher,它可以找到可用的司机。
现在我们已经了解了交互方式,让我们来看看如何定义 API。
定义 API
服务的 API 是服务与其客户端之间的契约。无论您选择哪种 IPC 机制,使用某种接口定义语言 (IDL) 精确定义服务的 API 都很重要。甚至有很好的论据支持使用 API 优先的方法 来定义服务。您通过编写接口定义并与客户端开发人员一起审查它来开始服务的开发。只有在迭代 API 定义之后,您才能实现该服务。预先进行此设计可以增加构建满足客户需求的服务的机会。
正如您将在本文后面看到的那样,API 定义的性质取决于您使用的 IPC 机制。如果您使用消息传递,则 API 由消息通道和消息类型组成。如果您使用的是 HTTP,则 API 由 URL 以及请求和响应格式组成。稍后我们将更详细地描述一些 IDL。
不断发展的 API
服务的 API 总是会随时间发生变化。在单体应用程序中,更改 API 和更新所有调用者通常很简单。在基于微服务的应用程序中,这要困难得多,即使您的 API 的所有使用者都是同一应用程序中的其他服务。您通常不能强制所有客户端与服务同步升级。此外,您可能会 逐步部署服务的新版本 ,以便服务的旧版本和新版本同时运行。制定处理这些问题的策略很重要。
您如何处理 API 更改取决于更改的大小。一些更改很小,并且与以前的版本向后兼容。例如,您可以向请求或响应添加属性。设计客户端和服务以使其遵守 稳健性原则 是有意义的。使用旧 API 的客户端应继续使用新版本的服务。该服务为缺少的请求属性提供默认值,客户端忽略任何额外的响应属性。重要的是使用 IPC 机制和消息传递格式,使您能够轻松地改进 API。
然而,有时您必须对 API 进行重大的、不兼容的更改。由于您不能强制客户端立即升级,因此服务必须在一段时间内支持旧版本的 API。如果您使用的是基于 HTTP 的机制(如 REST),一种方法是将版本号嵌入 URL 中。每个服务实例可能同时处理多个版本。或者,您可以部署不同的实例,每个实例处理一个特定的版本。
处理部分故障
正如 上一篇关于 API 网关的文章 所述,在分布式系统中,部分故障的风险始终存在。由于客户端和服务是独立的进程,因此服务可能无法及时响应客户端的请求。服务可能因故障或维护而关闭。或者服务可能过载并且对请求的响应极其缓慢。
例如,考虑该文章中的 产品详细信息场景 。假设推荐服务没有响应。一个天真的客户端实现可能会无限期地等待响应。这不仅会导致糟糕的用户体验,而且在许多应用程序中,它还会消耗宝贵的资源,例如线程。最终,运行时将用完线程并变得无响应,如下图所示。
为防止出现此问题,必须将服务设计为处理部分故障。
Netflix 描述的 方法是一种很好的遵循方法。处理部分故障的策略包括:
- 网络超时——永远不要无限期地阻塞,在等待响应时总是使用超时。使用超时确保资源永远不会被无限期地占用。
- 限制未完成请求的数量——对客户端可以使用特定服务的未完成请求数量施加上限。如果已达到限制,则发出额外请求可能毫无意义,并且这些尝试需要立即失败。
- 断路器模式 ——跟踪成功和失败请求的数量。如果错误率超过配置的阈值,则使断路器跳闸,以便进一步尝试立即失败。如果大量请求失败,则表明该服务不可用并且发送请求毫无意义。超时后,客户端应重试,如果成功,则关闭断路器。
- 提供回退——请求失败时执行回退逻辑。例如,返回缓存数据或默认值(如空推荐集)。
Netflix Hystrix 是一个实现这些和其他模式的开源库。如果您正在使用 JVM,您绝对应该考虑使用 Hystrix。而且,如果您在非 JVM 环境中运行,您应该使用等效的库。
工控技术
有许多不同的 IPC 技术可供选择。服务可以使用基于同步请求/响应的通信机制,例如基于 HTTP 的 REST 或 Thrift。或者,他们可以使用异步的、基于消息的通信机制,例如 AMQP 或 STOMP。还有各种不同的消息格式。服务可以使用人类可读的、基于文本的格式,例如 JSON 或 XML。或者,他们可以使用二进制格式(效率更高),例如 Avro 或 Protocol Buffers。稍后我们将研究同步 IPC 机制,但首先让我们讨论异步 IPC 机制。
异步、基于消息的通信
使用消息传递时,进程通过异步交换消息进行通信。客户端通过向服务发送消息来向服务发出请求。如果服务需要回复,它会通过向客户端发送一条单独的消息来回复。由于通信是异步的,客户端不会阻塞等待回复。相反,客户端是假设不会立即收到回复的。
消息 由标题(元数据,如发件人)和消息正文组成。消息通过 通道 交换。任意数量的生产者都可以向一个频道发送消息。同样,任意数量的消费者都可以从一个通道接收消息。有两种渠道, 点对点 和 发布订阅 。点对点通道将消息准确地传递给正在从通道中阅读的消费者之一。服务使用点对点渠道实现前面描述的一对一交互方式。发布-订阅通道将每条消息传递给所有关联的消费者。服务使用发布-订阅渠道实现上述一对多交互方式。
下图显示了出租车叫车应用程序如何使用发布-订阅渠道。
Trip Management 服务通过将 Trip Created 消息写入发布-订阅通道来通知感兴趣的服务(例如 Dispatcher)有新的 Trip。 Dispatcher 找到可用的驱动程序并通过将驱动程序建议消息写入发布-订阅通道来通知其他服务。
有许多消息系统可供选择。您应该选择一种支持多种编程语言的语言。一些消息系统支持标准协议,例如 AMQP 和 STOMP。其他消息传递系统具有专有但已记录的协议。有大量的开源消息系统可供选择,包括 RabbitMQ 、 Apache Kafka 、 Apache ActiveMQ 和 NSQ 。在高层次上,它们都支持某种形式的消息和渠道。他们都努力做到可靠、高性能和可扩展。但是,每个代理的消息传递模型的细节存在显着差异。
使用消息传递有很多优点:
- 将客户端与服务分离——客户端只需将消息发送到适当的通道即可发出请求。客户端完全不知道服务实例。它不需要使用发现机制来确定服务实例的位置。
- 消息缓冲——使用同步请求/响应协议(如 HTTP),客户端和服务必须在交换期间可用。相反,消息代理将写入通道的消息排队,直到它们可以被消费者处理。这意味着,例如,即使订单履行系统速度缓慢或不可用,在线商店也可以接受客户的订单。订单消息只是排队。
- 灵活的客户端服务交互——消息传递支持前面描述的所有交互方式。
- 显式进程间通信——基于 RPC 的机制试图使调用远程服务看起来与调用本地服务相同。然而,由于物理定律和局部失效的可能性,它们实际上是完全不同的。消息传递使这些差异非常明确,因此开发人员不会陷入一种错误的安全感中。
但是,使用消息传递也有一些缺点:
- 额外的操作复杂性——消息传递系统是另一个必须安装、配置和操作的系统组件。消息代理必须具有高可用性,否则系统可靠性会受到影响。
- 实现基于请求/响应的交互的复杂性——请求/响应式交互需要一些工作来实现。每个请求消息必须包含一个回复通道标识符和一个关联标识符。该服务将包含关联 ID 的响应消息写入回复通道。客户端使用关联 ID 将响应与请求相匹配。使用直接支持请求/响应的 IPC 机制通常更容易。
现在我们已经了解了使用基于消息传递的 IPC,让我们检查一下基于请求/响应的 IPC。
同步、请求/响应 IPC
当使用同步的、基于请求/响应的 IPC 机制时,客户端向服务发送请求。该服务处理请求并发回响应。在许多客户端中,发出请求的线程在等待响应时会阻塞。其他客户端可能使用异步的、事件驱动的客户端代码,这些代码可能由 Futures 或 Rx Observables 封装。但是,与使用消息传递时不同,客户端假定响应将及时到达。有许多协议可供选择。两种流行的协议是 REST 和 Thrift。我们先来看看 REST。
休息
如今,以
RESTful
风格开发 API 很流行。 REST 是一种(几乎总是)使用 HTTP 的 IPC 机制。 REST 中的一个关键概念是资源,它通常代表一个业务对象,例如客户或产品,或业务对象的集合。 REST 使用 HTTP 动词来操作资源,这些资源是使用 URL 引用的。例如,
GET
请求返回资源的表示形式,它可能采用 XML 文档或 JSON 对象的形式。
POST
请求创建新资源,
PUT
请求更新资源。引用 REST 的创建者 Roy Fielding 的话:
“REST 提供了一组架构约束,当作为一个整体应用时,它们强调组件交互的可扩展性、接口的通用性、组件的独立部署和中间组件,以减少交互延迟、加强安全性和封装遗留系统。”
—Fielding、 架构风格和基于网络的软件架构设计
下图显示了出租车叫车应用程序可能使用 REST 的方式之一。
乘客的智能手机通过向行程管理服务的
/trips
资源发出
POST
请求来请求行程。该服务通过向 Passenger Management 服务发送有关乘客信息的
GET
请求来处理请求。在验证乘客有权创建行程后,行程管理服务会创建行程并向智能手机返回
201
响应。
许多开发人员声称他们基于 HTTP 的 API 是 RESTful 的。然而,正如 Fielding 在这篇 博文 中所描述的那样,实际上并非所有这些都是。 Leonard Richardson(无关系) 为 REST 定义了一个非常有用的成熟度模型 ,它包含以下级别。
-
级别 0 – 级别 0 API 的客户端通过向其唯一的 URL 端点发出 HTTP
POST
请求来调用服务。每个请求指定要执行的操作、操作的目标(例如业务对象)和任何参数。 -
级别 1 – 级别 1 API 支持资源的概念。要对资源执行操作,客户端发出
POST
请求,指定要执行的操作和任何参数。 -
级别 2 – 级别 2 API 使用 HTTP 动词来执行操作:
GET
检索,POST
创建,PUT
更新。请求查询参数和正文(如果有)指定操作的参数。这使服务能够利用 Web 基础设施,例如GET
请求的缓存。 -
级别 3 – 级别 3 API 的设计基于名副其实的 HATEOAS(超文本作为应用程序状态引擎)原则。基本思想是
GET
请求返回的资源表示包含用于对该资源执行允许操作的链接。例如,客户端可以使用 Order 表示中的链接取消订单,该链接是响应为检索订单而发送的GET
请求而返回的。 HATEOAS 的好处 包括不再需要将 URL 硬连接到客户端代码中。另一个好处是,因为资源的表示包含允许的操作的链接,所以客户端不必猜测在当前状态下可以对资源执行什么操作。
使用基于 HTTP 的协议有很多好处:
- HTTP 简单而熟悉。
-
您可以使用扩展程序(例如
Postman)
从浏览器中测试 HTTP API,或者使用
curl
(假设使用 JSON 或其他文本格式)从命令行测试 HTTP API。 - 它直接支持请求/响应式通信。
- HTTP 当然是防火墙友好的。
- 它不需要中间代理,从而简化了系统的架构。
使用 HTTP 有一些缺点:
- 它只直接支持交互的请求/响应风格。您可以使用 HTTP 进行通知,但服务器必须始终发送 HTTP 响应。
- 因为客户端和服务直接通信(没有中介来缓冲消息),所以它们必须在交换期间都在运行。
- 客户端必须知道每个服务实例的位置(即 URL)。正如 上一篇关于 API 网关的文章 所述,这在现代应用程序中是一个非常重要的问题。客户端必须使用服务发现机制来定位服务实例。
开发人员社区最近重新发现了用于 RESTful API 的接口定义语言的价值。有几个选项,包括 RAML 和 Swagger 。一些 IDL(例如 Swagger)允许您定义请求和响应消息的格式。 RAML 等其他规范要求您使用单独的规范,例如 JSON Schema 。除了描述 API 之外,IDL 通常还具有从接口定义生成客户端存根和服务器框架的工具。
节约
Apache Thrift 是 REST 的有趣替代品。它是一个用于编写跨语言 RPC 客户端和服务器的框架。 Thrift 提供了一个 C 风格的 IDL 来定义你的 API。您使用 Thrift 编译器生成客户端存根和服务器端框架。编译器为各种语言生成代码,包括 C++、Java、Python、PHP、Ruby、Erlang 和 Node.js。
Thrift 接口由一个或多个服务组成。服务定义类似于 Java 接口。它是强类型方法的集合。 Thrift 方法可以返回一个(可能为空的)值,也可以将它们定义为单向的。返回值的方法实现了交互的请求/响应样式。客户端等待响应并可能抛出异常。单向方法对应于交互的通知样式。服务器不发送响应。
Thrift 支持多种消息格式:JSON、二进制和压缩二进制。二进制比 JSON 更有效,因为它的解码速度更快。而且,顾名思义,紧凑二进制是一种节省空间的格式。 JSON 当然是人类和浏览器友好的。 Thrift 还为您提供了多种传输协议选择,包括原始 TCP 和 HTTP。原始 TCP 可能比 HTTP 更有效。然而,HTTP 是防火墙、浏览器和人类友好的。
消息格式
现在我们已经了解了 HTTP 和 Thrift,让我们检查一下消息格式的问题。如果您使用的是消息系统或 REST,则可以选择消息格式。 Thrift 等其他 IPC 机制可能只支持少量消息格式,也许只支持一种。无论哪种情况,使用跨语言消息格式都很重要。即使您今天使用一种语言编写微服务,您将来也可能会使用其他语言。
有两种主要的消息格式:文本和二进制。基于文本的格式示例包括 JSON 和 XML。这些格式的一个优点是它们不仅是人类可读的,而且是自我描述的。在 JSON 中,对象的属性由名称-值对的集合表示。同样,在 XML 中,属性由命名元素和值表示。这使消息的消费者能够挑选出它感兴趣的值并忽略其余值。因此,对消息格式的微小更改可以很容易地向后兼容。
XML 文档的结构由 XML 模式 指定。随着时间的推移,开发者社区开始意识到 JSON 也需要类似的机制。一种选择是单独使用 JSON Schema 或作为 IDL(例如 Swagger)的一部分。
使用基于文本的消息格式的缺点是消息往往很冗长,尤其是 XML。因为消息是自描述的,所以每条消息都包含属性的名称以及它们的值。另一个缺点是解析文本的开销。因此,您可能要考虑使用二进制格式。
有多种二进制格式可供选择。如果您使用的是 Thrift RPC,则可以使用二进制 Thrift。如果您要选择消息格式,流行的选项包括 Protocol Buffers 和 Apache Avro 。这两种格式都提供了类型化的 IDL 来定义消息的结构。然而,一个区别是 Protocol Buffers 使用标记字段,而 Avro 消费者需要知道模式才能解释消息。因此,使用 Protocol Buffers 比使用 Avro 更容易进行 API 演化。这篇 博文 很好地比较了 Thrift、Protocol Buffers 和 Avro。
概括
微服务必须使用进程间通信机制进行通信。在设计服务如何通信时,需要考虑各种问题:服务如何交互、如何为每个服务指定 API、如何演化 API 以及如何处理部分故障。微服务可以使用两种 IPC 机制,异步消息传递和同步请求/响应。在本系列的下一篇文章中,我们将研究微服务架构中的服务发现问题。
编者注: 这个由 7 部分组成的系列中的前几篇文章可通过以下方式获得:
客座博主 Chris Richardson 是最初 CloudFoundry.com 的创始人,它是 Amazon EC2 的早期 Java PaaS(平台即服务)。他现在为组织提供咨询,以改进他们开发和部署应用程序的方式。他还定期在 http://microservices.io 上发布有关微服务的博客。