第五章:分割单体
在上一章中,我们创建了一个单体应用程序作为助手;这样做非常迅速,专注于添加功能而不是长期架构。这种做法没有错——毕竟,如果应用程序永远不需要扩展,那么工程努力就是浪费。
但让我们假设我们的服务非常受欢迎,接收到的请求数量正在增长。我们现在必须确保它在负载下表现良好,同时也确保它对不断增长的开发团队来说易于维护。我们应该如何继续?在本章中,我们将检查如何根据代码复杂度和收集的使用数据来确定迁移到新微服务的最佳组件,并展示准备和执行迁移的技术。
识别潜在的微服务
对于我们熟悉的应用程序,我们可能对哪些组件过载或不稳定有很多直觉。然而,直觉可能会误导我们,因此让决定受到收集的数据的指导是一个好主意。开发者可能提出的问题包括 HTTP 请求的响应速度、不同端点的成功率与错误率、系统哪些部分在更改时最麻烦,以及峰值使用时一个组件平均需要处理多少个活动连接。
一些需要考虑的非技术性业务问题包括慢速响应是否意味着用户将停止使用工具,以及在基于网络的商店中转化率是多少。为了做出关于分割单体应用程序的决定,我们将牢记两个问题:当运行时,哪些组件最慢导致最多的延迟?哪些组件与应用程序的其他部分紧密耦合,因此在更改时变得脆弱?
代码复杂度和维护
随着项目规模的增加,推理变得更加困难。保持系统不同逻辑部分的分离,并在它们之间保持清晰的接口,有助于我们更有效地思考所有不同组件之间的交互。我们可以通过使用评估代码循环复杂度的工具来采取数据驱动的策略。Radon 是一个用于快速评估代码复杂度的 Python 工具。
在这里,我们告诉 Radon 计算循环复杂度,并且只报告那些复杂度评分为 C 或更差的区域:
$ git clone https://gitlab.com/pgjones/quart
$ cd quart
$ radon cc . --average --min c
容易认为高复杂度的函数总是不好的,但情况并不一定如此。我们应该追求简单,但不要过度简化到失去软件中的实用性。我们应该将这些分数作为决策的指南。
指标和监控
操作健康监控依赖于一系列高分辨率指标,使我们能够注意到并修复系统中的问题。为了确定是否需要更改架构,我们可能会查看服务的操作健康,但我们还希望查看服务的质量:质量保证发现服务是否满足我们的标准。
理解你的仪表和计数器正在收集什么信息很重要。选择要收集哪些指标通常是一个困难的选择。最好从具体问题开始,朝着解答它们的方向努力。幸运的是,我们可以从在 Web 应用程序中监控的两种最容易的事情开始:计数每个端点被访问的次数,以及每个端点完成请求处理所需的时间。
为了以云无关的方式调查这个问题,我们将转向一个称为 Prometheus 的通用操作监控工具。为了轻松地将指标集成到我们的应用程序中,我们可以使用 aioprometheus 库。
首先,我们需要设置我们想要收集的指标。活跃请求的数量是一个仪表,因为它是在某个时间点的当前状态的快照。每个请求的持续时间被记录为一个 Summary 对象。我们可以创建这两个注册表,然后将它们添加到我们的应用程序中:
app.registry = Registry()
app.api_requests_gauge = Gauge("quart_active_requests", "Number of active requests per endpoint")
app.request_timer = Summary("request_processing_seconds", "Time spent processing request")
app.registry.register(app.api_requests_gauge)
app.registry.register(app.request_timer)
我们还需要添加一个端点,让 Prometheus 能够访问我们的应用程序并请求收集的指标。完成这些后,我们可以利用 aioprometheus 提供的辅助函数来记录函数的持续时间,以及自动增加和减少仪表。
对于生产服务,指标收集服务是另一个需要部署和管理的组件;然而,在我们开发自己的实验时,Prometheus 的本地副本就足够了,我们可以在容器中运行它。如果我们设置了一个基本的配置文件,我们需要确保目标匹配我们运行应用程序的计算机的 IP 地址。
现在我们运行 Prometheus 并访问 Web 界面。关于查询 Prometheus 的更多信息,请查阅官方文档。
图 5.1 显示了我们对运行中的应用程序运行一系列查询后收集的数据。现在我们对 API 中每个端点被查询的次数以及这些请求所需的时间有了更清晰的了解。我们还可以添加额外的指标,例如 CPU 使用时间、内存消耗量或我们等待其他网络调用完成的时间。
日志记录
数字可以告诉我们应用程序中发生了很多事情,但不是全部。我们还需要使用日志记录。一旦我们了解系统哪些部分运行缓慢,接下来的问题将是'它到底在做什么?'阅读代码只能给我们部分答案——通过记录所做的决策、发送的数据和遇到的错误,日志记录将给我们其余的答案。
Python 拥有强大的日志选项,可以自动为我们格式化消息,并根据消息的严重性进行过滤。Quart 提供了一个接口,允许在应用程序中轻松使用 Python 的内置日志记录。
虽然我们的日志消息目前有一个特定的格式,但产生的仍然是一个单一的文本字符串。如果有一个程序想要检查日志条目中的重要错误或寻找事件中的模式,这可能会很尴尬。对于应该由计算机读取的日志消息,结构化日志是最佳选择。结构化日志通常是以 JSON 格式生成的日志消息。
在 Quart 中使用 structlog 需要设置 structlog,并替换创建日志消息所用的函数。进一步配置 structlog 允许您将 JSON 直接发送到中央日志服务器,这对于从运行在不同计算机上的多个不同副本的软件中收集日志非常有用。
在了解了关于代码复杂性和我们单体中每个组件的工作情况的所有这些信息后,我们应该对哪些区域需要最多的工作,以及哪些可以从自己的微服务中提取出来以获得最大的好处有一个很好的想法。
拆分单体
现在我们知道了哪些组件消耗了最多的资源,并且花费了最多的时间,我们应该如何将它们拆分?我们已经可以将我们服务中的几个组件移动到单独的服务器上。RabbitMQ、Celery 和数据库都通过网络进行通信。
开发者还必须考虑设置网络安全、账户、访问控制和与运行和保障服务相关的其他问题。我们自己应用程序的部分更复杂:我们调用函数来调用我们自己的功能,我们将需要调用 REST API。谨慎、有节制的改变总是更安全。任何方法的一个优秀的第一步是回到我们的面向服务的架构原则,并定义未来微服务与应用程序其余部分之间的清晰接口。
我们看到,我们的 weather_action 函数从 process_message 中获取了它所需的所有信息,但它还需要理解如何解析作为消息一部分接收到的文本。理想情况下,只有回复功能的函数需要理解这些元数据。如果我们想将天气功能转变为微服务,那么我们就需要有一种方式来理解来自不同来源的消息。
首先,测试从接收到的消息中提取位置的方式并不容易。两个新的专业函数应该有助于解决这个问题。现在生成更复杂的文本分析以找到其中的位置也更容易了。应该调用这两个函数的是什么?答案是新的预处理器,它可以接受人类编写的自由格式文本消息,并尝试结构化其中的数据。
现在,当迁移到微服务的时候,我们有一个清晰的模型,知道微服务应该接受什么以及需要返回什么数据。因为函数调用可以被替换为执行基于 Web 查询的函数,并且使用相同结构化的数据,我们可以将此数据纳入我们的测试中,并更有信心新微服务将按预期运行。
特性标志
更改大型代码库通常涉及多个大型补丁。特性标志是一种仅用于开启或关闭特定功能的配置选项。启用新功能只是一个调整配置文件的问题。尽管进行了所有细致的计划,但在某些情况下,你可能需要紧急关闭新行为。功能标志意味着这是一个简单的操作。
实现功能标志不应复杂。一个简单的开关标志和一个用于部分流量的路由器可以像以下示例一样简单。第一个示例将在配置值更改时完全切换到新工作者,第二个配置为将一定百分比的流量发送到新工作者,以允许新代码的受控发布:
@app.route("/migrating_endpoint")
async def migration_example():
if current_app.config.get("USE_NEW_WORKER"):
return await new_worker()
else:
return await original_worker()
@app.route("/migrating_gradually")
async def migrating_gradually_example():
percentage_split = current_app.config.get("NEW_WORKER_PERCENTAGE")
if percentage_split and random.randint(1, 100) <= percentage_split:
return await new_worker()
else:
return await original_worker()
使用 Prometheus,我们可以监控迁移过程。一旦新功能稳定,可以更改配置选项的默认状态。现在应该可以安全地假设,如果选项缺失,则应该开启。这将让您移除功能标志,并允许您移除旧版本的功能以及任何检查标志的代码,完成迁移。
重构 Jeeves
检查 Jeeves 以查看哪些方面可以作为微服务进行改进,我们可能会发现一些外部查询正在减慢我们的响应速度或使用过多的资源。然而,我们也发现架构有一个更根本的改变。向 Slack 发送消息与接收消息是独立的,所以这两个元素可以是独立的服务。
其中一些服务将需要联系数据库,如果我们保持当前的数据库架构,那么每个新的微服务都需要数据库模型。这是一个紧密耦合的设计。为了防止这种情况,我们可以将我们的数据库转换为其自己的微服务,并设置它来回答我们知道它会得到的问题。
没有其他服务需要知道数据的内部结构,因为它只需要知道在哪里询问,并且答案总是以相同的方式结构化。这还有一个额外的优点:所有这些微服务都可以被任何其他工具使用。
工作流程
从 Slack 的角度来看,一切看起来都一样。当用户输入一条消息时,我们配置的 URL 会发送一些 JSON 格式的信息。这些数据被我们的 Slack 请求 API 接收,所有 Slack 消息处理都发生在这里,我们选择正确的微服务作为目的地。
如果我们的 Slack 请求服务随后向微服务发起网络请求,我们必须等待其响应,考虑到它需要等待所有调用的响应时间。这可能会使我们的 API 非常慢。幸运的是,我们有一个消息队列!我们不需要直接按顺序调用每个步骤,我们可以将消息传递给 RabbitMQ 并立即向 Slack 的基础设施返回适当的状态码。
如果我们的某个工人出现故障,消息将排队并保留在那里,直到我们恢复在线。一旦创建了回复,我们就可以再次使用 RabbitMQ 并向 Slack 发布服务发送消息。我们使用消息队列获得的可靠性改进与我们对传入消息的改进相同,但现在它们在出现任何故障时更加具有弹性。
第六章:与其他服务交互
在上一章中,我们的单体应用被拆分为几个微服务,因此,不同部分之间的网络交互也相应增加。与其他组件的更多交互可能导致其自身的复杂性。由于我们许多有用的任务涉及与第三方服务的交互,因此管理这些变化的技术对我们应用程序内部和外部通信都很有用。
无论如何,底线是我们需要通过网络与其他服务进行交互,无论是同步还是异步。这些交互需要高效,当出现问题时,我们需要有一个计划。通过增加更多的网络连接引入的另一个问题是测试:如何测试一个需要调用其他微服务才能正常工作的独立微服务?在本章中,我们将详细探讨这个问题。
调用其他网络资源
正如我们在前几章所看到的,微服务之间的同步交互可以通过使用 JSON 有效载荷的 HTTP API 来实现。大多数这些接口也是 RESTful 的,这意味着它们遵循表示状态转移(REST)架构原则。
发送和接收 JSON 有效负载是微服务与其他服务交互的最简单方式。要做到这一点,你只需要使用一个 HTTP 客户端。由于我们处于异步环境中,我们将使用 aiohttp,它有一个创建异步 Web 请求的清晰方式。
aiohttp 库中的 HTTP 请求是围绕会话的概念构建的,最佳的使用方式是调用 CreateSession,创建一个每次与任何服务交互时都可以重用的 Session 对象。Session 对象可以保存认证信息和一些你可能想要为所有请求设置的默认头信息。
如果我们应该限制对外部端点发出的并发请求数量,有两种主要方法。aiohttp 有一个连接器的概念,我们可以设置选项来控制一个 session 一次可以操作多少个出站 TCP 连接。理想情况下,我们希望一个独立的工作块能够持续进行,直到完成,为此我们可以使用信号量。
这种简单的实现基于一切都会顺利进行的假设,但现实生活很少如此简单。我们可以在 ClientSession 中设置不同的错误处理选项,如重试和超时。
寻找去往何方
当我们向一个服务发出 Web 请求时,我们需要知道要使用哪个统一资源定位符(URL)。本书中的大多数示例都使用硬编码的 URL——也就是说,它们被写入源代码。这对于示例来说很方便,但在维护软件时可能会出现问题。
我们希望将有关要使用的 URL 作为配置的数据传递给我们的应用程序。有几种选项可以管理更多的配置选项,而无需直接将它们添加到代码中,例如环境变量和服务发现。
环境变量
基于容器的环境现在很常见。将配置选项传递到容器中最常见的方法是向容器传递一些环境变量。这有一个优点,即简单直接,因为代码在处理其配置时只需要检查环境。
这种方法的缺点是,如果 URL 发生变化,那么我们需要重新启动应用程序。如果你不期望配置经常改变,由于它们的简单性,环境变量仍然是一个好主意。
服务发现
但如果我们部署服务时不需要告诉它所有选项怎么办?服务发现是一种涉及仅用少量信息配置应用程序的方法:在哪里请求配置以及如何识别正确的提问方式。
例如,etcd 等服务提供了一个可靠的关键值存储,用于保存这些配置数据。当应用程序启动时,它可以检查它是否在生产环境中运行或在本地开发环境中运行,并请求 etcd 的正确值。还可以更新 etcd 中的值,当你的应用程序下次检查新值时,它将更新并使用该值。
数据传输
JSON 是一种可读的数据格式。互联网上有着悠久的人可读数据传输历史。这种可读性的缺点是数据的大小。长期来看,发送带有 JSON 有效负载的 HTTP 请求和响应可能会增加一些带宽开销。
然而,还有其他涉及缓存、压缩、二进制有效负载或 RPC 的数据传输方式。
HTTP 缓存头部
在 HTTP 协议中,有一些缓存机制可以用来向客户端指示它试图获取的页面自上次访问以来没有变化。缓存是我们可以在我们的微服务中的所有只读 API 端点上执行的操作。
实现它的最简单方法是在响应中返回结果的同时,返回一个 ETag 头部。ETag 值是一个字符串,可以被认为是客户端试图获取的资源的一个版本。当发起一个新的请求时,客户端可以查看其本地缓存,并在 If-Modified-Since 头部中传递一个存储的 ETag 值。
这种机制可以大大减少服务器的响应时间。当然,这意味着你调用的服务应该通过添加适当的 ETag 支持来实现这种缓存行为。一般规则是,为每个资源进行版本控制,并在数据更改时更改该版本。
GZIP 压缩
压缩是一个总称,指的是以这种方式减小数据的大小,以便可以恢复原始数据。GZIP 压缩几乎在所有系统中可用,并且像 Apache 或 nginx 这样的 Web 服务器为通过它们的响应提供了原生支持。
重要的是要记住,虽然这会节省网络带宽,但它会使用更多的 CPU。例如,这个 nginx 配置将启用端口 5000 上 Quart 应用程序产生的任何响应的 GZIP 压缩。
从客户端来看,向 localhost:8080 上的 nginx 服务器发送 HTTP 请求,通过带有 Accept-Encoding: gzip 头的代理为 localhost:5000 上的应用程序触发压缩。在 Python 中,使用 aiohttp 和 requests 库发出的请求将自动解压缩 GZIP 编码的响应。
协议缓冲区
虽然通常情况下并不相关,但如果你的微服务处理大量数据,使用替代格式可以是一个吸引人的选项。两种广泛使用的二进制格式是协议缓冲区(protobuf)和 MessagePack。
协议缓冲区要求你描述正在交换的数据,以便将其索引到某个将用于索引二进制内容的模式中。以下示例取自 protobuf 文档:
syntax = "proto2";
package tutorial;
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
}
使用 gRPC 框架与协议缓冲区结合可以抽象出你的应用程序的网络交互。
MessagePack
与 Protocol Buffers 不同,MessagePack 是无模式的,只需调用一个函数就可以序列化你的数据。它是 JSON 的简单替代品,并在大多数语言中有实现。
与 protobuf 相比,使用 MessagePack 很简单,但哪个更快,提供最佳的压缩比率,很大程度上取决于你的数据。很明显,无论你使用什么格式,最佳方式是使用 GZIP 来减少有效负载大小。
整合起来
在继续之前,我们将快速回顾一下到目前为止我们已经覆盖了什么:
- 实现 HTTP 缓存头是一个加快对数据重复请求的好方法。
- GZIP 压缩是一种有效的方法来减少请求和响应的大小,并且很容易设置
- 二进制协议是纯 JSON 的有吸引力的替代品,但这取决于具体情况
下一节将重点介绍异步调用。
异步消息
在微服务架构中,当原本在一个单一应用程序中执行的过程现在涉及到多个微服务时,异步调用扮演着基本角色。异步调用可以像微服务应用程序中的一个单独的线程或进程那样简单,它接收一些要执行的工作,并在不干扰同时发生的 HTTP 请求/响应往返过程中执行它。
消息队列可靠性
与任何分布式系统一样,在可靠性和一致性方面都需要考虑。理想情况下,我们希望将一条消息添加到队列中,并确保它被准确无误地投递并执行——恰好一次。在实践中,在分布式系统中几乎不可能实现这一点。
我们有两个实际的选择,这些选择编码在 RabbitMQ 的投递策略中:'最多一次'和'至少一次'。一种最多一次投递消息的策略不会考虑消息投递系统中的任何不可靠性或工作进程中的失败。有一个承诺至少发送一次消息,在出现任何失败的情况下,交付将再次尝试。
基本队列
Celery 工作者使用的模式是推拉任务队列。一个服务将消息推入特定的队列,一些工作者从另一端取走它们并对其执行操作。每个任务都只去一个工作者。当你想要执行一些异步并行任务时,这种盲目单向的消息传递是完美的,这使得它很容易进行扩展。
此外,一旦发送者确认消息已添加到代理,我们就可以让消息代理,如 RabbitMQ,提供一些消息持久化。
主题交换机和队列
主题是一种过滤和分类通过队列传输的消息的方式。当使用主题时,每条消息都会附带一个额外的标签,有助于识别其类型,我们的工作者可以订阅特定的主题或匹配多个主题的模式。
在我们的微服务中,这意味着我们可以拥有专门的工作者,它们都注册到同一个消息代理,并获取添加到其中的消息的子集。
要在代码中与 RabbitMQ 交互,我们可以使用 Pika。这是一个 Python RPC 客户端,它实现了 Rabbit 服务发布的所有 RPC 端点。
以下脚本展示了如何在 RabbitMQ 的入站交换机中发布两条消息。这些 RPC 调用将为每个入站主题交换机添加一条消息。一个等待需要发布到 Play Store 的工作的工作者脚本可能看起来像这样。
注意,Pika 会将一个 ACK 发送回 RabbitMQ 关于该消息,因此一旦工人成功处理,就可以安全地从队列中移除。这是至少一次消息传递策略。
AMQP 提供了许多可以调查的消息交换模式。要将以下示例集成到我们的微服务中,发布阶段是直接的。您的 Quart 应用程序可以使用 pika.BlockingConnection 创建到 RabbitMQ 的连接并通过它发送消息。
另一方面,消费者更难集成到微服务中。Pika 可以嵌入到与 Quart 应用程序在同一进程中运行的事件循环中,并在接收到消息时触发一个函数。
发布/订阅
之前的模式有处理特定消息主题的工人,工人消费的消息将完全从队列中消失。然而,当你想要将消息发布到多个工人时,必须使用发布/订阅(pubsub)模式。
这种模式是构建通用事件系统的基础,其实现方式与之前完全相同,其中有一个交换机和几个队列。区别在于交换部分具有扇出类型。在这种设置中,每个绑定到扇出交换机的队列都将接收到相同的信息。
整合
在本节中,我们介绍了以下关于异步消息传递的内容:
- 每当微服务可以执行一些非阻塞工作的时候,都应该使用非阻塞调用。
- 服务到服务的通信并不总是限于任务队列。
- 通过消息队列发送事件是防止组件紧密耦合的好方法。
- 我们可以在一个代理(如 RabbitMQ)周围构建一个完整的事件系统,使我们的微服务通过消息相互交互。
测试
如我们在第三章中学习的,为调用其他服务的服务编写功能测试时最大的挑战是隔离所有网络调用。在本节中,我们将看到如何模拟使用 aiohttp 进行的异步调用。
测试 aiohttp 及其出站 Web 请求需要与传统同步测试不同的方法。aioresponses 项目允许您轻松创建使用 aiohttpClientSession 进行的 Web 请求的模拟响应。
如果您使用 Requests 执行所有调用——或者您使用的是基于 Requests 的库,并且没有对其进行太多定制——由于 requests-mock 项目,这项隔离工作也变得容易进行。
话虽如此,模拟其他服务的响应仍然是一项相当多的工作,并且可能难以维护。这意味着需要关注其他服务随时间的发展,以确保您的测试不是基于不再反映真实 API 的模拟。
鼓励使用模拟来构建良好的功能测试覆盖率,但请确保您也在进行集成测试,在该测试中,服务在一个部署环境中被测试,它调用其他服务进行真实操作。
使用 OpenAPI
OpenAPI 规范,之前被称为 Swagger,是描述一组 HTTP 端点、它们的使用方式以及发送和接收的数据结构的标准方式。通过使用 JSON 或 YAML 文件描述 API,它使得意图变得机器可读。
以下是一个最小的 OpenAPI 描述文件示例,它定义了一个单一的 /apis/users_ids 端点,并支持 GET 方法来检索用户 ID 列表:
---
openapi: "3.0.0"
info:
title: Data Service
description: returns info about users
license:
name: APLv2
url: https://www.apache.org/licenses/LICENSE-2.0.html
version: 0.1.0
basePath: /api
paths:
/user_ids:
get:
operationId: getUserIds
description: Returns a list of ids
produces:
- application/json
responses:
'200':
description: List of Ids
schema:
type: array
items:
type: integer
完整的 OpenAPI 规范可以在 GitHub 上找到。您可以在该规范中提供有关您的 API 的许多详细信息。
使用 OpenAPI 描述您的 HTTP 端点提供了许多优秀的机会:有许多 OpenAPI 客户端可以消费您的描述并对其进行有用的操作;它为您的 API 提供了标准、语言无关的文档;服务器可以检查请求和响应是否符合规范。
当人们使用 OpenAPI 构建 HTTP API 时,有两种不同的观点:规范优先,即您首先创建 Swagger 规范文件,然后在它之上创建您的应用程序;规范提取,即您的代码生成 Swagger 规范文件。
第七章:保护你的服务
到目前为止,这本书中所有服务之间的交互都没有进行任何形式的身份验证或授权;每个 HTTP 请求都会愉快地返回结果。但在实际生产中,这不可能发生,有两个简单的原因:我们需要知道谁在调用服务(身份验证),并且我们需要确保调用者有权执行调用(授权)。
在单体 Web 应用程序中,简单的身份验证可以通过登录表单实现。在基于微服务的架构中,我们不能在所有地方使用这种方案,因为服务不是用户,也不会使用 Web 表单进行身份验证。我们需要一种自动接受或拒绝服务之间调用的方式。
OAuth2 授权协议为我们提供了在微服务中添加身份验证和授权的灵活性。在本章中,我们将了解 OAuth2 的基本特性和如何实现一个身份验证微服务。
OAuth2 协议
如果你正在阅读这本书,你很可能是那些使用用户名和密码登录网页的人。这是一个简单的模型来确认你是谁,但也有一些缺点。许多不同的网站存在,每个网站都需要妥善处理某人的身份和密码。相反,你可能遇到过允许你'使用 Google'、'Microsoft'、'Facebook'或'GitHub'登录的网站。这个功能使用了 OAuth2。
OAuth2 是一个广泛采用的标准,用于保护 Web 应用程序及其与用户和其他 Web 应用程序的交互。只有一个服务会被告知你的密码或多因素认证码,任何需要认证你的网站都会将你引导到那里。
这里我们将介绍两种认证类型,第一种是认证代码授权,它是由人类使用浏览器或移动应用程序发起的。用户驱动的认证代码授权流程看起来很复杂,但它发挥着重要的作用。
当为程序设置 OAuth2 以便使用,使一个服务能够连接到另一个服务时,有一个类似的过程称为客户端凭证授权(CCG),其中服务可以连接到认证微服务并请求一个它可以使用的令牌。
对于基于微服务的架构,使用这两种类型的授权将使我们能够集中管理系统的每个方面的认证和授权。构建一个实现 OAuth2 协议一部分的微服务,用于认证服务和跟踪它们之间的交互,是减少安全问题的良好解决方案。
基于 X.509 证书的认证
X.509 标准用于保护网络。每个使用 TLS 的网站——即带有 https:// URL 的网站——在其网络服务器上都有一个 X.509 证书,并使用它来验证服务器的身份并设置连接将使用的加密。
当客户端面对这样的证书时,它是如何验证服务器身份的?每个正确发行的证书都是由受信任的机构进行加密签名的。证书颁发机构(CA)通常会向您颁发证书,并且是浏览器依赖的最终组织,以了解可以信任谁。
获取一个好的证书比以前容易得多,这要归功于 Let's Encrypt。仍然收取证书费用的组织仍然提供价值。让我们使用 Let's Encrypt 生成一个证书,并使用一些命令行工具来检查它。
一旦安装了 certbot,为 nginx 等网络服务器获取证书就变得简单。nginx 配置中,我们会看到 certbot 已经添加了必要的 SSL 配置部分。
我们可以使用 OpenSSL 工具包来检查我们的证书,无论是通过查看文件还是通过向 Web 服务器发送查询。检查证书将提供大量信息,尽管对我们来说,重要的部分包括有效期和主题部分。
还可以使用 openssl 实用程序连接到正在运行的 Web 服务器,这可能有助于确认正在使用正确的证书。我们可以轻松地读取这个交换中的公共证书,并确认这是我们期望服务器从其配置文件中使用的证书。
到目前为止,我们只讨论了服务器使用证书来验证其身份并建立安全连接的情况。客户端也可以出示证书来验证自身。证书将允许我们的应用程序验证客户端是否是他们声称的身份,但我们应该小心,因为这并不意味着客户端被允许做某事。
基于令牌的认证
正如我们之前所说的,当一个服务想要在不进行任何用户干预的情况下访问另一个服务时,我们可以使用 CCG 流。CCG 的理念是,一个服务可以连接到身份验证服务并请求一个令牌,然后它可以使用这个令牌来对其他服务进行身份验证。
令牌可以包含对身份验证和授权过程有用的任何信息。以下是一些例子:username 或 ID、范围、时间戳、过期时间戳。
令牌通常构建为一个完整的证明,表明你有权使用一项服务。它是完整的,因为可以在不知道其他任何信息或无需查询外部资源的情况下,通过身份验证服务验证令牌。
OAuth2 使用 JWT 标准作为其令牌。OAuth2 中没有要求必须使用 JWT 的内容——它只是恰好适合 OAuth2 想要实现的功能。
JWT 标准
在 RFC 7519 中描述的 JSON Web Token(JWT)是一个常用的标准,用于表示令牌。JWT 是由三个点分隔的长字符串组成:头部、有效载荷、签名。
JWTs 是 Base64 编码的,因此它们可以安全地用于查询字符串。以下是一个 JWT 的编码形式:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlNpbW9uIEZyYXNlciIsImlhdCI6MTYxNjQ0NzM1OH0.K4ONCpK9XKtc4s56YCC-13L0JgWohZr5J61jrbZnt1M
令牌上方的每个部分在显示时通过换行符分隔——原始令牌是一行。你可以使用 Auth0 提供的实用工具来实验 JWT 编码和解码。
如果我们使用 Python 来解码它,数据就是简单的 Base64。JWT 的每一部分都是一个 JSON 映射,除了签名。头部通常只包含 typ 和 alg 键。有效载荷包含你需要的内容,每个字段在 RFC 7519 的术语中被称为 JWT 断言。
在以下有效载荷示例中,我们提供了自定义的 user_id 值以及使令牌在发行后 24 小时内有效的时戳。
这些头部为我们提供了很多灵活性,以控制我们的令牌将保持有效的时间。根据微服务的性质,令牌的生存时间(TTL)可以是极短到无限。
JWT 的最后部分是签名。它包含头部和有效载荷的签名哈希。用于签名哈希的算法有几种;一些基于密钥,而另一些基于公钥和私钥对。
PyJWT
在 Python 中,PyJWT 库提供了你生成和读取 JWT 所需的所有工具。一旦你使用 pip 安装了 pyjwt(和 cryptography),你就可以使用 encode() 和 decode() 函数来创建令牌。
当执行此代码时,令牌会以压缩和未压缩两种形式显示。如果你使用已注册的声明之一,PyJWT 将控制它们。例如,如果提供了 exp 字段且令牌已过期,库将引发错误。
使用密钥进行签名和验证签名在运行少量服务时很好,但很快可能会成为问题,因为它要求你需要在所有需要验证签名的服务之间共享密钥。因此,当需要更改密钥时,在堆栈中安全地更改它可能是一个挑战。
一个更好的技术是使用由公钥和私钥组成的非对称密钥。私钥由令牌发行者用来签名令牌,而公钥可以被任何人用来验证签名是否由该发行者签名。
使用 JWT 证书
为了简化这个例子,我们将使用之前为 nginx 生成的 letsencrypt 证书。如果 certbot 直接生成证书,它们将保存在 /etc/letsencrypt/live/your-domain/。首先,我们关注以下两个文件:cert.pem,其中包含证书;privkey.pem,其中包含 RSA 私钥。
为了使用这些与 PyJWT,我们需要从证书中提取公钥。RSA 代表 Rivest, Shamir, 和 Adleman。RSA 加密算法生成的密钥可以长达 4,096 字节,被认为是安全的。
从那里,我们可以在我们的 PyJWT 脚本中使用 pubkey.pem 和 privkey.pem 来签名和验证令牌的签名,使用 RSASSA-PKCS1-v1_5 签名算法和 SHA-512 哈希算法。
在每个请求中添加如此多的额外数据可能会对产生的网络流量产生影响,因此,基于密钥的 JWT 技术是一个可以考虑的选项,如果您需要减少网络开销。
TokenDealer 微服务
在构建认证微服务的第一步,我们将实现执行 CCG 流程所需的一切。为此,应用程序接收来自需要令牌的服务请求,并在需要时生成它们,假设请求中包含已知的密钥。生成的令牌将有一个一天的寿命。
这个服务将是唯一一个拥有用于签署令牌的私钥的服务,并将公开公钥供其他想要验证令牌的服务使用。这个服务也将是唯一一个保存所有客户端 ID 和密钥的地方。
为了实现我们所描述的,这个微服务将创建三个端点:GET /.well-known/jwks.json;POST /oauth/token;POST /verify_token。
使用微服务骨架,我们可以创建一个非常简单的 Quart 应用程序,该应用程序实现了这三个视图。让我们来看看这三个 OAuth 视图。
OAuth 实现
对于 CCG 流程,需要令牌的服务会发送一个包含以下字段的 URL 编码体的 POST 请求:client_id、client_secret、grant_type。
我们将做出一些假设以简化实现。首先,为了演示目的,我们将保持秘密列表在 Python 数据结构中。在生产服务中,它们应该在静态时加密,并保存在具有弹性的数据存储中。
第一个视图将是实际生成其他服务所需令牌的视图。在我们的例子中,我们每次创建令牌时都会读取私钥。令牌本身是一个具有多个字段的复杂数据结构:令牌的发行者(iss),通常是服务的 URL;令牌的目标受众(aud),即令牌的目标对象;令牌签发的时间(iat);以及其过期时间(exp)。
接下来要添加的视图是一个返回我们令牌生成所使用的公钥的功能,这样任何客户端都可以验证令牌而无需进行进一步的 HTTP 请求。这通常位于一个众所周知的 URL——地址中实际上包含字符串.well-known/,这是 IETF 鼓励的做法。
最后一个视图允许客户端验证令牌而无需自己进行工作。与令牌生成相比,这要简单得多,我们只需从输入数据中提取正确的字段,并调用 jwt.decode 函数来提供值。
TokenDealer 微服务的全部源代码可以在 GitHub 上找到。微服务可以提供更多关于令牌生成的功能。例如,管理作用域并确保微服务 A 不允许生成在微服务 B 中使用的令牌。
回顾我们的示例微服务,TokenDealer 现在作为生态系统中的一个独立微服务存在,创建和验证允许访问我们的数据服务的密钥,并授权访问我们查询其他网站所需的第三方令牌和 API 密钥。
使用 TokenDealer
在 Jeeves 中,数据服务是一个需要认证的好例子。通过数据服务添加信息需要限制在授权的服务范围内。为该链接添加认证分为四个步骤:TokenDealer 为 Strava 工作者管理一个 client_id 和 client_secret 对;Strava 工作者使用 client_id 和 client_secret 从 TokenDealer 检索令牌;工作者将令牌添加到每个请求到数据服务的头部;数据服务通过调用 TokenDealer 的验证 API 或执行本地 JWT 验证来验证令牌。
在完整实现中,第一步可以部分自动化。生成客户端密钥通常是通过认证服务的 Web 管理面板完成的。然后,该密钥提供给客户端微服务开发者。现在,每个需要令牌的微服务都可以获取一个,无论是首次连接,还是因为它们已经获得的令牌已过期。
以下是一个使用 requests 库进行此类调用的示例——假设我们的 TokenDealer 已经在 localhost:5000 上运行。
get_token() 函数检索一个令牌,该令牌随后可以在代码调用数据服务时用于授权头,我们假设数据服务在本例中监听端口 5001。 call_data_service() 函数会在调用数据服务并返回 401 响应时尝试获取新的令牌。这种在 401 响应上刷新令牌的模式可以用于你所有的微服务来自动化令牌生成。
这包括服务间的身份验证。你可以在示例 GitHub 仓库中找到完整的实现,以尝试基于 JWT 的身份验证方案,并将其作为构建你的身份验证过程的基础。
下一个部分将探讨保护你的网络服务的重要方面之一,即保护代码本身。
保护你的代码
无论我们做什么,应用程序都必须接收数据并对其采取行动,否则它将不会非常有用。如果一个服务接收数据,那么一旦你将你的应用程序暴露给世界,它就会面临众多可能的攻击类型,你的代码需要考虑到这一点进行设计。
开放网络应用安全项目(OWASP)是一个学习如何保护你的网络应用程序免受不良行为侵害的优秀资源。让我们看看一些最常见的攻击形式:注入、跨站脚本(XSS)、跨站请求伪造(XSRF/CSRF)。
像本地文件包含(LFI)、远程文件包含(RFI)或远程代码执行(RCE)这样的攻击都是通过客户端输入欺骗服务器执行某些操作或泄露服务器文件的攻击。
安全代码背后的理念简单,但在实践中很难做好。两个基本的原则是:在应用程序和数据中执行任何操作之前,都应该仔细评估来自外部世界的每个请求;应用程序在系统上所做的每一件事都应该有一个明确和有限的作用域。
限制应用程序的作用域
即使你信任认证系统,你也应该确保连接的人拥有完成工作所需的最小访问级别。如果有客户端连接到你的微服务并能够进行认证,这并不意味着他们应该被允许执行任何操作。
这种作用域限制可以通过 JWTs 通过定义角色(如读写)并在令牌中添加该信息来实现。这就是当你授予 GitHub 账户或 Android 手机上的应用程序访问权限时会发生的情况。
这是在网络级控制和防火墙的基础上。如果你控制着微服务生态系统的所有部分,你还可以在系统级别使用严格的防火墙规则来白名单允许与每个微服务交互的 IP 地址。
除了网络访问之外,任何其他应用程序可以访问的资源都应在可能的情况下进行限制。在 Linux 上以 root 用户运行应用程序不是一个好主意,因为如果你的应用程序拥有完整的行政权限,那么成功入侵的攻击者也会有。
从本质上讲,如果一层安全措施失败,后面应该还有另一层。在现代部署中,系统根访问已成为一种间接威胁,因为大多数应用程序都在容器或一个虚拟机(VM)中运行。
为了减轻这个问题,您应该遵循以下两条规则:所有软件都应该以尽可能小的权限集运行;在执行来自您的网络服务的进程时,要非常谨慎,并在可能的情况下避免。
对于第二条规则,除非绝对必要,否则应避免使用任何 Python 对 os.system() 的调用,因为它在计算机上创建一个新的用户 shell,增加了运行不良命令的风险。
不受信任的传入数据
大多数应用程序接受数据作为输入:要查找哪个账户;为哪个城市获取天气预报;要将钱转入哪个账户,等等。问题是来自我们系统之外的数据不容易被信任。
之前,我们讨论了 SQL 注入攻击;现在让我们考虑一个非常简单的例子,其中我们使用 SQL 查询来查找用户。我们有一个函数,它将查询视为要格式化的字符串,并使用标准的 Python 语法填充它。
当 user_id 总是合理的值时,这看起来是正常的。然而,如果有人提供了一个精心制作的恶意值呢?如果我们允许人们为上面的 get_user() 函数输入数据,并且他们不是输入一个数字作为 user_id,而是输入 '1'; insert into user(id, firstname, lastname, password) values (999,'pwnd','yup','somehashedpassword')。
现在我们的 SQL 语句实际上是两个语句。get_user 将执行预期的查询,以及一个将添加新用户的查询!它还可以删除表,或执行 SQL 语句可用的任何其他操作。可以通过引用构建原始 SQL 查询时使用的任何值来防止这种情况。
如果您有一个视图,它从传入的请求中获取 JSON 数据并将其用于向数据库推送数据,您应该验证传入的请求包含您期望的数据,而不是盲目地将其传递给您的数据库后端。
服务器端模板注入(SSTI)是一种可能的攻击,其中您的模板盲目执行 Python 语句。在 2016 年,在 Uber 网站的一个 Jinja2 模板上发现了一个这样的注入漏洞。
代码类似于这个小应用程序:通过在模板中使用原始的 % 格式化语法进行预格式化,视图在应用程序中创建了一个巨大的安全漏洞,因为它允许攻击者在 Jinja 脚本执行之前注入他们想要的内容。
这就是为什么避免使用输入数据进行字符串格式化很重要,除非有模板引擎或其他提供保护的层。如果您需要在模板中评估不受信任的代码,您可以使用 Jinja 的沙盒。
话虽如此,由于语言本身的性质,Python 沙盒很难配置正确。最安全的做法是完全避免评估不受信任的代码,并确保你不会直接依赖于传入数据用于模板。
重定向和信任查询
在处理重定向时,也适用相同的预防措施。一个常见的错误是创建一个登录视图,假设调用者将被重定向到内部页面,并使用一个普通的 URL 进行重定向。
这个视图可以将调用者重定向到任何网站,这是一个重大的威胁——尤其是在登录过程中。良好的做法是在调用 redirect() 时避免使用自由字符串,而是使用 url_for() 函数,这将创建一个指向你的应用域的链接。
一种解决方案是创建一个受限制的第三方域名列表,你的应用程序允许重定向到这些域名,并确保应用程序或底层第三方库执行的重定向都经过该列表的检查。
这可以通过在视图生成响应后、Quart 将响应发送回客户端之前调用的 after_request() 钩子来完成。如果应用程序尝试发送回一个 302 状态码,你可以检查其位置是否安全。
清洗输入数据
除了处理不受信任数据的其他做法之外,我们可以确保字段本身符合我们的预期。面对上述示例,我们可能会想过滤掉任何分号,或者可能所有花括号,但这让我们处于必须考虑数据可能出现的所有错误格式的位置。
相反,我们应该专注于我们对我们数据外观的了解——而不是它不应该是什么样子。这是一个更窄的问题,答案通常更容易定义。例如,如果我们知道一个端点接受 ISBN 来查找一本书,那么我们知道我们只应该期望一个由 10 或 13 位数字组成的序列。
即使是电子邮件地址的验证也非常复杂。有一句经常引用的话是,验证电子邮件地址的最佳方式是尝试发送一封电子邮件,这种方法既被合法网站使用也被垃圾邮件发送者使用。
总结来说,你应该始终将传入的数据视为潜在的威胁,将其视为可能注入你系统的攻击源。转义或删除任何特殊字符,避免在没有隔离层的情况下直接在数据库查询或模板中使用数据,并确保你的数据看起来是你预期的样子。
你还可以使用 Bandit 代码检查器持续检查代码中的潜在安全问题,这在下一节中进行了探讨。
使用 Bandit 代码检查器
由 Python 代码质量权威机构管理,Bandit 是另一个用于扫描源代码中潜在安全风险的工具。它可以在 CI 系统中运行,以在部署之前自动测试任何更改。
将 Bandit 添加到与其他检查并行的持续集成管道中,是捕捉代码中潜在安全问题的好方法。
依赖项
大多数项目都会使用其他库,因为程序员是在他人的工作上构建的,而且通常没有足够的时间密切关注那些其他项目的发展。如果我们的依赖项中存在安全漏洞,我们希望快速了解这一点,以便我们可以更新我们的软件。
Dependabot 是一个会对你的项目依赖进行安全扫描的工具。Dependabot 是 GitHub 的内置组件,其报告应显示在你的项目安全选项卡中。
PyUp 拥有一组类似的功能,但需要手动设置。
网络应用防火墙
即使数据处理得再安全,我们的应用程序仍然可能容易受到攻击。当你向世界公开 HTTP 端点时,这始终是一个风险。你希望调用者按预期行事,每个 HTTP 会话都遵循你在服务中编程的场景。
一个客户端可以发送合法的请求,并不断地用这些请求轰炸你的服务,导致由于所有资源都用于处理来自攻击者的请求而出现服务拒绝(DoS)。当使用数百或数千个客户端进行此类操作时,这被称为分布式拒绝服务(DDoS)攻击。
在服务器端添加保护以使这些热情的客户退却通常并不困难,并且这可以大大保护你的微服务堆栈。一些云服务提供商还提供针对 DDoS 攻击的保护以及这里提到的许多功能。
在本节中,我们将专注于创建一个基本的 WAF,该 WAF 将明确拒绝在我们的服务上请求过多的客户端。本节的目的不是创建一个完整的 WAF,而是让你更好地理解 WAF 的实现和使用方式。
OpenResty:Lua 和 nginx
OpenResty 是一个嵌入 Lua 解释器的 nginx 发行版,可以用来编写 Web 服务器脚本。然后我们可以使用脚本将规则和过滤器应用于流量。
Lua 是一种优秀、动态类型的编程语言,它拥有轻量级且快速的解释器。该语言提供了一套完整的特性,并内置了异步特性。你可以在纯 Lua 中直接编写协程。
如果你安装了 Lua,你可以使用 Lua 读取 - 评估 - 打印循环(REPL)来玩转这门语言,就像使用 Python 一样。
要了解 Lua 语言,这是你的起点页面。Lua 经常是嵌入到编译应用程序中的首选语言。它的内存占用非常小,并且允许快速动态脚本功能——这就是在 OpenResty 中发生的事情。
当你从你的 nginx 配置中调用一些 Lua 代码时,OpenResty 使用的 LuaJIT 解释器将运行它们,运行速度与 nginx 代码本身相同。
Lua 函数是协程,因此将在 nginx 中异步运行。这导致即使服务器收到大量并发请求时,开销也很低,这正是 WAF 所需要的。
OpenResty 以 Docker 镜像和一些 Linux 发行版的软件包的形式提供。如果需要,也可以从源代码编译。
安装 OpenResty 后,你将获得一个 openresty 命令,它可以像 nginx 一样使用来服务你的应用程序。在以下示例中,nginx 配置将代理请求到运行在端口 5000 上的 Quart 应用程序。
此配置可以使用 openresty 命令行,并在端口 8888 上以前台(守护进程关闭)模式运行,以代理转发到运行在端口 5000 上的 Quart 应用程序。
注意,此配置也可以用于普通的 nginx 服务器,因为我们还没有使用任何 Lua。这就是 OpenResty 的一个优点:它是 nginx 的直接替换品,可以运行你的现有配置文件。
本节中展示的代码和配置可以在 GitHub 上找到。
Lua 可以在请求到来时被调用;本章中最吸引人的两个时刻是:access_by_lua_block,这在构建响应之前对每个传入请求进行调用,并且是我们构建 WAF 访问规则的地方;content_by_lua_block,这使用 Lua 生成响应。
速率和并发限制
速率限制包括在给定时间段内统计服务器接受的请求数量,并在达到限制时拒绝新的请求。并发限制包括统计由 Web 服务器为同一远程用户服务的并发请求数量,并在达到定义的阈值时拒绝新的请求。
这些技术在我们知道应用可以同时响应多少请求的上限时,可以避免应用内部出现任何问题,并且这可能是跨多个应用实例进行负载均衡的一个因素。
OpenResty 附带了一个用 Lua 编写的速率限制库,名为 lua-resty-limit-traffic;你可以在 access_by_lua_block 部分中使用它。
该函数使用 Lua 的 Shared Dict,这是一个由同一进程内的所有 nginx 工作进程共享的内存映射。使用内存字典意味着速率限制将在进程级别上工作。
由于我们通常在每个服务节点上部署一个 nginx,因此速率限制将按每个 Web 服务器进行。所以,如果你为同一个微服务部署了多个节点,我们的有效速率限制将是单个节点可以处理的连接数乘以节点数。
在以下示例中,我们添加了一个 lua_shared_dict 定义和一个名为 access_by_lua_block 的部分来激活速率限制。
access_by_lua_block 部分可以被视为一个 Lua 函数,并且可以使用 OpenResty 公开的一些变量和函数。例如,ngx.var 是一个包含所有 nginx 变量的表,而 ngx.exit() 是一个可以用来立即向用户返回响应的函数。
该库使用传递给 resty.limit.req 函数的 my_limit_req_store 字典;每次请求到达服务器时,它都会使用 binary_remote_addr 值调用 incoming() 函数,这是客户端地址。
incoming() 函数将使用共享字典来维护每个远程地址的活跃连接数,并在该数字达到阈值时返回一个拒绝值;例如,当并发请求超过 300 时。
如果连接被接受,incoming() 函数会发送回一个延迟值。Lua 将使用该延迟和异步的 ngx.sleep() 函数保持请求。当远程客户端未达到 200 的阈值时,延迟将为 0,当在 200 和 300 之间时,会有一个小的延迟,这样服务器就有机会处理所有挂起的请求。
这种设计非常高效,可以防止服务因过多请求而超负荷。设置这样的上限也是避免达到一个你知道你的微服务将开始崩溃的点的好方法。
在这个例子中,用于计算速率的关键是请求的远程地址头。如果你的 nginx 服务器本身位于代理后面,确保你使用包含真实远程地址的头。否则,你将对单个远程客户端和代理服务器进行速率限制。
如果你需要一个功能更丰富的 WAF,lua-resty-waf 项目就像 lua-resty-limit-traffic 一样工作,但提供了很多其他保护。它还能够读取 ModSecurity 规则文件,因此你可以使用 OWASP 项目中的规则文件,而无需使用 ModSecurity 本身。
其他 OpenResty 功能
OpenResty 内置了许多 Lua 脚本,这些脚本可以用来增强 nginx。一些开发者甚至用它来直接提供数据。以下组件页面包含了一些有用的工具,用于让 nginx 与数据库、缓存服务器等交互。
此外,还有一个网站供社区发布 OpenResty 组件。如果你正在你的 Quart 微服务前面使用 OpenResty,可能还有其他用例,你可以将 Quart 应用中的一些代码转移到 OpenResty 的几行 Lua 代码中。
目标不应该是将应用的逻辑移动到 OpenResty,而应该是利用 Web 服务器在调用你的 Quart 应用之前或之后执行任何可以执行的操作。让 Python 专注于应用逻辑,而 OpenResty 则专注于一层保护。
例如,如果你正在使用 Redis 或 Memcached 服务器来缓存一些 GET 资源,你可以直接从 Lua 调用它们,为特定的端点添加或检索缓存的版本。srcache-nginx-module 就是这样一种行为的实现,如果你能缓存它们,它将减少对 Quart 应用程序的 GET 调用次数。
要总结关于 WAF 的这一部分:OpenResty 是一个强大的 nginx 发行版,可以用来创建一个简单的 WAF 来保护你的微服务。它还提供了超出防火墙功能的特性。实际上,如果你采用 OpenResty 来运行你的微服务,Lua 将打开一个全新的可能性世界。
第八章:制作仪表板
到目前为止,大部分工作都集中在构建微服务和使它们相互交互上。现在是时候将人类纳入方程,通过用户界面(UI)让我们的最终用户能够通过浏览器使用系统,并更改可能通过 Slack 进行操作显得尴尬或不智的设置。
现代 Web 应用在很大程度上依赖于客户端 JavaScript(JS,也称为 ECMAScript)。一些 JS 框架在提供完整的模型 - 视图 - 控制器(MVC)系统方面做到了极致,该系统在浏览器中运行并操作文档对象模型(DOM)。
Web 开发范式已经从在服务器端渲染一切转变为在客户端渲染一切,客户端根据需要从服务器收集数据。原因是现代 Web 应用动态地更改已加载网页的部分,而不是调用服务器进行完整渲染。这一客户端转变的最大例子之一是 Gmail 应用。
类似于 Facebook 的 ReactJS 这样的工具提供了高级 API,以避免直接操作 DOM,并提供了一种抽象级别,使得客户端 Web 开发如同构建 Quart 应用一样舒适。
话虽如此,每两周似乎都会出现一个新的 JS 框架,而且往往很难决定使用哪一个。AngularJS 曾经是最酷的玩具,但现在许多开发者已经转向使用 ReactJS 来实现他们的大部分应用 UI。
还有一些新的语言,例如 Elm,它提供了一种编译到 JavaScript 的函数式编程语言,允许在编译时检测许多常见的编程错误,同时其运行时也能与任何浏览器兼容。
如果你将 UI 与系统其他部分进行了清晰的分离,从一种 JS 框架迁移到另一种应该不会太难。这意味着你不应该改变你的微服务发布数据的方式,使其特定于 JS 框架。
对于我们的目的,我们将使用 ReactJS 来构建我们的小型仪表板,并将其包装在一个专门的 Quart 应用程序中,该应用程序将其与系统其他部分连接起来。我们还将看到该应用程序如何与所有我们的微服务交互。
本章由以下三个部分组成:构建 ReactJS 仪表板——ReactJS 简介及示例;如何在 Quart 应用程序中嵌入 ReactJS 并构建应用程序结构;身份验证和授权。
到本章结束时,你应该对如何使用 Quart 构建 Web UI 有很好的理解,并了解如何使其与微服务交互——无论你是否选择使用 ReactJS。
构建 ReactJS 仪表板
ReactJS 框架实现了对 DOM 的抽象,并提供快速高效的机制来支持动态事件。创建 ReactJS UI 涉及创建具有一些标准方法的类,这些方法将在事件发生时被调用,例如 DOM 准备就绪、React 类已加载或用户输入发生。
类似于 nginx 这样的网络服务器,处理所有困难和常见的网络流量部分,让你专注于端点的逻辑,ReactJS 允许你专注于方法实现,而不是担心 DOM 和浏览器状态。
JSX 语法
在编程语言中表示 XML 标记可能是一项艰巨的工作。一种看似简单的方法可能是将所有标记视为字符串,并将内容格式化为模板,但这种方法意味着你的代码并不理解所有这些标记的含义。
相反,有一个更好的混合模型,使用转换器——一种生成不同形式源代码而不是可执行程序的编译器。JSX 语法扩展向 JavaScript 添加 XML 标签,并可以转换为纯 JavaScript,无论是在浏览器中还是在之前。
JSX 被 ReactJS 社区推广为编写 React 应用程序的最佳方式。在下面的示例中,一个
从那里,ReactDOM.render() 函数可以在你指定的 id 处将 greeting 变量渲染到 DOM 中。这两个 ReactJS 脚本都是 React 分发的部分,在这里我们使用的是开发版本,它们在编写代码时将提供更有帮助的错误信息。
Babel 是一个转换器,可以将 JSX 即时转换为 JS,以及其他可用的转换。要使用它,你只需将脚本标记为 text/babel 类型。
JSX 语法是了解 React 的唯一特定语法差异,因为其他所有操作都是使用常见的 JavaScript 完成的。从那里,构建 ReactJS 应用程序涉及创建类来渲染标记并响应用户事件,这些类将被用来渲染网页。
React 组件
ReactJS 基于这样的想法:网页可以从基本组件构建,这些组件被调用以渲染显示的不同部分并响应用户事件,如键入、点击和新数据的出现。
例如,如果你想显示人员列表,你可以创建一个 Person 类,该类负责根据其值渲染单个人员的详细信息,以及一个 People 类,它遍历人员列表并调用 Person 类来渲染每个项目。
每个类都是通过 React.createClass() 函数创建的,该函数接收一个包含未来类方法的映射。createClass() 函数生成一个新的类,并设置一个 props 属性来存储一些属性以及提供的方法。
Person 类返回一个 div——一个部分或分区——通过引用实例中的 props 属性来包含关于该人的详细信息。更新这些属性将更新对象,从而更新显示。
当创建 Person 实例时,props 数组会被填充;这就是在 People 类的 render() 方法中发生的事情。peopleNodes 变量遍历 People.props.data 列表,其中包含我们要展示的人的列表。
剩下的工作就是实例化一个 People 类,并将要由 React 显示的人员列表放入其 props.data 列表中。在我们的 Jeeves 应用中,这个列表可以由适当的微服务提供。
以下代码中的 loadPeopleFromServer() 方法就是这种情况,它基于前面的示例——将其添加到同一个 jsx 文件中。代码在列出所有用户的端点上调用我们的数据服务,使用 GET 请求并期望得到一些 JSON 响应。
当状态发生变化时,一个事件会被传递给 React 类以更新 DOM 中的新数据。框架调用 render() 方法,该方法显示包含 People 的
要触发 loadPeopleFromServer() 方法,类实现了 componentDidMount() 方法,该方法在类实例在 React 中创建并挂载后调用,准备显示。最后但同样重要的是,类的构造函数提供了一个空的数据集,这样在数据加载之前,显示就不会中断。
这个分解和链式的过程一开始可能看起来很复杂,但一旦实施,它就非常强大且易于使用:它允许你专注于渲染每个组件,并让 React 处理如何在浏览器中以最有效的方式完成它。
每个组件都有一个状态,当某个东西发生变化时,React 首先更新其自身对 DOM 的内部表示——虚拟 DOM。一旦虚拟 DOM 发生变化,React 就可以在实际的 DOM 上高效地应用所需的更改。
我们在本节中看到的所有 JSX 代码都可以保存到一个 JSX 文件中——它是静态内容,所以让我们将其放在一个名为 static 的目录中——并在以下方式中用于 HTML 页面。
在这个演示中,PeopleBox 类使用 /api/users URL 实例化,一旦网页加载并处理完毕,componentDidMount 方法就会被触发,React 调用该 URL,并期望返回一个人员列表,然后将其传递给组件链。
注意,我们在最后两行中也设置了组件的渲染位置:首先,我们找到 HTML 中具有正确标识符的元素,然后告诉 React 在其中渲染一个类。
在浏览器中直接使用转译是不必要的,因为它可以在构建和发布应用程序时完成,正如我们将在下一节中看到的。
本节描述了 ReactJS 库的非常基本的用法,并没有深入探讨其所有可能性。如果您想了解更多关于 React 的信息,应该尝试在 reactjs.org/tutorial/tutorial.html 上的教程。
预处理 JSX
到目前为止,我们一直依赖网络浏览器为我们转换 JSX 文件。然而,我们仍然可以这样做,但这将是每个访问我们网站的浏览器所做的工作。相反,我们可以处理自己的 JSX 文件,并向访问我们网站的人提供纯 JavaScript。
首先,我们需要一个 JavaScript 包管理器。最重要的一个是要使用 npm。npm 包管理器通过 Node.js 安装。一旦安装了 Node.js 和 npm,您应该能够在 shell 中调用 npm 命令。
将我们的 JSX 文件转换过来很简单。将我们从 static/ 创建的 .jsx 文件移动到一个名为 js-src 的新目录中。我们的目录结构现在应该看起来像这样:mymicroservice/ -> templates/ – 我们所有的 html 文件;js-src/ – 我们的 jsx 源代码;static/ – 转译后的 JavaScript 结果。
我们可以使用以下命令安装我们需要的工具:
$ npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/preset-react
然后,为了我们的开发,我们可以启动一个命令,该命令将连续监视我们的 js-src 目录中的任何文件更改,并自动更新它们,这与 Quart 的开发版本自动重新加载 Python 文件的方式非常相似。
我们可以看到它为您创建了 .js 文件,并且每次您在 js-src/ 中的 JSX 文件上保存更改时,它都会这样做。
要部署我们的应用程序,我们可以生成 JavaScript 文件并将它们提交到仓库,或者作为 CI 流程的一部分生成它们。在两种情况下,处理文件一次的命令都非常相似——我们只是不监视目录,并使用生产预设。
在所有更改完成后,最终的 index.html 文件只需要进行一个小改动,使用 .js 文件而不是 .jsx 文件。
现在我们有了构建基于 React 的 UI 的基本布局,让我们看看我们如何将其嵌入到我们的 Quart 世界中。
ReactJS 和 Quart
从服务器的角度来看,JavaScript 代码是一个静态文件,因此使用 Quart 提供 React 应用程序根本不是问题。HTML 页面可以使用 Jinja2 渲染,并且可以将其与转换后的 JSX 文件一起作为静态内容提供,就像您为纯 JavaScript 文件所做的那样。
我们还可以获取 React 分发版并提供服务这些文件,或者依赖内容分发网络(CDN)来提供它们。在许多情况下,CDN 是更好的选择,因为检索文件将更快,浏览器随后可以选择识别它已经下载了这些文件,并可以使用缓存的副本来节省时间和带宽。
让我们将我们的 Quart 应用程序命名为 dashboard,并从以下简单结构开始:setup.py;dashboard/ -> init.py, app.py, templates/, index.html, static/, people.jsx。
基本的 Quart 应用程序,用于服务独特的 HTML 文件,将看起来像这样:
from quart import Quart, render_template
app = Quart(__name__)
@app.route('/')
def index():
return render_template('index.html')
if __name__ == '__main__':
app.run()
多亏了 Quart 对静态资源的约定,所有包含在 static/ 目录中的文件都将在 /static URL 下提供服务。index.html 模板看起来就像前一个章节中描述的那样,并且以后可以发展成为 Quart 特有的模板。
在本节中,我们一直假设 React 选择的 JSON 数据是由同一个 Quart 应用程序提供的。在同一域上进行 AJAX 调用不是问题,但如果你需要调用属于另一个域的微服务,服务器和客户端都需要进行一些更改。
跨源资源共享
允许客户端 JavaScript 执行跨域请求是一个潜在的安全风险。如果执行在您的域客户端页面上的 JS 代码试图请求您不拥有的另一个域的资源,它可能会执行恶意 JS 代码并损害您的用户。
这就是为什么所有浏览器在发起请求时都使用 W3C 标准进行跨源资源。它们确保请求只能发送到为我们提供页面的域。除了安全之外,这也是防止某人使用您的带宽来运行他们的 Web 应用程序的好方法。
然而,有一些合法的理由想要与其他域共享您的资源,并且您可以在您的服务上设置规则以允许其他域访问您的资源。这就是跨源资源共享(CORS)的全部内容。
当浏览器向你的服务发送请求时,会添加一个 Origin 头,你可以控制它是否在授权域的列表中。如果不是,CORS 协议要求你发送一些包含允许域的头信息。
在服务器端,然而,你需要确保你的端点能够响应 OPTIONS 调用,并且你需要决定哪些域可以访问你的资源。如果你的服务是公开的,你可以使用通配符授权所有域。然而,对于一个基于微服务的应用程序,其中你控制客户端,你应该限制域。
Quart-CORS 项目允许我们非常简单地添加对此的支持:
# quart_cors_example.py
from quart import Quart
from quart_cors import cors
app = Quart(__name__)
app = cors(app, allow_origin="https://quart.com")
@app.route("/api")
async def my_microservice():
return {"Hello": "World!"}
当运行此应用程序并使用 curl 进行 GET 请求时,我们可以在 Access-Control-Allow-Origin: * 头中看到结果。
Quart-CORS 允许更细粒度的权限,使用装饰器可以保护单个资源或蓝图,而不是整个应用程序,或者限制方法为 GET、POST 或其他。还可以使用环境变量设置配置,这有助于应用程序保持灵活性,并在运行时获得正确的设置。
要使我们的 JS 应用程序完全功能,我们还需要认证和授权。
认证和授权
React 仪表板需要能够验证其用户并在某些微服务上执行授权调用。它还需要允许用户授权访问我们支持的任何第三方网站,例如 Strava 或 GitHub。
我们假设仪表板只有在用户认证的情况下才能工作,并且有两种用户:新用户和回访用户。以下是新用户的用户故事:作为一名新用户,当我访问仪表板时,有一个'登录'链接。当我点击它时,仪表板将我重定向到 Slack 以授权我的资源。Slack 然后把我重定向回仪表板,我就连接上了。然后仪表板开始填充我的数据。
如描述所述,我们的 Quart 应用与 Slack 进行 OAuth2 会话以验证用户——我们知道,由于我们正在设置 Slack 机器人,人们应该已经在那里有账户了。连接到 Slack 还意味着我们需要在用户配置文件中存储访问令牌,以便我们可以在以后使用它来获取数据。
在进一步讨论之前,我们需要做出一个设计决策:我们希望仪表板与数据服务合并,还是希望有两个独立的应用?
关于微前端的一些说明
现在我们正在讨论使用 Web 前端验证我们的用户,这就引出了一个问题:我们应该把相应的代码放在哪里。前端架构中的一个近期趋势是微前端的概念。面对与后端相同的许多扩展性和互操作性难题,一些组织正在转向小型、自包含的用户界面组件,这些组件可以包含在一个更大的网站上。
让我们想象一个购物网站。当你访问首页时,会有几个不同的部分,包括:购物类别;网站范围内的新闻和活动,例如即将到来的销售;销售的突出和推广商品,包括定制推荐;你最近查看的商品列表;一个允许你登录或注册账户的小部件,以及其他管理工具。
如果我们开发一个单独的网页来处理所有这些元素,它很快就会变得庞大而复杂,尤其是如果我们需要在网站上的不同页面上重复元素的话。
这种方法引入了一些与单体后端相同的复杂性。对后端或其用户界面的任何更改都意味着更新微服务及其查询的用户界面元素,而这些可能位于不同的源代码控制存储库中,或者由不同的团队管理。
通过使用微前端架构,这些 UI 功能都可以由不同的团队和服务负责。如果'推荐'功能突然需要新的后端或不同的 JavaScript 框架,这是可能的,因为主站只知道它是一个要包含的自包含功能。
这也解放了每个组件的工作人员,因为他们可以在自己的时间表上发布新功能和错误修复,而无需进行大量跨团队协调来部署多个区域的新功能。
值得注意的是,这种架构不需要很多不同的 URL。同一个 nginx 负载均衡器可以被配置为将不同的 URL 路由到不同的后端服务,而客户端对此一无所知——这可能会为迁移到这种架构提供一种有用的方法。
话虽如此,微前端模型仍然相对较新,许多最佳实践甚至术语都还在变化之中。因此,我们将关注这种方法的简化版本,并让认证服务提供自己的 HTML 以登录用户并创建账户,如果需要,可以将其包含在另一个页面中的 iframe 中。
获取 Slack 令牌
Slack 提供了一个典型的三脚 OAuth2 实现,使用一组简单的 HTTP GET 请求。实现交换是通过将用户重定向到 Slack 并暴露一个用户浏览器在访问权限被授予后会被重定向到的端点来完成的。
如果我们请求特殊的身份识别范围,那么我们从 Slack 获得的就是用户身份的确认和唯一的 Slack ID 字符串。我们可以将所有这些信息存储在 Quart 会话中,用作我们的登录机制,并在需要时将电子邮件和令牌值传递给 DataService 用于其他组件。
正如我们在第四章,设计 Jeeves 中所做的那样,让我们实现一个生成要发送给用户的 URL 的函数,结合 Slack 需要的其他信息。
在这里,我们正在使用 Let's Encrypt 证书在 nginx 后面运行我们的 Quart 应用程序,正如我们在第四章,设计 Jeeves 中设置的那样。这就是为什么我们使用配置中的回调 URL 而不是尝试动态处理它,因为这个 URL 与 nginx 相关联。
该函数使用在 Slack 中生成的 Jeeves 应用程序的 client_id,并返回一个我们可以向用户展示的重定向 URL。仪表板视图可以根据需要更改,以便将此 URL 传递给模板。
如果会话中存储了任何 user 变量,我们也会传递一个 user 变量。模板可以使用 Strava URL 来显示登录/注销链接。
当用户点击登录链接时,他们会被重定向到 Strava,然后返回到我们定义的 SLACK_REDIRECT_URI 端点的我们的应用程序。该视图的实现可能如下所示:
使用我们从 Slack 的 OAuth2 服务获得的响应,我们将收到的临时代码放入查询中,将其转换为真实的访问令牌。然后我们可以将令牌存储在会话中或将其发送到数据服务。
我们不详细说明仪表板如何与 TokenDealer 交互,因为我们已经在第七章,保护您的服务中展示了这一点。过程是类似的——仪表板应用程序从 TokenDealer 获取令牌,并使用它来访问 DataService。
身份验证的最后部分在 ReactJS 代码中,我们将在下一节中看到。
JavaScript 身份验证
当仪表板应用程序与 Slack 执行 OAuth2 交换时,它在会话中存储用户信息,这对于在仪表板上进行身份验证的用户来说是一个很好的方法。然而,当 ReactJS UI 调用 DataService 微服务来显示用户跑步时,我们需要提供一个身份验证头。
以下有两种处理此问题的方法:通过仪表板 Web 应用程序使用现有的会话信息代理所有对微服务的调用;为最终用户生成一个 JWT 令牌,该令牌可以存储并用于另一个微服务。
代理解决方案看起来最简单,因为它消除了为访问 DataService 而为每个用户生成一个令牌的需求,尽管这也意味着如果我们想追踪一个交易回一个个人用户,我们必须将 DataService 事件连接到前端事件列表中。
代理允许我们隐藏 DataService 的公共视图。将所有内容隐藏在仪表板后面意味着我们在保持 UI 兼容性的同时有更多的灵活性来更改内部结构。问题在于我们正在强制所有流量通过 Dashboard 服务,即使它不是必需的。
对最终用户来说,我们的公开 API 和 Dashboard 看起来有通往数据的不同路由,这可能会引起混淆。这也意味着如果 DataService 发生故障,那么 Dashboard 也会受到影响,可能停止对试图查看页面的人做出响应。
如果 JavaScript 直接联系 DataService,那么 Dashboard 将继续运行,并且可以发布通知让人们知道正在发生问题。这强烈地引导我们走向第二个解决方案,为最终用户生成一个用于 React 前端的令牌。
如果我们已经将令牌处理给其他微服务,那么网络用户界面只是客户端之一。然而,这也意味着客户端有一个第二个身份验证循环,因为它必须首先使用 OAuth2 进行身份验证,然后获取 JWT 令牌用于内部服务。
正如我们在上一章中讨论的,一旦我们进行了身份验证,我们就可以生成一个 JWT 令牌,然后使用它来与其他受我们控制的服务进行通信。工作流程完全相同——它只是从 JavaScript 中调用。
摘要
在本章中,我们探讨了使用 Quart 应用程序提供的 ReactJS UI 仪表板的构建基础。ReactJS 是在浏览器中构建现代交互式 UI 的绝佳方式,因为它引入了一种名为 JSX 的新语法,这可以加快 JS 执行速度。
我们还探讨了如何使用基于 npm 和 Babel 的工具链来管理 JS 依赖项并将 JSX 文件转换为纯 JavaScript。仪表板应用程序使用 Slack 的 OAuth2 API 连接用户,并使用我们的服务进行身份验证。
我们做出了将 Dashboard 应用程序与 DataService 分离的设计决策,因此令牌被发送到 DataService 微服务进行存储。该令牌然后可以被周期性工作进程以及 Jeeves 动作使用,代表用户执行任务。
最后,构建仪表板对不同的服务进行的调用是独立于仪表板进行的,这使得我们能够专注于在各个组件中做好一件事。我们的授权服务处理所有令牌生成,而我们的仪表板可以专注于对观众做出响应。
图 8.2 包含了新架构的图表,其中包括 Dashboard 应用程序:您可以在 GitHub 上 PythonMicroservices 组织找到 Dashboard 的完整代码。
由于它由几个不同的 Quart 应用组成,当您是一名开发者时,开发一个像 Jeeves 这样的应用程序可能是一个挑战。在下一章中,我们将探讨如何打包和运行应用程序,以便维护和升级变得更加容易。

