构建可复用的微服务框架

异步社区官方博客

本章主要内容

在组织全面拥抱微服务以后,随着组织内的团队规模越来越大,其中的每一个团队都很可能开始专注于一组特定的编程语言和工具。有时候,即便使用同样的编程语言,不同团队也会在实现同一个目标时选择不同的工具组合。尽管这并没有错,但是这会导致开发者换到其他团队的难度增大。创建新服务的习惯以及代码结构会有很大差异。即使这些团队最终能够采用各种不同方式解决这个问题,我们仍然相信潜在的重复性要好于多出来一个信息沟通和同步的环节。

严格规定团队能够使用的工具和编程语言,并强制不同团队采用同一套标准方式来创建服务,会损害团队的工作效率和创新能力,最终导致所有问题都采用同样的工具。幸运的是,我们可以让团队在为服务自由选择编程语言的同时遵循一些通用的实践方案。我们可以针对所使用的每种语言封装一套工具集,同时确保工程师能够访问那些能够方便各个团队遵守实践的资源。如果团队A决定使用Elixir来创建通知管理服务,而团队B决定使用Python来开发一个图像分析服务,他们应该都有对应的工具来让这两个服务可以向通用的度量指标收集基础服务发送度量指标数据。

开发者应该以相同的格式将日志集中保存到同一个地方,像断路器、功能标志的功能以及共用相同的事件总线的能力也应该是现成可用的。这样,团队不仅可以做出选择,还可以使用这些工具来与运行其服务的基础设施保持一致。这些工具就组成了服务底座。我们可以在服务底座的基础之上构建新的服务,而不需要做太多的前期调查和准备工作。接下来,我们思考一下如何为服务构建一套底座——将普遍关注的内容和架构选型抽象化,同时还能够加快团队启动新服务的速度。

7.1 微服务底座

假设某组织有8个不同的工程师团队,每个团队有4名成员。这几个团队中各有一名工程师分别负责用Python、Java和C#来启动一个新的服务。这些语言就像大部分主流语言那样,有很多可供选择的类库。从http客户端到日志类库,可选择的太多了。两个选择相同语言的团队最终采用相同类库组合的概率是多少呢?我觉得非常小!这个问题并不仅存在于微服务应用;在我曾经参与过的一个单体应用中,几位不同的程序员使用了3种不同的http客户端类库!

从图7.1~图7.3中,读者可以看到在挑选新项目所使用的组件时团队所面对的众多选择。

c07_01{65%}

图7.1 .NET生态系统中对象关系映射(ORM)类库的查询结果

正如图7.1~图7.3所示的那样,这并不是一个简单的选择!不管我们使用哪种语言,都有很多选项,所以挑选组件所花费的时间会越来越长,同时还要冒着选出来的类库不够理想的风险。一个组织很有可能根据它们所需要解决的问题来使用两到三种语言作为主要语言。因此,使用同一种语言团队是会同时存在的。一旦某个团队在某些类库上获得了一些经验,为什么不将这些经验造福于其他团队呢?大家可以提供一套已经应用于生产环境的类库和工具集合,这样,开发者在开发新服务时就可以直接从这些类库和工具中进行选择,而无须通过深入了解和挖掘每个类库的功能和特点来权衡它们的优缺点。

c07_02{70%}

图7.2 java生态系统中高级消息队列协议(AMQP)类库的查询结果

c07_03{70%}

图7.3 Python中断路器类库的查询结果

为了简化团队创建服务的工作,花些时间来为组织内构建和维护服务所使用的每种语言提供一套基本框架和经过审查的工具是很值得的。开发者同样应该确保这个框架遵循了可观测性标准以及对基础设施相关代码的抽象。同样,这个框架应该能够反映对服务通信方式的架构选型,比如,如果组织倾向于服务间采用异步通信,那么开发者就要提供使用现有的事件总线基础设施所必需的类库。

我们不仅能够以温和的方式遵循某些实践,还可以简化快速生成新服务的工作,并支持快速构建原型。毕竟,初始化创建一个服务的时间超过编写业务逻辑代码的时间是不合理的。

底座框架能够让团队选择一个技术栈(语言+类库)并快速搭建起一个服务。读者可以问一下自己:如果没有这样一个所谓的底座,初始化创建一个服务的难度有多大呢?如果我们不考虑下列这些问题的话,可以很容易。

(1)从一开始就支持在容器调度器中部署服务(CI/CD)。

(2)支持日志聚合。

(3)收集度量指标数据。

(4)具备同步和异步通信机制。

(5)错误报告。

在SimpleBank公司,不管团队使用哪种编程语言或者技术栈,每个服务都应该提供上面列出的功能。要实现这种类型的配置并不是一件容易的事,而且取决于选择的技术栈,可能需要花上不止一天的时间才能搭建完成。同样,两个团队针对同一目标所选择的类库组合可能也会有很大不同。读者可以通过微服务底座来消除这种差异化问题,这样每个团队就可以专注于交付SimpleBank公司的客户所使用的功能。

7.2 微服务底座的目的

微服务底座的目的是简化服务的创建过程,同时确保开发者拥有一套所有服务都要遵循的标准,而不管哪个团队负责相应的服务。我们看看采用微服务底座的一些优点。

(1)让新加入的团队成员更容易上手。

(2)不管团队使用哪种技术栈,都能很好地理解代码结构和关注内容。

(3)随着团队共同的知识越来越丰富,生产环境的试验范围得到控制,即便这些知识并不总是采用相同的技术栈。

(4)有助于遵循最佳实践。

拥有可预测的代码结构和普遍使用的类库能够让团队成员更加容易和快速理解服务的实现。他们只需要操心业务逻辑实现就可以了,因为其他代码在所有服务上都是非常相似的。比如,这些通用代码包括对如下功能的处理和配置。

(1)日志。

(2)配置获取。

(3)度量指标收集。

(4)数据存储设置。

(5)健康检查。

(6)服务注册和发现。

(7)与所选择的传输协议相关的样例代码(AMQP、HTTP)。

如果在有人创建新服务时,这些代码已经考虑了这些问题的话,那么编写样例代码的需要就减少或者消除了,开发者就不大需要重复“造轮子”了,组织内的优秀实践也更加容易推行和实施。

从知识共享的角度来讲,微服务底座还会降低不同团队的成员之间代码评审的难度。如果他们采用了同一套底座,就会对代码结构比较熟悉,也了解系统是如何执行的。这就可以提升系统的关注度,并能够从其他团队的工程师那里收集他们的想法和意见。多一个角度来看待一个专业团队要解决的问题是有意义的。

7.2.1 降低风险

提供微服务底座能够降低开发者所面对的风险,这是因为微服务底座能够降低开发者所选择的语言和类库组合并不适用于特定需求的可能性。设想一下,我们正在创建的一个服务需要完全通过已有的事件总线来和其他服务进行异步通信。如果我们的服务底座已经包含了这种使用场景,就不大可能出现服务搭建完成以后开发者还需要反复调整最终却不可用的情况。开发者可以把这种异步通信以及同步通信的用例包含到底座中,这样就不再需要花费更多精力来寻找一套可行的解决方案。

底座可以通过不断地包含进不同团队的新发现、新成果来持续演进,这样开发者就可以始终和组织内部的实践和经验保持同步更新,以应对不同的使用场景。总而言之就是,团队要面对其他所有团队之前都没有解决过的难题的可能性就会降低。万一真的有一类问题还没有哪个团队解决过,那么也只需要一个团队来解决就可以,然后开发者就可以将对应的解决方案添加到服务底座中,降低其他团队未来所要承担的风险。

微服务底座已经挑选了一系列类库供开发者使用,这会减少工程师团队要处理的依赖管理的工作。回顾图7.1~图7.3,如果开发者已经分别为ORM、AMQP以及断路器选定了一个类库,那么这些类库最终肯定会为所有的团队所周知,如果有人发现这些类库存在漏洞,那么开发者就能够轻松地更新相应的类库。

7.2.2 快速启动

花一到两天的时间来启动一个服务是不合理的,尤其是在可以省下大量时间去实现业务逻辑的情况下。此外,将必需的组件连接起来组成一套服务是一项重复性的工作,并且很容易出错。为什么要让工程师们在每次创建新服务的时候都重走老路,然后一遍遍地搭建各种组件呢?持续地应用、维护和更新微服务底座,能够保证这些设置都是经过验证的、可靠的和可复用的。这也能够加快服务的启动速度。然后,开发者就可以用节省下来的时间来开发、测试和部署相应的功能。

团队内部有一套广泛使用且熟知的坚实基础,能够让开发者在不用过多担心初始工作的情况下进行更多的试验。如果开发者可以快速将一个想法转换成可部署的服务,就可以很容易地对想法进行验证并决定是继续推进还是完全放弃。核心理念就是要快、要让创建新功能的工作尽可能地容易。服务底座同样还能够显著降低团队新成员的进入门槛,因为对于新成员来说,一旦了解了每种语言下所有服务共同的结构,他们就可以很快地从一个项目换到另一个项目中。

7.3 设计服务底座

在 SimpleBank公司,负责实现股票买卖功能的团队决定为大团队创建一套共用的服务底座——他们遇到过许许多多的难题,现在想把他们的宝贵经验分享给大家。我们在第2章介绍过出售股票的功能(图2.7)。为了更好地理解这个过程,我们看一下它的流程图(图7.4)。

c07_04{80%}

图7.4 出售股票的流程涉及内部服务之间的同步和异步通信

为了出售股票,用户通过网页或者移动端应用发起一个请求。API网关接收到这个请求后,会作为一个用户侧应用和所有内部服务之间的中介,这些内部服务会相互协作来提供相应的功能。

考虑到将订单发布到股票交易中心(stock exchange)是需要一些时间的,所以大部分操作都是异步的,开发者会向客户端返回一条消息来表示他们的请求会被尽快处理。我们观察一下服务之间的交互以及通信类型。

(1)网关将用户的请求传给order服务。

(2)order服务会向事件队列发送一条OrderCreated事件消息。

(3)order服务请求account transaction服务预留股票仓位。

(4)order服务响应网关最初的调用,然后网关会告诉用户订单正在处理中。

(5)market服务消费OrderCreated事件消息,将订单发布到股票交易中心。

(6)market服务向事件队列发送一条OrderPlaced事件消息。

(7)fee服务和order服务消费OrderPlaced事件,它们会分别对该出售操作收取手续费和将订单状态更新为“placed”。

对于这个功能,有4个内部服务相互协作、一个外部实体(股票交易中心)调用,它们之间的通信既有同步的又有异步的。通过使用消息队列可以让其他系统对一些变化做出反应,比如,负责向顾客发送电子邮件或者实时通知的服务可以很容易地消费OrderPlaced事件,这样它就可以发送通知告知用户订单发布成功了。

假设负责这个功能的团队习惯使用Python,他们用nameko框架创建了一个最初的原型。这个框架提供了一些开箱即用的功能:AMQP、RPC和事件(发布-订阅);HTTP GET、POST和Websocket;用于简化开发过程提高开发效率的命令行工具;用于单元测试和集成测试的实用工具类。

但是它也少了一些东西,如断路器、错误报告、功能标记以及度量指标发送,所以这个团队决定创建一个代码库来保存负责这些事情的类库。他们还创建了一个Dockerfile和Docker compose文件,这样既可以让大家可以以最小的代价来构建和运行这个功能,还为其他团队提供了一个在以Python语言开发服务时可以应用的基础。最初的Python服务底座以及所描述的功能代码可以在本书的配套资源中找到。

我们现在进一步了解下如何构建一套可以处理服务发现、可观测性、传输、负载均衡和限流的服务底座。

7.3.1 服务发现

在实现前面描述的功能的过程中,我们构建了相应的Python底座,其中的服务发现功能非常简单。各个服务之间的通信要么通过RPC调用同步来完成,要么通过发布事件异步来实现。SimpleBank公司使用RabbitMQ 作为消息代理,所以它间接地提供了一种同时适用于同步通信和异步通信两种场景的服务注册方式。RabbitMQ支持基于队列实现同步的请求/响应形式的RPC通信,并且还会默认使用轮询算法来实现消费方的负载均衡。这样,开发者就可以使用基础的消息服务来实现服务注册的功能并自动将负载分发到同一个服务的不同实例上。各个服务所连接的RPC交换器(exchange)如图7.5所示。

所有运行中的服务会把自己注册到exchange中。这样它们就可以无缝地进行通信了,而不需要相互明确知道对方服务位于何处。这也就是通过AMQP协议进行RPC通信的方式,这样开发者就可以像使用HTTP那样相同的请求/响应行为。

我们来看看通过使用底座提供的功能来实现这个功能有多容易。在本例中,我们使用的是nameko框架,如代码清单7.1所示。

c07_05{70%}

图7.5 服务通过exchange中的RPC注册中心相互通信。同一服务的
不同实例使用同一个路由键(routing key),RabbitMQ将到达的请求路由到这些实例

代码清单7.1 microservices-in-action/chapter-7/chassis/rpc_demo.py

from nameko.rpc import rpc, RpcProxy

class RpcResponderDemoService:
    name = "rpc_responder_demo_service"    ⇽---  为服务名称分配一个变量——这是特定服务注册所使用的名称,以供其他人调用 

    @rpc    ⇽---  允许nameko设置必要的RabbitMQ队列用以执行请求/响应类型的调用——rpc调用将同步执行 
    def hello(self, name):
        return "Hello, {}!".format(name)

class RpcCallerDemoService:
    name = "rpc_caller_demo_service"

    remote = RpcProxy("rpc_responder_demo_service")    ⇽---  为要通过RPC被调用的服务创建一个RPC代理——我们会将远程服务的名称传给这个代理      

    @rpc
    def remote_hello(self, value="John Doe"):
        res = u"{}".format(value)
        return self.remote.hello(res)    ⇽---  通过RpcProxy对象调用远程服务——它会执行RpcResponderDemoService类中的hello函数

在本例中,我们定义了两个类:响应方responder和调用方caller。在这两个类中,我们分别定义了一个name变量来标识对应的服务。我们使用@rpc注解来装饰一个函数。这个装饰器就会将一个看起来很普通的函数转换成一个可以利用底层的AMQP基础设施(RabbitMQ提供)来调用运行在其他地方的服务的方法的东西。调用类RpcCallerDemoService中的remote_hello方法会触发RpcResponderDemoService类调用hello函数,因为这个RpcResponderDemoService服务已经通过框架提供的RpcProxy类被注册为remote对象了。

运行这些代码后,RabbitMQ就会展示出图7.6所示的内容。在图7.6中,读者可以观察到,在启动了rpc_demo.py所定义的两个服务后,这两个服务会分别注册到以服务名称命名的队列中:rpc-rpc_caller_demo_servicerpc-rpc_responder_demo_service。此外还出现了另两个队列rpc.reply-rpc_caller_demo_service*rpc.reply-standalone_ rpc_proxy*。它们负责将响应结果传送会调用方服务。这是一种在RabbitMQ中实现阻塞式同步通信的方法。

c07_06{75%}

图7.6 在RabbitMQ队列中注册的调用方和响应方服务

底座使得实现这一功能变得超级简单,所以我们可以使用相同的基础设施来用于实现服务间的同步和异步通信。这种方式能让我们在开发解决方案原型时的速度得到巨大提升,因为团队可以把这部分时间投入到新的功能开发中,而非从零开始搭建所有功能。无论我们是选择服务间阻塞调用的编配式功能,抑或完全异步通信的编排式功能,还是两者兼有的功能,我们都可以使用同一套基础设施和类库。

代码清单7.2展示了利用服务底座的功能来实现完整的异步服务通信的方法。

代码清单7.2 microservices-in-action/chapter-7/chassis/events_demo.py

from nameko.events import EventDispatcher, event_handler
from nameko.rpc import rpc
from nameko.timer import timer

class EventPublisherService:
    name = "publisher_service"     ⇽---  注册服务名称供其他服务引用 

    dispatch = EventDispatcher()    ⇽---  提供该服务创建事件消息并发送到RabbitMQ的某个队列中 

    @rpc
    def publish(self, event_type, payload):
        self.dispatch(event_type, payload)

class AnEventListenerService:


    name = "an_event_listener_service"    ⇽---  注册服务名称供其他服务引用 

    @event_handler("publisher_service", "an_event")    ⇽---  通过使用该注解,发布方服务发送事件消息后,ListenBothEventsService就会执行该函数,该注解的第一个参数是所要监听的事件所属的服务名称,第二个参数是事件的名称
    def consume_an_event(self, payload):
        print("service {} received:".format(self.name), payload)

class AnotherEventListenerService:
    name = "another_event_listener_service"

    @event_handler("publisher_service", "another_event")
    def consume_another_event(self, payload):
        print("service {} received:".format(self.name), payload)

class ListenBothEventsService:
    name = "listen_both_events_service"    ⇽---    

    @event_handler("publisher_service", "an_event")    ⇽---  
    def consume_an_event(self, payload):
        print("service {} received:".format(self.name), payload)

    @event_handler("publisher_service", "another_event")    ⇽---  
    def consume_another_event(self, payload):
        print("service {} received:".format(self.name), payload)

正如前面的代码示例所示,Python的class实现的这两个服务会分别声明了一个name变量供框架用来创建服务通信所需要的底层队列。当运行本文件中的几个class所定义服务时,RabbitMQ会创建 4 个队列,每个队列对应一个服务。正如在图7.7看到的那样,发布服务会注册一个RPC队列。和之前图7.6所展示的例子相比,少了reply队列。其他的监听服务为每种要消费的事件注册一个队列。

c07_07{75%}

图7.7 在运行events_demo.py文件定义的服务时,RabbitMQ所创建的队列

团队之所以选择nameko作为微服务底座的一部分,是因为它可以简化在现有消息代理上实现和设置同步和异步两种通信类型的细节。在7.3.3节中,我们还将研究nameko的另一个开箱即用的优势,因为消息代理还实现了负载平衡。

7.3.2 可观测性

为了运行和维护服务,开发者需要时刻关注生产环境的运行状况。因此开发者会希望服务能够发送一些能反映它们的运行方式的度量指标、报告错误信息以及采用合适的格式来聚合日志。在本书的第四部分,我们会进一步详细讨论这些主题。但是现在,读者要记住的是,从一开始,服务就需要设法解决这些问题。服务的运行和维护与编写代码同等重要,而且大部分情况下,服务运行的时间要远远大于开发所需要的时间。

我们的微服务底座有如代码清单7.3所示的依赖。

代码清单7.3 microservices-in-action/chapter-7/chassis/setup.py

(…)

    keywords='microservices chassis development',

    packages=find_packages(exclude=['contrib', 'docs', 'tests']),

    install_requires=[
        'nameko>=2.6.0',
        'statsd>=3.2.1',    ⇽---  以StatsD格式发送度量指标数据的类库 
        'nameko-sentry>=0.0.5',    ⇽---  集成Sentry错误报告功能的类库 
        'logstash_formatter>=0.5.16',    ⇽---  将日志处理为logstash格式的类库 
        'circuitbreaker>=1.0.1',
        'gutter>=0.5.0',
        'request-id>=0.2.1',
    ],

(…)

在声明的这7个依赖类库中,有3个是用于可观测性目的的。这些类库能够让开发者收集度量指标、报告错误信息,收集与错误相关的上下文信息,以及将日志适配成SimpleBank公司所有服务都在使用的格式。

1.度量指标

我们先从数据采集和StatsD的使用[1]讲起。Etsy(一家以手工艺成品买卖为主要特色的网络商店平台)最初开发StatsD是想将其作为一种聚合应用度量指标的方式,但是它很快变得越来越流行,以至于现在成了收集应用度量指标的事实上的协议,拥有众多编程语言的客户端。为了能够使用StatsD,我们需要修改一下代码来采集所有能找到的相关度量指标。然后是客户端类库,在本例中我们使用Python的statsd库,它会收集这些度量指标并将其发送到一个代理服务器上,这个代理会监听来自客户端类库的UDP通信、聚合数据,并定期将数据发送到监控系统中。监控系统既有商业的解决方案,也有开源的解决方案。

在代码库中,读者可以找到一个运行在Docker容器中用来模拟度量指标收集的简单代理服务器。它是一个很短的ruby脚本,监听8125的UDP端口,然后将结果输出到控制台,如代码清单7.4所示。

代码清单7.4 microservices-in-action/chapter-7/feature/statsd-agent/statsd-agent.rb

#!/usr/bin/env ruby
#
# This script was originally found in a post by Lee Hambley
# (http://lee.hambley.name)
#
require 'socket'
require 'term/ansicolor'

include Term::ANSIColor

$stdout.sync = true

c = Term::ANSIColor
s = UDPSocket.new
s.bind("0.0.0.0", 8125)
while blob = s.recvfrom(1024)
  metric, value = blob.first.split(':')
  puts "StatsD Metric: #{c.blue(metric)} #{c.green(value)}"
end

这个简单的脚本可以供读者在开发服务时模拟度量指标收集的过程。图7.8展示了发布出售订单时服务所收集的度量指标,为每个服务的代码添加一段注解,它们就可以针对某些操作发送度量指标了。尽管这只是一个简单的发送计时数据的例子,我们的目的是说明如何配置代码来收集相关数据。下面我们通过其中一个服务来了解一下具体如何操作,如代码清单7.5所示。

c07_08{70%}

图7.8 StatsD代理收集出售订单提交过程中各个协作服务发出的度量指标数据

代码清单7.5 microservices-in-action/chapter-7/feature/fees/app.py

import json
import datetime
from nameko.events import EventDispatcher, event_handler
from statsd import StatsClient    ⇽---  导入StatsD客户端供该模块使用   

class FeesService:
    name = "fees_service"
    statsd = StatsClient('statsd-agent', 8125,
                         prefix='simplebank-demo.fees')    ⇽---  通过传递主机、端口和所发送的度量指标所使用的消息前缀prefix这3个参数来配置StatsD客户端 

    @event_handler("market_service", "order_placed")   
    @statsd.timer('charge_fee')    ⇽---  使用该注解,就可以收集“charge_fee”函数的执行耗时。StatsD类库会使用该注解传递的参数值来作为度量指标名称。在本例中,charege_fee函数会发送名称为“simplebank-demo.fees.charge_fee”的度量指标。我们上面所配置的前缀会追加到传给这个注解的度量指标名的前面        
    def charge_fee(self, payload):
        print("[{}] {} received order_placed event ... charging fee".format(
            payload, self.name))

为了使用StatsD客户端类库收集度量指标,我们需要对客户端进行初始化,传的参数有主机名(在上面的例子中,对应的是statsd-agent)、端口号以及可选的前缀用于标识是本服务范围内收集的度量指标。如果开发者对charge_fee 方法添加@statsd.timer('charge_fee')注解,那么这个StatsD客户端类库就会将这个方法的执行包装到一个计时器中,然后收集计时器记录的数据,最后将数据发送到代理中。开发者可以收集这些度量指标并将其提供给监控系统,这样就可以观测系统的行为和设置告警甚至是对服务进行自动扩容。

假设fee服务处理的请求越来越多,StatsD报告的执行时间超过了设定的阈值。系统就会自动向开发者发送告警信息,这样开发者就可以立刻进行检查来判断服务是否有出错或者是否需要增加更多的服务实例来扩容。图7.9中的仪表盘展示了StatsD收集的度量指标。

c07_09{80%}

图7.9 展示StatsD所收集的度量指标的仪表盘

2.错误报告

度量指标使得开发者可以持续观察系统的行为,但遗憾的是,这并不是开发者唯一需要关心的内容。有时候,错误发生后,需要把错误信息以告警形式发送给开发者,如果可能的话,还需要收集一些错误发生时的上下文信息,比如,可以获取到堆栈跟踪记录,用于诊断、复现和解决错误。有些服务提供了错误聚合和告警功能。为服务集成错误报告功能是很简单的,如代码清单7.6所示。

代码清单7.6 microservices-in-action/chapter-7/chassis/http_demo.py

import json
from nameko.web.handlers import http
from werkzeug.wrappers import Response
from nameko_sentry import SentryReporter    ⇽---  导入错误报告模块 

class HttpDemoService:
    name = "http_demo_service"
    sentry = SentryReporter()    ⇽---  初始化错误报告服务

    @http("GET", "/broken")
    def broken(self, request):
        raise ConnectionRefusedError()    ⇽---  产生一个异常用于测试错误报告服务

    @http('GET', '/books/<string:uuid>')
    def demo_get(self, request, uuid):
        data = {'id': uuid, 'title': 'The unbearable lightness of being',
                'author': 'Milan Kundera'}
        return Response(json.dumps({'book': data}),
                        mimetype='application/json')

    @http('POST', '/books')
    def demo_post(self, request):
        return Response(json.dumps({'book': request.data.decode()}),
                        mimetype='application/json')

在服务底座中,配置错误报告功能是很简单的。我们初始化一个错误报告器,它会负责捕获异常信息并将其发送到错误报告服务的后端。在错误报告器中,错误信息和堆栈跟踪记录等上下文信息一起发送是很常见的。图7.10所示的仪表盘展示了访问示例服务的/broken 接口生成的错误记录。

c07_10{75%}

图7.10 访问/broken接口后,错误报告服务(Sentry)的仪表盘界面

3.日志

服务要么将信息输出到日志文件中,要么将信息输出到标准输出流。这些文件可以记录特定的交互(如一次http调用的结果和耗时)或者开发者认为需要记录的任何其他有用信息。有多个服务进行记录意味着在整个组织内可能有多个服务日志信息。在微服务架构中,众多服务相互调用,所以开发者需要确保可以采用相同的方式来追踪和访问这些调用信息。

在任何组织中,日志都是所有团队都需要关注的内容,并且起着非常重要的作用。

之所以会这样,有合规的原因——开发者有时候需要记录特定的操作;也有方便理解的原因——开发者需要了解不同系统之间的执行流程。正因为日志如此重要,所以不管团队使用哪种编程语言开发服务,都要确保采用统一的方式来记录日志并且将日志聚合到共同的地方。

在SimpleBank公司,日志聚合系统能够让开发者对日志执行复杂的查找,所以开发者都愿意采用相同的格式将日志发送到同一个地方。开发者使用logstash格式保存日志,所以Python的服务底座包含了一个以logstash格式发送日志的类库。

Logstash是一个开源的数据处理流水线工具,它能够从不同的日志源提取数据。由于logstash格式是一个的json消息并且拥有一些默认字段,所以变得特别流行和被广泛使用,如代码清单7.7所示。

代码清单7.7 logstash json 格式化的消息

{
  "message"    => "hello world",
  "@version"   => "1",
  "@timestamp" => "2017-08-01T23:03:14.111Z",
  "type"       => "stdin",
  "host"       => "hello.local"
}

图7.11展示了网关服务在收到客户端的提交出售订单的请求后生成的日志结果。在这其中,它生成了两条消息。它们都包含了大量有价值的信息,如文件名、模块和执行代码的行数以及完成操作所耗费的时间。唯一明确传递给日志的是message字段中的内容。其他所有信息都是由我们所使用的类库插入日志中的。

c07_11{80%}

图7.11 网关服务生成的logstash格式的日志消息

通过向日志聚合工具发送这些信息,读者就可以通过各种有趣的方式来关联这些数据。在本例中,一些查询示例包括:按模块和函数名分组、选择执行时间超过x毫秒的操作记录以及按主机分组。

最有意义的是host、type、version和timestamp字段会出现在所有使用服务底座的服务所生成的消息中,所以读者可以将不同服务的消息关联起来。

在Python服务底座中,代码清单7.8负责生成图7.11中的日志记录。

代码清单7.8 Python服务基底中logstash日志配置

import logging
from logstash_formatter import LogstashFormatterV1

logger = logging.getLogger(__name__)
handler = logging.StreamHandler()
formatter = LogstashFormatterV1()
handler.setFormatter(formatter)
logger.addHandler(handler)

(…)

# to log a message …
logger.info("this is a sample message")

该代码负责初始化logging并添加一个处理器(handler)用于将结果格式化成logstash的json格式。

通过使用微服务底座,读者可以建立出一套使用这些工具的标准方法,这样就能够实现运行服务具备可观测性的目标。通过选择某些类库,读者能够让所有团队使用相同的底层技术设施,却又不强迫任何团队选择特定的语言。

7.3.3 平衡和限流

我们在7.3.1节中提到,消息代理不仅提供了一种隐式的服务相互发现的方式,还提供了负载均衡的功能。

在对提交出售订单功能进行基准测试时,我们意识到系统在处理过程中存在瓶颈。market服务需要和外部的股票交易中心系统进行交互,并且只有在收到成功的结果后才会创建OrderPlaced事件消息,而这个消息会被fee服务以及order服务消费使用。请求会不断积压,因为对外部服务的HTTP请求要慢于系统中的其他处理操作。因此,开发者决定增加运行market服务的实例数量。开发者部署了3台实例来弥补订单提交到股票交易市场所耗费的时间。这一变化是无缝的,因为一旦开发者增加了实例,这些实例就会注册到RabbitMQ的rpc-market_ service队列中。图7.12列出了RabbitMQ所连接的3个服务实例。

正如大家看到的那样,有3个实例连到了队列上。每个实例被设置为一旦有消息到达队列就从该队列中预获取10条消息。现在,我们有多个实例来消费同一个队列的消息,就需要确保一个请求只被一个实例处理。RabbitMQ再次简化了我们的工作,因为它会处理负载均衡的工作。默认情况下,它会使用轮询算法来将消息依次发给各个服务实例。这意味着,它会将前10条消息分配给实例1,然后在分配10条消息给实例2,最后再分配10条消息给实例3。它会一直重复这个操作。这是一种很简单和天真的任务分配方式,因为某个实例的执行时间可能大于另一个实例。但是一般情况下,这种方式的效果还是很好的,并且易于理解。

c07_12{65%}

图7.12 注册到RPC队列上的多个market服务实例

我们唯一需要关注的内容是检查连接的服务是否健康,以确保它们不会积压消息了。读者可以通过使用StatsD和度量指标来监控每个实例正在处理的消息数量以及是否出现消息积压。在代码中,读者也可以实现健康检查功能,这样就可以把所有不响应健康检查请求的实例标记出来,然后重启这些实例。RabbitMQ同样也会作为一个限流缓冲区来存储消息,直到服务实例能够把这些消息处理掉。按照图7.12中的配置,每个服务实例每次会收到10条消息进行处理,并且只有在实例处理完前面的10条消息后,才会被分配新的消息。

值得一提的是,在market服务和第三方系统交互的特定场景中,我们同样实现了断路器方案。调用股票交易系统的代码如代码清单7.9所示。

代码清单7.9 microservices-in-action/chapter-7/feature/market/app.py

import json
import requests
(…)
from statsd import StatsClient
from circuitbreaker import circuit    ⇽---  导入断路器功能供模块使用class MarketService:
    name = "market_service"
    statsd = StatsClient('statsd-agent', 8125,
                         prefix='simplebank-demo.market')

    (…)

    @statsd.timer('place_order_stock_exchange')
    @circuit(failure_threshold=5, expected_exception=ConnectionError)    ⇽---  在断开断路器前所能容忍的异常次数以及视作失败的异常类型 
    def __place_order_exchange(self, request):
        print("[{}] {} placing order to stock exchange".format(
            request, self.name))
        response = requests.get('https://jsonplaceholder.typicode.com/
    posts/1')
        return json.dumps({'code': response.status_code, 'body': response.
    text})

读者可以使用断路器类库来配置能够容忍的连续失败的次数。在本示例中,如果连续出现5次生成ConnectionError异常的失败调用,我们就会断开断路器,30秒内不会调用该方法。30秒后,服务进入恢复阶段,允许一次测试调用。如果本次调用成功,那么它会再次闭合断路器,恢复正常操作并允许调用外部服务;否则,它会继续阻止调用30秒。

注意:

因为30秒是断路器给recovery_timeout参数设置的默认值,所以并不会在代码7.9中看到。如果用户想要调整该值,可以显式传递该参数。

读者不仅可以对外部调用使用这一技术,还可以将该其用于内部组件,这样就可以对服务进行降级处理。在market服务的案例中,使用这一技术意味着该服务从队列中收到的消息并不会被确认,而是会被积压到消息代理中。一旦外部服务的连接恢复,读者就能够开始处理队列中的消息。然后,读者就可以完成对股票交易系统的调用并创建OrderPlaced事件供fee服务和order服务完成下单购买股票请求。

7.4 探索使用底座实现的特性

在前面的小节中,读者见到了实现下单购买股票功能的示例代码。我们简单看一下借助服务底座而实现的最终功能原型。基于代码库中chapter7/chassis文件夹下的服务底座代码,假定读者创建了5个服务:网关服务、order服务、market服务、account transaction服务和fee服务。

图7.13展示了整个项目的组织结构以及一个Docker Compose文件供读者在本地启动这5个服务组件以及前面提到过的StatsD代理服务器。Docker Compose文件除了可以启动服务,还会启动必要的基础设施组件:RabbitMQ、Redis和本地的用于模拟度量指标收集的StatsD代理服务器。

此刻,我们不会深入研究Docker和Docker Compose,因为我们会在后续章节中展开介绍。但是如果读者已经安装好了Docker和Docker Compose,可以进入该功能对应的文件夹运行docker-compose up–build命令来启动服务。它会为每个服务构建一个Docker容器并启动。

图7.14列出了所有运行中和正在处理对网关接口shares/sellPOST请求的服务。

c07_13{65%}

图7.13 下单出售股票功能的项目结构以及用于启动服务和必需的基础设施组件的Docker Compose文件

c07_14{75%}

图7.14 本地运行用于下单出售股票的服务

虽然这个功能中的各个组件之间同时采用了同步和异步两种通信方式,服务底座也能够让我们快速搭建原型,并使用一些能够用于模拟并发请求的工具来运行初始的基准测试。测试结果如下所示(注意:这些测试是运行在本地开发环境的,仅仅是象征性的)。

$ siege -c20 -t300S -H 'Content-Type: application/json'
     'http://192.168.64.3:5001/shares/sell POST'

    (benchmark running for 5 minutes …)

Lifting the server siege...
Transactions:               12663 hits
Availability:               100.00 %
Elapsed time:               299.78 secs
Data transferred:           0.77 MB
Response time:              0.21 secs
Transaction rate:                  42.24 trans/sec
Throughput:                 0.00 MB/sec
Concurrency:                9.04
Successful transactions:           12663
Failed transactions:        0
Longest transaction:        0.52
Shortest transaction:       0.08

这些数字看起来还不错,但是需要指出的是,在测试结束以后,market服务还需要消息3000条消息,这几乎是网关处理的请求总和的四分之一。这个测试能帮助开发者发现我们之前在7.3.3节提到过的market服务的瓶颈。参考图7.4,读者可以看到,网关服务会收到order服务的响应,但是在此之后,系统还会执行异步处理。

SimpleBank公司的工程师团队会持续改进Python服务底座,使其能够体现团队持续学习的知识。不过就目前来看,已经可以用于实现一些重要功能了。

7.5 差异性是否是微服务的承诺

在前面的小节中,我们介绍了在SimpleBank为Python应用构建和使用服务底座的内容。读者可以将这些原则应用到自己组织内使用的任何编程语言中。在SimpleBank公司,有些团队还在使用Java、Ruby和Elixir开发服务。我们要为每种语言和技术栈搭建一套服务底座吗?如果这门语言在组织内被广泛采用并且不同的团队要启动和创建很多服务的话,那么我的答案是“当然”!但是创建服务底座并不是必要工作。唯一要牢记的是,不管有没有服务底座,我们都需要坚持可观测性等这些原则。

微服务架构的一大优势就是能够支持不同的编程语言、范式和工具。最后,微服务架构能够让团队为任务选择合适的工具。尽管从理论上讲选择是无穷无尽的,但事实是,团队都会为他们的日常开发工作专门选择一些技术栈。很自然地,他们会对一两门语言以及它们的生态系统有更深入的了解。生态系统很重要。独立的团队,比如成功运行微服务架构所需要的团队,也需要关注运维并了解运行他们的应用程序的平台,如Java虚拟机(JVM)或Erlang虚拟机(BEAM)。掌握这些基础设施知识有助于交付更高质量和更高效的应用。

Netflix就是很好的例子,因为他们对JVM有非常深入的了解,这使得他们成为许多开源工具的专家贡献者,这样社区也受益于这些他们用来运行自己的服务的工具。Netflix有太多以JVM为运行环境的工具,这也使得对他们的工程师团队而言,JVM生态系统成为第一选择。某种程度上,有点类似于“开发者有充分的自由来选择任何开发者想要的语言和工具,只要它符合指定的规则并实现一些接口。或者开发者可以选择这个已经处理了所有问题的服务底座”。

为组织内已经采用的语言和技术栈提供一套服务底座,有助于指导团队选择语言和技术栈。这不但能够简化和加快服务的启动,而且从风险的角度看,也能够提升这些服务的可维护性。服务底座是一种间接加强工程团队的核心关注内容和实践的方式。

提示

DRY(don’t repeat yourself)并不是强制的。服务底座不应该是包含在服务中并以中心化的形式进行更新的一系列的共享库或者依赖。我们应该使用服务底座来创建和启动新的服务,而不需要为了特定的功能而更新所有运行中的服务。相较于引入共享库来增大耦合,我们更倾向于适当地重复一些内容。如果能够保持系统的解耦以及独立维护和管理,就可以重复。

7.6 小结

(1)微服务底座能够加快新服务的启动速度,扩大试验领域和降低风险。

(2)使用服务底座能够让开发者将与某些基础设施相关的代码实现抽取出来。

(3)服务发现、可观测性以及不同的通信协议都是服务底座所关注的内容,服务底座需要提供这些功能。

(4)如果有适合的工具存在,我们可以为下单出售股票这样的复杂功能快速开发出原型。

(5)虽然微服务架构经常和使用任意语言开发系统的可能性联系起来,但是在生产环境中,这些系统需要保证并提供机制来让运行和维护都是可管理的。

(6)微服务底座能够实现上述这些保证,同时能够让开发者快速启动和开发以验证想法的正确性,如果验证通过,就可以部署到生产环境。


[1] 参见伊恩·马尔帕斯(Ian Malpass)在Code as Craft网站发表的Measure Anything, Measure Everything

本文摘自《微服务实战》

《微服务实战》主要介绍如何开发和维护基于微服务的应用。本书源自作者从日常开发中得到的积累和感悟,其中给出的案例覆盖从微服务设计到部署的各个阶段,能够带给你真实的沉浸式体验。通过阅读本书,你不仅能够了解用微服务搭建高效的持续交付流水线的方法,还能够运用Kubernetes、Docker 以及Google Container Engine 进一步探索书中的示例。