Go-微服务-全-

Go 微服务(全)

原文:zh.annas-archive.org/md5/6bc87551e0916c976b61d40a76013782

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自从首次发布以来,Go 编程语言在所有类型的软件开发者中获得了流行。简单的语言语法、易用性和丰富的库使得 Go 成为编写各种软件的主要语言之一,从小型工具到由数百个组件组成的大型系统。

在 Go 的主要使用场景中,微服务开发占据重要地位——即开发单个应用,称为微服务,它们可以扮演各种角色,从处理支付到存储用户数据。将大型系统组织成一组微服务通常带来多种优势,例如提高开发和部署速度,但也带来了多种类型的挑战。这些挑战包括服务发现和通信、集成测试以及服务监控。

在本书中,我们将展示如何实现 Go 微服务并建立它们之间的通信,如何启用单个微服务的部署并确保其交互的安全性,以及如何存储和检索服务数据并提供服务 API,使其他应用程序能够使用我们的微服务。您将了解与所有这些主题相关的行业最佳实践,并详细了解可能遇到的挑战以及可能的收益。通过阅读本书,您将获得的知识将帮助您创建新的微服务,并有效地维护现有的微服务。我希望这次旅程对您来说将是激动人心的!

本书面向的对象

本书面向所有类型的开发者:从对学习如何用 Go 编写微服务感兴趣的人到希望掌握编写可扩展和可靠微服务系统艺术的资深专业人士。本书的前两部分,涵盖微服务开发,对刚开始使用 Go 或对根据行业最佳标准组织 Go 应用程序代码库的最佳实践感兴趣的开发者来说将是有用的;最后一部分对所有开发者都很有用,即使是经验最丰富的开发者,因为它提供了关于在规模上维护和操作微服务的深刻见解。

本书涵盖的内容

第一章微服务简介,将涵盖微服务架构的关键优势和常见问题,帮助您了解微服务解决哪些问题以及通常引入哪些挑战。本章强调 Go 编程语言在微服务开发中的作用,并为本书的其余部分奠定基础。

第二章, 构建 Go 微服务,将向您介绍 Go 编程语言的主要原则,并提供编写 Go 代码的重要建议。它将涵盖设置正确结构以组织 Go 微服务代码的过程,并介绍一个由三个微服务组成的示例应用程序。最后,本章将说明如何为每个示例微服务构建代码。本章中实现的示例微服务将在整本书中使用,每个章节都会为它们添加新功能。

第三章, 服务发现,将讨论服务发现的问题,并说明不同服务如何在微服务环境中找到彼此。它将涵盖最流行的服务发现工具,并引导您将服务发现逻辑添加到上一章的示例微服务中。

第四章, 序列化,将带我们了解数据序列化的概念,这对于理解即将到来的章节中关于微服务通信的内容是必要的。您将了解 Protocol Buffers 数据格式,它将被用于编码和解码我们示例微服务之间传输的数据。本章将提供如何定义可序列化数据类型以及为它们生成代码的示例,以及如何在 Go 微服务中使用生成的代码。

第五章, 同步通信,将涵盖微服务之间的同步通信话题。它将说明如何使用 Protocol Buffers 格式定义服务 API,并介绍 gRPC,一个服务通信框架。本章将以实现微服务网关和客户端以及在我们微服务之间执行远程调用的示例结束。

第六章, 异步通信,将讨论微服务之间的异步通信。它将向您介绍一个流行的异步通信工具 Apache Kafka,并提供使用它为我们的示例微服务发送和接收消息的示例。本章将以在微服务环境中使用异步通信的最佳实践概述结束。

第七章, 存储服务数据,将涵盖在数据库中持久化服务数据的话题。您将了解常见的数据库类型以及它们为软件开发者带来的好处。本章将引导您了解在 MySQL 数据库中存储服务数据逻辑的实现过程。

第八章, 使用 Kubernetes 进行部署,将讨论服务部署,并概述一个流行的部署和编排平台 Kubernetes。本章将说明如何为部署准备服务代码,以及如何使用 Kubernetes 进行部署。本章将包括部署微服务应用的最佳实践。

第九章, 单元和集成测试,将描述测试 Go 微服务代码的常见技术。它将涵盖 Go 单元和集成测试的基础,并演示如何测试前几章中的微服务代码。本章将以编写和组织测试的行业最佳实践结束。

第十章, 可靠性概述,将向您介绍系统可靠性的主题,并描述构建可靠且高度可用的微服务的核心原则、工具和行业最佳实践。它将说明如何自动化服务对各种类型故障的响应,以及如何建立保持服务可靠性在控制之下的流程。

第十一章**, 收集服务遥测数据,将提供收集服务遥测数据(如日志、指标和跟踪)的现代工具和解决方案的详细概述。本章将提供大量收集不同类型遥测数据的详细示例,并列出一些与它们一起工作的最佳实践。

第十二章, 设置服务警报,将说明如何使用前一章收集的遥测数据为微服务设置自动事件检测和通知。它将向您介绍一个流行的警报和监控工具 Prometheus,并展示如何为我们的示例微服务设置 Prometheus 警报。

第十三章, 高级主题,将总结本书的最后部分,并涵盖微服务开发的一些高级主题,例如性能分析、仪表盘、框架、服务所有权和安全。本章将包括一些使用 JWT 协议在 Go 微服务之间设置安全通信的示例。

为了充分利用本书

我建议您通过实现一些应用程序,例如简单的 Web 服务,来熟悉 Go。熟悉 Docker 工具将是一个加分项,因为我们将在运行微服务将使用的一些工具时使用它。最后,我强烈建议实现、运行和玩转我们将要实现的示例微服务,以便通过实践巩固您的所有知识。

本书涵盖的软件/硬件 操作系统要求
Go 1.11或更高版本 Windows、macOS 或 Linux
Docker Windows、macOS 或 Linux
grpcurl Windows、macOS 或 Linux
Kubernetes Windows、macOS 或 Linux
Prometheus Windows、macOS 或 Linux
Jaeger Windows、macOS 或 Linux
Graphviz Windows、macOS 或 Linux

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/microservices-with-go。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。

您可以在此处下载:packt.link/1fb2C

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "time"

    "github.com/confluentinc/confluent-kafka-go/kafka"
    "movieexample.com/rating/pkg/model"
)

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

    if err := p.Produce(&kafka.Message{
TopicPartition: kafka.TopicPartition{Topic: &topic, Partition: kafka.PartitionAny},
Value:          []byte(encodedEvent),
}, nil); err != nil {
        return err
    }
    return nil

任何命令行输入或输出都应如下编写:

mysql movieexample -h localhost -P 3306 --protocol=tcp -u root -p

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要注意事项

看起来是这样的。

联系我们

欢迎读者反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及本书标题。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并在邮件主题中提及本书标题。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com

前言

分享您的想法

读完《使用 Go 进行微服务》后,我们非常乐意听到您的想法!请点击此处直接进入本书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,它将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM-free PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件访问权限。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

图片

https://packt.link/free-ebook/978-1-80461-700-7

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。

第一部分:简介

本部分提供了微服务架构模型的概述。它涵盖了微服务的关键优势和常见问题,帮助读者理解微服务架构有助于解决哪些问题,以及通常会引入哪些问题。本部分的单章专注于 Go 编程语言在微服务开发中的作用,并为本书的其余部分奠定了基础。

本部分包含以下章节:

  • 第一章微服务简介

第一章:微服务简介

在本章中,你将了解微服务及其背后的动机。你将了解微服务架构模型的关键优点和常见问题,并学习何时使用它,以及一些微服务开发的最佳实践。这些知识将帮助你为阅读下一章节打下坚实的基础,并给你一些关于未来可能面临的微服务挑战的想法。

在本章中,我们将涵盖以下主题:

  • 什么是微服务?

  • 使用微服务的动机

  • 微服务的优缺点

  • 何时使用微服务架构

  • Go 在微服务开发中的作用

什么是微服务?

全世界的企业广泛使用了微服务架构模型,以至于它几乎已经成为软件开发的一种默认方式。这些公司拥有成百上千的微服务可供使用。

那么,微服务模型究竟是什么呢?

微服务架构模型是将应用程序组织为一系列服务,称为微服务,每个微服务进一步负责应用程序逻辑的某个部分,通常由特定的业务能力定义。

例如,考虑一个在线市场应用程序。该应用程序可能具有多个功能,包括搜索、购物车、支付、订单历史记录等。每个功能可能都如此不同,以至于代码可能(在某些情况下,应该)与应用程序的其他部分完全独立。在这个例子中,搜索和支付在技术上没有任何共同之处。在微服务架构模型中,每个组件都是一个独立的服务,在系统中扮演着自己的角色。

将应用程序的每一部分组织为独立的服务并不是一个必要条件。与任何架构模型或软件开发方面的任何方面一样,工程师在选择特定的方法或解决方案时需要谨慎——进行初步分析和理解在给定条件下的解决方案。

在我们继续讨论微服务的关键优点和缺点之前,让我们看看当应用程序没有分成多个服务时,你可能会面临哪些挑战。

使用微服务的动机

为了理解使用微服务架构背后的动机,非常重要的一点是看到相反的方法——当应用程序作为一个单一程序构建和执行时。这样的应用程序被称为单体应用程序单体

单体架构在大多数情况下是最简单的实现模型,因为它不涉及将应用程序分割成需要相互协调的多个部分。这可以在许多情况下为你提供主要优势,例如以下内容:

  • 小的代码库:将应用程序分割成多个独立部分可能会通过引入组件之间通信所需的额外逻辑,显著增加代码库的大小。

  • 应用程序逻辑仍然定义松散:应用程序或整个系统在开发初期经历重大的结构或逻辑变化是非常常见的。这可能是由于需求、优先级、商业模式的变化,或开发方法的不同。在开发初期,快速迭代不仅对开发过程至关重要,对整个公司也是如此。

  • 应用程序范围狭窄:并非每个服务都需要分解和分割成单独的部分。考虑一个生成随机密码的服务——它只有一个逻辑功能,在大多数情况下,将其分割成多个部分是不必要的。

在所有上述情况下,单体架构可能更适合该应用程序。然而,在某个时刻,服务变得太大,无法保持单体结构。开发者开始遇到以下问题:

  • 大型应用程序和缓慢的部署:在某个时刻,应用程序可能变得如此之大,以至于构建、启动或部署可能需要几分钟甚至几小时。

  • 无法独立部署应用程序的特定部分:无法替换大型应用程序的一部分,很容易成为瓶颈,减缓开发和发布过程。

  • 更大的影响范围:如果某个广泛用于应用程序代码的函数或库中存在一个错误,它将一次性影响系统的所有部分,可能造成重大问题。

  • 垂直扩展瓶颈:应用程序的逻辑越多,运行它所需的资源就越多。在某个时刻,考虑到 CPU 和 RAM 的限制,可能很难甚至不可能进一步扩展应用程序。

  • 干扰:应用程序的某些部分可能会大量占用 CPU、I/O 或 RAM,导致系统其他部分的延迟。

  • 组件之间的不必要依赖性:将整个应用程序表示为单个可执行文件,会在组件之间留下不必要的依赖性空间。想象一下,一个开发者正在重构代码库,突然一个改动影响了系统的某些重要部分,比如支付系统。组件之间有更多的隔离性,可以提供更多的保护,防止这类问题发生。

  • 安全性:应用程序中可能存在的安全漏洞可能导致所有组件同时被未经授权访问。

除了我们刚才描述的可能问题之外,不同的组件可能有不同的需求,如下所示:

  • 资源和硬件要求:某些组件可能更依赖于 CPU 或内存,并且可能以更高的速率执行 I/O 操作。将此类组件分离可能减少整个系统的负载,提高系统可用性并减少延迟。

  • 部署节奏:系统的某些部分可能基本保持不变,而其他部分可能每天需要多次部署。

  • 部署监控和自动化测试:某些组件可能需要更严格的检查和监控,并且可能因为多步骤部署而需要更慢的部署。

  • 技术或编程语言:系统的一部分可以编写在不同的编程语言中,或使用根本不同的技术、库和框架,这种情况并不少见。

  • 独立的 API:组件可能提供完全独立的 API。

  • 代码审查流程:某些组件可能需要更严格的代码审查流程和额外的要求。

  • 安全性:组件可能具有不同的安全要求,并且可能需要出于安全原因从应用程序的其他部分进行额外的隔离。

  • 合规性:系统的某些部分可能受到更严格的合规性要求。例如,处理来自特定地区的用户的个人身份信息PII)可能对整个系统提出更严格的要求。此类组件的逻辑分离有助于减少保持系统合规所需的工作范围。

在描述了所有上述问题之后,我们可以看到,在某个点上,单体应用程序可能变得太大,无法适应“一刀切”的模式。随着应用程序的增长,其某些部分可能开始变得独立,并具有不同的要求,从逻辑上将其与其他应用程序部分分离是有益的。

在下一节中,我们将看到如何通过将应用程序拆分为微服务来解决上述问题,以及你应该注意哪些方面。

微服务的优缺点

为了了解如何从使用微服务中获得最佳结果以及需要注意哪些问题,让我们回顾一下微服务模型的优缺点。

微服务的优势

如前所述,不同的应用程序组件可能具有根本不同的要求,并在某些点上差异如此之大,以至于将它们分开是有益的。在这种情况下,微服务架构通过解耦系统的各个部分提供了一个明确的解决方案。

微服务为开发者提供了以下好处:

  • 更快的编译和构建时间:更快的构建和编译时间可能在加快所有开发过程中发挥关键作用。

  • 更快的部署,更小的部署大小:当系统的每个部分分别部署时,部署大小可以显著减小,以至于单个部署的时间只需是单体应用程序的一小部分。

  • 自定义部署节奏:微服务模型解决了遵循自定义部署计划的问题。每个服务都可以独立部署并遵循自己的计划。

  • 自定义部署监控:一些服务在系统中可能比其他服务扮演更关键的角色,可能需要更细粒度的监控和额外的检查。

  • 独立和可配置的自动化测试:服务可以配置为在构建和部署管道中执行不同的自动化测试。此外,可以减少对单个微服务的检查范围,也就是说,我们不需要对整个应用程序进行测试,这可能需要更长的时间。

  • 跨语言支持:不再需要将应用程序作为一个单一的可执行文件运行,因此可以使用不同的技术实现系统的不同部分,为每个问题找到最佳匹配。

  • 更简单的 APIs:细粒度的 API 是微服务开发的关键方面之一,拥有清晰高效的 API 有助于确保系统的正确组成。

  • 水平扩展:微服务更容易且通常更便宜地进行水平扩展。单体应用程序通常资源密集,由于硬件要求高,在多个实例上运行可能会非常昂贵。然而,微服务可以独立扩展。因此,如果系统的某个部分需要在数百或数千个服务器上运行,其他部分不需要遵循相同的要求。

  • 硬件灵活性:拆分应用程序通常意味着减少系统大部分的硬件要求。这为选择硬件或云提供商以执行应用程序提供了更多的灵活性。

  • 故障隔离:服务解耦提供了一个有效的安全机制,以防止部分系统故障时出现重大问题。

  • 可理解性:由于代码库规模较小,服务更容易理解和维护。

  • 成本优化:与昂贵的资源密集型单体实例相比,在低级实例上运行大多数应用程序组件可能会为公司节省显著的成本。

  • 分布式开发:去除组件之间的耦合有助于在代码开发中实现更多的独立性,这在分布式团队中可以发挥重要作用。

  • 重构的便捷性:一般来说,由于变更范围较小以及独立的发布和测试流程,对微服务进行重构要容易得多,这有助于检测可能的问题并减少故障的范围。

  • 技术自由度:在微服务架构中,由于每个服务规模较小且在结构上相互独立,因此切换到新技术变得更加容易。这对于具有开放和实验性开发文化的公司来说可以发挥关键作用,帮助它们找到特定问题的正确解决方案,并保持其技术栈的更新。

  • 独立决策:开发者可以自由选择最适合他们需求的编程语言、库和工具。然而,这并不意味着不应该有标准化,但通常在实现分布式决策的一定程度的自由度方面非常有益。

  • 移除不必要的依赖:由于组件之间的耦合更加紧密,因此在单体应用程序的组件之间很容易忽略检测到不想要的依赖。微服务架构有助于您注意到组件之间的不想要的依赖,并限制某些服务仅用于应用程序的特定部分。

正如我们所见,微服务带来了高度的灵活性,并有助于在组件之间实现更高的独立性。这些方面对于大型开发团队的成功可能至关重要,允许他们分别构建和维护独立的组件。然而,任何模型都有其自身的成本,在下一节中,我们将看到在微服务集合中可能会遇到的挑战。

微服务的常见问题

与任何解决方案一样,微服务架构有其自身的问题和局限性。微服务架构的一些问题包括以下内容:

  • 更高的资源开销:当一个应用程序由多个组件组成时,由于这些组件不共享相同的进程空间,因此需要在这些涉及更高网络使用的组件之间进行通信。这给整个系统增加了更多的负载,并增加了流量、延迟和 I/O 使用。此外,由于每个组件单独运行带来的额外开销,总的 CPU 和 RAM 也会更高。

  • 调试难度:当处理多个服务时,故障排除和调试通常会更加困难。例如,如果多个服务处理一个失败请求,开发者需要访问多个服务的日志以了解导致失败的原因。

  • 集成测试:分离一个系统需要构建大量集成测试和其他自动化检查,以监控每个组件的兼容性和可用性。

  • 一致性和事务性:在微服务应用程序中,数据通常分散在整个系统中。虽然这有助于分离应用程序的独立部分,但它使得在系统中进行事务性和原子性更改变得更加困难。

  • 发散性:不同的服务可能使用不同版本的库,这其中包括不兼容或过时的版本。发散性使得进行系统升级和解决各种问题(包括软件漏洞修复)变得更加困难。

  • 技术债务的可解决性:在一个由不同团队拥有的每个组件的分布式系统中解决技术债务要困难得多。

  • 可观察性:管理多个应用程序会带来额外的挑战,包括收集和使用系统事件和消息,如日志、跟踪和指标。开发者需要确保所有这些信号都被收集,并且对所有应用程序都是可用的,包括所有必要的上下文信息,以便调试任何问题并定位问题的根本原因。

  • 可能的重复,功能重叠:在高度分布式开发环境中,系统中有多个组件执行类似角色的情况并不少见。在系统中设定清晰的边界并提前决定组件分配的具体角色是很重要的。

  • 所有权和责任:当有多个不同的团队维护和开发独立组件时,所有权成为开发过程中的一个重要方面。定义明确的所有权合同来解决开发请求、安全和支持问题以及所有其他类型的维护工作至关重要。

正如我们刚才所展示的,微服务模型是有代价的,你应该预期在某个时刻你需要解决所有这些挑战。意识到可能遇到的挑战并在解决它们方面采取主动是成功的关键——我们之前描述的好处可以轻易地超过可能的问题。

在下一节中,我们将总结何时使用微服务,并学习一些与微服务一起工作的最佳实践。

何时使用微服务架构

我们已经涵盖了微服务的优势和常见问题,提供了使用微服务架构模型在应用程序中应用的良好概述。让我们总结使用微服务模型的关键点,如下所示:

  • 不要过早引入微服务:如果产品定义不明确或可能经历重大变化,不要过早使用微服务架构。即使开发者知道系统的确切目的,在开发过程的早期阶段也可能会出现各种变化。从一个单体应用程序开始——一旦有明确定义的业务能力和边界,再逐步拆分——有助于减少工作量并建立组件之间的正确接口。

  • 没有一种大小适合所有人: 每家公司都是独特的,最终的决定应该取决于许多因素,包括团队的大小、分布和地理位置。一个小的本地团队可能对使用单体应用程序感到舒适,而一个地理上分布的团队可能高度受益于将应用程序拆分为多个微服务,以实现更高的灵活性。

此外,让我们总结一下使用微服务架构模型的最佳实践,以下是一些:

  • 设计容错性: 在微服务架构中,组件之间存在许多交互,其中大部分是通过远程调用和事件发生的。这增加了各种失败的可能性,包括网络超时、客户端错误等等。构建系统时,要考虑到每一个可能的失败场景以及处理这些场景的不同方式。

  • 拥抱自动化: 拥有更多独立组件需要更严格的检查,以便在服务之间实现稳定的集成。投资于坚实的自动化是绝对必要的,以实现高度的可靠性并确保所有更改都是安全部署的。

  • 不要发货层次结构: 根据组织结构将应用程序拆分为服务是一种相对常见的做法,其中每个团队可能负责其自己的服务。如果组织结构与微服务的业务能力完美匹配,这种模式效果很好,但这种情况并不常见。与其使用按团队划分的服务模式,不如尝试定义清晰的领域和业务能力,这些领域和业务能力是代码结构的基础,并观察组件之间是如何相互作用的。实现完美的组合并不容易,但你会为此得到丰厚的回报。

  • 投资于集成测试: 确保你对微服务之间的集成进行全面的测试,并且这些测试是自动执行的。

  • 考虑向后兼容性: 总是记得保持你的更改向后兼容,以确保新的更改是安全部署的。此外,使用诸如版本控制等技术,这些技术我们将在本书的下一章中介绍。

到目前为止,我们已经涵盖了微服务开发的关键方面,你也学习了它的好处以及你可能会面临的挑战。在我们进入下一章之前,让我们再讨论一个话题,以确保我们为微服务开发的旅程做好准备。让我们熟悉 Go 编程语言及其在微服务开发中的作用。

Go 在微服务开发中的作用

在过去的十年中,Go 编程语言已经成为应用开发中最受欢迎的语言之一。许多因素促成了它的成功,包括其简洁性、编写网络应用的简便性以及轻松开发并行和并发应用的能力。

此外,更大的开发者社区在提高其所有类型开发者中的知名度方面发挥了关键作用。Go 社区对所有编程初学者和拥有几十年不同类型应用程序构建经验的资深专家都持开放态度。

Go 的标准库提供了一套包,通常足以构建完整的网络应用程序或整个服务,有时甚至不需要任何外部依赖。许多开发者都对编写应用程序和工具的简便性着迷,这些工具可以执行网络调用、数据序列化和编码、文件处理以及许多其他类型的常见操作。

这种简单性,加上快速高效的编译成原生二进制文件以及丰富的工具集,使得 Go 成为编写网络工具和服务的首选语言之一。Go 语言在开发网络服务方面的高采用率,使其成为行业内编写微服务的主要选择之一。

Go 在微服务开发中的最大优势包括以下内容:

  • 学习曲线平缓:作为增长团队中应用程序开发的关键方面之一,Go 语言的简单性有助于缩短新、缺乏经验的开发者的入职时间。

  • 显式错误处理:虽然错误处理在 Go 社区是一个热门话题,但 Go 中的错误处理鼓励显式处理所有应用程序错误。这与微服务开发的一个关键原则——设计应用程序以应对故障——相一致。

  • 有用的标准库:Go 的标准库包含许多可以在生产级系统中使用而无需外部解决方案的包。

  • 社区支持:Go 社区是行业内最大的之一,最受欢迎的库得到了足够的支持和维护。

  • 编写并发代码的易用性:在微服务应用程序逻辑中,并发调用非常常见——微服务通常调用多个其他服务并合并它们的结果。利用内置的 sync 包和核心语言特性,如通道和 Goroutines,在 Golang 中编写并发代码可以是一项相当简单的工作。

Go 社区的增长加速了该语言额外库的开发速度,并导致了整个工具生态系统的创建,这些工具支持应用程序日志记录、调试以及所有广泛使用的网络协议和标准的实现。社区持续增长,新版本发布的速度也在不断加快。

摘要

我们已经讨论了微服务开发模型的关键方面,包括使用它的动机、常见的好处以及可能遇到的挑战。您已经了解到微服务模型带来了许多优势,帮助您实现更高的灵活性。但它也伴随着自己的成本,这通常包括额外的复杂性和系统中的不统一性。微服务架构要求您积极思考这些问题,以便在它们成为大问题之前解决它们。

在下一章中,我们将开始使用 Go 语言进入微服务开发的旅程。您将学习 Go 编程语言的重要基础知识,我们将搭建我们的微服务,这些微服务将在本书的其余部分得到改进。

进一步阅读

第二部分:基础

本部分涵盖了 Go 微服务开发的基础方面,例如服务发现、数据序列化、同步和异步通信、部署和测试。您将学习如何搭建 Go 微服务,建立它们之间的通信,存储服务数据,以及实现服务 API,以及微服务开发的其他许多重要方面。

本部分包含以下章节:

  • 第二章**,搭建 Go 微服务

  • 第三章**,服务发现

  • *第四章**,序列化

  • 第五章,同步通信

  • 第六章,异步通信

  • 第七章,存储服务数据

  • 第八章,使用 Kubernetes 进行部署

  • 第九章,单元和集成测试

第二章:搭建 Go 微服务

在本章中,我们最终将开始搭建我们的微服务代码。本章的目标是为编写 Go 微服务和为未来的变更设置正确的结构奠定坚实的基础。虽然 Go 使得编写小型应用程序相对容易,但工程师在过程中可能会遇到多个挑战,包括以下内容:

  • 如何设置合适的项目结构,使其更容易演进和维护代码库

  • 如何编写符合最大 Go 代码库规范的惯用 Go 代码

  • 如何分离微服务的组件并将它们连接起来

在本章中,我们将解决这些挑战中的每一个。首先,你将了解编写惯用和传统 Go 代码的关键方面。你将学习编写和组织代码库的重要建议,以及如何为你的服务设置适当的代码结构。然后,我们将向你介绍一个示例应用,它将包含三个微服务,我们将在整本书中使用这些微服务。在接下来的章节中,我们将向这些服务添加更多功能,展示微服务开发的所有重要领域。

在本章中,我们将涵盖以下主题:

  • Go 基础

  • 项目结构

  • 搭建示例应用

技术要求

要完成本章,你需要安装 Go 1.11 或更高版本。如果你还没有安装 Go,你可以从官方网站 go.dev/dl 下载。

你可以在 GitHub 上找到本章的代码示例:github.com/PacktPublishing/microservices-with-go/tree/main/Chapter02

Go 基础

Go 是编写微服务的优秀语言。它相对容易学习,学习曲线平缓,使得新工程师的入职更加容易。虽然你可能已经对 Go 有了一些经验,但本书的一个目的就是为所有类型的开发者提供足够的信息——从初学者到经验丰富的专业人士。

在本节中,我们将总结语言的重要概念。如果你已经对 Go 有了一些经验,你仍然可以快速浏览这部分内容。它还包括一些有用的建议和最佳实践,即使是经验丰富的工程师也常常会忽略。

核心原则

在我们继续探讨 Go 的基础知识之前,我将与你分享一些基本原理,这些原理将帮助你编写和组织代码时做出决策。这些原则包括以下内容:

  • 始终遵循官方指南。我们工程师对各种风格和编码实践有强烈的意见并不罕见。然而,在任何开发者社区中,一致性比个人意见更重要。确保您熟悉 Go 团队编写的最基本 Go 编程指南:

  • 遵循标准库中的风格。任何 Go 安装都附带的标准 Go 库是代码示例和注释的最佳来源。熟悉一些库中的包,例如contextnet。遵循这些包中使用的编码风格将帮助您编写一致、可读和可维护的代码,无论将来谁使用它。

  • 不要试图将其他语言中的思想应用到 Go 中。相反,理解 Go 的哲学,并查看最优雅的 Go 包的实现——您可以检查net包以获取一些好的示例:pkg.go.dev/net

现在,我们已经明确了核心原则,让我们继续讨论编写传统和惯用 Go 代码的关键建议。

写惯用的 Go 代码

本节总结了Effective Go文档中描述的关键主题。遵循本节中提供的建议将帮助您使代码与官方指南保持一致。

命名

命名是 Go 开发最重要的方面之一。以惯用的方式编写 Go 代码需要理解其核心命名原则:

  • 导出的名称以大写字母开头。

  • 当从另一个包导入变量、结构体或接口时,其名称包括包名或别名,例如bytes.Buffer

  • 由于引用包括包名,因此您不应在您的名称前加上包名。如果包名是xml,则使用名称Reader,而不是XMLReader——在后一种情况下,完整名称将是xml.XMLReader

  • 包通常使用小写、单词命名。

  • Get前缀开始获取器的名称不是惯用的。如果您的函数返回用户的年龄,请将函数命名为Age(),而不是GetAge()。然而,使用Set前缀是可以的;您可以安全地调用您的函数SetAge()

  • 单方法接口使用方法名加上er后缀命名。例如,具有Write函数的接口将被称为Writer

  • 缩写和首字母缩略词应保持一致的字母大小写。正确的版本将是URLurlID,而UrlId将是错误的。

  • 变量名应该比长变量名更短。一般来说,遵循这个简单的规则——名称使用得越接近声明,它就越短。在遍历数组时,使用 i 作为索引变量。

其他命名建议包括以下内容:

  • 包名应该简短、简洁、富有启发性,并且应该为其内容提供上下文,例如,json

  • 保持包的内容与名称一致。如果你开始注意到一个包包含与包名称无关的额外逻辑,考虑将其导出到单独的一个包或使用更具有描述性的名称。

  • 只有当它们被广泛使用时才使用名称缩写(例如,fmtcmd)。

  • 当可能时,避免名称冲突。例如,如果你引入了一组字符串函数,避免将其称为 strings 包,因为 Go 标准库中已经存在一个同名包并且已被广泛使用。

  • 在给代码命名时,考虑客户端的视角。在给代码命名时,考虑代码将被如何使用,例如,用于提供写入功能的 Writer 接口。

除了这些规则之外,请记住在整个代码库中保持命名的一致性。这有助于使阅读和编写新代码变得更加容易——好的命名将作为其他工程师的示例。

注释

注释是 Go 开发的下一个重要方面。Go 注释可以以两种不同的方式使用:

  • 在代码旁边查看注释

  • 查看 godoc 工具生成的包文档

Go 代码注释的一般原则包括以下内容:

  • 每个包都应该有一个描述其内容的注释。

  • Go 中每个导出的名称都应该有注释。

  • 注释应该是完整的句子,并以句号结尾。

  • 注释的第一句话应该以导出的名称开头,并提供其摘要,如下例所示:

    // ErrNotFound is returned when the record is not found.
    
    var ErrNotFound = errors.New("not found")
    

Go 标准库提供了许多优秀的代码注释示例,所以我总是建议熟悉其中的一些示例。

错误

Go 错误的一般建议包括以下内容:

  • 只在真正异常的情况下使用 panic。

  • 总是处理每个错误;不要通过使用 _ 赋值来丢弃错误。

  • 错误字符串应以小写字母开头,除非它们以需要大写的名称开头,例如首字母缩略词。

  • 错误字符串,与注释不同,不应以标点符号结尾,如下例所示:

    return errors.New("user not found")
    
    var errUserNotFound = errors.New("user not found")
    
  • 当调用返回错误的函数时,始终先处理错误。

  • 如果想为条款添加附加信息,请包装错误。Go 中包装错误的传统方法是在格式化错误末尾使用 %w

    if err != nil {
    
        return fmt.Errorf("upload failed: %w", err)
    
    }
    
  • 在检查错误时,使用 == 操作符可能会导致对包装错误的处理不当。有两个解决方案。对于与哨兵错误(例如 errors.New("some error"))的比较,使用 errors.Is

    if errors.Is(err, ErrNotFound) {
    
        // err or some error it wraps is ErrNotFound.
    
    }
    

对于错误类型,使用 errors.As

var e *QueryError
if errors.As(err, &e) {
    // err has *QueryError type.
}

此外,保持错误描述性且简洁。应该总是通过阅读错误消息就能轻松理解到底出了什么问题。

接口

Go 接口的关键原则包括以下内容:

  • 在没有实际使用示例的情况下,不要在它们被使用之前定义接口。

  • 在你的函数中,返回具体的(使用指针或结构体)类型而不是接口。

  • 单方法接口应该通过方法名调用,并包含er后缀,例如,具有Write函数的Writer接口。

看一些内置接口,例如WriterReader,以获取在 Go 中定义和使用接口的好例子。

测试

我们将在本书的第八章中详细讨论测试。让我们在这里提供一些关于以惯用方式编写 Go 测试的关键建议:

  • 测试应该在失败的情况下向用户提供有关到底出了什么问题的信息。

  • 在可能的情况下,考虑编写表驱动测试。参见这个例子:github.com/golang/go/blob/master/src/fmt/errors_test.go

  • 通常,我们只应该测试公共函数。你的私有函数应该通过它们间接测试。

确保你总是为你的代码编写测试。这不仅有助于尽早发现错误,还有助于了解你的代码如何被使用。我个人发现后者特别有用。

上下文

Go 语言与其他流行语言之间的一个关键区别是显式上下文传播。上下文传播是一种将额外的调用参数,称为上下文,传播到函数调用中的机制,传递额外的元数据。

Go 上下文有一个名为context.Context的类型。有多种使用它的方法:

  • 取消逻辑:你可以传递一个特殊的上下文实例,它可以被取消。在这种情况下,所有你打算用这个上下文调用的函数都能够检测到这一点。这种逻辑对于处理应用程序关闭或停止任何处理非常有用。

  • 超时:你可以通过使用相应的上下文函数来设置你的执行超时。

  • 传播额外元数据:你可以在上下文中传播额外的键值元数据。这样,任何后续调用的函数都会在上下文对象中接收到这些元数据。这种方法有一些有用的应用,其中之一是分布式跟踪,我们将在接下来的章节中介绍。

我们将在接下来的章节中回到上下文传播。现在,我们可以定义一些使用 Go 上下文的重要方面:

  • 上下文是不可变的,但可以通过额外的元数据进行克隆。

  • 使用上下文的函数应该将其作为第一个参数接受。

此外,以下是一些上下文最佳实践:

  • 总是传递上下文给执行 I/O 调用的函数。

  • 限制上下文的使用,以传递任何元数据。你应该仅在真正特殊的情况下使用元数据传播,例如前面提到的分布式跟踪。

  • 不要将上下文附加到结构中。

现在,我们已经讨论了编写惯用 Go 代码的关键建议,我们可以继续到下一节,该节将涵盖项目结构建议和 Go 应用的标准。

项目结构

项目结构是代码可读性和可维护性的基础,并在其中发挥着重要作用。正如我们在前面的章节中讨论的,在 Go 项目中,结构可能比其他语言更重要,因为每个导出的名称通常包括其包的名称。这要求你为你的包和目录提供良好且描述性的命名,以及正确的代码层次结构。

虽然官方指南定义了一些关于命名和编码风格的强烈建议,但约束 Go 项目结构的规则并不多。每个项目在本质上都是独特的,开发者通常可以自由选择他们组织代码的方式。然而,在本节中,我们将介绍一些常见的 Go 包组织实践和具体细节。

私有包

在 Go 中,存储在名为 internal 的目录中的所有代码只能由存储在同一目录或其包含的目录中的包导入和使用。将代码放入内部目录可以确保你的代码不会被导出并由外部包使用。这可以在以下不同情况下很有用:

  • 如果某些类型或函数需要导出,则隐藏实现细节以供用户查看。

  • 确保没有外部包依赖于你不想广泛暴露的类型和函数。

  • 移除包之间可能的不必要依赖。

  • 如果你的代码意外地被其他开发者/团队使用,避免额外的重构和维护困难。

我发现使用内部包作为防止不受欢迎的依赖很有用。这在大型存储库和应用中起着重要作用,在这些应用中,包之间意外依赖的可能性很高。没有在私有和公共包之间进行分离的大型代码库通常会受到一种称为 spaghettification 的影响——当包以不受控制和混乱的方式相互依赖时。

公共包

在 Go 中,还有一种具有语义意义的目录名称——名为 pkg 的目录。它意味着可以使用此包中的代码。

官方并不推荐使用 pkg 目录,但它被广泛使用。具有讽刺意味的是,Go 团队在库代码中使用了这个模式,然后又放弃了它,而 Go 社区的其他部分广泛采用了它,以至于它成为了一种常见做法。

是否在你的应用程序中使用pkg目录取决于你。但与内部目录结合使用,可以帮助你组织代码,使私有和公共部分清晰,便于开发者进行代码导航。

可执行包

cmd包在 Go 社区中常用,用于存储一个或多个具有main函数的可执行包的代码。这可能包括启动应用程序的代码或任何可执行工具的代码。对于单应用程序目录,你可以直接在cmd包中存储你的 Go 代码:

cmd/
cmd/main.go

对于多应用程序目录,你可以在cmd包中包含子包:

cmd/
cmd/indexer/main.go
cmd/crawler/main.go

其他常用目录

以下列表包括 Go 社区中一些其他常用的目录或包名称:

  • api: 包含 JSON 模式文件和各种协议(包括 gRPC)的定义。我们将在第四章中介绍这些主题。

  • testdata: 包含测试中使用的数据的文件。

  • web: 网络应用程序组件和资产。

常见文件

这里是一份常见文件名的列表,这将使你的包与官方库和许多第三方库保持一致:

  • main.go: 包含main()函数的文件

  • doc.go: 包文档(对于小型包,不需要单独的文件)

  • *_test.go: 测试文件

  • README.md: 用 Markdown 语言编写的自述文件

  • LICENSE: 如果有的话,许可证文件

  • CONTRIBUTING.md/CONTRIBUTORS/AUTHORS: 贡献者列表和/或作者列表

现在,让我们来探讨组织 Go 应用程序代码库的最佳实践。

最佳实践

在本节中,你可以找到组织 Go 应用程序项目结构的最佳实践列表。这将帮助你使你的代码与成千上万的 Go 包保持一致,并使其符合传统和习惯用法。Go 项目组织的最佳实践包括以下内容:

  • 使用内部目录分离私有代码。

  • 熟悉流行的开源 Go 项目(如github.com/kubernetes/kubernetes)的组织方式。这可以为你提供如何结构化你的存储库的绝佳示例。

  • 以足够细粒度的方式进行拆分。不要过早地拆分包,但也避免在单个包中有太多逻辑。通常,你会发现,给包起一个简短且具体的自我描述性名称越容易,你的代码结构就越好。

  • 避免使用过长的包名。

  • 如果需求发生变化或结构不再反映包名/原始意图,请随时准备更改结构。

这总结了本章描述 Go 应用程序开发和代码组织的核心原则和最佳实践的章节。现在,我们准备进入本章的实际部分。

搭建示例应用程序

我们已经涵盖了编写和组织 Go 应用程序的一般建议,我们终于准备好开始编写代码了!在本节中,我们将介绍一个应用程序,它由多个将在整本书中使用的微服务组成。在每一章中,我们将对其进行添加或改进,将它们从小型示例转换为可用于生产的级别服务。

你将学习如何构建微服务代码并将代码拆分为独立的逻辑部分,每个部分都有自己的角色。我们将应用在本章中获得的项目的结构和 Go 知识,以说明如何为每个服务设置正确的结构并以传统和惯用的方式编写其代码。

电影应用程序

让我们想象我们正在为电影爱好者构建一个应用程序。该应用程序将提供以下功能:

  • 获取电影元数据(如标题、年份、描述和导演)以及聚合的电影评分

  • 评分电影

所列出的功能似乎都密切相关。然而,让我们更仔细地看看它们。

电影元数据

假设我们有一组电影的元数据,包括以下字段:

  • ID

  • 标题

  • 年份

  • 描述

  • 导演

  • 演员名单

关于电影的信息通常不会改变,除非有人想要更新描述,但为了简单起见,我们可以假设我们正在处理一个静态数据集。我们将根据它们的 ID 检索记录,因此我们可以使用任何键值或文档数据库来存储和访问元数据。

评分

现在我们来回顾存储和检索电影评分所需的功能。

通常,我们需要执行以下评分操作:

  • 存储电影评分

  • 获取聚合的电影评分

之后,我们还需要支持评分删除,但就目前而言,我们可以在设计应用程序时记住这个逻辑。

评分数据与电影元数据相当不同——我们都可以添加和删除记录。除此之外,我们还需要返回聚合评分,因此我们或者能够返回一个项目的所有存储评分并在运行时进行聚合,或者有单独的逻辑来执行和存储聚合。你会注意到我们访问评分和电影元数据的方式不同。这暗示着评分数据可以,并且可能应该,与电影元数据分开存储。

在设计应用程序时,提前思考并想象应用程序可能如何发展是有益的。这并不意味着你应该试图预测未来的用例来构建应用程序,因为这可能导致不必要的抽象,如果计划改变,可能后来不再需要。然而,提前思考可能会在找到更有效的方式建模和存储数据时节省你时间,这有助于你适应不断变化的需求。

让我们看看评分服务可能如何演变。在某个时候,我们可能希望将评分功能扩展到其他类型的电影相关记录。用户可能能够执行以下操作:

  • 评分某些电影中的演员表现

  • 评分电影原声带

  • 评分电影的服装设计

在做出支持未来用例的决定时,你应该问自己,在可观察的未来(6 到 12 个月)内,我需要实现该逻辑的可能性有多大?你应该通常避免过多地展望未来,因为需求和目标可能会改变。然而,如果你非常确定你有计划支持特定的功能,你应该确保你的数据模型可以在不进行重大更改的情况下支持这些功能。

假设我们确实想实现之前提到的附加评分。在这种情况下,我们想确保我们可以以支持不同类型对象评分的方式设计我们的应用程序。

让我们定义这样一个评分组件的 API:

  • 存储评分记录,包括以下内容:

    • 给出评分的用户 ID

    • 记录类型

    • 记录 ID

    • 评分值

  • 通过记录 ID 和类型获取聚合评分。

此 API 支持记录类型,因此我们可以轻松地添加更多类型的评分,而无需更改系统。我们在这里做出的权衡是相当合理的——基于仅一个字段(记录类型)的 API 与仅针对电影设计的评分系统的 API 不同。然而,这给了我们在未来引入新的评分类型时完全的自由!鉴于我们决定我们肯定需要在未来需要那些评分,这种权衡似乎是合理的。

我们应该拆分应用程序吗?

让我们总结一下我们刚刚描述的应用程序的两个部分:

  • 电影元数据:

    • 通过电影 ID 检索电影元数据。
  • 评分:

    • 为记录存储一个评分。

    • 通过记录 ID 检索聚合评分。

在我们通过使其支持各种记录类型来抽象评分组件之后,它就不再是电影评分组件,而变成了一个更通用的记录评分系统。电影元数据组件现在与评分系统松散耦合——评分系统可以存储电影的评分,以及任何其他可能的记录类型。

如我们之前讨论的,这两个组件的数据模型也相当不同。电影元数据组件存储静态数据,这些数据将通过 ID 检索,而评分组件存储动态数据,需要聚合。

这两个组件似乎相对独立。这是一个我们可能从将应用程序拆分为独立服务中受益的完美例子:

  • 逻辑是松散耦合的

  • 数据模型不同

  • 数据通常是独立的

此列表并不完整,您需要考虑在第一章中描述的所有方面,以做出关于拆分应用程序的决定。然而,由于本书涵盖了微服务开发,让我们在这里做出决定,并决定将系统拆分为单独的服务。

让我们列出我们将拆分应用程序的服务:

  • 电影元数据服务:通过电影 ID 存储和检索电影元数据记录。

  • 评分服务:存储不同类型记录的评分并检索记录的聚合评分。

  • 电影服务:向调用者提供关于电影或一组电影的完整信息,包括电影元数据和其评分。

为什么我们最终有了三个服务?我们这样做有以下原因:

  • 电影元数据服务将仅负责访问电影元数据记录。

  • 电影服务将提供面向客户端的 API,聚合两种不同的记录类型——电影元数据和评分。这些记录将存储在两个不同的系统中,因此该组件将它们连接起来并返回给调用者。

  • 如果我们在系统中引入任何其他类型的记录,例如点赞、评论和推荐,我们将把它们连接到电影服务,而不是电影元数据服务。电影元数据服务将仅用于访问静态电影元数据,而不是任何其他类型的记录。

  • 电影元数据服务可能在未来通过获取更多与元数据相关的功能而演变,例如编辑或添加不同语言的描述。这也暗示了最好将此组件仅用于与元数据相关的功能。

让我们用图表来展示这些服务:

Figure 2.1 – Movie application services

Figure 2.1 – Movie application services

图 2.1 – 电影应用程序服务

现在,我们已经定义了三个微服务,让我们最终进入编码部分。

应用程序代码结构

让我们确定我们将如何结构化所有微服务的代码,以便相互关联。我建议将它们存储在单个目录中,这将是我们应用程序的根目录。创建一个新的目录(您可以称其为movieapp),并在其中为我们的微服务创建以下目录:

  • rating

  • metadata

  • movie

在整本书中,我将使用相对于您创建的应用程序目录的目录路径,因此当您看到目录或文件名时,请假设它存储在您为此次选择的应用目录中。

项目结构部分,我们知道包含main函数的逻辑通常位于cmd目录中。我们将在我们的微服务中使用这种方法——例如,评分服务的主文件将被称为rating/cmd/main.go

每个服务可能包含一个或多个与以下逻辑角色相关的包:

  • API 处理器

  • 业务/应用程序逻辑

  • 数据库逻辑

  • 与其他服务的交互

注意,尽管应用程序的主要目的是处理 API 请求,但处理器和业务/应用程序逻辑是分开的。这并不是绝对必要的,但将业务逻辑与 API 处理层分开是一种相对良好的实践。这样,如果你从一种类型的 API 迁移到另一种类型(例如,从 HTTP 到 gRPC),或者同时支持两种类型,你不需要两次实现相同的逻辑或重写它。相反,你只需从你的处理器调用业务逻辑,使处理器尽可能简单,并使其主要目的是将请求传递到相关的接口。

我们可以用一张图来展示这种关系:

Figure 2.2 – 服务层

Figure 2.2 – 服务层

Figure 2.2 – 服务层

正如你在图中看到的,API 处理器不直接访问数据库。相反,数据库访问是在业务逻辑层完成的。

在 Go 社区中,关于如何命名服务于这些目的的包没有约定,因此我们可以自由选择我们包的名称。然而,保持这些名称在所有微服务中的一致性是很重要的,所以让我们为这些类型的包达成一个共同的命名约定。

在这本书中,我们将使用以下名称来命名我们的应用程序组件:

  • controller:业务逻辑

  • gateway:与其他服务交互的逻辑

  • handler:API 处理器

  • repository:数据库逻辑

现在,既然我们已经达成了命名上的共识,让我们继续进行设置项目的最后一步。在应用程序根目录中执行以下命令:

go mod init movieexample.com

这个命令创建了一个名为 movieexample.com 的 Go 模块。Go 模块是一组相关包的集合,存储在文件树中。它们帮助管理项目的依赖关系,我们将在所有章节中使用这个特性。

现在,我们可以继续为我们的第一个微服务进行代码脚手架搭建。

电影元数据服务

让我们总结一下电影元数据服务的逻辑:

  • API:获取电影的元数据

  • 数据库:电影元数据数据库

  • 与服务交互:无

  • 数据模型类型:电影元数据

这种逻辑可以转化为以下包:

  • cmd:包含启动服务的 main 函数

  • controller:我们的服务逻辑(读取电影元数据)

  • handler:服务的 API 处理器

  • repository:访问电影元数据数据库的逻辑

让我们将我们服务的逻辑存储在一个名为 metadata 的目录中。根据我们在本章中之前描述的约定,包含主文件的可执行代码将存储在 cmd 包中。我们不会导出的所有代码将存储在 internal 目录中,这包括我们的大部分应用程序。导出的结构将位于 pkg 目录中。

根据我们刚才描述的规则,我们将以以下方式组织我们的包:

  • metadata/cmd

  • metadata/internal/controller

  • metadata/internal/handler

  • metadata/internal/repository

  • metadata/pkg

一旦你创建了这里列出的目录,让我们继续为我们的微服务实现代码。

模型

首先,我们将实现电影元数据的结构。在metadata/pkg目录内,创建一个metadata.go文件,使用以下代码:

package model
// Metadata defines the movie metadata.
type Metadata struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Director    string `json:"director"`
}

这个结构将被我们的服务调用者使用。它包括 JSON 注解,我们将在本章后面使用。

仓库

现在,让我们为处理数据库逻辑创建一个存根逻辑。在metadata/internal/repository目录内,添加一个error.go文件,使用以下代码:

package repository
import "errors"
// ErrNotFound is returned when a requested record is not // found.
var ErrNotFound = errors.New("not found")

此文件定义了当记录未找到时的错误。我们将在我们的实现中使用这个错误。

在下一步中,我们将添加仓库实现。即使你有特定的技术来存储数据,通常提供多个数据库逻辑实现也是有用的。我发现包括一个内存中的数据库逻辑实现非常有用,它可以用于测试和本地开发,减少对任何额外数据库或额外库的需求。我将展示如何做到这一点。

metadata/internal/repository目录内,创建一个名为memory的目录,它将包含内存中的实现或我们的电影元数据库。向其中添加一个memory.go文件,使用以下代码:

package memory
import (
    "context"
    "sync"
    "movieexample.com/metadata/internal/repository"
    "movieexample.com/metadata/pkg/model"
)
// Repository defines a memory movie metadata repository.
type Repository struct {
    sync.RWMutex
    data map[string]*model.Metadata
}
// New creates a new memory repository.
func New() *Repository {
    return &Repository{data: map[string]*model.Metadata{}}
}
// Get retrieves movie metadata for by movie id.
func (r *Repository) Get(_ context.Context, id string) (*model.Metadata, error) {
    r.RLock()
    defer r.RUnlock()
    m, ok := r.data[id]
    if !ok {
         return nil, repository.ErrNotFound
    }
    return m, nil
}
// Put adds movie metadata for a given movie id.
func (r *Repository) Put(_ context.Context, id string, metadata *model.Metadata) error {
    r.Lock()
    defer r.Unlock()
    r.data[id] = metadata
    return nil
}

让我们强调一下我们刚刚添加的代码的一些方面:

  • 首先,我们调用Repository结构,因为它与它的包名结合提供了一个对用户来说很好的名字——memory.Repository

  • 第二,我们使用了之前定义的导出ErrNotFound,这样调用者就可以检查他们的代码。通常这是一个好的实践,因为它允许开发者检查他们代码中的特定错误。我们将在第八章中展示如何为它编写测试。

  • 此外,创建仓库的函数被命名为New。当只有一个类型被创建时,这通常是短包的一个好名字。

  • 我们的GetPut函数接受上下文作为第一个参数。我们在编写惯用 Go 代码部分提到了这种方法——所有执行 I/O 操作的功能都必须接受上下文。

  • 我们的实现使用sync.RWMutex结构来保护并发读写

现在,让我们继续到业务逻辑层。

控制器

下一步是为封装我们的业务逻辑添加一个控制器。即使你的逻辑很简单,从一开始就将它与处理器分开也是一个好的实践。这将帮助你避免进一步的更改,更重要的是,保持你应用程序的结构一致。

metadata/internal/controller 包内,添加一个名为 metadata 的目录。在其内部,添加一个 controller.go 文件,并包含以下逻辑:

package metadata
import (
    "context"
    "errors"
    "movieexample.com/metadata/internal/repository"
    "movieexample.com/metadata/pkg/model"
)
// ErrNotFound is returned when a requested record is not // found.
var ErrNotFound = errors.New("not found")
type metadataRepository interface {
    Get(ctx context.Context, id string) (*model.Metadata, error)
}
// Controller defines a metadata service controller.
type Controller struct {
    repo metadataRepository
}
// New creates a metadata service controller.
func New(repo metadataRepository) *Controller {
    return &Controller{repo}
}
// Get returns movie metadata by id.
func (c *Controller) Get(ctx context.Context, id string) (*model.Metadata, error) {
    res, err := c.repo.Get(ctx, id)
    if err != nil && errors.Is(err, repository.ErrNotFound) {
        return nil, ErrNotFound
    }
    return res, err
}

我们创建的控制器目前只是仓库的一个包装器。然而,控制器通常会有更多的逻辑,因此最好将其保持独立。

处理器

现在,我们将创建 API 处理器。在 metadata/internal/handler 目录内,创建一个名为 http 的目录。在其内部,创建一个名为 http.go 的文件,并包含以下逻辑:

package http
import (
    "encoding/json"
    "errors"
    "log"
    "net/http"
    "movieexample.com/metadata/internal/controller/metadata"
    "movieexample.com/metadata/internal/repository"
)
// Handler defines a movie metadata HTTP handler.
type Handler struct {
    ctrl *metadata.Controller
}
// New creates a new movie metadata HTTP handler.
func New(ctrl *metadata.Controller) *Handler {
    return &Handler{ctrl}
}

现在,让我们实现检索电影元数据的逻辑:

// GetMetadata handles GET /metadata requests.
func (h *Handler) GetMetadata(w http.ResponseWriter, req *http.Request) {
    id := req.FormValue("id")
    if id == "" {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    ctx := req.Context()
    m, err := h.ctrl.Get(ctx, id)
    if err != nil && errors.Is(err, repository.ErrNotFound) {
        w.WriteHeader(http.StatusNotFound)
        return
    } else if err != nil {
        log.Printf("Repository get error: %v\n", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    if err := json.NewEncoder(w).Encode(m); err != nil {
        log.Printf("Response encode error: %v\n", err)
    }
}

我们刚刚创建的处理程序使用我们的仓库来检索信息并以 JSON 格式返回。我们在这里选择 JSON 只是为了简单起见。在 第四章 中,我们将介绍更多的数据格式,并展示它们如何为您的应用程序带来益处。

您可能会注意到,我们为我们的 HTTP 处理器命名了 http 包。这里有一个权衡——虽然我们确实与同名的标准库包发生了冲突,但我们得到了一个相当描述性的 http.Handler 导出名称。由于我们的包将用于内部使用,这种权衡是合理的。

主文件

现在,既然我们已经创建了一个数据库和一个 API 处理器,让我们创建元数据服务的可执行文件。在 metadata/cmd 目录内,创建 main.go 文件并添加以下代码:

package main
import (
    "log"
    "net/http"
    "movieexample.com/metadata/internal/controller/metadata"
    httphandler "movieexample.com/metadata/internal/handler/http"
    "movieexample.com/metadata/internal/repository/memory"
)
func main() {
    log.Println("Starting the movie metadata service")
    repo := memory.New()
    ctrl := metadata.New(repo)
    h := httphandler.New(ctrl)
    http.Handle("/metadata", http.HandlerFunc(h.GetMetadata))
    if err := http.ListenAndServe(":8081", nil); err != nil {
        panic(err)
    }
}

我们刚刚创建的函数初始化了我们服务的所有结构,并启动了我们之前实现的 http API 处理器。服务已准备好处理用户请求,因此让我们继续其他服务。

评分服务

让我们总结一下评分服务的逻辑:

  • API:获取记录的聚合评分并写入评分。

  • 数据库:评分数据库。

  • 交互服务:无。

  • 数据模型类型:评分。

这逻辑将转化为以下包:

  • cmd:包含启动服务的 main 函数

  • controller:我们的服务逻辑(读取和写入评分)

  • handler:服务的 API 处理器

  • repository:访问电影元数据数据库的逻辑

我们将使用与元数据服务相同的目录结构:

  • rating/cmd

  • rating/internal/controller

  • rating/internal/handler

  • rating/internal/repository

  • rating/pkg

一旦创建了这些目录,让我们继续服务实现的实现。

模型

rating/pkg 内创建一个模型目录,并创建一个 rating.go 文件,使用以下代码:

package model
// RecordID defines a record id. Together with RecordType
// identifies unique records across all types.
type RecordID string
// RecordType defines a record type. Together with RecordID
// identifies unique records across all types.
type RecordType string
// Existing record types.
const (
    RecordTypeMovie = RecordType("movie")
)
// UserID defines a user id.
type UserID string
// RatingValue defines a value of a rating record.
type RatingValue int
// Rating defines an individual rating created by a user for  // some record.
type Rating struct {
    RecordID   string      `json:"recordId"`
    RecordType string      `json:"recordType"`
    UserID     UserID      `json:"userId"`
    Value      RatingValue `json:"value"`
}

该文件包含我们评分服务的模型,它也将被其他与之交互的服务使用。请注意,我们创建了单独的类型,RecordIDRecordTypeUserID。这将有助于提高可读性,并增加额外的类型保护,正如您将在实现中看到的。

仓库

rating/internal/repository/memory/memory.go 文件内为我们的评分仓库创建内存实现:

package memory
import (
    "context"
    "movieexample.com/rating/internal/repository"
    "movieexample.com/rating/pkg/model"
)
// Repository defines a rating repository.
type Repository struct {
    data map[model.RecordType]map[model.RecordID][]model.Rating
}
// New creates a new memory repository.
func New() *Repository {
    return &Repository{map[model.RecordType]map[model.RecordID][]model.Rating{}}
}

然后,添加Get函数的实现,如下所示:

// Get retrieves all ratings for a given record.
func (r *Repository) Get(ctx context.Context, recordID model.RecordID, recordType model.RecordType) ([]model.Rating, error) {
    if _, ok := r.data[recordType]; !ok {
        return nil, repository.ErrNotFound
    }
    if ratings, ok := r.data[recordType][recordID]; !ok || len(ratings) == 0 {
        return nil, repository.ErrNotFound
    }
    return r.data[recordType][recordID], nil
}

最后,让我们在它内部实现一个Put函数,如下所示:

// Put adds a rating for a given record.
func (r *Repository) Put(ctx context.Context, recordID model.RecordID, recordType model.RecordType, rating *model.Rating) error {
    if _, ok := r.data[recordType]; !ok {
        r.data[recordType] = map[model.RecordID][]model.Rating{}
    }
    r.data[recordType][recordID] =
append(r.data[recordType][recordID], *rating)
    return nil
}

上述实现使用嵌套映射来存储所有记录。如果我们没有定义单独的类型,如RatingIDRatingTypeUserID,那么在映射中理解键的类型会更困难,因为我们可能会使用如stringint这样的原始类型,这些类型描述性较差。

控制器

让我们在rating/internal/controller/rating包中添加一个控制器。创建一个controller.go文件:

package rating
import (
    "context"
    "errors"
    "movieexample.com/rating/internal/repository"
    "movieexample.com/rating/pkg/model"
)
// ErrNotFound is returned when no ratings are found for a
// record.
var ErrNotFound = errors.New("ratings not found for a record")
type ratingRepository interface {
    Get(ctx context.Context, recordID model.RecordID, recordType model.RecordType) ([]model.Rating, error)
    Put(ctx context.Context, recordID model.RecordID, recordType model.RecordType, rating *model.Rating) error
}
// Controller defines a rating service controller.
type Controller struct {
    repo ratingRepository
}
// New creates a rating service controller.
func New(repo ratingRepository) *Controller {
    return &Controller{repo}
}

让我们添加写入和获取聚合评分的功能:

// GetAggregatedRating returns the aggregated rating for a
// record or ErrNotFound if there are no ratings for it.
func (c *Controller) GetAggregatedRating(ctx context.Context, recordID model.RecordID, recordType model.RecordType) (float64, error) {
    ratings, err := c.repo.Get(ctx, recordID, recordType)
    if err != nil && err == repository.ErrNotFound {
        return 0, ErrNotFound
    } else if err != nil {
        return 0, err
    }
    sum := float64(0)
    for _, r := range ratings {
        sum += float64(r.Value)
    }
    return sum / float64(len(ratings)), nil
}
// PutRating writes a rating for a given record.
func (c *Controller) PutRating(ctx context.Context, recordID model.RecordID, recordType model.RecordType, rating *model.Rating) error {
    return c.repo.Put(ctx, recordID, recordType, rating)
}

在这个例子中,很容易看出控制器逻辑与存储库逻辑的不同。存储库提供了一个接口来获取记录的所有评分,而控制器实现了对这些评分的聚合逻辑。

处理器

让我们在rating/internal/handler/http/http.go文件中实现服务处理器,使用以下代码:

package http
import (
    "encoding/json"
    "errors"
    "log"
    "net/http"
    "strconv"
    "movieexample.com/rating/internal/controller"
    "movieexample.com/rating/pkg/model"
)
// Handler defines a rating service controller.
type Handler struct {
    ctrl *rating.Controller
}
// New creates a new rating service HTTP handler.
func New(ctrl *rating.Controller) *Handler {
    return &Handler{ctrl}
}

现在,让我们为我们的服务添加一个处理 HTTP 请求的功能:

// Handle handles PUT and GET /rating requests.
func (h *Handler) Handle(w http.ResponseWriter, req *http.Request) {
    recordID := model.RecordID(req.FormValue("id"))
    if recordID == "" {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    recordType := model.RecordType(req.FormValue("type"))
    if recordType == "" {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    switch req.Method {
    case http.MethodGet:
        v, err := h.ctrl.GetAggregatedRating(req.Context(), recordID, recordType)
        if err != nil && errors.Is(err, rating.ErrNotFound) {
            w.WriteHeader(http.StatusNotFound)
            return
        }
        if err := json.NewEncoder(w).Encode(v); err != nil {
            log.Printf("Response encode error: %v\n", err)
        }
    case http.MethodPut:
        userID := model.UserID(req.FormValue("userId"))
        v, err := strconv.ParseFloat(req.FormValue("value"), 64)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := h.ctrl.PutRating(req.Context(), recordID, recordType, &model.Rating{UserID: userID, Value: model.RatingValue(v)}); err != nil {
            log.Printf("Repository put error: %v\n", err)
            w.WriteHeader(http.StatusInternalServerError)
        }
    default:
        w.WriteHeader(http.StatusBadRequest)
    }
}

我们实现的处理器处理GETPUT请求。注意我们处理一些特殊情况的办法,例如请求中的空id值——在这种情况下,我们返回一个特殊的错误代码http.StatusBadRequest,表示 API 请求无效。如果记录未找到,我们返回http.StatusNotFound,如果在访问我们的数据库时遇到任何意外错误,我们返回http.StatusInternalServerError

使用这样的标准 HTTP 错误代码有助于客户端区分错误类型并实现检测和正确处理这些问题的逻辑。

让我们编写我们服务的主体文件。在rating/cmd/main.go中,编写以下逻辑:

package main
import (
    "log"
    "net/http"
    "movieexample.com/rating/internal/controller/rating"
    httphandler "movieexample.com/rating/internal/handler/http"
    "movieexample.com/rating/internal/repository/memory"
)
func main() {
    log.Println("Starting the rating service")
    repo := memory.New()
    ctrl := rating.New(repo)
    h := httphandler.New(ctrl)
    http.Handle("/rating", http.HandlerFunc(h.Handle))
    if err := http.ListenAndServe(":8082", nil); err != nil {
        panic(err)
    }
}

我们创建的main函数类似于元数据服务的main函数;它初始化服务的所有组件并启动一个 HTTP 处理器。

现在,我们已准备好实现我们的最后一个服务。

电影服务

让我们总结一下电影服务的逻辑:

  • API:获取电影的详细信息,包括聚合的电影评分和电影元数据。

  • 数据库:无。

  • 与服务交互:电影元数据和评分。

  • 数据模型类型:电影详情。

这个逻辑将转换为以下包:

  • cmd:包含启动服务的主体函数

  • controller:我们的服务逻辑(读取评分和元数据)

  • gateway:调用其他服务的逻辑

  • handler:服务的 API 处理器

目录结构如下:

  • movie/cmd

  • movie/internal/controller

  • movie/internal/gateway

  • movie/internal/handler

  • movie/pkg

一旦创建了这些目录,让我们继续实现服务的逻辑。

模型

movie/pkg/model目录中创建一个model.go文件,并编写以下逻辑:

package model
import "movieexample.com/metadata/pkg/model"
// MovieDetails includes movie metadata its aggregated
// rating.
type MovieDetails struct {
    Rating   *float64    `json:"rating,omitEmpty"`
    Metadata model.Metadata `json:"metadata`
}

注意,该文件导入了包含Metadata结构的元数据服务的模型包,我们可以在我们的服务中重用它。

网关

在前面的例子中,服务之间没有交互,只是提供了一个 API。电影服务本身不会访问任何数据库,而是将与电影元数据和评分服务进行交互。

让我们创建与两个服务交互的逻辑。

首先,让我们创建一个我们将在网关中使用的错误。在movie/internal/gateway包中,创建一个error.go文件,使用以下代码块:

package gateway
import "errors"
// ErrNotFound is returned when the data is not found.
var ErrNotFound = errors.New("not found")

现在,让我们为电影元数据服务编写一个 HTTP 网关。在movie/gateway/metadata/http目录下,创建一个metadata.go文件:

package http
import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "movieexample.com/metadata/pkg/model"
    "movieexample.com/movie/internal/gateway"
)
// Gateway defines a movie metadata HTTP gateway.
type Gateway struct {
    addr string
}
// New creates a new HTTP gateway for a movie metadata
// service.
func New(addr string) *Gateway {
    return &Gateway{addr}
}

让我们在其中实现一个Get函数:

// Get gets movie metadata by a movie id.
func (g *Gateway) Get(ctx context.Context, id string) (*model.Metadata, error) {
    req, err := http.NewRequest(http.MethodGet, g.addr+"/metadata", nil)
    if err != nil {
        return nil, err
    }
    req = req.WithContext(ctx)
    values := req.URL.Query()
    values.Add("id", id)
    req.URL.RawQuery = values.Encode()
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusNotFound {
        return nil, gateway.ErrNotFound
    } else if resp.StatusCode/100 != 2 {
        return nil, fmt.Errorf("non-2xx response: %v", resp)
    }
    var v *model.Metadata
    if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
        return nil, err
    }
    return v, nil
}

现在,让我们为评分服务编写一个 HTTP 网关。在movie/gateway/rating/http目录下,创建一个rating.go文件:

package http
import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "movieexample.com/movie/internal/gateway"
    "movieexample.com/rating/pkg/model"
)
// Gateway defines an HTTP gateway for a rating service.
type Gateway struct {
    addr string
}
// New creates a new HTTP gateway for a rating service.
func New(addr string) *Gateway {
    return &Gateway{addr}
}

让我们添加获取聚合评分的逻辑:

// GetAggregatedRating returns the aggregated rating for a
// record or ErrNotFound if there are no ratings for it.
func (g *Gateway) GetAggregatedRating(ctx context.Context, recordID model.RecordID, recordType model.RecordType) (float64, error) {
    req, err := http.NewRequest(http.MethodGet, g.addr+"/rating", nil)
    if err != nil {
        return 0, err
    }
    req = req.WithContext(ctx)
    values := req.URL.Query()
    values.Add("id", string(recordID))
    values.Add("type", fmt.Sprintf("%v", recordType))
    req.URL.RawQuery = values.Encode()
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusNotFound {
        return 0, gateway.ErrNotFound
    } else if resp.StatusCode/100 != 2 {
        return 0, fmt.Errorf("non-2xx response: %v", resp)
    }
    var v float64
    if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
        return 0, err
    }
    return v, nil
}

最后,让我们添加一个处理评分创建请求的函数:

// PutRating writes a rating.
func (g *Gateway) PutRating(ctx context.Context, recordID model.RecordID, recordType model.RecordType, rating *model.Rating) error {
    req, err := http.NewRequest(http.MethodPut, g.addr+"/rating", nil)
    if err != nil {
        return err
    }
    req = req.WithContext(ctx)
    values := req.URL.Query()
    values.Add("id", string(recordID))
    values.Add("type", fmt.Sprintf("%v", recordType))
    values.Add("userId", string(rating.UserID))
    values.Add("value", fmt.Sprintf("%v", rating.Value))
    req.URL.RawQuery = values.Encode()
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode/100 != 2 {
        return fmt.Errorf("non-2xx response: %v", resp)
    }
    return nil
}

到目前为止,我们有了两个网关,可以实施聚合从它们获取数据的控制器。

控制器

movie/internal/controller/movie目录下,创建一个controller.go文件:

package movie
import (
    "context"
    "errors"
    metadatamodel "movieexample.com/metadata/pkg/model"
    "movieexample.com/movie/internal/gateway"
    "movieexample.com/movie/pkg/model"
    ratingmodel "movieexample.com/rating/pkg/model"
)
// ErrNotFound is returned when the movie metadata is not
// found.
var ErrNotFound = errors.New("movie metadata not found")

让我们定义我们将要调用的服务的接口:

type ratingGateway interface {
    GetAggregatedRating(ctx context.Context, recordID ratingmodel.RecordID, recordType ratingmodel.RecordType) (float64, error)
    PutRating(ctx context.Context, recordID ratingmodel.RecordID, recordType ratingmodel.RecordType, rating *ratingmodel.Rating) error
}
type metadataGateway interface {
    Get(ctx context.Context, id string) (*metadatamodel.Metadata, error)
}

现在,我们可以定义我们的服务控制器:

// Controller defines a movie service controller.
type Controller struct {
    ratingGateway   ratingGateway
    metadataGateway metadataGateway
}
// New creates a new movie service controller.
func New(ratingGateway ratingGateway, metadataGateway metadataGateway) *Controller {
    return &Controller{ratingGateway, metadataGateway}
}

最后,让我们实现获取电影详情的函数,包括其评分和元数据:

// Get returns the movie details including the aggregated
// rating and movie metadata.
// Get returns the movie details including the aggregated rating and movie metadata.
func (c *Controller) Get(ctx context.Context, id string) (*model.MovieDetails, error) {
    metadata, err := c.metadataGateway.Get(ctx, id)
    if err != nil && errors.Is(err, gateway.ErrNotFound) {
        return nil, ErrNotFound
    } else if err != nil {
        return nil, err
    }
    details := &model.MovieDetails{Metadata: *metadata}
    rating, err := c.ratingGateway.GetAggregatedRating(ctx, ratingmodel.RecordID(id), ratingmodel.RecordTypeMovie)
    if err != nil && !errors.Is(err, gateway.ErrNotFound) {
        // Just proceed in this case, it's ok not to have ratings yet.
    } else if err != nil {
        return nil, err
    } else {
        details.Rating = &rating
    }
    return details, nil
}

注意,我们在不同的组件中重新定义了ErrNotFound。虽然我们可以将其导出到某个共享包中,但有时保持其独立性更好。否则,我们可能会混淆一个错误与另一个错误(例如,评分未找到或元数据未找到)。

处理器

movie/internal/handler/http包中,添加http.go文件,使用以下逻辑:

package http
import (
    "encoding/json"
    "errors"
    "log"
    "net/http"
    "movieexample.com/movie/internal/controller/movie"
)
// Handler defines a movie handler.
type Handler struct {
    ctrl *movie.Controller
}
// New creates a new movie HTTP handler.
func New(ctrl *movie.Controller) *Handler {
     return &Handler{ctrl}
}
// GetMovieDetails handles GET /movie requests.
func (h *Handler) GetMovieDetails(w http.ResponseWriter, req *http.Request) {
    id := req.FormValue("id")
    details, err := h.ctrl.Get(req.Context(), id)
    if err != nil && errors.Is(err, movie.ErrNotFound) {
        w.WriteHeader(http.StatusNotFound)
        return
    } else if err != nil {
        log.Printf("Repository get error: %v\n", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    if err := json.NewEncoder(w).Encode(details); err != nil {
        log.Printf("Response encode error: %v\n", err)
    }
}

现在,我们终于准备好为电影服务编写主文件了。

主文件

movie/cmd包中,创建一个main.go文件,使用以下代码块:

package main
import (
    "log"
    "net/http"
    "movieexample.com/movie/internal/controller/movie"
    metadatagateway "movieexample.com/movie/internal/gateway/metadata/http"
    ratinggateway "movieexample.com/movie/internal/gateway/rating/http"
    httphandler "movieexample.com/movie/internal/handler/http"
)
func main() {
    log.Println("Starting the movie service")
    metadataGateway := metadatagateway.New("localhost:8081")
    ratingGateway := ratinggateway.New("localhost:8082")
    ctrl := movie.New(ratingGateway, metadataGateway)
    h := httphandler.New(ctrl)
    http.Handle("/movie", http.HandlerFunc(h.GetMovieDetails))
    if err := http.ListenAndServe(":8083", nil); err != nil {
        panic(err)
    }
}

到目前为止,我们已经有了所有三个服务的逻辑。注意,在这个例子中,我们使用了静态服务地址,localhost:8081localhost:8082localhost:8083。这允许你在本地运行服务;然而,如果我们把服务部署到云端或其他部署平台,这就不起作用了。在下一章中,我们将讨论这个方面,并继续改进我们的微服务。你可以在每个服务的cmd目录中执行以下命令来运行我们刚刚创建的服务:

go run *.go

然后,你可以使用以下命令调用元数据服务 API:

curl localhost:8081?id=1

你可以使用类似的命令调用评分服务 API:

curl localhost:8082?id=1&type=2

最后,你可以使用以下命令调用电影服务:

curl localhost:8083?id=1

所有的前一个请求都应该返回 HTTP 404 错误,表示未找到记录——我们还没有任何数据,这是预期的。

到目前为止,我们已经说明了如何启动和手动测试我们的示例微服务,并准备好进入下一章。

摘要

在本节中,我们涵盖了众多主题,包括编写 Go 应用程序最重要的建议以及 Go 应用程序项目布局的标准。我们获得的知识在微服务的代码脚手架搭建过程中给予了我们帮助——我们尽可能地以惯用的方式实现我们的微服务代码。

你还学会了如何将每个微服务分割成多个层次,每个层次负责其自身的逻辑。我们说明了如何将业务逻辑从访问数据库的代码中分离出来,以及如何将 API 处理逻辑从两者以及服务之间执行远程调用的逻辑中分离出来。

尽管本章的信息量相当庞大,但我们已经取得了坚实的基础,并准备好进入更高级的主题。在下一章中,我们将看到我们创建的微服务如何相互探索,这样我们就可以最终测试它们。

进一步阅读

第三章:服务发现

在上一章中,我们创建了我们的示例微服务,并让它们使用静态本地地址相互通信,这些地址硬编码在每个服务中。这种方法在需要动态添加或删除服务实例时(称为服务发现)会工作,即让微服务在动态环境中找到彼此。在真实的生产环境中编写和准备可扩展的微服务的第一步是设置服务发现。

在本章中,我们将涵盖以下主题:

  • 服务发现概述

  • 服务发现解决方案

  • 采用服务发现

我们将使用上一章中创建的微服务来说明如何使用服务发现解决方案。现在,让我们继续了解服务发现概念概述。

技术要求

为了完成本章,您需要 Go 1.11 或更高版本。此外,您还需要一个 Docker 工具,您可以从 www.docker.com 下载。

您可以在 GitHub 上找到本章的源文件:github.com/PacktPublishing/microservices-with-go/tree/main/Chapter03

服务发现概述

在上一章中,我们创建了一个由三个微服务组成的应用程序。服务之间的关系如下图中所示:

![图 3.1 – 我们微服务之间的关系]

图 3.1 – 我们微服务之间的关系

图 3.1 – 我们微服务之间的关系

如您所见,电影服务调用元数据和评分服务以获取完整的电影详情。

但我们的服务如何发送请求?它们如何知道彼此的地址?

在我们的示例中,我们为 API 处理器使用了预编程的静态值。我们使用的设置如下:

  • localhost:8081

  • localhost:8082

  • localhost:8083

在我们的方法中,每个服务都需要知道它将与之通信的其他服务的确切地址。这种方法在只有一个微服务的每个实例的情况下会工作。在这种情况下,我们会面临多个挑战:

  • 当您有多个实例时,您应该使用什么地址?

  • 如何处理某些实例不可用的情况?

如果您有一组静态的实例集,第一个问题相对容易解决——您的服务需要保留每个需要调用的服务的地址列表。然而,这种方法以下列原因不够灵活:

  • 每次您需要添加或删除实例时,您都需要更新每个调用服务的配置。

  • 如果一个实例因长时间不可用(例如,由于网络故障)而变得不可用,您的服务仍然会继续调用它,直到您更新它们的配置。

如何正确解决这些问题?

我们刚才描述的微服务问题被称为服务发现。一般来说,服务发现解决多个问题,如下所述:

  • 如何发现特定服务的实例

  • 如何在可发现环境中添加和删除服务的实例

  • 如何处理实例无响应的问题

让我们看看这些功能是如何工作的。

注册表

服务发现的基础是一个注册表(也称为服务注册表),它存储有关可用服务实例的信息。它具有以下功能:

  • 注册一个服务的实例。

  • 注销一个服务的实例。

  • 以网络地址的形式返回服务的所有实例列表。

这是一个服务发现注册表数据的示例:

服务名称 地址列表
电影服务 172.18.10.2:2520 172.18.12.55:8800 172.18.89.10:2450
评分服务 172.18.25.11:1100 172.18.9.55:2830
电影元数据服务 172.18.79.115:3512 172.17.3.8:9900

表 3.1 – 注册表数据

每个服务都可以自行在注册表中注册,或者使用某些库或工具在服务启动时自动注册。一旦服务注册,它将通过健康检查开始被监控,以确保注册表只包含可用实例。

现在我们来看看采用服务发现的两种常见模型。

服务发现模型

对于应用程序,有两种与注册表交互的方式:

  • 客户端服务发现:使用注册表客户端从应用程序直接访问注册表。

  • 服务器端服务发现:通过负载均衡器间接访问注册表,这是一个特殊的服务器,它将请求转发到可用的实例。

让我们看看每种模型的优缺点。

客户端服务发现

在客户端服务发现模型中,每个应用程序或服务通过直接请求目标服务的所有可用实例来直接访问服务注册表。当应用程序收到响应时,它使用目标服务的地址进行请求。逻辑如下所示:

图 3.2 – 客户端服务发现

图 3.2 – 客户端服务发现

图 3.2 – 客户端服务发现

在此模型中,应用程序负责平衡它所调用的服务的负载——如果应用程序只从列表中选择一个实例并一直调用它,那么它将超载该实例并低估其他实例。

这种模型的缺点是调用应用程序需要编程负载均衡逻辑。此外,这会将服务发现和负载均衡逻辑与应用程序代码耦合在一起,使应用程序更加复杂。

服务器端服务发现

服务器端服务发现模型在调用应用程序与注册表之间的交互中添加了一个额外的层。应用程序不是直接调用注册表,而是通过一个称为负载均衡器的特殊服务器将请求发送到目标微服务。负载均衡器负责与注册表交互并在所有可用实例之间分配请求。

下面的图将帮助您理解服务器端服务发现模型:

图 3.3 – 服务器端服务发现

图 3.3 – 服务器端服务发现

图 3.3 – 服务器端服务发现

在图中,应用程序通过负载均衡器调用目标服务,通过服务注册表读取活动服务实例列表。在这个模型中,应用程序不需要了解注册表。这是服务器端服务发现模型的主要优势:它有助于将与服务注册表的交互与每个调用应用程序解耦,使应用程序逻辑更简单。该模型的缺点是需要设置和使用负载均衡器。后者是一个相当复杂的操作,我们不会在本书中涉及。

现在,让我们看看注册表如何仅保留每个服务的活动实例列表。

服务健康监控

注册表通过拉取或推送模型保持实例信息最新:

  • 拉取模型:服务注册表定期对每个已知实例进行健康检查。

  • 推送模型:应用程序通过联系注册表来更新其状态。

拉取模型消除了在服务级别实现状态更新的需求。在推送模型中,应用程序负责更新其状态或向服务注册表报告其健康状态。

现在,我们已经涵盖了服务发现的理论基础,让我们看看您可以使用哪些现有解决方案来启用您的微服务。

服务发现解决方案

在本节中,我们将描述现有的服务发现解决方案,这些解决方案可供您使用——HashiCorp ConsulKubernetes。然后,您将了解微服务开发者可以使用哪些最流行的工具来执行服务发现。

HashiCorp Consul

HashiCorp Consul 多年来一直是一种非常流行的服务发现解决方案。这个用 Go 编写的工具允许您通过其客户端或 API 轻松设置服务和应用程序的服务发现。

Consul 有一个相当直观的 API,包括以下关键端点:

  • PUT /catalog/register:注册服务实例。

  • PUT /catalog/deregister:注销服务实例。

  • GET /catalog/services:获取服务的可用实例。

客户端应用程序可以通过 API 或使用 DNS 服务在服务器端服务发现模式下访问 Consul 目录。

你可以通过查看官方网站了解更多的 Consul 信息:consul.io

Kubernetes

Kubernetes 是一个流行的开源平台,用于运行、扩展和管理应用程序集合,如微服务。

Kubernetes 的一个特性是能够注册和发现其内部运行的服务。Kubernetes 提供了一个 API,用于检索每个正在更新的服务的网络地址列表,用户可以在客户端发现模式下使用它。或者,它允许用户插入一个负载均衡器,以用于服务器端发现。

我们将在本书的第八章中稍后介绍 Kubernetes。现在,让我们看看我们如何可以将服务发现添加到上一章中创建的应用程序中。

采用服务发现

在本节中,我们将说明如何开始为你的应用程序使用服务发现。我们将使用上一章中创建的微服务作为示例。然后,你将学习如何将负责服务发现的逻辑添加到你的微服务代码中。

当你考虑为你的服务启用服务发现时,你需要回答多个问题,例如以下问题:

  • 你更倾向于使用哪种模型——客户端或服务器端发现?

  • 你将使用哪个平台来部署和编排你的微服务?

回答第二个问题可能已经给你提供了一个解决方案——包括 Kubernetes 在内的各种部署平台,以及像 AWS 这样的流行云服务,都为你提供了服务发现功能。

如果你不知道你将为你的服务使用哪个部署平台,并且你对微服务开发是新手,你可能考虑使用客户端服务发现。客户端发现模型稍微简单一些,因为你的服务直接与服务注册表协调。稍后,如果你想要的话,你可以切换到服务器端服务发现。

让我们开始准备你的应用程序以添加服务发现逻辑。

准备应用程序

让我们列出我们希望从我们的服务发现代码中实现的目标。

  • 在服务启动时注册我们打算使用的服务的功能

  • 在服务关闭时注销我们打算使用的服务的功能

  • 获取我们打算用于调用其他服务的特定服务的地址列表的功能。

  • 设置服务健康监控,以便服务注册表能够移除不活跃的服务实例

如果我们的服务发现逻辑不直接绑定到特定的工具上,那就太好了。通常,使用更通用的接口来抽象实际技术是一种良好的实践,这允许我们交换实现。我们可以用一个例子来说明这一点——想象我们正在使用 Hashicorp Consul 库,它以以下形式返回服务地址列表:

func Service(string, string) ([]*consul.ServiceEntry, *consul.QueryMeta, error)

如果我们在代码中公开这些 Consul 结构并在代码库中传递这些结构,我们的服务代码将与 Consul 紧密耦合。如果我们决定切换到另一个服务发现工具,我们需要替换不仅服务发现实现逻辑,还要替换所有使用它的代码。

相反,让我们定义一个更通用且与技术无关的接口。为了提供服务实例列表,我们可以简单地以[]string格式返回 URL 列表。

我们服务发现逻辑的完整接口如下:

// registry defines a service registry.
type Registry interface {
    // Register creates a service instance record in the registry.
    Register(ctx context.Context, instanceID string, serviceName string, hostPort string) error
    // Deregister removes a service instance record from the registry.
    Deregister(ctx context.Context, instanceID string, serviceName string) error
    // ServiceAddresses returns the list of addresses of active instances of the given service.
    ServiceAddresses(ctx context.Context, serviceID string) ([]string, error)
    // ReportHealthyState is a push mechanism for reporting healthy state to the registry.
    ReportHealthyState(instanceID string, serviceName string) error
}

如您可能注意到的,该接口相当通用,但它允许您根据需要创建基于不同技术的多个实现。

您可能还会注意到,该接口包括一个ReportHealthyState函数,用于报告服务实例的健康状态。此函数允许我们实现之前提到的基于推送的服务健康监控,因此每个微服务将定期向服务注册表报告其健康状态。如果服务实例在定义的时间间隔内没有报告健康状态,注册表将能够删除每个服务的非活动实例(在我们的实现中,我们将假设该间隔为 5 秒)。

现在,让我们考虑在哪里存储我们的微服务服务发现逻辑。我建议使用一个所有三个服务都可以访问的包——让我们在应用程序的根目录下pkg文件夹中创建它。我们可以称它为pkg/discovery。在其内部,添加一个discovery.go文件,并将以下代码添加到其中:

package discovery
import (
    "context"
    "errors"
    "fmt"
    "math/rand"
    "time"
)
// Registry defines a service registry.
type Registry interface {
    // Register creates a service instance record in the
    // registry.
    Register(ctx context.Context, instanceID string, serviceName string, hostPort string) error
    // Deregister removes a service insttance record from
    // the registry.
    Deregister(ctx context.Context, instanceID string, serviceName string) error
    // ServiceAddresses returns the list of addresses of
    // active instances of the given service.
    ServiceAddresses(ctx context.Context, serviceID string) ([]string, error)
    // ReportHealthyState is a push mechanism for reporting
    // healthy state to the registry.
    ReportHealthyState(instanceID string, serviceName string) error
}
// ErrNotFound is returned when no service addresses are
// found.
var ErrNotFound = errors.New("no service addresses found")
// GenerateInstanceID generates a pseudo-random service
// instance identifier, using a service name
// suffixed by dash and a random number.
func GenerateInstanceID(serviceName string) string {
    return fmt.Sprintf("%s-%d", serviceName, rand.New(rand.NewSource(time.Now().UnixNano())).Int())
}

在我们刚刚添加的代码中,我们定义了一个Registry接口用于服务注册。此外,我们还定义了ErrNotFound错误,当ServiceAddresses函数找不到任何活动服务地址时将返回此错误。最后,我们创建了一个GenerateInstanceID函数,该函数将帮助我们生成用于RegisterDeregister函数的随机实例标识符。

我们已经准备好开始对其实现的工作。

实现发现逻辑

我们之前定义的接口的一个好处是,我们可以创建多个实现并在我们的应用程序中使用它们。例如,我们可以创建一个用于测试的实现,而另一个实现则用于生产。为了说明这种方法,我们将创建两个实现:

  • 内存服务发现:使用内存注册表来存储地址集合。

  • 基于 Consul 的服务发现:使用 Hashicorp Consul 服务注册表来存储和检索服务地址。

现在,让我们继续实现逻辑。

内存实现

让我们从内存实现开始。在这个实现中,我们将使用简单的映射数据结构在内存中存储服务注册记录。以下是步骤:

  1. 创建一个名为pkg/discovery/memorypackage的文件和一个名为memory.go的文件,然后添加以下内容:

    package memory
    
    import (
    
        "context"
    
        "errors"
    
        "net"
    
        "sync"
    
        "time"
    
        "movieexample.com/pkg/discovery"
    
    )
    
    type serviceName string
    
    type instanceID string
    
    // Registry defines an in-memory service registry.
    
    type Registry struct {
    
        sync.RWMutex
    
        serviceAddrs map[serviceName]map[instanceID]*serviceInstance
    
    }
    
    type serviceInstance struct {
    
        hostPort   string
    
        lastActive time.Time
    
    }
    
    // NewRegistry creates a new in-memory service
    
    // registry instance.
    
    func NewRegistry() *Registry {
    
        return &Registry{serviceAddrs: map[serviceName]map[instanceID]*serviceInstance{}}
    
    }
    
  2. 让我们实现我们的RegisterDeregister函数:

    // Register creates a service record in the registry.
    
    func (r *Registry) Register(ctx context.Context, instanceID string, serviceName string, hostPort string) error {
    
        r.Lock()
    
        defer r.Unlock()
    
        if _, ok := r.serviceAddrs[serviceName]; !ok {
    
            r.serviceAddrs[serviceName] = map[string]*serviceInstance{}
    
        }
    
        r.serviceAddrs[serviceName][instanceID] = &serviceInstance{hostPort: hostPort, lastActive: time.Now()}
    
        return nil
    
    }
    
    // Deregister removes a service record from the
    
    // registry.
    
    func (r *Registry) Deregister(ctx context.Context, instanceID string, serviceName string) error {
    
        r.Lock()
    
        defer r.Unlock()
    
        if _, ok := r.serviceAddrs[serviceName]; !ok {
    
            return nil
    
        }
    
        delete(r.serviceAddrs[serviceName], instanceID)
    
        return nil
    
    }
    
  3. 最后,让我们实现Registry接口的剩余两个函数:

    // ReportHealthyState is a push mechanism for
    
    // reporting healthy state to the registry.
    
    func (r *Registry) ReportHealthyState(instanceID string, serviceName string) error {
    
        r.Lock()
    
        defer r.Unlock()
    
        if _, ok := r.serviceAddrs[serviceName]; !ok {
    
            return errors.New("service is not registered yet")
    
        }
    
        if _, ok := r.serviceAddrs[serviceName][instanceID]; !ok {
    
            return errors.New("service instance is not registered yet")
    
        }
    
        r.serviceAddrs[serviceName][instanceID].lastActive = time.Now()
    
        return nil
    
    }
    
    // ServiceAddresses returns the list of addresses of
    
    // active instances of the given service.
    
    func (r *Registry) ServiceAddresses(ctx context.Context, serviceName string) ([]string, error) {
    
        r.RLock()
    
        defer r.RUnlock()
    
        if len(r.serviceAddrs[serviceName]) == 0 {
    
            return nil, discovery.ErrNotFound
    
        }
    
        var res []string
    
        for _, i := range r.serviceAddrs[serviceName] {
    
            if i.lastActive.Before(time.Now().Add(-5 * time.Second)) {
    
                continue
    
            }
    
            res = append(res, i.hostPort)
    
        }
    
        return res, nil
    
    }
    

这种实现可以用于测试或运行在单个服务器上的简单应用程序。该实现基于一种组合的映射数据结构和sync.RWMutex,允许并发地对映射进行读写操作。在映射中,我们存储包含实例地址和最后一次成功健康检查时间的serviceInstance结构,这可以通过调用ReportHealthyState函数来设置。在ServiceAddresses函数中,我们只返回在过去 5 秒内成功进行健康检查的实例。

现在,让我们转向基于 Consul 的服务注册实现。

基于 Consul 的实现

我们现在将要工作的实现将使用 Hashicorp Consul 作为服务注册中心:

  1. 首先,创建一个名为pkg/discovery/consul的包,并向其中添加一个名为consul.go的文件:

     package consul
    
    import (
    
        "context"
    
        "errors"
    
        "fmt"
    
        "strconv"
    
        "strings"
    
        consul "github.com/hashicorp/consul/api"
    
        "movieexample.com/pkg/discovery"
    
    )
    
    // Registry defines a Consul-based service regisry.
    
    type Registry struct {
    
        client *consul.Client
    
    }
    
    // NewRegistry creates a new Consul-based service
    
    // registry instance.
    
    func NewRegistry(addr string) (*Registry, error) {
    
        config := consul.DefaultConfig()
    
        config.Address = addr
    
        client, err := consul.NewClient(config)
    
        if err != nil {
    
            return nil, err
    
        }
    
        return &Registry{client: client}, nil
    
    }
    
  2. 现在,让我们实现我们接口的注册和重新注册记录的功能:

     // Register creates a service record in the registry.
    
    func (r *Registry) Register(ctx context.Context, instanceID string, serviceName string, hostPort string) error {
    
        parts := strings.Split(hostPort, ":")
    
        if len(parts) != 2 {
    
            return errors.New("hostPort must be in a form of <host>:<port>, example: localhost:8081")
    
        }
    
        port, err := strconv.Atoi(parts[1])
    
        if err != nil {
    
            return err
    
        }
    
        return r.client.Agent().ServiceRegister(&consul.AgentServiceRegistration{
    
            Address: parts[0],
    
            ID:      instanceID,
    
            Name:    serviceName,
    
            Port:    port,
    
            Check:   &consul.AgentServiceCheck{CheckID: instanceID, TTL: "5s"},
    
        })
    
    }
    
    // Deregister removes a service record from the
    
    // registry.
    
    func (r *Registry) Deregister(ctx context.Context, instanceID string, _ string) error {
    
        return r.client.Agent().ServiceDeregister(instanceID)
    
    }
    
  3. 最后,让我们实现剩余的注册功能:

    // ServiceAddresses returns the list of addresses of
    
    // active instances of the given service.
    
    func (r *Registry) ServiceAddresses(ctx context.Context, serviceName string) ([]string, error) {
    
        entries, _, err := r.client.Health().Service(serviceName, "", true, nil)
    
        if err != nil {
    
            return nil, err
    
        } else if len(entries) == 0 {
    
            return nil, discovery.ErrNotFound
    
        }
    
        var res []string
    
        for _, e := range entries {
    
            res = append(res, res = append(res, fmt.Sprintf("%s:%d", e.Service.Address, e.Service.Port)))
    
        }
    
        return res, nil
    
    }
    
    // ReportHealthyState is a push mechanism for
    
    // reporting healthy state to the registry.
    
    func (r *Registry) ReportHealthyState(instanceID string, _ string) error {
    
        return r.client.Agent().PassTTL(instanceID, "")
    
    }
    

我们的客户端依赖于一个外部库,github.com/hashicorp/consul/api。我们需要现在通过在src目录内运行go mod tidy来获取它。之后,Go 应该获取依赖项,我们的逻辑应该能够编译。

现在,我们已经准备好将刚刚创建的逻辑应用到我们的微服务中。

使用发现逻辑

现在,我们需要添加初始化和发现服务的逻辑。目前,只有电影服务与其他两个服务进行通信,所以我们将以电影服务为例说明如何添加服务发现。

让我们从我们的网关开始:

  1. 在上一章中,我们创建了两个网关用于调用元数据和评分服务。让我们修改它们的结构,使其与下面所示的结构一致:

    type Gateway struct {
    
        registry discovery.Registry
    
    }
    
  2. 此外,将New函数的格式改为以下:

    func New(registry discovery.Registry) *Gateway {
    
        return &Gateway{registry}
    
    }
    
  3. 现在,网关在创建时需要一个注册中心。我们可以将元数据网关的Get函数的开始部分改为现在这样:

    func (g *Gateway) Get(ctx context.Context, id string) (*model.Metadata, error) {
    
        addrs, err := g.registry.ServiceAddresses(ctx, "metadata")
    
        if err != nil {
    
            return nil, err
    
        }
    
        url := "http://" + addrs[rand.Intn(len(addrs))] + "/metadata"
    
        log.Printf("Calling metadata service. Request: GET " + url)
    
        req, err := http.NewRequest(http.MethodGet, url, nil)
    

你可能会注意到,我们现在不是调用静态预配置的地址,而是首先从注册中心获取元数据的可用地址。这就是服务发现的本质——我们使用注册中心的数据来在服务之间进行远程调用。在我们获取服务地址列表后,我们使用rand.Intn函数随机选择一个。通过这样做,我们在活动实例之间平衡负载,在每个请求中随机选择任何可用的实例。

  1. 现在,以与修改元数据服务相同的方式更新评分网关。

  2. 下一步是更新我们服务的main函数,以便每个服务将在服务注册中心中注册和注销自己。让我们首先更新元数据服务。将其main函数更新为以下:

    const serviceName = "metadata"
    
    func main() {
    
        var port int
    
        flag.IntVar(&port, "port", 8081, "API handler port")
    
        flag.Parse()
    
        log.Printf("Starting the metadata service on port %d", port)
    
        registry, err := consul.NewRegistry("localhost:8500")
    
        if err != nil {
    
            panic(err)
    
        }
    
        ctx := context.Background()
    
        instanceID := discovery.GenerateInstanceID(serviceName)
    
        if err := registry.Register(ctx, instanceID, serviceName, fmt.Sprintf("localhost:%d", port)); err != nil {
    
            panic(err)
    
        }
    
        go func() {
    
            for {
    
                if err := registry.ReportHealthyState(instanceID, serviceName); err != nil {
    
                    log.Println("Failed to report healthy state: " + err.Error())
    
                }
    
                time.Sleep(1 * time.Second)
    
            }
    
        }()
    
        defer registry.Deregister(ctx, instanceID, serviceName)
    
        repo := memory.New()
    
        svc := metadata.New(repo)
    
        h := httphandler.New(svc)
    
        http.Handle("/metadata", http.HandlerFunc(h.GetMetadataByID))
    
        if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil {
    
            panic(err)
    
        }
    
    }
    

在前面的代码中,我们添加了在基于 Consul 的服务注册表中注册和注销服务的逻辑,并每秒向它报告其健康状态。

  1. 让我们在评分服务中添加类似的逻辑。更新其main函数如下:

    func main() {
    
        var port int
    
        flag.IntVar(&port, "port", 8082, "API handler port")
    
        flag.Parse()
    
        log.Printf("Starting the rating service on port %d", port)
    
        registry, err := consul.NewRegistry("localhost:8500")
    
        if err != nil {
    
            panic(err)
    
        }
    
        ctx := context.Background()
    
        instanceID := discovery.GenerateInstanceID(serviceName)
    
        if err := registry.Register(ctx, instanceID, serviceName, fmt.Sprintf("localhost:%d", port)); err != nil {
    
            panic(err)
    
        }
    
        go func() {
    
            for {
    
                if err := registry.ReportHealthyState(instanceID, serviceName); err != nil {
    
                    log.Println("Failed to report healthy state: " + err.Error())
    
                }
    
                time.Sleep(1 * time.Second)
    
            }
    
        }()
    
        defer registry.Deregister(ctx, instanceID, serviceName)
    
        repo := memory.New()
    
        svc := controller.New(repo)
    
        h := httphandler.New(svc)
    
        http.Handle("/rating", http.HandlerFunc(h.Handle))
    
        if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil {
    
            panic(err)
    
        }
    
    }
    

我们刚刚所做的更改与我们对元数据服务所做的更改类似。

  1. 最后一步是修改电影服务的main函数,将其替换为以下内容:

     func main() {
    
        var port int
    
        flag.IntVar(&port, "port", 8083, "API handler port")
    
        flag.Parse()
    
        log.Printf("Starting the movie service on port %d", port)
    
        registry, err := consul.NewRegistry("localhost:8500")
    
        if err != nil {
    
            panic(err)
    
        }
    
        ctx := context.Background()
    
        instanceID := discovery.GenerateInstanceID(serviceName)
    
        if err := registry.Register(ctx, instanceID, serviceName, fmt.Sprintf("localhost:%d", port)); err != nil {
    
            panic(err)
    
        }
    
        go func() {
    
            for {
    
                if err := registry.ReportHealthyState(instanceID, serviceName); err != nil {
    
                    log.Println("Failed to report healthy state: " + err.Error())
    
                }
    
                time.Sleep(1 * time.Second)
    
            }
    
        }()
    
        defer registry.Deregister(ctx, instanceID, serviceName)
    
        metadataGateway := metadatagateway.New(registry)
    
        ratingGateway := ratinggateway.New(registry)
    
        svc := movie.New(ratingGateway, metadataGateway)
    
        h := httphandler.New(svc)
    
        http.Handle("/movie", http.HandlerFunc(h.GetMovieDetails))
    
        if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil {
    
            panic(err)
    
        }
    
    }
    

在这一点上,我们已经成功地将基于 Consul 的服务发现添加到我们的应用程序中。让我们通过实际操作来展示它是如何工作的:

  1. 为了现在运行我们的应用程序,你需要本地运行 Hashicorp Consul。最简单的方法是使用 Docker 工具运行它。假设你已经从其网站安装了 Docker,docker.com,你可以运行以下命令:
docker run -d -p 8500:8500 -p 8600:8600/udp --name=dev-consul consul agent -server -ui -node=server-1 -bootstrap-expect=1 -client=0.0.0.0

上述命令在开发模式下运行 Hashicorp Consul,将其端口85008600暴露给本地使用。

  1. 通过在每个cmd目录内执行此命令来运行每个微服务:
go run *.go
  1. 现在,通过其链接进入 Consul 的 Web UI,localhost:8500/。当你打开服务标签时,你应该能看到我们的服务列表和一个活动的 Consul 实例:

![图 3.4 – Consul Web 视图中的活动服务实例图片

图 3.4 – Consul Web 视图中的活动服务实例

你可以选择通过运行以下命令为每个服务添加一些额外的实例:

go run *.go --port <PORT>

如果你运行上述命令,将<PORT>占位符替换为尚未使用的唯一端口号(在我们的示例中,我们使用了端口号808180828083,因此你可以从8084开始的端口号运行)。每个命令的结果将在之前展示的 Consul 服务视图中显示额外的健康实例。

你也可以尝试手动关闭任何服务,通过终止go run命令,并观察实例状态如何从通过变为关键

  1. 为了测试 API 请求,确保每个服务至少有一个健康的实例,并向电影服务发送以下请求:
curl -v localhost:8083/movie?id=1
  1. 检查电影服务的输出日志(你应该能在运行电影服务go run命令的终端中看到它们)。如果你一切操作正确,你应该能看到类似的行:

    2022/06/08 13:37:42 Calling metadata service. Request: GET http://localhost:8081/metadata
    

上面的行是调用由 Consul 支持的服务注册表的结果。在我们的元数据服务网关实现中,我们从注册表中随机选择一个活动实例,并在调用之前记录其地址。如果你有多个元数据服务实例,你可以按照之前列出的多次执行curl请求,你会看到电影服务总是在它们中随机选择一个实例。

到目前为止,我们已经展示了如何使用服务发现来管理我们的微服务。现在,我们可以通过添加和删除它们的实例来动态扩展我们的微服务,而无需更改服务代码。我们还提供了两个服务注册表的实现,您可以在代码中使用。现在,我们准备进入下一章,讨论另一个重要主题,数据序列化。

摘要

在本章中,我们对服务发现进行了概述,并比较了其不同的模型。您已经了解了服务注册表是什么以及其主要的服务发现模型有哪些。我们通过提供两种实现来展示了如何使用客户端服务发现模型,一种使用内存中的数据集,另一种使用 Hashicorp Consul。我们还把基于 Consul 的实现集成到我们的微服务中,以演示如何在微服务逻辑中使用它。现在,您已经知道如何在您的应用程序中添加和使用服务发现。

在下一章中,我们将讨论另一个重要主题:序列化。您将学习如何编码和解码服务之间传输的数据。这将帮助我们进一步探讨服务之间的通信,我们将在第五章中介绍。

进一步阅读

第四章:序列化

在前面的章节中,我们学习了如何构建 Go 微服务、创建 HTTP API 端点,以及设置服务发现以使我们的微服务能够相互通信。这些知识已经为我们构建微服务提供了一个坚实的基础;然而,我们将继续我们的旅程,探讨更多高级主题。

在本章中,我们将探讨序列化,这是一个允许数据编码和解码以在服务之间存储或发送的过程。

为了说明如何使用序列化,我们将使用Protocol Buffers格式定义服务之间传输的数据结构,该格式在业界广泛使用,具有简单的语法,以及非常高效的编码。

最后,我们将说明如何为 Protocol Buffers 结构生成代码,并展示与 XML 和 JSON 等其他格式相比,Protocol Buffers 编码是多么高效。

在本章中,我们将涵盖以下主题:

  • 序列化的基础知识

  • 使用 Protocol Buffers

  • 序列化的最佳实践

现在,让我们继续探讨序列化的基础知识。

技术要求

为了完成本章,您需要拥有 Go 1.11 或更高版本以及 Protocol Buffers 编译器。我们将使用官方的 Protocol Buffers 编译器;您可以通过运行以下命令来安装它:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
export PATH="$PATH:$(go env GOPATH)/bin"

您可以在以下链接在 GitHub 上找到本章的代码示例:

https://github.com/PacktPublishing/microservices-with-go/tree/main/Chapter04

序列化的基础知识

序列化是将数据转换为一种格式的过程,允许您传输、存储它,并在以后将其重构回原始形式。

该过程在以下图中进行了说明:

![图 4.1 – 序列化和反序列化过程图片

图 4.1 – 序列化和反序列化过程

如图中所示,将原始数据转换的过程称为序列化,而将其转换回原始形式的过程称为反序列化

序列化有两个主要用途:

  • 在服务之间传输数据,充当它们之间的通用语言

  • 编码和解码任意数据以进行存储,允许您将复杂的数据结构作为字节数组或常规字符串存储

第二章中,当我们构建应用程序时,我们创建了我们的 HTTP API 端点,并将它们设置为向调用者返回 JSON 响应。在这种情况下,JSON 扮演了序列化格式的角色,使我们能够将我们的数据结构转换为它,然后再将其解码回来。

让我们以在 metadata/pkg/model/metadata.go 文件中定义的 Metadata 结构为例:

// Metadata defines the movie metadata.
type Metadata struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Director    string `json:"director"`
}

我们的结构包括称为注释的记录,这些记录帮助 JSON 编码器将我们的记录转换为输出。例如,我们创建了一个我们的结构实例:

Metadata{
    ID:          "123",
    Title:       "The Movie 2",
    Description: "Sequel of the legendary The Movie",
    Director:    "Foo Bars",
}

当我们用 JSON 对其进行编码时,结果将是以下内容:

{"id":"123","title":"The Movie 2","description":"Sequel of the legendary The Movie","director":"Foo Bars"}

一旦数据被序列化,它就可以以多种不同的方式使用。在我们的例子中第二章,我们使用了 JSON 格式来在我们的微服务之间发送和接收数据。序列化的其他一些用例包括以下内容:

  • 存储配置: 序列化格式常用于存储配置。例如,您可以使用这些格式定义您的服务设置,然后在服务代码中读取它们。

  • 在数据库中存储记录: 例如,JSON 这样的格式常用于在数据库中存储任意数据。例如,键值数据库需要将整个记录值编码到字节数组中,开发者通常使用 JSON 这样的格式来编码和解码这些记录值。

  • 日志记录: 应用程序日志通常以 JSON 格式存储,这使得它们对人类和诸如数据可视化软件等各种应用程序都易于阅读。

JSON 是目前最受欢迎的序列化格式之一,对 Web 开发至关重要。它具有以下优点:

  • 语言支持: 大多数编程语言都包括用于编码和解码 JSON 的工具。

  • 浏览器支持: JSON 是 Web 应用程序的一个基本组成部分,所有现代浏览器都包括在浏览器本身中与之交互的开发者工具。

  • 可读性: JSON 记录易于阅读,在 Web 应用程序的开发和调试过程中通常也容易使用。

然而,它也有一定的局限性:

  • 大小: JSON 不是一种大小高效的格式。在本章中,我们将探讨哪些格式和协议提供了更小的输出记录。

  • 速度: 与其他流行的序列化协议相比,JSON 的编码和解码速度并不是最快的。

让我们探索其他流行的序列化格式。

流行的序列化格式

行业中使用了许多流行的序列化格式和协议。让我们了解一下一些最流行的格式:

  • XML

  • YAML

  • Apache Thrift

  • Apache Avro

  • 协议缓冲区

本节将提供每个格式的高级概述,以及这些协议之间的一些关键差异。

XML

XML 是用于 Web 服务开发的最早序列化格式之一。它于 1998 年创建,目前在行业中仍然被广泛使用,尤其是在企业应用中。

XML 将数据表示为称为元素的节点树。一个元素示例是<example>Some value</example>。如果我们序列化上面提到的元数据结构,结果将是以下内容:

<Metadata><ID>123</ID><Title>The Movie 2</Title><Description>Sequel of the legendary The Movie</Description><Director>Foo Bars</Director></Metadata>

您可能会注意到,我们数据的序列化 XML 表示形式比 JSON 长一些。这是 XML 格式的一个缺点——输出通常是所有流行序列化协议中最大的,这使得阅读和传输数据更困难。另一方面,XML 的优点包括其广泛的采用和受欢迎程度、可读性以及其广泛的库支持。

YAML

YAML 是一种序列化格式,它最初于 2001 年发布。多年来,它越来越受欢迎,成为行业中最受欢迎的序列化格式之一。该语言的开发者非常注重其可读性和紧凑性,使其成为定义任意可读数据的完美工具。我们可以在我们的元数据结构上说明这一点:在 YAML 格式中,它看起来如下所示:

metadata:
  id: 123
  title: The Movie 2
  description: Sequel of the legendary The Movie
  director: Foo Bars

YAML 格式广泛用于存储配置数据。其中一个原因是它能够包含注释,这是其他格式,如 JSON 所缺乏的。YAML 用于服务间通信的使用较少,主要是因为序列化数据的大小较大。让我们来看看一些更高效的序列化格式。

Apache Thrift

到目前为止,我们已经审查了 JSON、XML 和 YAML,它们主要用于定义和序列化任意类型的数据。当我们不仅想要序列化和反序列化数据,还要在多个服务之间传输数据时,还有其他更广泛的解决方案。这些解决方案结合了两个角色:它们既作为序列化格式,也作为通信协议——在网络中发送和接收任意数据的机制。HTTP 是此类协议的一个例子,但开发者并不局限于在他们的应用程序中使用它。

Apache Thrift 是一种结合了序列化和通信协议的工具,可用于定义您的数据类型,并允许您的服务通过传递数据相互通信。它最初是在 Facebook 上创建的,但后来成为 Apache 软件基金会下的社区支持的开源项目。

与 JSON 和 XML 不同,Thrift 要求您首先以自己的格式定义您的结构。在我们的示例中,对于元数据结构,我们需要创建一个以 .thrift 扩展名结尾的文件,包括 Thrift 语言中的定义:

struct Metadata {
  1: string id,
  2: string title,
  3: string description,
  4: string director
}

一旦您有了 Thrift 文件,您可以使用自动 Thrift 代码生成器来生成大多数编程语言的代码,这些代码将包含定义的结构和逻辑,用于编码和解码它。除了数据结构之外,Thrift 允许您定义Thrift 服务——可以远程调用的函数集。以下是一个 Thrift 服务定义的示例:

service MetadataService {
  Metadata get(1: string id)
}

此处定义了一个名为 MetadataService 的服务,它提供了一个 get 函数,返回一个 Metadata Thrift 对象。一个兼容 Thrift 的服务器可以充当这样的 Thrift 服务,处理来自客户端应用程序的传入请求——我们将在 第五章 中学习如何编写这样的服务器。

让我们探讨 Apache Thrift 的优势和局限性。优势包括以下内容:

  • 与 XML 和 JSON 相比,输出数据更小,编码和解码速度更快。Thrift 序列化数据的大小可以比 XML 和 JSON 小 30% 到 50%。

  • 不仅能够定义结构,还能定义整个服务并为它们生成代码,从而允许服务器与其客户端之间进行通信。

局限性包括以下内容:

  • 近年来相对较低的人气和采用率,因为转向了更流行和高效的格式。

  • 它缺乏官方文档。Thrift 是一种相对复杂的技术,大多数文档都是非官方的。

  • 与 JSON 和 XML 不同,Thrift 序列化数据不可读,因此更难以用于调试。

  • 近年来几乎没有任何支持——Facebook 一直在维护一个名为 Facebook Thrift 的独立分支,但它的受欢迎程度远低于 Apache 版本。

让我们看看其他在业界广泛使用的流行序列化格式。

Apache Avro

Apache Avro 是一种序列化格式和通信协议的组合,与 Apache Thrift 有一定的相似性。Apache Avro 也要求开发者为他们数据定义一个架构(可以是 JSON 或其自己的语言 Avro IDL 编写),在我们的案例中,Metadata 结构将具有以下架构:

{
   "namespace": "example.avro",
   "type": "record",
   "name": "Metadata",
   "fields": [
      {"name": "id", "type": "string"},
      {"name": "title", "type": "string"},
      {"name": "description", "type": "string"},
      {"name": "director", "type": "string"},
   ] 
}

然后,该架构将用于将结构转换为序列化状态,并再次转换回来。

类型和结构随时间变化并不罕见,微服务 API 和结构定义需要进化。使用 Avro,开发者可以创建一个新版本的架构(通常表示为单独的文件,后缀为增量版本号),并在代码库中保留新旧版本。这样,应用程序可以以任一格式编码和解码数据,即使它们有一些不兼容的更改,例如字段名称的更改。这是使用 Apache Avro 而不是许多其他序列化协议的关键优势之一。此外,Apache Avro 允许你为现有架构生成代码,这使得在不同编程语言之间翻译序列化数据及其对应的数据结构变得更加容易。

Protocol Buffers

Protocol Buffers 是一种在 20 多年前由 Google 创建的序列化格式。2008 年,该格式公开,并立即在开发者中获得了流行。该格式的优势包括以下内容:

  • 定义语言的简洁性

  • 较小的数据输出大小

  • 序列化和反序列化的高性能

  • 除了数据结构外,还能够定义服务,并且能够在多种语言中编译客户端和服务器代码

  • 由 Google 提供的协议演变和官方支持

Protocol Buffers 的流行度、其简单性以及其数据编码的效率,使其非常适合用于微服务开发。我们将使用 Protocol Buffers 来序列化和反序列化服务之间传输的数据,以及定义我们的服务 API。在下一节中,您将学习如何开始使用 Protocol Buffers,并将我们的微服务逻辑从 JSON 迁移到 Protocol Buffers。

使用 Protocol Buffers

在本节中,我们将说明如何将 Protocol Buffers 用于您的应用程序。我们将使用前几章中的微服务示例,并以 Protocol Buffers 格式定义我们的数据模型。然后,我们将使用 Protocol Buffers 的代码生成工具来生成我们的数据结构。最后,我们将说明如何使用我们生成的代码来序列化和反序列化我们的数据。

首先,让我们准备我们的应用程序。在我们的应用程序的src目录下创建一个名为api的目录。在这个目录内,创建一个movie.proto文件,并将其以下内容添加到其中:

syntax = "proto3";
option go_package = "/gen";

message Metadata {
    string id = 1;
    string title = 2;
    string description = 3;
    string director = 4;
}

message MovieDetails {
    float rating = 1;
    Metadata metadata = 2;
}

让我们描述一下我们刚刚添加的代码。在第一行,我们将语法设置为proto3,这是 Protocol Buffers 协议的最新版本。第二行定义了代码生成的输出路径。文件的其他部分包括两个结构,这些结构是我们微服务所需的,类似于我们在第二章中创建的 Go 结构。

现在,让我们为我们的结构生成代码。在我们的应用程序的src目录中,运行以下命令:

protoc -I=api --go_out=. movie.proto

如果命令执行成功,您应该会找到一个名为src/gen的新目录。该目录应包含一个名为movie.pb.go的文件,其中包含我们的结构和序列化/反序列化它们的代码。例如,生成的MovieDetails结构代码如下:

type Metadata struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Id          string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
    Title       string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
    Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
    Director    string `protobuf:"bytes,4,opt,name=director,proto3" json:"director,omitempty"`
}

现在,让我们描述一下我们刚刚实现了什么。我们创建了一个movie.proto文件,它定义了我们的数据模式——我们数据结构的定义。现在,该模式独立于我们的 Go 代码定义,为我们提供了以下好处:

  • 显式模式定义:我们的数据模式现在与代码解耦,并显式定义了应用程序数据类型。这使得查看应用程序 API 提供的数据类型变得更容易。

  • 代码生成:我们的模式可以通过代码生成转换为代码。我们将在第五章中使用它来在服务之间发送数据。

  • 跨语言支持:我们不仅可以为 Go 生成代码,还可以为其他编程语言生成代码。如果我们的模型发生变化,我们就不需要为所有语言重写结构。相反,我们只需运行单个命令即可为所有语言重新生成代码。

让我们快速进行基准测试,比较三种序列化协议(XML、JSON 和 Protocol Buffers)序列化数据的尺寸。为此,让我们编写一个小工具来完成这个任务。

src 目录中,创建一个名为 cmd/sizecompare 的目录,并向其中添加一个 main.go 文件,内容如下:

package main
import (
    "encoding/json"
    "encoding/xml"
    "fmt"
    "github.com/golang/protobuf/proto"
    "movieexample.com/gen"
    "movieexample.com/metadata/pkg/model"
)
var metadata = &model.Metadata{
    ID:          "123",
    Title:       "The Movie 2",
    Description: "Sequel of the legendary The Movie",
    Director:    "Foo Bars",
}
var genMetadata = &gen.Metadata{
    Id:          "123",
    Title:       "The Movie 2",
    Description: "Sequel of the legendary The Movie",
    Director:    "Foo Bars",
}

让我们实现 main 函数:

func main() {
    jsonBytes, err := serializeToJSON(metadata)
    if err != nil {
        panic(err)
    }
    xmlBytes, err := serializeToXML(metadata)
    if err != nil {
        panic(err)
    }
    protoBytes, err := serializeToProto(genMetadata)
    if err != nil {
        panic(err)
    }
    fmt.Printf("JSON size:\t%dB\n", len(jsonBytes))
    fmt.Printf("XML size:\t%dB\n", len(xmlBytes))
    fmt.Printf("Proto size:\t%dB\n", len(protoBytes))
}

此外,添加以下函数:

func serializeToJSON(m *model.Metadata) ([]byte, error) {
    return json.Marshal(m)
}
func serializeToXML(m *model.Metadata) ([]byte, error) {
    return xml.Marshal(m)
}
func serializeToProto(m *gen.Metadata) ([]byte, error) {
    return proto.Marshal(m)
}

在前面的代码中,我们使用 JSON、XML 和 Protocol Buffers 格式对 Metadata 结构进行编码,并打印每个编码结果的输出大小(以字节为单位)。

您可能需要通过运行以下命令来获取我们基准测试所需的 github.com/golang/protobuf/proto 包:

go mod tidy

现在,您可以在其目录中执行 go run *.go 来运行我们的基准测试,并将看到以下输出:

JSON size: 106B
XML size: 148B
Proto size: 63B

结果非常有趣。XML 输出几乎比 JSON 大 40%。同时,Protocol Buffers 的输出比 JSON 数据小 40% 以上,并且比 XML 结果小两倍以上。这很好地说明了与另外两种格式相比,Protocol Buffers 格式在输出大小方面的效率。通过从 JSON 切换到 Protocol Buffers,我们减少了需要通过网络发送的数据量,并使我们的通信更快。

现在我们进行一个额外的实验,测试三种格式的序列化速度。为此,我们将进行一个 基准测试 — 一个自动的性能检查,将测量目标操作的速度。

在同一目录下创建一个名为 main_test.go 的文件,并向其中添加以下内容:

package main
import (
    "testing"
)
func BenchmarkSerializeToJSON(b *testing.B) {
    for i := 0; i < b.N; i++ {
        serializeToJSON(metadata)
    }
}
func BenchmarkSerializeToXML(b *testing.B) {
    for i := 0; i < b.N; i++ {
        serializeToXML(metadata)
    }
}
func BenchmarkSerializeToProto(b *testing.B) {
    for i := 0; i < b.N; i++ {
        serializeToProto(genMetadata)
    }
}

我们刚刚创建了一个 Go 基准测试,它将告诉我们 JSON、XML 和 Protocol Buffers 编码的速度有多快。我们将在 第八章 中详细介绍基准测试的细节,现在让我们执行以下命令来查看输出:

go test -bench=.

命令的结果应该如下所示:

goos: darwin
goarch: amd64
pkg: movieexample.com/cmd/sizecompare
cpu: Intel(R) Core(TM) i7-8850H CPU @ 2.60GHz
BenchmarkSerializeToJSON-12          3308172           342.2 ns/op
BenchmarkSerializeToXML-12            480728          2519 ns/op
BenchmarkSerializeToProto-12         6596490           185.7 ns/op
PASS
ok      movieexample.com/cmd/sizecompare    5.239s

您可以看到我们刚刚实现的三个函数的名称以及它们旁边的两个数字:

  • 第一个是函数执行的次数

  • 第二个是平均处理速度,以每操作纳秒来衡量

从输出中,我们可以看到 Protocol Buffers 序列化平均耗时 185.7 纳秒,而 JSON 序列化几乎慢两倍,达到 342.2 纳秒。XML 序列化平均耗时 2519 纳秒,比 Protocol Buffers 慢 13 倍以上,比 JSON 序列化慢 7 倍以上。

这个基准测试确实很有趣——它说明了不同序列化格式的平均编码速度差异有多大。如果你的服务对性能很重要,你应该考虑使用更快的序列化格式以实现更高的编码和解码速度。

目前,我们将把生成的结构保存在我们的仓库中。我们将在下一章,第五章,中使用它们来替换我们的 JSON API 处理程序。

现在,让我们学习一些使用序列化的最佳实践。

序列化的最佳实践

本节总结了序列化和反序列化数据的最佳实践。这些实践将帮助你做出高效的决定,在应用程序中使用序列化,并在 Protocol Buffers 和其他格式中编写你的模式定义:

  • 保持模式向后兼容: 避免任何会破坏现有调用者的数据模式的变化。这些变化包括字段名称和类型的修改(重命名或删除)。

  • 确保客户端和服务器之间的数据模式保持同步: 对于具有显式模式定义的序列化格式,如 Apache Thrift、Protocol Buffers 和 Apache Avro,你应该保持客户端和服务器与最新的模式版本同步。

  • 记录隐含细节: 让调用者了解与你的数据模式相关的任何隐含细节。例如,如果你的 API 不允许结构中某个字段的空值,请将其包括在模式文件的注释中。

  • int timestamp 字段会被视为一种不良实践。正确的方法是使用 google.protobuf.Timestamp

  • 使用一致的命名: 在你的模式文件中,选择使用与你的代码类似的一致的命名

  • 遵循官方风格指南: 如果你使用的是 Thrift 或 Protocol Buffers 等模式定义语言,请熟悉官方风格指南。你可以在下面的进一步阅读部分找到 Protocol Buffers 的官方风格指南链接。

此列表提供了一些适用于所有序列化协议的高级建议。对于特定协议的建议,请遵循官方文档,并检查流行的开源项目以获取一些实际的代码示例。

摘要

在本章中,我们介绍了序列化的基础知识,并说明了我们的数据结构可以使用各种序列化协议进行编码,包括 XML、JSON 和 Protocol Buffers。你了解了最流行的序列化协议之间的差异以及它们的主要优缺点。

我们介绍了 Protocol Buffers 的基础知识,并展示了如何在其模式定义语言中定义自定义数据结构。然后,我们使用示例代码来说明如何为 Go 语言生成模式文件。最后,我们讨论了 XML、JSON 和 Protocol Buffers 之间的压缩效率差异。

在下一章中,我们将继续使用 Protocol Buffers,并展示如何将其用于服务之间的通信。

进一步阅读

第五章:同步通信

在本章中,我们将介绍微服务之间最常见的通信方式——同步通信。在 第二章 中,我们已经在我们的微服务中实现了通过 HTTP 协议进行通信并在 JSON 格式返回结果的逻辑。在 第四章 中,我们说明了 JSON 格式在数据大小方面并不是最有效的,并且有许多不同的格式为开发者提供了额外的优势,包括代码生成。在本章中,我们将向您展示如何使用 Protocol Buffers 定义服务 API 并为它们生成客户端和服务器代码。

到本章结束时,您将了解微服务之间同步通信的关键概念,并学会如何实现微服务的客户端和服务器。

您在本章中获得的知识将帮助您更好地组织客户端和服务器代码,生成序列化和通信的代码,并在您的微服务中使用它。在本章中,我们将涵盖以下主题:

  • 同步通信简介

  • 使用 Protocol Buffers 定义服务 API

  • 实现网关和客户端

现在,让我们继续探讨同步通信的主要概念。

技术要求

要完成本章,您需要 Go 1.11、我们在上一章中安装的 Protocol Buffers 编译器以及一个 gRPC 插件。

您可以通过运行以下命令来安装 gRPC 插件:

go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest 
export PATH="$PATH:$(go env GOPATH)/bin" 

您可以在 github.com/PacktPublishing/microservices-with-go/tree/main/Chapter05 找到本章的 GitHub 代码。

同步通信简介

在本节中,我们将介绍同步通信的基础知识,并介绍我们将用于微服务的 Protocol Buffers 的额外好处。

同步通信是网络应用(如微服务)之间交互的方式,其中服务通过请求-响应模型交换数据。该过程在以下图中展示:

图 5.1 – 同步通信

图 5.1 – 同步通信

有许多协议允许应用以这种方式进行通信。HTTP 是同步通信中最受欢迎的协议之一。在 第二章 中,我们已经在我们的微服务中实现了调用和处理 HTTP 请求的逻辑。

HTTP 协议允许您以不同的方式发送请求和响应数据:

  • https://www.google.com/search?q=portugal URL,q=portugal 是一个 URL 参数。

  • User-Agent: Mozilla/5.0.

  • 请求和响应体:请求和响应可以包含包含任意数据的正文。例如,当客户端将文件上传到服务器时,文件内容通常作为请求正文发送。

当服务器由于错误无法处理客户端请求,或者由于网络问题未收到请求时,客户端会收到一个特定的响应,表明发生了错误。在 HTTP 协议的情况下,有两种类型的错误:

  • 客户端错误:这种错误是由客户端引起的。此类错误的例子包括无效的请求参数(例如错误的用户名)、未经授权的访问以及访问未找到的资源(例如,不存在的网页)。

  • 服务器错误:这种错误是由服务器引起的。这可能是一个应用程序错误或上游组件(例如数据库)的错误。

第二章中,我们通过将结果数据作为 JSON 格式的 HTTP 响应体发送来实现我们的 API 处理器。我们通过使用 Go JSON 编码器实现了这一点:

if err := json.NewEncoder(w).Encode(details); err != nil {
    log.Printf("Response encode error: %v\n", err)
}

如前一章所述,JSON 格式在数据大小方面并不是最优的。此外,它不提供有用的工具,例如由 Protocol Buffers 等格式提供的数据结构的跨语言代码生成工具。此外,通过 HTTP 发送请求并手动编码数据不是服务之间通信的唯一形式。有一些现有的远程过程调用(RPC)库和框架有助于在多个服务之间进行通信,并为应用程序开发者提供一些附加功能:

  • 客户端和服务器代码生成:开发者可以生成连接到其他微服务并发送数据的客户端代码,以及生成接受传入请求的服务器代码。

  • 认证:大多数 RPC 库和框架为跨服务请求提供认证选项,例如基于 TLS 和基于令牌的认证。

  • 上下文传播:这是在请求中发送附加数据的能力,例如跟踪,我们将在第十一章中介绍。

  • 文档生成:Thrift 可以为服务和数据结构生成 HTML 文档。

在下一节中,我们将介绍一些您可以在 Go 服务中使用并了解它们提供的功能的 RPC 库。

Go RPC 框架和库

让我们回顾一些适用于 Go 开发者的流行 RPC 框架和库。

Apache Thrift

我们已经在第四章中介绍了 Apache Thrift,并提到了其定义 RPC 服务的能力——由应用程序提供的一组函数,例如微服务。以下是一个 Thrift RPC 服务定义的示例:

service MetadataService {
  Metadata get(1: string id)
}

服务的 Thrift 定义可以用来生成客户端和服务器代码。客户端代码将包括连接到服务实例的逻辑,以及向其发送请求、序列化和反序列化请求和响应结构。使用如 Apache Thrift 这样的库而不是手动进行 HTTP 请求的优势是能够为多种语言生成这样的代码:用 Go 编写的服务可以轻松与用 Java 编写的服务通信,而两者都将使用生成的代码进行通信,从而消除了实现序列化/反序列化逻辑的需求。此外,Thrift 允许我们为 RPC 服务生成文档。

gRPC

gRPC 是由 Google 创建的一个 RPC 框架。gRPC 使用 HTTP/2 作为传输协议,并使用 Protocol Buffers 作为序列化格式。类似于 Apache Thrift,它提供了定义 RPC 服务并生成服务客户端和服务器代码的能力。除此之外,它还提供了一些额外功能,例如以下内容:

  • 认证

  • 上下文传播

  • 文档生成

gRPC 的采用率比 Apache Thrift 高得多,它对流行的 Protocol Buffers 格式的支持使其非常适合微服务开发者。在本章中,我们将使用 gRPC 作为我们微服务之间同步通信的框架。在下一节中,我们将展示如何利用 Protocol Buffers 提供的功能来定义我们的服务 API。

使用 Protocol Buffers 定义服务 API

让我们演示如何使用 Protocol Buffers 格式定义服务 API,并使用 proto 编译器为与我们的每个服务进行通信生成客户端和服务器 gRPC 代码。这些知识将帮助您为使用行业中最受欢迎的通信工具之一,为您的微服务定义和实现 API 建立基础。

让我们从我们的元数据服务开始,并使用 Protocol Buffers 模式语言编写其 API 定义。

打开我们在上一章中创建的 api/movie.proto 文件,并向其中添加以下内容:

service MetadataService {
    rpc GetMetadata(GetMetadataRequest) returns (GetMetadataResponse);
    rpc PutMetadata(PutMetadataRequest) returns (PutMetadataResponse);
}

message GetMetadataRequest {
    string movie_id = 1;
}

message GetMetadataResponse {
    Metadata metadata = 1;
}

我们刚刚添加的代码定义了我们的元数据服务和其 GetMetadata 端点。我们现在已经有了上一章中的 Metadata 结构,现在可以重用它。

让我们注意我们刚刚添加的代码的一些方面:

  • GetMetadataRequestGetMetadataResponse

  • 命名规范:您应该为所有端点遵循一致的命名规则。我们将使用函数名作为所有请求和响应函数的前缀。

现在,让我们将评分服务的定义添加到同一个文件中:

service RatingService {
    rpc GetAggregatedRating(GetAggregatedRatingRequest) returns (GetAggregatedRatingResponse);
    rpc PutRating(PutRatingRequest) returns (PutRatingResponse);
}

message GetAggregatedRatingRequest {
    string record_id = 1;
    int32 record_type = 2;
}

message GetAggregatedRatingResponse {
    double rating_value = 1;
}

message PutRatingRequest {
    string user_id = 1;
    string record_id = 2;
    int32 record_type = 3;
    int32 rating_value = 4;
}

message PutRatingResponse {
}

我们的服务评分有两个端点,我们以与元数据服务类似的方式定义了它们的请求和响应。

最后,让我们将电影服务的定义添加到同一个文件中:

service MovieService {
    rpc GetMovieDetails(GetMovieDetailsRequest) returns (GetMovieDetailsResponse);
}

message GetMovieDetailsRequest {
    string movie_id = 1;
}

message GetMovieDetailsResponse {
    MovieDetails movie_details = 1;
}

现在我们的 movie.proto 文件包括了我们的结构定义和服务的 API 定义。我们准备好为新添加的服务定义生成代码。在应用程序的 src 目录中运行以下命令:

protoc -I=api --go_out=. --go-grpc_out=. movie.proto  

前面的命令与我们之前用于生成数据结构代码的命令类似。然而,它还向编译器传递了一个 --go-grpc_out 标志。此标志告诉 Protocol Buffers 编译器以 gRPC 格式生成服务代码。

让我们看看我们的命令生成的输出编译后的代码。如果命令执行没有错误,你将在 src/gen 目录下找到一个 movie_grpc.pb.go 文件。该文件将包含为我们服务生成的 Go 代码。让我们看看生成的客户端代码:

type MetadataServiceClient interface {
    GetMetadata(ctx context.Context, in *GetMetadataRequest, opts ...grpc.CallOption) (*GetMetadataResponse, error)
}

type metadataServiceClient struct {
    cc grpc.ClientConnInterface
}

func NewMetadataServiceClient(cc grpc.ClientConnInterface) MetadataServiceClient {
    return &metadataServiceClient{cc}
}
func (c *metadataServiceClient) GetMetadata(ctx context.Context, in *GetMetadataRequest, opts ...grpc.CallOption) (*GetMetadataResponse, error) {
    out := new(GetMetadataResponse)
    err := c.cc.Invoke(ctx, "/MetadataService/GetMetadata", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

这生成的代码可以用于我们的应用程序中,从 Go 应用程序调用我们的 API。此外,我们可以为其他语言,如 Java,生成这样的客户端代码,向编译器命令中添加更多参数。这是一个可以节省我们大量时间的出色功能——在编写微服务应用程序时,我们不必编写调用我们服务的客户端逻辑,而可以使用生成的客户端并将它们插入到我们的应用程序中。

除了客户端代码外,Protocol Buffers 编译器还生成了可以用于处理请求的服务代码。在同一个 movie_grpc.pb.go 文件中,你会找到以下内容:

type MetadataServiceServer interface {
    GetMetadata(context.Context, *GetMetadataRequest) (*GetMetadataResponse, error)
    mustEmbedUnimplementedMetadataServiceServer()
}
func RegisterMetadataServiceServer(s grpc.ServiceRegistrar, srv MetadataServiceServer) {
s.RegisterService(&MetadataService_ServiceDesc, srv)
}

我们将在我们的应用程序中使用我们刚刚看到的客户端和服务器代码。在下一节中,我们将修改我们的 API 处理程序以使用生成的代码,并使用 Protocol Buffers 格式处理请求。

实现网关和客户端

在本节中,我们将说明如何将生成的客户端和服务器 gRPC 代码插入到我们的微服务中。这将帮助我们切换它们之间的通信,从 JSON 序列化的 HTTP 到 Protocol Buffers gRPC 调用。

元数据服务

第二章中,我们创建了我们的内部模型结构,例如元数据,而在第四章中,我们创建了它们的 Protocol Buffers 对应版本。然后,我们为我们的 Protocol Buffers 定义生成了代码。结果,我们有了我们模型结构的两个版本——内部版本,定义在 metadata/pkg/model 中,以及生成的版本,位于 gen 包中。

你可能会认为现在有两个类似的结构是多余的。虽然确实存在一定程度的冗余,因为这些重复定义,但这些结构实际上服务于不同的目的:

  • 内部模型:您为应用程序手动创建的结构应在其代码库中跨用,例如存储库、控制器和其他逻辑。

  • 生成的模型:由像 protoc 编译器这样的工具生成的结构,我们在前两章中使用过,应仅用于序列化。用例包括在服务之间传输数据或存储序列化数据。

你可能好奇为什么不建议在应用程序代码库中使用生成的结构。这里有多个原因,如下列所示:

  • 应用程序和序列化格式之间的不必要耦合:如果你想要从一种序列化格式切换到另一种格式(例如,从 Thrift 切换到 Protocol Buffers),并且你的所有应用程序代码库都使用为先前序列化格式生成的结构,那么你需要重写不仅序列化代码,而且整个应用程序。

  • 生成的代码结构可能在不同版本之间有所不同:虽然生成的结构的字段命名和高级结构在代码生成工具的不同版本之间通常是稳定的,但生成的代码的内部函数和结构可能从版本到版本有所不同。如果你的应用程序的任何部分使用了一些在代码生成器版本更新期间发生变化的生成函数,那么在代码生成器版本更新期间,你的应用程序可能会意外地崩溃。

  • 生成的代码通常更难使用:在如 Protocol Buffers 这样的格式中,所有字段始终是可选的。在生成的代码中,这导致了许多可以具有 nil 值的字段。对于应用程序开发者来说,这意味着需要在所有应用程序中进行更多的 nil 检查,以防止可能的 panic。

由于这些原因,最佳实践是保留内部结构和生成的结构,并且仅使用生成的结构进行序列化。让我们通过以下示例说明如何实现这一点。

我们需要添加一些metadata/pkg/model目录,创建一个mapper.go文件,并将其内容添加如下:

package model

import (
    "movieexample.com/gen"
)

// MetadataToProto converts a Metadata struct into a 
// generated proto counterpart.
func MetadataToProto(m *Metadata) *gen.Metadata {
    return &gen.Metadata{
        Id:          m.ID,
        Title:       m.Title,
        Description: m.Description,
        Director:    m.Director,
    }
}

// MetadataFromProto converts a generated proto counterpart 
// into a Metadata struct.
func MetadataFromProto(m *gen.Metadata) *Metadata {
    return &Metadata{
        ID:          m.Id,
        Title:       m.Title,
        Description: m.Description,
        Director:    m.Director,
    }
}

我们刚刚添加的代码将内部模型转换为生成的结构,然后再转换回来。在以下代码块中,我们将在服务器代码中使用它。

现在,让我们实现一个处理元数据服务的 gRPC 处理器,该处理器将处理对服务的客户端请求。在metadata/internal/handler包中,创建一个grpc目录并添加一个grpc.go文件:

package grpc

import (
    "context"
    "errors"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "movieexample.com/gen"
    "movieexample.com/metadata/internal/controller"
    "movieexample.com/metadata/internal/repository"
    "movieexample.com/metadata/pkg/model"
)

// Handler defines a movie metadata gRPC handler.
type Handler struct {
    gen.UnimplementedMetadataServiceServer
    svc *controller.MetadataService
}

// New creates a new movie metadata gRPC handler.
func New(ctrl *metadata.Controller) *Handler {
    return &Handler{ctrl: ctrl}
}

让我们实现GetMetadataByID函数:

// GetMetadataByID returns movie metadata by id.
func (h *Handler) GetMetadata(ctx context.Context, req *gen.GetMetadataRequest) (*gen.GetMetadataResponse, error) {
    if req == nil || req.MovieId == "" {
        return nil, status.Errorf(codes.InvalidArgument, "nil req or empty id")
    }
    m, err := h.svc.Get(ctx, req.MovieId)
    if err != nil && errors.Is(err, controller.ErrNotFound) {
        return nil, status.Errorf(codes.NotFound, err.Error())
    } else if err != nil {
        return nil, status.Errorf(codes.Internal, err.Error())
    }
    return &gen.GetMetadataResponse{Metadata: model.MetadataToProto(m)}, nil
}

让我们突出显示实现中的某些部分:

  • 处理器嵌入生成的gen.UnimplementedMetadataServiceServer结构。这是由 Protocol Buffers 编译器强制执行未来兼容性所必需的。

  • 我们的处理器以与生成的MetadataServiceServer接口中定义的完全相同的格式实现了GetMetadata函数。

  • 我们正在使用MetadataToProto映射函数将我们的内部结构转换为生成的结构。

现在,我们已经准备好更新主文件并将其切换到 gRPC 处理器。更新metadata/cmd/main.go文件,更改其内容如下:

package main

import (
    "context"
    "flag"
    "fmt"
    "log"
    "net"
    "time"

    "google.golang.org/grpc"
    "movieexample.com/gen"
    "movieexample.com/metadata/internal/controller"
    grpchandler "movieexample.com/metadata/internal/handler/grpc"
    "movieexample.com/metadata/internal/repository/memory"
    "movieexample.com/metadata/pkg/model"
)

func main() {
    log.Println("Starting the movie metadata service")
    repo := memory.New()
    svc := controller.New(repo)
    h := grpchandler.New(svc)
    lis, err := net.Listen("tcp", "localhost:8081")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    srv := grpc.NewServer()
    gen.RegisterMetadataServiceServer(srv, h)
    srv.Serve(lis)
}

更新的main函数说明了我们如何实例化我们的 gRPC 服务器并在其中监听请求。函数的其余部分与之前类似。

我们已经完成了对元数据服务的更改,现在可以继续进行评分服务的开发。

评分服务

让我们为评分服务创建一个 gRPC 处理器。在rating/internal/handler包中创建一个grpc目录,并向其中添加一个包含以下代码的grpc.go文件:

package grpc

import (
    "context"
    "errors"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "movieexample.com/gen"
    "movieexample.com/rating/internal/controller"
    "movieexample.com/rating/pkg/model"
)

// Handler defines a gRPC rating API handler.
type Handler struct {
    gen.UnimplementedRatingServiceServer
    svc *controller.RatingService
}

// New creates a new movie metadata gRPC handler.
func New(svc *controller.RatingService) *Handler {
    return &Handler{ctrl: ctrl}
}

现在,让我们实现GetAggregatedRating端点:

// GetAggregatedRating returns the aggregated rating for a 
// record.
func (h *Handler) GetAggregatedRating(ctx context.Context, req *gen.GetAggregatedRatingRequest) (*gen.GetAggregatedRatingResponse, error) {
    if req == nil || req.RecordId == "" || req.RecordType == "" {
        return nil, status.Errorf(codes.InvalidArgument, "nil req or empty id")
    }
    v, err := h.svc.GetAggregatedRating(ctx, model.RecordID(req.RecordId), model.RecordType(req.RecordType))
    if err != nil && errors.Is(err, controller.ErrNotFound) {
        return nil, status.Errorf(codes.NotFound, err.Error())
    } else if err != nil {
        return nil, status.Errorf(codes.Internal, err.Error())
    }
    return &gen.GetAggregatedRatingResponse{RatingValue: v}, nil
}

最后,让我们实现PutRating端点:

// PutRating writes a rating for a given record.
func (h *Handler) PutRating(ctx context.Context, req *gen.PutRatingRequest) (*gen.PutRatingResponse, error) {
    if req == nil || req.RecordId == "" || req.UserId == "" {
        return nil, status.Errorf(codes.InvalidArgument, "nil req or empty user id or record id")
    }
    if err := h.svc.PutRating(ctx, model.RecordID(req.RecordId), model.RecordType(req.RecordType), &model.Rating{UserID: model.UserID(req.UserId), Value: model.RatingValue(req.RatingValue)}); err != nil {
        return nil, err
    }
    return &gen.PutRatingResponse{}, nil
}

现在,我们准备好更新我们的rating/cmd/main.go文件。用以下内容替换它:

package main

import (
    "log"
    "net"

    "google.golang.org/grpc"
    "movieexample.com/gen"
    "movieexample.com/rating/internal/controller"
    grpchandler "movieexample.com/rating/internal/handler/grpc"
    "movieexample.com/rating/internal/repository/memory"
)

func main() {
    log.Println("Starting the rating service")
    repo := memory.New()
    svc := controller.New(repo)
    h := grpchandler.New(svc)
    lis, err := net.Listen("tcp", "localhost:8082")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    srv := grpc.NewServer()
    gen.RegisterRatingServiceServer(srv, h)
    srv.Serve(lis)
}

我们启动服务的方式与元数据服务类似。现在,我们准备好将电影服务链接到元数据和评分服务。

电影服务

在前面的示例中,我们创建了 gRPC 服务器来处理客户端请求。现在,让我们说明如何添加调用我们的服务器的逻辑。这将帮助我们通过 gRPC 在我们的微服务之间建立通信。

首先,让我们实现一个可以在我们的服务网关中重用的函数。创建src/internal/grpcutil目录,并向其中添加一个名为grpcutil.go的文件。向其中添加以下代码:

package grpcutil
import (
    "context"
    "math/rand"
    "pkg/discovery"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "movieexample.com/pkg/discovery"
)
// ServiceConnection attempts to select a random service 
// instance and returns a gRPC connection to it.
func ServiceConnection(ctx context.Context, serviceName string, registry discovery.Registry) (*grpc.ClientConn, error) {
    addrs, err := registry.ServiceAddresses(ctx, serviceName)
    if err != nil {
        return nil, err
    }
    return grpc.Dial(addrs[rand.Intn(len(addrs))], grpc.WithTransportCredentials(insecure.NewCredentials()))
}

我们刚刚实现的函数将尝试使用提供的服务注册表随机选择目标服务的一个实例,然后为它创建一个 gRPC 连接。

现在,让我们为我们的元数据服务创建一个网关。在movie/internal/gateway包中,创建一个名为metadata的目录。在其内部,创建一个名为grpc的目录,并添加一个metadata.go文件,其中包含以下代码:

package grpc

import (
    "context"
    "google.golang.org/grpc"
    "movieexample.com/gen"
    "movieexample.com/internal/grpcutil"
    "movieexample.com/metadata/pkg/model"
    "movieexample.com/pkg/discovery"
)

// Gateway defines a movie metadata gRPC gateway.
type Gateway struct {
    registry discovery.Registry
}

// New creates a new gRPC gateway for a movie metadata 
// service.
func New(registry discovery.Registry) *Gateway {
    return &Gateway{registry}
}

让我们实现从远程 gRPC 服务获取元数据的函数:

// Get returns movie metadata by a movie id.
func (g *Gateway) Get(ctx context.Context, id string) (*model.Metadata, error) {
    conn, err := grpcutil.ServiceConnection(ctx, "metadata", g.registry)
    if err != nil {
        return nil, err
    }
    defer conn.Close()
    client := gen.NewMetadataServiceClient(conn)
    resp, err := client.GetMetadataByID(ctx, &gen.GetMetadataByIDRequest{MovieId: id})
    if err != nil {
        return nil, err
    }
    return model.MetadataFromProto(resp.Metadata), nil
}

让我们突出显示我们网关实现的一些细节:

  • 我们使用grpcutil.ServiceConnection函数创建到我们的元数据服务的连接。

  • 我们使用来自gen包生成的客户端代码创建一个客户端。

  • 我们使用MetadataFromProto映射函数将生成的结构转换为内部结构。

现在,我们准备好为我们的评分服务创建一个网关。在movie/internal/gateway包内,创建一个rating/grpc目录,并向其中添加一个包含以下内容的grpc.go文件:

package grpc

import (
    "context"
    "pkg/discovery"
    "rating/pkg/model"

    "google.golang.org/grpc"
    "movieexample.com/internal/grpcutil"
    "movieexample.com/gen"
)

// Gateway defines an gRPC gateway for a rating service.
type Gateway struct {
    registry discovery.Registry
}

// New creates a new gRPC gateway for a rating service.
func New(registry discovery.Registry) *Gateway {
    return &Gateway{registry}
}

添加GetAggregatedRating函数的实现:

// GetAggregatedRating returns the aggregated rating for a 
// record or ErrNotFound if there are no ratings for it.
func (g *Gateway) GetAggregatedRating(ctx context.Context, recordID model.RecordID, recordType model.RecordType) (float64, error) {
    conn, err := grpcutil.ServiceConnection(ctx, "rating", g.registry)
    if err != nil {
        return 0, err
    }
    defer conn.Close()
    client := gen.NewRatingServiceClient(conn)
    resp, err := client.GetAggregatedRating(ctx, &gen.GetAggregatedRatingRequest{RecordId: string(recordID), RecordType: string(recordType)})
    if err != nil {
        return 0, err
    }
    return resp.RatingValue, nil
}

到目前为止,我们已经完成了大部分更改。最后一步是更新电影服务的main函数。将其更改为以下内容:

package main

import (
    "context"
    "log"
    "net"

    "google.golang.org/grpc"
    "movieexample.com/gen"
    "movieexample.com/movie/internal/controller"
    metadatagateway "movieexample.com/movie/internal/gateway/metadata/grpc"
    ratinggateway "movieexample.com/movie/internal/gateway/rating/grpc"
    grpchandler "movieexample.com/movie/internal/handler/grpc"
"movieexample.com/pkg/discovery/static"
)
func main() {
    log.Println("Starting the movie service")
    registry := static.NewRegistry(map[string][]string{
        "metadata": {"localhost:8081"},
        "rating":   {"localhost:8082"},
        "movie":    {"localhost:8083"},
    })
    ctx := context.Background()
    if err := registry.Register(ctx, "movie", "localhost:8083"); err != nil {
        panic(err)
    }
    defer registry.Deregister(ctx, "movie")
    metadataGateway := metadatagateway.New(registry)
    ratingGateway := ratinggateway.New(registry)
    svc := controller.New(ratingGateway, metadataGateway)
    h := grpchandler.New(svc)
    lis, err := net.Listen("tcp", "localhost:8083")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    srv := grpc.NewServer()
    gen.RegisterMovieServiceServer(srv, h)
    srv.Serve(lis)
}

您可能已经注意到格式没有改变,我们只是更新了网关的导入,将它们从 HTTP 更改为 gRPC。

我们已经完成了对服务的更改。现在,服务可以使用 Protocol Buffers 序列化相互通信,并且您可以在每个cmd目录内使用go run *.go命令运行它们。

摘要

在本章中,我们介绍了同步通信的基础,并学习了如何使用 Protocol Buffers 格式使微服务相互通信。我们展示了如何使用 Protocol Buffers 架构语言定义我们的服务 API,并生成可在用 Go 和其他语言编写的微服务应用程序中重用的代码。

本章所获得的知识应有助于您使用 Protocol Buffers 和 gRPC 编写和维护现有服务。它还作为了如何为您的服务使用代码生成的一个示例。在下一章中,我们将继续探索不同的通信方式,通过介绍另一个模型,即异步通信。

进一步阅读

第六章:异步通信

在上一章中,我们展示了服务如何使用同步请求-响应模型相互通信。还有其他通信模型可以为应用开发者提供各种好处,例如异步通信,我们将在本章中介绍。

在本章中,您将学习异步通信的基础和一些使用它的常见技术,以及它给微服务开发者带来的好处和挑战。我们将介绍一种流行的异步通信软件 Apache Kafka,并说明如何使用它来建立微服务之间的通信。

在本章中,我们将涵盖以下主题:

  • 异步通信基础

  • 使用 Apache Kafka 进行消息传递

  • 异步通信的最佳实践

让我们继续探讨异步通信的基础。

技术要求

为了完成本章,您需要 Go 1.11 或更高版本,类似于前几章。

您可以在此处找到本章的 GitHub 代码:github.com/PacktPublishing/microservices-with-go/tree/main/Chapter06

异步通信基础

在本节中,我们将讨论异步通信的一些理论方面。您将了解异步通信模型的好处和常见问题,以及常见的使用方式,以及一些异步通信的实例。

异步通信是发送者和一个或多个接收者之间的通信,其中发送者并不一定期望立即收到对他们的消息的回复。在我们在第五章中讨论的同步通信模型中,发送请求的调用者会期望立即(或几乎是立即,考虑到网络延迟)收到对请求的响应。在异步通信中,接收者对请求的回复可能需要任意长的时间,或者根本不回复(例如,在收到无回复通知时)。

我们可以通过两个例子来说明这两种模型之间的区别。同步通信的一个例子是电话通话——两个进行电话交谈的人直接并且立即相互通信,并且他们期望实时听到对方的回复。异步通信的一个例子是给人们发送邮件。回复这样的邮件可能需要时间,发送者并不期望立即收到对他们的消息的回复。

然而,这并不意味着异步通信必然比同步模型慢。在大多数情况下,异步处理与同步处理一样快,有时甚至更快:异步处理通常中断较少,导致更高的处理效率。这就像逐个回复 10 封电子邮件,而不是在 10 个并行电话之间切换——后者是同步处理的例子,有时可能因为上下文切换和频繁的中断而变得非常慢。

异步通信的优缺点

异步通信模型有其自身的优点和挑战。开发者需要考虑这两者,以便决定是否使用这种模型。使用异步通信有哪些好处?让我们来了解一下:

  • 第一个好处是处理消息的更流畅的方法。想象一下,你有一个服务,其目的是处理数据并向另一个服务报告状态。报告服务不需要等待它所报告的服务返回任何响应,就像在同步模型中那样。在异步模式下,它只需要确认状态消息已成功发送。这就像给亲戚寄大量的明信片——如果你寄了十几张明信片,你不想在寄下一张之前等待每张明信片送达!

  • 异步通信模型的第二个好处是能够解耦请求的发送和处理。想象一下,一个呼叫者请求下载一个大视频,而远程服务器需要执行这样的任务。在同步模型中,呼叫者将实时等待整个视频处理完毕。这可能会花费几分钟甚至几个小时,使得这种等待非常低效。相反,这样的任务可以通过异步方式执行,其中呼叫者将任务发送到服务器,收到任务已接收的确认,然后执行任何其他活动,直到最终收到完成通知(或处理失败通知)。

  • 异步通信的第三个好处是更好的负载均衡。某些应用程序可能存在不均匀的请求负载,并且容易发生请求的突然激增。如果通信是同步的,服务器需要实时回答每个请求,这很容易导致服务器过载。想象一下,一位餐厅服务员接到一千份晚餐订单——如此高的请求量将完全压垮工作人员,并影响客户。

我们刚才描述的好处相当显著,在许多情况下,异步通信是执行某些类型任务或提供更好系统性能的唯一方式。以下是一些适合异步通信的问题示例:

  • 长时间运行的处理任务:长时间运行的任务,如视频处理,通常更适合异步执行。请求此类处理的调用者不一定需要等待其完成,最终会收到最终结果的通知。

  • 一次发送,由多个组件处理:某些类型的消息,如状态报告,可以由多个独立组件处理。想象一个系统,其中多个员工需要接收相同的信息——而不是独立地给每个人发送,消息可以发布到一个可以被所有感兴趣的人消费的组件。

  • 高性能顺序处理:某些类型的操作在顺序执行和/或批量执行时更为高效。例如,某些操作,如对硬盘的写入,通常在顺序执行时性能更佳(例如,顺序写入一个非常大的文件,没有任何中断)。在这种情况下,与更互动和中断的同步通信相比,异步处理提供了巨大的性能提升,因为请求的接收者可以控制处理速度并依次处理任务。

虽然异步通信的描述性好处可能看起来很有吸引力,但重要的是要注意,它通常会给开发者带来一些难以克服的挑战:

  • 更复杂的错误处理:想象一下你给朋友发了一条消息,但没有收到回复。这是否是因为朋友没有收到消息?在这段时间内是否发生了什么?回复是否丢失了?在同步通信,例如电话通话中,我们会立即知道朋友是否可用,并且能够回拨。在异步场景中,我们需要考虑更多可能的问题,例如我们之前描述的那些。

  • 依赖额外的组件进行消息传递:某些异步通信用例,如下一节中描述的发布-订阅或消息代理模型,需要额外的软件来传递消息。此类软件通常执行额外的操作,如消息批处理和存储,以换取它提供的额外功能,从而给系统带来额外的复杂性。

  • 异步数据流对许多开发者来说可能看起来不直观,并且更复杂:与同步请求-响应模型不同,其中每个请求在逻辑上都会跟随一个响应,异步通信可能是单向的(根本不会收到任何响应)或者可能需要调用者执行额外的步骤以接收响应(例如,当响应作为单独的通知发送时)。因此,异步系统中的数据流可能比同步请求-响应交互更复杂。

现在,让我们来探讨一些可以帮助您组织服务和在它们之间建立异步通信的异步通信技术和模式。

异步通信的技术和模式

有各种技术可以帮助在各种场景中使多个服务之间的异步交互更高效,例如向多个收件人发送消息。在本节中,我们将描述多个有助于促进此类交互的模式。

消息代理

消息代理是通信链中的一个中介组件,可以扮演多个角色:

  • 消息投递:它执行将消息投递给一个或多个接收者的操作。

  • 消息转换:它将传入的消息转换成可以被接收者稍后消费的另一种格式。

  • 消息聚合:它将多个消息聚合为一个,以实现更高效的投递或处理。

  • 消息路由:它根据预定义的规则将传入的消息路由到适当的目的地。

当你给你的朋友或亲戚寄明信片时,邮局扮演了消息代理的角色,在将其投递到目的地时起到中介作用。在这个例子中,使用消息代理的主要好处是发送消息(在我们的例子中是明信片)的便利性,无需考虑如何投递。使用消息代理的另一个好处是投递保证。消息代理可以提供各种级别的投递保证。以下是一些保证的例子:

  • 至少一次:消息至少被投递一次,但在出现故障的情况下可能会被多次投递。

  • 精确一次:消息代理保证消息被投递,并且它将恰好被投递一次。

  • 至多一次:消息可以被投递 0 次或 1 次。

在实践中,精确一次保证通常比至少一次和至多一次更难实现。在至少一次模型中,消息代理在出现任何故障(例如突然断电或重启)的情况下可以重新发送消息。在精确一次模型中,消息代理需要执行额外的检查或存储额外的元数据,以确保在任何可能的情况下消息都不会被重新发送给接收者。

消息代理的另一种分类是基于它们丢失消息的可能性:

  • 损失性:偶尔(例如,在出现故障的情况下)会丢失消息的消息代理

  • 无损:提供不丢失任何消息保证的消息代理

至多一次保证是损失性消息代理的一个例子,而至少一次和精确一次代理是无损性代理的例子。损失性消息代理比无损性代理更快,因为它们不需要处理额外的逻辑来保证消息的投递,例如持久化消息。

发布-订阅模型

发布-订阅模型是多组件(如微服务)之间通信的模型,其中每个组件都可以发布消息并订阅相关的消息。

以 Twitter 为例。任何用户都可以向他们的动态发布消息,其他用户可以订阅它们。同样,微服务可以通过发布其他服务可以消费的数据来进行通信。想象一下,我们有一组处理各种类型用户数据的微服务,例如用户照片、视频和文本消息。如果用户删除了他们的个人资料,我们需要通知所有服务。而不是逐个通知每个服务,我们可以发布一个事件,表明用户个人资料已被删除,所有服务都可以消费它并执行任何相关的操作,例如存档用户数据。

发布者、订阅者以及发布者产生的数据之间的关系在以下图中展示:

图 6.1 – 发布-订阅模型

图 6.1 – 发布-订阅模型

发布-订阅模型为在消息可以被多个组件处理的环境中发送和传递数据提供了一个灵活的解决方案。每个发布者可以发布他们的消息,而不必关心交付过程以及将消息发送到任意数量(甚至是非常大的数量)的接收者可能遇到的任何困难。每个订阅者可以订阅相关的消息并接收它们,而不需要直接联系发布者并检查是否有任何新的数据可以消费。后一个特性对于消息率较低的场景特别有用,例如偶尔的通知交付。

现在,我们已经介绍了一些高级异步通信模型,接下来让我们转向本章的实际部分,并说明如何在您的微服务中实现异步通信。

使用 Apache Kafka 进行消息传递

在本节中,我们将向您介绍 Apache Kafka,这是一个流行的消息代理系统,我们将使用它来在我们的微服务之间建立异步通信。您将学习 Kafka 的基础知识,如何向其发布消息,以及如何从我们在前几章中创建的微服务中消费这些消息。

Apache Kafka 基础

Apache Kafka 是一个开源的消息代理系统,它提供了发布和订阅包含任意数据的消息的能力。最初在 LinkedIn 开发,Kafka 已经成为可能最受欢迎的开源消息代理软件,并被全球成千上万家公司使用。

在 Kafka 模型中,一个发布消息的组件被称为生产者。消息按照顺序发布到称为主题的对象中。每个主题中的消息都有一个唯一的数值偏移量。Kafka 为现有主题提供了消费消息的 API(消费消息的组件称为消费者)。主题也可以分区,以便多个消费者从中消费(例如,用于并行数据处理)。

我们可以在以下图中说明 Kafka 数据模型:

图 6.2 – Apache Kafka 数据模型

图 6.2 – Apache Kafka 数据模型

虽然数据模型看似简单,但 Kafka 是一个强大的系统,为用户提供了许多好处:

  • 高读写吞吐量:Kafka 针对高性能的读写操作进行了优化。它通过尽可能多地执行顺序读写来实现这一点,从而使其能够有效地利用硬盘驱动器等硬件,以及顺序发送大量数据到网络上。

  • 可伸缩性:开发者可以利用 Kafka 提供的主题分区来实现数据的高效并行处理。

  • 灵活的持久性:Kafka 允许用户配置存储数据的策略,例如消息保留。消息可以存储固定的时间(例如,7 天)或无限期地,直到数据存储空间足够。

注意

虽然 Kafka 为开发者提供了许多好处,但重要的是要注意,它是一个相当复杂的基础设施组件,可能难以管理和维护。在本章中,我们将为了说明目的使用它,特别是考虑到它在开发者社区中的广泛采用和流行。在本章中,我们将通过使用其 Docker 版本来避免设置 Kafka 集群的困难,但对于生产用例,你可能需要熟悉可用的相关 Kafka 维护文档,可在kafka.apache.org/documentation/找到。

让我们探索如何利用 Kafka 为我们之前章节中开发的微服务提供的优势。

在我们的微服务中采用 Kafka

让我们回到前几章中的评分服务示例。该服务提供了一个同步 API 用于插入评分记录,允许调用者调用端点并立即从服务中获得响应。这样的 API 在许多实际用例中都很有用,包括用户从用户界面或网页表单提交评分的情况。

现在考虑一个场景,其中我们与一个经常发布评分记录(例如,来自流行的电影数据库,如 IMDb 的电影评分)的数据提供者合作,我们可以在我们的评分服务中使用这些记录。在这里,我们需要消费此类记录并将它们摄入到我们的系统中,以便我们可以使用这些数据,以及通过我们的 API 创建的数据。我们在本章前面描述的发布-订阅模型非常适合这个用例 – 发布者将是提供评分数据的数据提供者,而订阅者将是我们的应用程序的一部分(例如,评分服务),它将消费数据。

我们可以使用以下图表来说明所描述的模型:

图 6.3 – 从数据提供者处获取评分的发布-订阅模型

](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/msvc-go/img/Figure_6.3_B18865.jpg)

图 6.3 – 从数据提供者处获取评分的发布-订阅模型

数据提供者和评分服务之间的交互模型是异步通信的一个完美示例 – 评分服务不一定要立即处理提供者的数据。何时以及如何消费这些数据取决于我们 – 我们的评分服务可以定期(例如,每小时一次,或每天一次)执行此操作,或者在新评分数据发布时立即处理新评分数据。让我们在本章中选择第二种方法。

我们模型中唯一缺少的部分是允许我们从数据提供者发布评分数据并从我们的评分服务订阅它的组件。我们之前描述的 Apache Kafka 对于这个用例非常合适 – 它提供了一个高性能、可扩展且持久的解决方案,用于生产和消费任意数据,使我们能够将其用作评分数据消息代理。

为了说明我们刚刚描述的模型,让我们实现以下逻辑:

  • 一个新的示例应用程序,将为 Apache Kafka 生成评分数据

  • 评分服务中用于从 Apache Kafka 消费评分数据并将其保存到我们的评分数据库的逻辑

在我们继续实现这两个组件之前,我们需要决定在它们之间使用哪种数据序列化格式。为了简单起见,让我们假设数据提供者以 JSON 格式提供评分数据。提供的评分数据的一个示例如下:

[{"userId":"105","recordId":"1","recordType":1,"value":5,"providerId":"test-provider","eventType":"put"},{"userId":"105","recordId":"2","recordType":1,"value":4,"providerId":"test-provider","eventType":"put"}]

让我们定义一个用于此类评分记录的 Go 结构。在 src/rating/pkg/model/rating.go 文件中,添加以下代码:

// RatingEvent defines an event containing rating information.
type RatingEvent struct {
    UserID     UserID      `json:"userId"`
    RecordID   RecordID    `json:"recordId"`
    RecordType RecordType  `json:"recordType"`
    Value      RatingValue `json:"value"`
    EventType  RatingEventType `json:"eventType"`
}
// RatingEventType defines the type of a rating event.
type RatingEventType string
// Rating event types.
const (
    RatingEventTypePut    = "put"
    RatingEventTypeDelete = "delete"
)

现在,让我们实现一个示例应用程序,该应用程序从提供的文件中读取评分数据并将其输出到 Kafka。创建一个 cmd/ratingingester 目录并添加一个 main.go 文件,包含以下代码:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "time"

    "github.com/confluentinc/confluent-kafka-go/kafka"
    "movieexample.com/rating/pkg/model"
)

func main() {
    fmt.Println("Creating a Kafka producer")

    producer, err := kafka.NewProducer(&kafka.ConfigMap{"bootstrap.servers": "localhost"})
    if err != nil {
        panic(err)
    }
    defer producer.Close()

    const fileName = "ratingsdata.json"
    fmt.Println("Reading rating events from file " + fileName)

    ratingEvents, err := readRatingEvents(fileName)
    if err != nil {
        panic(err)
    }

    const topic = "ratings"
    if err := produceRatingEvents(topic, producer, ratingEvents); err != nil {
        panic(err)
    }

    const timeout = 10 * time.Second
    fmt.Println("Waiting " + timeout.String() + " until all events get produced")

    producer.Flush(int(timeout.Milliseconds()))
}

在我们刚刚添加的代码中,我们通过调用kafka.NewProducer初始化了一个 Kafka 生产者,从文件中读取评分数据,并在 Kafka 中生产包含评分数据的评分事件。请注意,我们导入了github.com/confluentinc/confluent-kafka-go Kafka 库——由 Kafka 的创始人创立的公司 Confluent 制作的 Kafka 客户端。对于 Go,有多个流行的开源 Kafka 库,包括github.com/Shopify/sarama,它维护良好,并被广泛用于许多 Go 项目中。你可以根据你的偏好在你的项目中使用任一库。

现在,让我们为刚刚创建的文件添加一个用于读取评分事件的函数:

func readRatingEvents(fileName string) ([]model.RatingEvent, error) {
    f, err := os.Open(fileName)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    var ratings []model.RatingEvent
    if err := json.NewDecoder(f).Decode(&ratings); err != nil {
        return nil, err
    }
    return ratings, nil
}

最后,添加一个用于生产评分事件的函数:

func produceRatingEvents(topic string, producer kafka.Producer, events []model.RatingEvent) error {
    for _, ratingEvent := range ratingEvents {
        encodedEvent, err := json.Marshal(ratingEvent)
    if err != nil {
        return err
    }

    if err := p.Produce(&kafka.Message{
TopicPartition: kafka.TopicPartition{Topic: &topic, Partition: kafka.PartitionAny},
Value:          []byte(encodedEvent),
}, nil); err != nil {
        return err
    }
    return nil
}

让我们描述一下我们刚刚编写的代码的一些部分:

  • 我们通过调用kafka.NewProducer函数并使用localhost作为本地测试的 Kafka 地址来创建了一个 Kafka 生产者。

  • 我们创建的程序预期将从ratingsdata.json文件中读取评分数据。

  • 当我们使用Produce函数向 Kafka 生产事件时,我们使用kafka.TopicPartition结构指定一个主题分区。在这个结构中,我们提供了主题名称(在我们的例子中,我们称之为ratings)和主题分区(在我们的例子中,我们使用kafka.PartitionAny来生产一个分区——我们将在后面的异步通信最佳实践部分中介绍这部分)。

  • 在我们主函数的末尾,我们调用Flush函数以确保所有消息都已发送到 Kafka。

我们刚刚创建的函数正在使用github.com/confluentinc/confluent-kafka-go/kafka库,我们需要将其包含在我们的 Go 模块中。让我们通过运行以下代码来完成此操作:

go mod tidy

让我们也在该目录中添加一个包含评分事件的文件。在刚刚使用的目录中创建一个ratingsdata.json文件,内容如下:

[{"userId":"105","recordId":"1","recordType":1,"value":5,"providerId":"test-provider","eventType":"put"},{"userId":"105","recordId":"2","recordType":1,"value":4,"providerId":"test-provider","eventType":"put"}]

现在,我们的应用程序已经准备好了。我们已经实现了从文件中读取评分数据并将其发布到 Apache Kafka 的逻辑,以便评分服务进一步消费。让我们在评分服务中实现消费已发布数据的逻辑。创建一个rating/internal/ingester/kafka目录,并添加一个ingester.go文件,内容如下:

package kafka

import (
    "context"
    "encoding/json"
    "fmt"
    "rating/pkg/model"

    "github.com/confluentinc/confluent-kafka-go/kafka"
    "movieexample.com/rating/pkg/model"
)

// Ingester defines a Kafka ingester.
type Ingester struct {
    consumer kafka.Consumer
    topic    string
}

// NewIngester creates a new Kafka ingester.
func NewIngester(addr string, groupID string, topic string) (*Ingester, error) {
    consumer, err := kafka.NewConsumer(&kafka.ConfigMap{
        "bootstrap.servers": addr,
        "group.id":          groupID,
        "auto.offset.reset": "earliest",
    })
    if err != nil {
        return nil, err
    }
    return &Ingester{consumer, topic}, nil
}

此外,添加以下代码段:

// Ingest starts ingestion from Kafka and returns a channel // containing rating events
// representing the data consumed from the topic.
func (i *Ingester) Ingest(ctx context.Context) (chan model.RatingEvent, error) {
    if err := i.consumer.SubscribeTopics([]string{i.topic}, nil); err != nil {
        return nil, err
    }

    ch := make(chan model.RatingEvent, 1)
    go func() {
        for {
            select {
            case <-ctx.Done():
                close(ch)
                i.consumer.Close()
            default:
        }
        msg, err := i.consumer.ReadMessage(-1)
        if err != nil {
            fmt.Println("Consumer error: " + err.Error())
            continue
        }
        var event model.RatingEvent
        if err := json.Unmarshal(msg.Value, &event); err != nil { 
            fmt.Println("Unmarshal error: " + err.Error())
            continue
        }
        ch <- event
        }
    }()
    return ch, nil
}

在我们刚刚创建的代码中,我们实现了一个NewIngester函数来创建一个新的 Kafka ingester,该组件将从其中摄取评分事件。Ingest函数在后台启动消息摄取并返回一个包含RatingEvent结构的 Go 通道。

你可能会注意到,在我们的ReadMessage函数调用中,我们提供了-1作为参数。我们指定的-1是 Kafka 特有的,意味着我们将始终从主题的开始处消费,读取所有现有消息。

让我们在我们的评分服务控制器中使用这个结构。在我们的rating/internal/controller/controller.go文件中,添加以下代码:

type ratingIngester interface {
    Ingest(ctx context.Context) (chan model.RatingEvent, error)
} 
// StartIngestion starts the ingestion of rating events.
func (s *RatingService) StartIngestion(ctx context.Context) error {
    ch, err := s.ingester.Ingest(ctx)
    if err != nil {
        return err
    }
    for e := range ch {
        if err := s.PutRating(ctx, e.RecordID, e.RecordType, &model.Rating{UserID: e.UserID, Value: e.Value}); err != nil {
            return err
        }
    }
    return nil
}

在我们的代码中,我们调用Ingest函数,并返回一个包含来自主题的评分事件的 Go 通道。我们使用for运算符遍历它。它将一直返回可用的评分事件,直到通道关闭(例如,当服务关闭时 Kafka 客户端关闭)。

现在,更新此文件中现有的RatingService结构和New函数,如下所示:

// RatingService encapsulates the rating service business 
// logic.
type RatingService struct {
    repo     ratingRepository
    ingester ratingIngester
}

// New creates a rating service.
func New(repo ratingRepository, ingester ratingIngester) *RatingService {
    return &RatingService{repo, ingester}
}

现在,我们的评分服务能够异步消费来自 Kafka 的评分事件,并为每个事件执行Put函数,将其写入评分数据库。在此阶段,评分服务为希望实时创建评分的调用者提供同步 API,并为从 Apache Kafka 摄取评分事件提供异步逻辑。

我们已经介绍了异步通信的基础知识,并展示了如何在我们的微服务中使用它。现在,让我们继续本章的最后一部分,看看在使用此模型时应牢记的一些最佳实践。

异步通信最佳实践

在本节中,我们将介绍使用异步通信模型的最佳实践。您将了解一些针对在您的应用程序中采用该模型和使用它的建议,以最大限度地发挥其为您带来的好处。

版本控制

版本控制是将数据格式(或模式)与其版本关联的技术。想象一下,您正在开发一个评分服务,并使用发布者-订阅模型来生产和使用评分事件。如果在某个时刻,您的评分事件格式发生变化,一些已经生产的事件将具有旧的数据格式,而一些将具有新的格式。这种情况可能很难处理,因为消费此类数据的逻辑需要知道如何区分这些格式以及如何处理每个格式。在不了解数据模式或其版本的情况下区分两种格式可能是一项非平凡的任务。想象一下,我们有两个 JSON 事件:

{"recordID": "1", "rating": 5}
{"recordID": "2", "rating": 17, "userId": "alex"}

第二个事件有一个不在第一个事件中存在的userId字段。这是否是因为生产者没有提供它,或者因为数据格式之前没有这个字段?

明确提供模式版本将帮助数据消费者处理这个问题。考虑以下更新后的示例:

{"recordID": "1", "rating": 5, "version": 1}
{"recordID": "2", "rating": 17, "userId": "alex", "version": 2}

在这些示例中,我们知道事件的版本,现在可以单独处理每个事件。例如,我们可能完全忽略某个版本的某些事件(假设存在应用程序错误,我们希望用更新的版本重新处理事件)或使用特定版本的验证(例如,对于版本 1 允许没有userId字段的记录,但对于更高版本则不允许)。

版本控制对于可以随时间演变的系统非常重要,因为它使得处理不同的数据格式变得更容易。即使您不期望您的数据格式发生变化,也请考虑使用版本控制来提高您系统未来的可维护性。

利用分区

在“采用 Apache Kafka 为我们的微服务”部分的代码示例中,我们实现了将我们的数据生产到 Apache Kafka 消息主题的逻辑。生产消息的函数如下:

if err := p.Produce(&kafka.Message{
TopicPartition: kafka.TopicPartition{Topic: &topic, Partition: kafka.PartitionAny},
Value:          []byte(encodedEvent),
}, nil); err != nil {
    return err
}

在这个函数中,我们使用了 kafka.PartitionAny 选项。正如我们在“Apache Kafka 基础”部分中提到的,Kafka 主题可以被分区,以便多个消费者可以消费主题的不同分区。想象一下,你有一个包含三个分区的主题 – 你可以独立地消费每一个,如下面的图示所示:

Figure 6.4 – 分区主题消费示例

图 6.4 – 分区主题消费示例

你可以控制主题分区的数量,以及你服务产生的每个消息的分区。手动设置分区可能有助于你实现数据本地性 — 将各种记录的数据存储在一起的能力(在我们的用例中,在同一个主题分区中)。例如,你可以使用用户标识符来分区数据,确保任何用户的数据都存储在单个主题分区中,这有助于你简化跨主题分区的数据搜索。

我们刚才描述的最佳实践列表并不全面。它并没有涵盖在微服务中使用异步通信的所有建议,但它提供了一些你应该考虑的极好想法。熟悉“进一步阅读”部分中列出的文章,以获取更多想法和建议。

摘要

在本章中,我们介绍了异步通信的基础,并说明了如何在微服务中使用它。你学习了异步通信的好处以及常见的模式,如发布-订阅和消息代理。除此之外,我们还介绍了 Apache Kafka 消息代理的基础,并说明了如何在我们的微服务中使用它以及如何实现从它生产和使用数据的逻辑。

在下一章中,我们将介绍微服务开发中的另一个重要主题 – 数据存储。你将学习如何持久化和读取不同类型的服务数据,以及如何在你的 Go 微服务中实现与 MySQL 数据库交互的逻辑。

进一步阅读

第七章:存储服务数据

在本章中,我们将回顾一个非常重要的主题:在持久数据库中存储服务数据。在前几章中,我们使用内存存储库存储和检索电影元数据和用户评分。虽然实现内存存储数据很容易,但由于许多原因,使用它们将是不切实际的。其中一个原因是缺乏持久性保证:如果存储数据的我们的服务实例重启(例如,由于应用程序故障或主机重启),我们就会丢失存储在先前运行的实例内存中的所有数据。为了确保我们的数据不会随时间丢失,我们需要一个可以持久化我们的数据并允许我们在微服务中读取和写入它的解决方案。此类解决方案中包括数据库,我们将在本章中对其进行回顾。

我们将涵盖以下主题:

  • 数据库简介

  • 使用 MySQL 存储我们的服务数据

让我们继续本章的第一部分,它将概述微服务持久存储解决方案。

技术要求

为了完成本章,你需要 Go 1.11 或更高版本。此外,你还需要以下工具:

你可以在这里找到本章的 GitHub 代码:https://github.com/PacktPublishing/microservices-with-go/tree/main/Chapter07。

数据库简介

数据库是允许我们存储和检索不同类型数据的系统。数据库提供了与数据存储相关的各种保证,例如持久性 – 一个保证所有记录以及任何相关的数据更改都将随时间持久化的保证。持久性保证有助于确保存储在数据库中的数据在软件和硬件重启等事件中不会丢失,这对于微服务来说相当常见。

数据库有助于解决与数据存储相关的许多其他问题。让我们用一个我们创建在第二章中的元数据服务来说明这样一个问题。在我们的元数据服务代码中,我们实现了一个内存存储库来存储和检索电影数据,它提供了两个函数,GetPut。如果我们只有一个元数据服务实例,所有调用者都能够成功从服务内存中写入和读取元数据记录,只要服务实例不重启。然而,让我们想象一下,我们添加了另一个元数据服务实例,如下面的图所示:

图 7.1 – 电影服务与两个元数据服务实例之间的交互

图 7.1 – 电影服务与两个元数据服务实例之间的交互

假设电影服务想要写入电影元数据,并调用元数据服务来完成此操作。电影服务实例会选择一个元数据服务的实例(让我们假设它选择了实例 0)并向其发送一个写入请求,将记录存储在处理请求的实例的内存中。

现在,让我们假设电影服务想要读取之前存储的电影元数据,并向元数据服务发送一个读取请求。根据处理请求的实例,可能会有两种可能的后果:

  • 实例 0:成功返回之前保存的电影元数据

  • ErrNotFound

我们刚刚展示了在元数据仓库的两个实例之间数据不一致的情况。因为我们没有在我们的内存元数据仓库之间实现任何协调,每个仓库都作为一个独立的数据存储。以这种方式使用我们的元数据服务将非常不切实际:每个新添加的服务实例都会存储一个完全独立的数据集。

为了解决我们的数据不一致问题,我们可以使用数据库来存储电影元数据:数据库将处理来自所有可用元数据服务实例的所有写入和读取操作,帮助将它们的数据集中存储在单一类型的逻辑存储中。以下图示了这种情况:

![图 7.2 – 在多个实例间使用共享元数据数据库图 7.2 – 在多个实例间使用共享元数据数据库

图 7.2 – 在多个实例间使用共享元数据数据库

在我们的图中,多个元数据服务的实例正在使用一个共享数据库。这有助于我们聚合和存储来自不同元数据服务实例的数据。

我们刚刚展示了数据库如何帮助为我们的服务提供数据持久性。这还可以提供其他好处:

  • 事务支持:许多数据库支持事务 – 数据变更的类型 – 它们具有以下属性,简称为 ACID

    • 原子性:一个变更要么完全发生,要么完全不发生

    • 一致性:一个变更将数据库从一个有效状态带到另一个有效状态

    • 隔离性:并发变更按顺序执行

    • 持久性:所有变更都会被持久化

这些事务属性帮助提供了一种可靠的方式来修改不同类型的数据(例如,同时更新两个财务账户余额)。

  • 数据复制:数据库可以提供数据复制 – 将数据复制到额外的实例,称为 副本。复制可以帮助使数据库对数据丢失更具弹性(例如,当数据库主机不可用时,可以在其副本上访问数据)并减少读取延迟(例如,当用户从位于更近处的副本读取数据时)。

  • 额外的查询功能:有许多数据库(如 MySQL 和 PostgreSQL),提供对不同的查询语言的支持,例如 SQL (en.wikipedia.org/wiki/SQL)).

不同类型的数据库可以帮助您高效地存储和检索各种类型的数据。让我们回顾一些这些流行的数据库类型:

  • 键值数据库:这些数据库以键值格式存储数据,其中每条记录包含一个键(例如,用户标识符)和一个值(例如,用户元数据)。键和值通常表示为字符串或字节数组。键值数据库提供的操作通常限于基于键的写入(为提供的键存储值)和基于键的读取(为提供的键读取值)。由于它们的函数简单性,键值数据库在性能方面表现优异,因为它们不涉及任何复杂的数据处理或索引。

  • 关系数据库:这些数据库将数据存储为表的集合,每个表由一组行和列组成。用户可以运行 SQL 查询从单个或多个表中检索数据,能够将它们之间的数据进行连接或根据各种条件执行复杂搜索。历史上,由于它们能够执行任何复杂度的查询以及存储各种类型的结构化数据(具有将行数据映射到表列的明确模式),关系数据库在所有数据库类型中一直是最受欢迎的。

  • 文档数据库:这些数据库以文档格式存储数据,例如 JSON 或 XML。文档数据库不需要您定义数据模式,因此非常适合存储各种不同结构的文档(例如,包含来自多个网站、不同格式的电影元数据的 YAML 文件集合)。

  • 图数据库:这些数据库以顶点的形式存储信息——具有不同属性的对象(例如,用户详细信息),以及——顶点之间的关系(例如,如果用户 A 正在关注用户 B)。图数据库在提供的读取查询类型方面与关系数据库不同:大多数图数据库支持遍历查询(检查图中的每个顶点)、连通性查询(获取与目标顶点相连的所有顶点),以及许多其他查询。

  • Blob 数据库:这些数据库用于存储Blob(二进制大对象)数据,例如音频或视频文件。Blob 记录通常是不可变的(在成功写入后其内容不会改变),因此 Blob 数据库非常适合于追加写入(例如写入新文件),以及 Blob 读取(例如检索大文件的内容)。

我们不会深入探讨各种数据库类型,因为这是一个单独书籍的主题,但重要的是要注意,在它们中并没有一个 万能 的解决方案,每个数据库都提供一组独特的功能,这些功能可以用于解决特定问题。例如,如果你的服务唯一目的是存储文件,使用 blob 数据库就足够了,而图数据库可以帮助构建社交网络以存储用户关系数据。然而,许多用例可以使用关系模型来建模,该模型是关系数据库的基石:自 1970 年由 E.F. Codd 提出,它已在软件开发行业中用于几乎所有类型的问题。流行的关系型数据库,如 MySQL 和 PostgreSQL,仍然是使用最广泛的软件解决方案,帮助构建各种类型的应用程序,从小型单主机运行的服务到跨越数十万个主机的超大规模集群。由于流行关系型数据库的广泛采用和成熟,许多公司将它们作为存储各种类型数据的标准方式。我们将展示如何使用流行的关系型数据库:MySQL 来存储我们的微服务数据。

使用 MySQL 存储我们的服务数据

在本节中,我们将简要介绍 MySQL 并演示如何从我们在前几章中创建的微服务中写入和读取数据。

MySQL 是一个开源的关系型数据库,它于 1995 年创建,根据 DB-Engines 排名(db-engines.com/en/ranking),自那时起已成为开发行业中最常用的数据库之一。它将数据存储为一系列表,每个表由预定义类型的行和列组成(例如字符串、数值、二进制等),并允许通过 SQL 查询来访问数据。例如,假设你有以下数据,存储在一个名为 movies 的表中:

id title director
922 New York Stories John Jones
1055 Christmas Day Ben Miles
1057 Sunny Weather 3 Ben Miles

表 7.1 – 电影表示例

要获取由特定导演拍摄的所有电影的 SQL 查询如下所示:

SELECT * FROM movies WHERE director = "Ben Miles"

现在,让我们假设在同一个名为 ratings 的数据库中包含以下数据:

record_id record_type user_id rating
1055 movie alex001 5
1055 movie chris.rocks 3
1057 movie alex001 4

表 7.2 – 评分表示例

使用 SQL 语言,我们可以编写一个更复杂的查询来获取与特定导演的电影相关的所有评分:

SELECT * FROM ratings r INNER JOIN movies m ON r.record_id = m.id WHERE r.record_type = "movie" AND m.director = "Ben Miles"

在我们的 SQL 查询中,我们执行了两个表的 连接 操作 – 这是一个允许我们将两个表中的数据分组并执行额外过滤(在我们的情况下,我们只选择 record_type 列的值等于 moviedirector 列的值等于 Ben Miles 的评分事件)的操作。

为了演示如何使用 MySQL 存储我们的服务数据,让我们定义我们想要存储的数据以及我们想要如何访问它。让我们从元数据服务开始,它执行两个数据存储操作:

  • 为给定电影 ID 存储电影元数据

  • 获取给定电影 ID 的电影元数据

现在,让我们回顾电影元数据对象的数据模式。我们的电影元数据包含以下字段:

  • ID: 字符串

  • 标题: 字符串

  • 描述: 字符串

  • 导演: 字符串

现在,让我们看看评分服务存储了哪些数据,该服务执行以下存储相关操作:

  • 存储给定记录(通过记录 ID 及其类型组合标识)的评分

  • 获取给定记录的所有评分

让我们回顾评分的数据模式:

  • 用户 ID: 字符串

  • 记录 ID: 字符串

  • 记录类型: 字符串

  • 评分值: 整数

到目前为止,我们知道我们想在数据库中存储哪些数据,并可以设置我们的数据库。我们将使用一个可以通过执行以下命令运行的 MySQL Docker 版本:

docker run --name movieexample_db -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=movieexample -p 3306:3306 -d mysql:latest

在我们的命令中,我们将 MySQL root 用户的密码设置为password,以便我们可以用于测试。我们还设置了数据库名为movieexample,并在端口3306上公开,以便我们可以使用它来访问我们的 MySQL 数据库。

让我们验证我们的容器是否成功启动。运行以下命令以查看正在运行的 Docker 容器列表:

docker ps

输出应包括一个名为movieexample_db的容器,一个mysql:latest镜像,以及一个Up状态。

下一步是创建我们的数据模式。我们将在src目录的单独文件夹中定义它,称为schema。创建此目录并在其中创建一个schema.sql文件,然后将以下代码添加到新创建的文件中:

CREATE TABLE IF NOT EXISTS movies (id VARCHAR(255), title VARCHAR(255), description TEXT, director VARCHAR(255));
CREATE TABLE IF NOT EXISTS ratings (record_id VARCHAR(255), record_type VARCHAR(255), user_id VARCHAR(255), value INT);

在我们的模式文件中,我们定义了两个表,分别称为moviesratings。我们刚刚定义的表由VARCHAR(255)TEXT列组成。VARCHAR是 MySQL 用于存储字符串数据的数据类型,255是列值的最大长度。TEXT是另一种 MySQL 数据类型,常用于存储长文本记录,因此我们使用它来存储可能包含长文本的电影描述。

现在,让我们连接到我们新配置的数据库并初始化我们的数据模式。在我们的项目src目录中运行以下命令:

docker exec -i movieexample_db mysql movieexample -h localhost -P 3306 --protocol=tcp -uroot -ppassword < schema/schema.sql

如果一切正常,我们的数据库应该准备好使用。你可以通过运行以下命令来检查表是否已成功创建:

docker exec -i movieexample_db mysql movieexample -h localhost -P 3306 --protocol=tcp -uroot -ppassword -e "SHOW tables"

前一个命令的输出应包括我们的两个表:

Tables_in_movieexample
movies
ratings

我们已经准备好实现写入和读取它的逻辑。创建一个metadata/internal/repository/mysql目录,并向其中添加一个名为mysql.go的文件,其内容如下:

package mysql
import (
    "context"
    "database/sql"
    "metadata/pkg/model"
    _ "github.com/go-sql-driver/mysql"
    "movieexample.com/metadata/internal/repository"
    "movieexample.com/metadata/pkg/model"
)
// Repository defines a MySQL-based movie matadata repository.
type Repository struct {
    db *sql.DB
}
// New creates a new MySQL-based repository.
func New() (*Repository, error) {
    db, err := sql.Open("mysql", "root:password@/movieexample")
    if err != nil {
        return nil, err
    }
    return &Repository{db}, nil
}

在我们的代码中,我们定义了一个基于 MySQL 的存储库,我们将使用它来存储和检索电影元数据。请注意,我们在导入中添加了以下行:

_ "github.com/go-sql-driver/mysql"

我们添加的行在New函数中初始化了一个 Go MySQL root:password@/movieexample连接 - 这个值被称为连接字符串,它包括用户名、密码和要连接的数据库名。连接字符串还可以包括主机名、MySQL 端口和其他值,但因为我们正在使用默认值来访问本地版本的 MySQL,所以我们不需要设置它们。

重要提示

请注意,将数据库凭据存储在代码中是一种不良做法,建议将此类数据(通常称为机密)单独存储:例如,作为单独的配置文件。在第八章中,我们将回顾如何使用 Go 微服务创建和使用配置文件。

现在,向刚刚创建的文件中添加以下代码:

// Get retrieves movie metadata for by movie id.
func (r *Repository) Get(ctx context.Context, id string) (*model.Metadata, error) {
    var title, description, director string
    row := r.db.QueryRowContext(ctx, "SELECT title, description, director FROM movies WHERE id = ?", id)
    if err := row.Scan(&title, &description, &director); err != nil {
        if err == sql.ErrNoRows {
            return nil, repository.ErrNotFound
        }
        return nil, err
    }
    return &model.Metadata{
        ID:          id,
        Title:       title,
        Description: description,
        Director:    director,
    }, nil
}
// Put adds movie metadata for a given movie id.
func (r *Repository) Put(ctx context.Context, id string, metadata *model.Metadata) error {
    _, err := r.db.ExecContext(ctx, "INSERT INTO movies (id, title, description, director) VALUES (?, ?, ?, ?)",
        id, metadata.Title, metadata.Description, metadata.Director)
    return err
}

在我们的代码中,我们实现了GetPut函数,以便我们可以从 MySQL 存储和检索电影元数据。在我们的Get函数内部,我们使用数据库实例的QueryRowContext函数从我们的表中读取一行。如果查询错误,我们检查它是否等于sql.ErrNoRows;如果是,我们返回ErrNotFound

现在,让我们实现我们的 MySQL 评分仓库。创建一个rating/internal/repository/mysql目录,并向其中添加一个mysql.go文件,内容如下:

package mysql
import (
    "context"
    "database/sql"
    "rating/pkg/model"
    _ "github.com/go-sql-driver/mysql"
    "movieexample.com/rating/internal/repository"
    "movieexample.com/rating/pkg/model"
)
// Repository defines a MySQL-based rating repository.
type Repository struct {
    db *sql.DB
}
// New creates a new MySQL-based rating repository.
func New() (*Repository, error) {
    db, err := sql.Open("mysql", "root:password@/movieexample")
    if err != nil {
        return nil, err
    }
    return &Repository{db}, nil
}

到目前为止,我们的评分仓库代码与元数据仓库代码相似。在同一个文件中,让我们实现两个函数来读取和写入评分数据:

// Get retrieves all ratings for a given record.
func (r *Repository) Get(ctx context.Context, recordID model.RecordID, recordType model.RecordType) ([]model.Rating, error) {
    rows, err := r.db.QueryContext(ctx, "SELECT user_id, value FROM ratings WHERE record_id = ? AND record_type = ?", recordID, recordType)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var res []model.Rating
    for rows.Next() {
        var userID string
        var value int32
        if err := rows.Scan(&userID, &value); err != nil {
            return nil, err
        }
        res = append(res, model.Rating{
            UserID: model.UserID(userID),
            Value:  model.RatingValue(value),
        })
    }
    if len(res) == 0 {
        return nil, repository.ErrNotFound
    }
    return res, nil
}
// Put adds a rating for a given record.
func (r *Repository) Put(ctx context.Context, recordID model.RecordID, recordType model.RecordType, rating *model.Rating) error {
    _, err := r.db.ExecContext(ctx, "INSERT INTO ratings (record_id, record_type, user_id, value) VALUES (?, ?, ?, ?)",
        recordID, recordType, rating.UserID, rating.Value)
    return err
}

在我们的Get处理程序中,我们使用Query函数从我们的表中读取评分行。我们通过调用rows.Scan函数扫描每一行,将 MySQL 数据转换为必要的结构。

我们的仓库代码已经准备好了,所以我们可以通过运行以下命令来导入新使用的包github.com/go-sql-driver/mysql

go mod tidy

让我们通过手动测试评分仓库来验证我们的逻辑是否正确:

  1. rating/cmd/main.go文件中,将movieexample.com/rating/internal/repository/memory导入更改为movieexample.com/rating/internal/repository/mysql

  2. 在同一个文件中,找到以下代码块:

repo := memory.New()
  1. 将其更改为以下内容:
repo := mysql.New()
if err != nil {
    panic(err)
}
  1. 导航到评分服务的cmd目录,并运行以下命令:
go run *.go
  1. 手动发送一个写入评分的请求:
grpcurl -plaintext -d '{"record_id":"1", "record_type": "movie"}' localhost:8082 RatingService/GetAggregatedRating

你应该看到以下信息:

ERROR:
  Code: NotFound
  Message: ratings not found for a record
  1. 现在,让我们写入一个评分来测试我们的数据库是否正确工作。执行以下命令:
grpcurl -plaintext -d '{"record_id":"1", "record_type": "movie", "user_id": "alex", "rating_value": 5}' localhost:8082 RatingService/PutRating
  1. 现在,让我们获取同一电影的更新评分。执行与步骤 4相同的命令:
grpcurl -plaintext -d '{"record_id":"1", "record_type": "movie"}' localhost:8082 RatingService/GetAggregatedRating
  1. 你应该得到以下响应:
{
  "ratingValue": 5
}

哈喽,我们刚刚确认我们的仓库逻辑是有效的!你现在可以关闭评分服务,重新运行它,并重复步骤 6。当你这样做时,你会得到相同的结果,这将确认我们的数据现在是持久的,并且不会受到服务重启的影响。

摘要

在本章中,我们简要概述了用于存储微服务数据的数据库存储解决方案。我们说明了如何将我们的服务数据写入 MySQL 的逻辑,MySQL 是一个流行的开源关系型数据库,在软件开发行业中得到广泛应用。

在下一章中,我们将展示如何使用流行的平台 Kubernetes 构建和运行我们的服务实例,该平台允许我们协调各种与服务相关的操作,例如代码更新、自动增加服务实例数量等。

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:

第八章:使用 Kubernetes 进行部署

既然你已经到达了本章,你已经知道如何引导微服务,设置访问数据库的逻辑,实现服务 API,使用序列化,并启用微服务之间的异步通信。现在,我们准备介绍一个在实践中非常重要的主题——微服务部署。

localhost 用于 Kafka。在某个时候,你将需要在远程运行你的服务——例如,在远程服务器或云中,如 Amazon Web ServicesAWS)或 Microsoft Azure。

本章将帮助你学习如何构建和设置你的应用程序,以便部署到这样的远程基础设施。此外,我们将展示如何使用最受欢迎的部署和编排系统之一,Kubernetes。你将了解它提供的优势,以及如何为我们在前几章中创建的微服务设置它。

在本章中,我们将涵盖以下主题:

  • 准备应用程序代码以进行部署

  • 通过 Kubernetes 部署

  • 部署最佳实践

现在,让我们继续本章的第一部分,这将帮助你更好地理解部署过程背后的核心思想,并为你的微服务准备部署。

技术要求

为了完成本章,你需要 Go 1.11+ 或更高版本,类似于前几章。此外,你还需要 Docker,你可以在 www.docker.com 下载它。你需要在 Docker 网站上注册,以便在本章测试服务部署。

除了 Docker 之外,为了完成本章,你还需要 Kubernetes,你可以在 kubernetes.io 下载它(你需要其中的 kubectlminikube 工具)。

你可以在此处找到本章的 GitHub 代码:

github.com/PacktPublishing/microservices-with-go/tree/main/Chapter08

准备应用程序代码以进行部署

在本节中,我们将提供一个服务部署过程的高级概述,并描述准备你的微服务进行部署所需的操作。你将学习如何配置 Go 微服务以在不同的环境中运行,如何为不同的操作系统构建它们,以及一些准备你的微服务进行远程执行的其他技巧。

让我们继续了解部署过程的基本知识。

部署基础知识

如本章引言中所述,部署允许你在单个或多个服务器上运行和更新你的应用程序。这样的服务器通常位于远程位置(云或专用网络托管),并且全天候运行,以便你的应用程序能够 24/7 处理请求或处理数据。

每个环境的部署过程通常包括多个步骤。这些步骤包括以下内容:

  1. 构建:通过编译(对于编译语言,如 Go)并包含其他必需文件来构建服务。

  2. 部署:将新创建的构建复制到目标环境的服务器上,并用新构建的代码替换任何现有的运行代码。

部署过程通常是顺序的:不是并行替换所有主机上的构建,而是每次只替换一个。例如,如果你有十个服务实例,部署过程将首先更新一个实例,然后验证该实例是否健康,然后移动到第二个实例,一直更新到最后一个服务实例。这样做是为了提高服务可靠性,因为如果新版本包含错误或完全无法在某些服务器上启动,部署就不会一次性影响所有服务器。

为了能够测试微服务,服务器可以被分类到多个类别,称为环境:

  • 本地/开发环境:在编写代码时用于运行和测试代码的服务器。这个环境永远不会处理来自用户的任何请求,通常只由开发者的计算机组成。它也可以配置为使用数据库和其他组件的简化版本,例如单服务器和内存实现。

  • 生产环境:用于处理用户请求的服务器。

  • 预发布环境:生产环境的镜像,但用于测试。预发布环境与本地/生产环境的不同之处在于配置和独立的数据存储,这有助于在测试期间避免与生产数据的任何干扰。

生产部署可以通过金丝雀模式进行——这种部署模式只对生产主机的一小部分(例如 1%)进行更改。金丝雀部署在更新服务所有生产实例之前对新代码进行最终测试非常有用。

现在我们来看看开发者如何配置他们的微服务以部署到多个环境。

应用配置

在上一节中,我们描述了不同环境之间的差异,例如本地/开发和生产。每个环境通常配置不同——如果你的服务可以访问数据库,每个环境通常会有一个不同的数据库,具有不同的凭证。为了使你的服务能够在这样的环境中运行,你需要有多个服务配置,每个环境一个。

配置你的服务有两种方式:

  • 就地/硬编码:所有必需的设置都存储在服务代码中(在我们的例子中是 Go 代码)。

  • 分离代码和配置:配置存储在单独的文件中,以便可以独立修改。

将服务代码和配置分离通常会使代码更易于阅读,这使得配置更改更加容易。每个环境都可以有一个单独的配置文件或一组文件,这样您可以轻松地读取、审查和更新特定环境的配置。此外,各种数据格式,如 YAML,可以帮助保持配置文件的紧凑性。以下是一个 YAML 配置示例:

mysql:
  database: ratings
kafka:
  topic: ratings

在这本书中,我们将使用一种将应用程序代码和配置文件分离的方法,并将配置存储在 YAML 格式中。这种方法在许多 Go 应用程序中很常见,并且可以在许多流行的开源 Go 项目中看到。

重要提示

注意,无效的配置更改是大多数生产系统中服务中断的主要原因之一。我建议您探索各种方法来自动验证配置文件,作为代码提交流程的一部分。以下文章提供了一个基于 Git 的 YAML 配置验证示例:ruleoftech.com/2017/git-pre-commit-and-pre-receive-hooks-validating-yaml

让我们回顾一下我们的微服务代码,看看哪些设置可以从应用程序配置中提取出来:

  1. 我们的metadata 服务除了其 gRPC 处理器的地址localhost:8081外,没有其他设置,您可以在其main.go文件中找到这个地址:

    lis, err := net.Listen("tcp", fmt.Sprintf("localhost:%v", port))
    
  2. 我们可以将此设置提取到服务配置中。具有此设置的 YAML 配置文件看起来如下所示:

    api:
    
      port: 8081
    
  3. 让我们进行更改,以便从文件中读取配置。在metadata/cmd目录内,创建一个config.go文件,并将以下代码添加到其中:

    package main
    
    type serviceConfig struct {
    
      APIConfig apiConfig `yaml:"api"`
    
    }
    
    type apiConfig struct {
    
      Port string `yaml:"port"`
    
    }
    
  4. 此外,在metadata服务目录内创建一个configs目录,并向其中添加一个包含以下内容的base.yaml文件:

    api:
    
      port: 8081
    
  5. 我们刚刚创建的文件包含我们服务的 YAML 配置。现在,让我们向我们的main.go文件添加代码以读取配置。用以下内容替换main函数中打印日志消息的第一行:

    log.Println("Starting the movie metadata service")
    
    f, err := os.Open("base.yaml")
    
    if err != nil {
    
        panic(err)
    
    }
    
    defer f.Close()
    
    var cfg serviceConfig
    
    if err := yaml.NewDecoder(f).Decode(&cfg); err != nil {
    
        panic(err)
    
    }
    

此外,将包含net.Listen调用的行替换为以下内容:

lis, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", cfg.     APIConfig.Port))
  1. 我们刚刚添加的代码正在使用gopkg.in/yaml.v3包来读取 YAML 文件。通过运行以下命令将其导入到我们的模块中:
go mod tidy

对我们之前创建的两个其他服务执行相同的更改。在您的 YAML 文件中使用端口号8082rating服务,8083movie服务。

我们刚刚所做的更改帮助我们引入了与应用程序逻辑分离的应用程序配置。这有助于我们在想要引入额外的可配置选项时——要做出任何配置更改,我们只需更新 YAML 文件,而无需修改我们的服务 Go 代码。

现在我们已经完成了对微服务的配置,以便部署,我们准备进入下一节,该节将介绍微服务的部署过程。

通过 Kubernetes 部署

在本节中,我们将展示如何使用一个流行的开源部署和编排平台 Kubernetes 来设置我们的微服务部署。您将学习 Kubernetes 的基础知识,如何设置我们的微服务以使用它,以及如何在 Kubernetes 中测试我们的微服务部署。

Kubernetes 简介

Kubernetes 是一个开源的部署和编排平台,最初由 Google 创建,后来由 Linux 基金会支持的一个大型开发者社区维护。Kubernetes 为运行和部署任何规模的应用程序提供了一种强大、可扩展和灵活的解决方案,从小型单实例应用程序到拥有数万个实例的应用程序。Kubernetes 有助于编排多个操作,如部署、回滚、应用程序的向上和向下扩展(更改应用程序实例数量向上和向下),以及更多。

在 Kubernetes 中,每个应用程序由一个或多个Pod组成——最小的可部署单元。每个 Pod 包含一个或多个容器——包含应用程序代码的轻量级软件块。以下图中展示了单个容器部署到多个 Pod 的示例:

Figure 8.1 – Kubernetes 部署模型

Figure 7.1 – Kubernetes 部署模型

Figure 8.1 – Kubernetes 部署模型

Kubernetes Pod 可以在单个或多个主机上运行,这些主机被称为节点。一组节点被称为集群,集群、节点及其 Pod 之间的关系在以下图中展示:

![Figure 8.2 – Kubernetes 集群模型Figure 7.2 – Kubernetes 部署模型

Figure 8.2 – Kubernetes 集群模型

在 Kubernetes 中部署服务时,开发者通常需要执行以下步骤:

  1. 准备容器镜像容器镜像包含应用程序代码或其编译的二进制文件(两种选项都可以使用,只要容器镜像包含运行代码的指令和任何工具),以及运行它所需的任何附加文件。容器镜像本质上是一个准备就绪的、可用于部署的程序。

  2. 创建部署配置:Kubernetes 部署配置告诉它如何运行应用程序。它包括设置,如副本数量(要运行的 Pod 数量)、容器名称以及更多。

  3. 运行部署命令:Kubernetes 将通过运行所需数量的 Pod 并针对目标应用程序(程序)应用提供的配置。

Kubernetes 的一个好处是抽象出所有部署的低级细节,例如选择目标服务器进行部署(如果您有很多,您需要平衡它们的负载否则),复制和提取您的文件,以及运行健康检查。除此之外,还有一些其他有用的好处:

  • 服务发现:Kubernetes 为应用程序提供内置的服务发现 API。

  • 回滚:如果在部署中遇到任何问题,Kubernetes 允许您将更改回滚到之前的状态。

  • 自动重启:如果任何 pod 遇到任何问题,例如应用程序崩溃,Kubernetes 将重启该 pod。

现在,让我们描述如何使用 Kubernetes 设置我们的微服务的部署。

为 Kubernetes 部署设置我们的微服务

为我们三个微服务在 Kubernetes 中设置部署的所有必要步骤都列在这里:

  1. 第一步是为每个服务创建一个容器镜像。Kubernetes 支持多种类型的容器,而 Docker 目前是最流行的容器类型。我们已经在 第三章 中使用了 Docker,现在我们将展示如何使用它为我们的服务创建容器。

metadata 服务目录内部,创建一个名为 Dockerfile 的文件,并将以下代码添加到其中:

FROM alpine:latest

COPY main .
COPY configs/. . EXPOSE 8081
CMD ["/main"]

在我们刚刚添加的文件中,我们指定了为 metadata 服务的容器准备镜像时,Docker 应该使用 alpine:latest 作为基础镜像。将 main 添加到容器中,复制服务的 configs 目录,并暴露 8081 端口,以便我们可以接受其上的入站请求。

  1. 作为下一步,在 ratingmovie 服务目录中添加一个具有相同内容的文件。确保在文件中使用正确的端口(分别为 80828083)。

一旦创建了 Docker 配置文件,请在每个服务目录中运行 build 命令:

GOOS=linux go build -o main cmd/*.go

上一个命令的结果应该是名为 main 的可执行文件,存储在每个服务目录中。请注意,我们使用了 GOOS=linux 变量——这告诉 go 工具为 Linux 操作系统构建我们的代码。

  1. 下一步是构建服务镜像。从 metadata 服务目录运行此命令:
docker build -t metadata .

类似地,从 rating 服务目录运行此命令:

docker build -t rating .

最后,从 movie 服务目录运行此命令:

docker build -t movie .

如果每个命令都成功执行,我们就准备好使用以下命令运行我们的容器:

docker run -p 8081:8081 -it metadata
docker run -p 8082:8082 -it rating
docker run -p 8083:8083 -it movie

每次执行的成果应该是每个服务的成功执行。

  1. 下一步是在您的账户中创建 Docker Hub 仓库,以便您可以将服务镜像发布到它们。登录到 hub.docker.com,转到 metadataratingmovie

执行以下命令以发布镜像:

docker tag metadata <Your Docker username>/metadata:1.0.0
docker push <Your Docker username>/metadata:1.0.0
docker tag metadata <Your Docker username>/rating:1.0.0
docker push <Your Docker username>/rating:1.0.0
docker tag metadata <Your Docker username>/movie:1.0.0
docker push <Your Docker username>/movie:1.0.0

这些命令应将我们刚刚创建的镜像上传到您的 Docker Hub 仓库,以便 Kubernetes 在部署期间下载它们。

到目前为止,我们已经准备好创建一个 Kubernetes 部署配置,这将告诉 Kubernetes 如何部署我们的服务。

  1. metadata 服务目录内部,创建一个名为 kubernetes-deployment.yml 的文件,内容如下:

    apiVersion: apps/v1
    
    kind: Deployment
    
    metadata:
    
      name: metadata
    
    spec:
    
      replicas: 2
    
      selector:
    
        matchLabels:
    
          app: metadata
    
      template:
    
        metadata:
    
          labels:
    
            app: metadata
    
        spec:
    
          containers:
    
          - name: metadata
    
            image: microservices-with-go/metadata:1.0.0 
    
            imagePullPolicy: IfNotPresent
    
            ports:
    
              - containerPort: 8081
    

我们刚刚创建的文件提供了 Kubernetes 如何部署我们的服务的说明。以下是一些重要的设置:

  • 副本数: 运行的 pod 数量

  • 镜像: 部署要使用的容器镜像名称

  • 端口: 需要暴露的容器端口

注意,容器端口与应用端口(我们在 APIConfig 结构中配置的那个端口)是不同的。这些设置的映射是由 Docker 在 docker run 设置中完成的。

  1. 现在,在 rating 服务目录中创建一个具有相同名称的文件,内容如下:

    apiVersion: apps/v1
    
    kind: Deployment
    
    metadata:
    
      name: rating
    
    spec:
    
      replicas: 2
    
      selector:
    
        matchLabels:
    
          app: rating
    
      template:
    
        metadata:
    
          labels:
    
            app: rating
    
        spec:
    
          containers:
    
          - name: rating
    
            image: <Your Docker username>/rating:1.0.3
    
            imagePullPolicy: IfNotPresent
    
            ports:
    
              - containerPort: 8082
    

记得用你在 步骤 4 中创建的 Docker 镜像名称替换 image 属性。

  1. 最后,在 movie 服务目录中创建一个名为 kubernetes-deployment.yml 的文件,内容如下:

    apiVersion: apps/v1
    
    kind: Deployment
    
    metadata:
    
      name: movie
    
    spec:
    
      replicas: 2
    
      selector:
    
        matchLabels:
    
          app: movie
    
      template:
    
        metadata:
    
          labels:
    
            app: movie
    
        spec:
    
          containers:
    
          - name: movie
    
            image: ashuiskov/movie:1.0.0
    
            imagePullPolicy: IfNotPresent
    
            ports:
    
              - containerPort: 8083
    
  2. 下一步是使用 minikube 工具启动本地 Kubernetes 集群,你应该已经将其作为 Kubernetes 的一部分安装了。运行以下命令以启动集群:

minikube start
  1. 然后,从 metadata 服务目录运行以下命令来应用我们的 metadata 部署配置:
kubectl apply -f kubernetes-deployment.yml 
  1. 如果前一个命令执行成功,你可以通过运行以下命令来查看新的部署:
kubectl get deployments

命令的输出应该是这样的:

NAME       READY   UP-TO-DATE   AVAILABLE   AGE
metadata   0/2     2            0           6s

还可以通过运行以下命令来检查服务 pod 的状态:

kubectl get pods

输出应该显示 metadata 服务 pod 的 Running 状态,如下所示:

NAME                        READY   STATUS    RESTARTS   AGE
metadata-5f87cbbf65-st69m   1/1     Running   0          116s
metadata-5f87cbbf65-t4xsk   1/1     Running   0          116s

如你所注意到的,Kubernetes 为我们的服务创建了两个 pod,这与我们在部署配置中指定的数量相同。每个 pod 都有一个 metadata 服务。

你可以通过运行以下命令来检查每个 pod 的日志:

kubectl logs -f <POD_ID>

现在,对其他两个服务执行与 metadata 服务相同的更改,并验证 pod 是否正在运行。

如果你想要向服务发送一些手动 API 请求,你需要通过运行以下命令来设置端口转发:

kubectl port-forward <POD_ID> 8081:8081

此命令适用于 metadataratingmovie 服务;但是,你需要将 8081 端口值分别替换为 80828083

如果你一切做得很好,恭喜!我们已经完成了微服务的基本 Kubernetes 部署设置。让我们总结一下本节中我们做了什么:

  • 首先,我们为每个服务创建了容器镜像,以便我们可以部署它们。

  • 然后,我们将容器镜像发布到 Docker Hub,以便 Kubernetes 在部署过程中可以拉取这些镜像。

  • 我们创建了一个 Kubernetes 部署配置,以告诉它如何部署我们的微服务。

  • 最后,我们使用 minikubekubectl 命令的组合测试了我们的 Kubernetes 部署。

到目前为止,你应该对 Kubernetes 部署有一些了解,并知道如何使用它们来部署你的微服务。这些知识将帮助你将服务运行在许多平台上,包括所有流行的云平台,如 AWS、Azure 和 Google Cloud PlatformGCP)。

部署最佳实践

在本节中,我们将描述一些与部署过程相关的最佳实践。以下列出的这些实践将帮助你为你的微服务设置一个可靠的部署过程:

  • 自动回滚

  • 金丝雀部署

  • 持续部署(CD)

自动回滚

自动回滚是在部署过程中出现失败时自动回滚部署的机制。想象一下,你正在部署你服务的新版本,而这个版本有一些应用程序错误,阻止它成功启动。在这种情况下,部署过程将用失败的实例替换你的服务活动实例(如果服务已经运行),从而使你的服务不可用。自动回滚是一种检测和回滚此类不良部署的方法,帮助你避免在服务因此类问题而不可用时出现中断。

自动回滚在撰写本书时不是 Kubernetes 的默认功能,类似于许多流行的部署平台。然而,这不应该阻止你使用这项技术,尤其是如果你旨在实现服务的高可靠性。使用 Kubernetes 实现自动回滚的高级思路如下:

  • 对你的服务进行持续的健康检查(我们将在本书的第十二章中介绍此类逻辑)。

  • 当你发现你的服务存在健康问题时,检查是否有最近的服务部署。例如,你可以通过运行 kubectl describe deployment 命令来做到这一点。

  • 如果最近有部署,并且其时间与检测到健康检查问题的时刻非常接近,你可以通过执行以下回滚命令来回滚它:kubectl rollout undo deployment <DEPLOYMENT_NAME>

金丝雀部署

正如我们在本章开头提到的,金丝雀是一种特殊的部署类型,其中你只更新一小部分(1% 到 3%)的实例。金丝雀部署的想法是在生产实例的子集上测试你代码的新版本,并在进行常规生产部署之前验证其正确性。

我们不会涵盖在 Kubernetes 中设置金丝雀部署的细节,但可以介绍一些基本理念,这些理念将帮助你在想要为你的微服务启用金丝雀部署时进行操作,如下所述:

  • 创建两个独立的 Kubernetes 部署配置,一个用于金丝雀,一个用于生产。

  • 指定每个配置中所需的副本数——如果你想在一个服务上运行 50 个 pod,并让金丝雀处理 2% 的流量,则为金丝雀设置 1 个副本,为生产环境设置 49 个副本。

  • 你也可以为部署名称添加环境特定的后缀。例如,你可以将一个评分服务的金丝雀部署命名为 rating-canary,而将生产环境的部署命名为 rating-production

  • 当你进行服务的部署时,首先使用金丝雀配置进行部署。

  • 一旦你验证了部署成功,就使用生产配置进行部署。

金丝雀部署强烈推荐用于提高部署的可靠性。在少量流量上测试新更改有助于减少各种应用程序错误和其他类型的问题的影响,这些问题可能是你的服务可能遇到的。

用持续部署(CD)替换

持续部署CD)是一种频繁重复部署的技术。使用 CD,服务会自动部署——例如,在每次代码更改时。CD 的主要好处是早期部署失败检测——如果任何更改(如新服务代码的 Git 提交)导致部署失败,失败通常会比手动部署更快地被发现。

你可以通过程序化监控变更日志(如 Git 提交历史)或使用 kubectl apply 命令来自动化部署。

由于版本更新的高频率,CD 需要一些工具来自动检查服务健康。我们将在本书的第 第十一章第十二章 中介绍此类工具。

摘要

在本章中,我们讨论了一个非常重要的主题——服务部署。你已经了解了服务部署过程的基础,以及为部署我们的微服务所需的必要步骤。然后,我们介绍了 Kubernetes,这是一个由许多公司和云服务提供商提供的流行部署和编排平台。我们展示了如何设置本地 Kubernetes 集群并将我们的微服务部署到其中,运行每个服务的多个实例,以说明在 Kubernetes 平台中运行任意数量的实例是多么容易。

你所获得的知识应该能帮助你设置更复杂的部署流程,以及与通过 Kubernetes 已部署的服务一起工作。

本章总结了我们在服务部署方面的材料。在下一章,我们将描述另一个重要主题:单元和集成。

进一步阅读

如果你想要了解更多信息,请参考以下链接:

第九章:单元和集成测试

测试是任何开发过程的重要组成部分。始终重要的是用自动化测试覆盖你的代码,确保所有重要的逻辑在所有代码更改上都能持续测试。编写好的测试通常有助于确保在整个开发过程中所做的任何更改都能保持代码的正常和可靠运行。

在微服务开发中,测试尤为重要,但它也给开发者带来了一些额外的挑战。仅仅测试每个服务是不够的——测试服务之间的集成也同样重要,确保每个服务都能与其他服务协同工作。

在本章中,我们将涵盖单元测试和集成测试,并说明如何将测试添加到我们在前几章中创建的微服务中。我们将涵盖以下主题:

  • Go 测试概述

  • 单元测试

  • 集成测试

  • 测试最佳实践

你将学习如何在 Go 中编写单元和集成测试,如何使用模拟技术,以及如何组织微服务的测试代码。这些知识将帮助你构建更可靠的服务。

让我们继续概述 Go 测试工具和技术。

技术要求

要完成本章,你需要 Go 版本 1.11 或更高版本。

你可以在此处找到本章的 GitHub 代码:github.com/PacktPublishing/microservices-with-go/tree/main/Chapter09

Go 测试概述

在本节中,我们将提供一个关于 Go 测试功能的概述。我们将涵盖为 Go 代码编写测试的基础,列出 Go SDK 提供的有用函数和库,并描述各种测试编写技术,这些技术将有助于你在微服务开发中。

首先,让我们来了解一下为 Go 应用程序编写测试的基础。

Go 语言内置了对编写自动化测试的支持,并提供了一个名为 testing 的包来实现这一目的。

Go 代码与其测试之间存在一种约定关系。如果你有一个名为 example.go 的文件,其测试将位于同一包中的名为 example_test.go 的文件中。使用 _test 文件名后缀可以让你区分被测试的代码及其测试,从而更容易地导航源代码。

Go 测试函数遵循这种约定名称格式,每个测试函数名称都以 Test 前缀开头:

func TestXxx(t *testing.T)

在这些函数内部,你可以使用 testing.T 结构来报告测试失败或使用它提供的任何其他辅助函数。

让我们以这个测试为例:

func TestAdd(t *testing.T) {
  a, b := 1, 2
  if got, want := Add(1, 2), 3; got != want {
    t.Errorf("Add(%v, %v) = %v, want %v", a, b, got, want)
  }
}

在前面的函数中,我们使用了 testing.T 来报告测试失败,如果 Add 函数提供了意外的输出。

在执行方面,我们可以运行以下命令:

go test

该命令会执行目标目录中的每个测试,并打印输出,其中包含任何失败的测试或其他必要的数据的错误消息。

开发者可以自由选择测试的格式;然而,有一些常见的技巧,如表驱动测试,通常有助于优雅地组织测试代码。

表驱动测试是指将输入以表格或一系列行的形式存储的测试。让我们以这个例子为例:

func TestAdd(t *testing.T) {
    tests := []struct {
        a    int
        b    int
        want int
    }{
        {a: 1, b: 2, want: 3},
        {a: -1, b: -2, want: -3},
        {a: -3, b: 3, want: 0},
        {a: 0, b: 0, want: 0},
    }
    for _, tt := range tests {
        assert.Equal(t, tt.want, Add(tt.a, tt.b), fmt.Sprintf("Add(%v, %v)", tt.a, tt.b))
    }
}

在此代码中,我们使用我们的函数的测试用例初始化tests变量,然后遍历它。请注意,我们使用github.com/stretchr/testify库提供的assert.Equal函数来比较被测试函数的预期结果和实际结果。这个库提供了一组方便的函数,可以简化你的测试逻辑。如果不使用assert库,比较测试结果的代码将如下所示:

        if got, want := Add(tt.a, tt.b), tt.want; got != want {
            t.Errorf ("Add(%v, %v) = %v, want %v", tt.a, tt.b, got, want)
        }

表驱动测试通过将测试用例和执行实际检查的逻辑分离,有助于减少测试的重复性。通常,当你需要针对定义的目标状态执行大量类似检查时,这些测试是良好的实践,如我们的示例所示。

表驱动格式还有助于提高测试代码的可读性,使得查看和比较相同函数的不同测试用例变得更加容易。这种格式在 Go 测试中相当常见;然而,你始终可以根据你的用例组织测试代码。

现在,让我们回顾 Go 内置测试库提供的基本功能。

子测试

Go 测试库的一个有趣特性是能够创建子测试——在另一个测试中执行的测试。子测试的好处之一是能够单独执行它们,以及在长时间运行的测试中并行执行它们,并以更细粒度的方式结构化测试输出。

子测试是通过调用testing库的Run函数创建的:

func (t *T) Run(name string, f func(t *T)) bool

当使用Run函数时,你需要传递测试用例的名称和要执行的功能,Go 将负责单独执行每个测试用例。以下是一个使用Run函数的测试示例:

func TestProcess(t *testing.T) {
  t.Run("test case 1", func(t *testing.T) {
    // Test case 1 logic.
  })
  t.Run("test case 2", func(t *testing.T) {
    // Test case 2 logic.
  })
}

在前面的例子中,我们通过两次调用Run函数创建了两个子测试,一次为每个子测试。

为了对子测试有更精细的控制,你可以使用以下选项:

  • 当使用带有-v参数的go test命令运行测试时,每个子测试(无论是通过还是失败)都可以在输出中单独显示

  • 你可以使用go test命令的-run参数运行单个测试用例

使用Run函数还有另一个有趣的优点。让我们想象一下,你有一个名为Process的函数,它需要几秒钟才能完成。如果你有一个包含大量测试用例的表格测试,并且按顺序执行它们,整个测试的执行可能需要很长时间。在这种情况下,你可以通过调用t.Parallel()函数让 Go 测试运行器以并行模式执行测试。以下是一个示例:

func TestProcess(t *testing.T) {
    tests := []struct {
        name  string
        input string
           want  string
    }{
        {name: "empty", input: "", want: ""},
        {name: "dog", input: "animal that barks", want: "dog"},
        {name: "cat", input: "animal that meows", want: "cat"},
    }
    for _, tt := range tests {
        input := tt.input
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            assert.Equal(t, tt.want, Process(input), fmt.Sprintf("Process(%v)", input))
        })
    }
}

在我们的例子中,我们为每个测试用例调用 t.Run 函数,传递测试用例名称和要执行的功能。然后,我们调用 t.Parallel() 使每个测试用例并行执行。这种优化将显著减少我们的 Process 函数运行缓慢时的执行时间。

跳过

假设你希望在计算机上的每次更改后执行你的 Go 测试,但你有一些运行缓慢的测试,需要很长时间才能运行。在这种情况下,你将想要找到一种方法在特定条件下跳过运行测试。Go 测试库对此有内置支持——Skip 函数。让我们以这个测试函数为例:

func TestProcess(t *testing.T) {
  if os.Getenv("RUNTIME_ENV") == "development" {
    t.Skip("Skipping a test in development environment")
  }
  ...
}

在前面的代码中,如果存在具有 development 值的 RUNTIME_ENV 运行时环境变量,则会跳过测试执行。请注意,我们在 t.Skip 调用中也提供了跳过的原因,以便在测试执行时记录。

跳过功能可以特别有用,用于绕过执行长时间运行的测试,例如执行缓慢的 I/O 操作或进行大量数据处理。为此,Go 测试库提供了一种将特定标志 -test.short 传递给 go test 命令的能力:

go test -test.short

使用 -test.short 标志,你可以让 Go 测试运行器知道你想要以 简短模式 运行测试——在这种模式下,只有特殊的短测试被执行。你可以在所有长时间运行的测试中添加以下逻辑来排除它们在简短模式下的执行:

func TestLongRunningProcess(t *testing.T) {
  if testing.Short() {
    t.Skip("Skipping a test in short mode")
  }
  ...
}

在前面的例子中,当将 -test.short 标志传递给 test 命令时,会跳过测试。

当一些测试用例比其他测试用例慢得多,并且你需要非常频繁地运行测试时,使用简短测试模式是有用的。跳过慢速测试并减少它们的执行频率可以显著提高你的开发速度,并使你的开发体验变得更好。

你可以通过查看 testing 包的官方文档来熟悉其他 Go 测试功能:pkg.go.dev/testing。我们现在将进入下一节,重点关注为我们的微服务实现单元测试的细节。

单元测试

我们已经涵盖了为 Go 应用程序自动化测试的许多有用功能,现在我们准备说明如何在我们的微服务代码中使用它们。首先,我们将从 单元测试 开始——对单个代码单元的测试,例如结构和单个函数。

让我们以元数据服务控制器为例,通过实现单元测试的过程。目前,我们的控制器文件看起来像这样:

package metadata
import (
    "context"
    "movieexample.com/metadata/pkg/model"
)
type metadataRepository interface {
    Get(ctx context.Context, id string) (*model.Metadata, error)
}
// Controller defines a metadata service controller.
Type Controller struct {
    repo metadataRepository
}
// New creates a metadata service controller.
Func New(repo metadataRepository) *Controller {
    return &Controller{repo}
}
// Get returns movie metadata by id.
Func (c *Controller) Get(ctx context.Context, id string) (*model.Metadata, error) {
    return c.repo.Get(ctx, id)
}

让我们列出我们希望在代码中测试的内容:

  • 当仓库返回 ErrNotFound 时进行的 Get 调用

  • 当仓库返回除 ErrNotFound 之外的错误时的 Get 调用

  • 当仓库返回元数据和没有错误时的 Get 调用

到目前为止,我们有三个测试案例需要实现。所有测试案例都需要对元数据仓库进行操作,我们需要模拟它从三个不同的响应。我们如何在测试中模拟元数据仓库的响应呢?让我们探索一种强大的技术,它允许我们通过测试代码实现这一点。

模拟

从组件模拟响应的技术称为模拟。模拟通常用于测试以模拟各种场景,例如返回特定的结果或错误。在 Go 代码中,有多种使用模拟的方法。第一种是手动实现组件的版本,称为模拟。让我们以我们的元数据仓库为例说明如何实现这些模拟。我们的元数据仓库接口定义如下:

type metadataRepository interface {
    Get(ctx context.Context, id string) (*model.Metadata, error)
}

这个接口的模拟实现可能看起来像这样:

type mockMetadataRepository struct {
    returnRes *model.Metadata
    returnErr error
}
func (m *mockMetadataRepository) setReturnValues(res *model.Metadata, err error) {
    m.returnRes = res
    m.returnErr = err
}
func (m *mockMetadataRepository) Get(ctx context.Context, id string) (*model.Metadata, error) {
    return m.returnRes, m.returnErr
}

在我们的元数据仓库示例模拟中,我们通过提供setReturnValues函数允许在即将到来的Get函数调用中返回设置值。模拟可以用来测试我们的控制器如下:

m := mockMetadataRepository{}
m.setReturnValues(nil, repository.ErrNotFound)
c := New(m)
res, err := c.Get(context.Background(), "some-id")
// Check res, err.

手动实现模拟是测试测试包范围之外的各个组件调用的相对简单的方法。这种方法的不利之处在于,你需要自己编写模拟代码,并在任何接口更改时更新其代码。

使用模拟的另一种方法是使用生成模拟代码的库。这类库的一个例子是github.com/golang/mock,它包含一个名为mockgen的模拟生成工具。你可以通过运行以下命令来安装它:

go install github.com/golang/mock/mockgen

然后,可以使用mockgen工具如下所示:

mockgen -source=foo.go [options]

让我们通过以下命令从我们项目的src目录生成我们元数据仓库的模拟代码:

mockgen -package=repository -source=metadata/internal/controller/metadata/controller.go

你应该得到模拟源文件的 内容作为输出。内容将类似于以下内容:

// MockmetadataRepository is a mock of metadataRepository 
// interface
type MockmetadataRepository struct {
    ctrl     *gomock.Controller
    recorder *MockmetadataRepositoryMockRecorder
}
// NewMockmetadataRepository creates a new mock instance
func NewMockmetadataRepository(ctrl *gomock.Controller) *MockmetadataRepository {
    mock := &MockmetadataRepository{ctrl: ctrl}
    mock.recorder = &MockmetadataRepositoryMockRecorder{mock}
    return mock
}
// EXPECT returns an object that allows the caller to indicate // expected use
func (m *MockmetadataRepository) EXPECT() *MockmetadataRepositoryMockRecorder {
    return m.recorder
}
// Get mocks base method.
func (m *MockmetadataRepository) Get(ctx context.Context, id string) (*model.Metadata, error) {
    ret := m.ctrl.Call(m, "Get", ctx, id)
    ret0, _ := ret[0].(*model.Metadata)
    ret1, _ := ret[1].(error)
    return ret0, ret1
}

生成的模拟代码实现了我们的接口,并允许我们以下方式设置对Get函数的预期响应:

ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := NewMockmetadataRepository(gomock.NewController())
ctx := context.Background()
id := "some-id"
m.EXPECT().Get(ctx, id).Return(nil, repository.ErrNotFound)

gomock库生成的模拟代码提供了一些我们在手动创建的模拟版本中没有实现的有用功能。其中之一是使用Times函数设置目标函数应被调用的预期次数的能力:

m.EXPECT().Get(ctx, id).Return(nil, repository.ErrNotFound).Times(1)

在前面的例子中,我们将Get函数被调用的次数限制为一次。gomock库在测试执行结束时验证这些约束,并报告函数是否被调用不同次数。当你想确保目标函数在测试中确实被调用时,这种机制非常有用。

到目前为止,我们已经展示了两种不同的使用模拟的方法,你可能想知道哪种方法是首选的。让我们比较这两种方法来找出答案。

手动实现模拟的好处是可以在不使用任何外部库的情况下进行,例如gomock。然而,这种方法的缺点如下:

  • 手动实现模拟需要花费时间

  • 对模拟接口的任何更改都需要手动更新模拟代码

  • 实现由如gomock之类的库提供的额外功能(如调用计数验证)更困难

使用如gomock之类的库提供模拟代码将具有以下好处:

  • 当所有模拟以相同方式生成时,代码一致性更高

  • 无需编写样板代码

  • 扩展的模拟功能集

在我们的比较中,自动模拟代码生成似乎提供了更多的优势,因此我们将遵循基于gomock的自动模拟生成方法。在下一节中,我们将展示如何为我们服务实现这一点。

实现单元测试

我们将展示如何使用生成的gomock代码实现控制器单元测试。首先,我们需要在我们的仓库中找到一个合适的地方来放置生成的代码。我们已经有了一个名为gen的目录,它被服务共享。我们可以创建一个名为mock的子目录,我们可以用它来存储各种生成的模拟。再次运行元数据仓库的模拟生成命令:

mockgen -package=repository -source=metadata/internal/controller/metadata/controller.go

将其输出复制到名为gen/mock/metadata/repository/repository.go的文件中。现在,让我们为我们的元数据服务控制器添加一个测试。在其目录中创建一个名为controller_test.go的文件,并添加以下代码:

package metadata
import (
    "context"
    "errors"
    "testing"
    "github.com/golang/mock/gomock"
    "github.com/stretchr/testify/assert"
    gen "movieexample.com/gen/mock/metadata/repository"
    "movieexample.com/metadata/internal/repository"
    "movieexample.com/metadata/pkg/model"
)

然后,添加以下代码,包含以表格格式组织的测试用例:

func TestController(t *testing.T) {
    tests := []struct {
        name       string
        expRepoRes *model.Metadata
        expRepoErr error
        wantRes    *model.Metadata
        wantErr    error
    }{
        {
            name:       "not found",
            expRepoErr: repository.ErrNotFound,
            wantErr:    ErrNotFound,
        },
        {
            name:       "unexpected error",
            expRepoErr: errors.New("unexpected error"),
            wantErr:    errors.New("unexpected error"),
        },
        {
            name:       "success",
            expRepoRes: &model.Metadata{},
            wantRes:    &model.Metadata{},
        },
    }

最后,添加执行我们的测试的代码:

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            ctrl := gomock.NewController(t)
            defer ctrl.Finish()
            repoMock := gen.NewMockmetadataRepository(ctrl)
            c := New(repoMock)
            ctx := context.Background()
            id := "id"
            repoMock.EXPECT().Get(ctx, id).Return(tt.expRepoRes, tt.expRepoErr)
            res, err := c.Get(ctx, id)
            assert.Equal(t, tt.wantRes, res, tt.name)
            assert.Equal(t, tt.wantErr, err, tt.name)
        })
    }
}

我们刚刚添加的代码实现了针对我们的Get函数的三个不同测试用例,使用了生成的仓库模拟。我们通过调用EXPECT函数并传递期望的值,让模拟返回特定的值。我们以表格驱动的方式组织了我们的测试,这在章节中已经描述过。

要运行测试,请使用常规命令:

go test

如果你一切操作都正确,测试的输出应该包含ok。恭喜你,我们刚刚实现了单元测试并展示了如何使用模拟!我们将让你自己实现微服务的剩余测试——这将是一项相当多的工作,但这是确保代码始终经过测试和可靠的绝佳投资。

在下一节中,我们将要处理另一种类型的测试——集成测试。了解为什么以及如何为你的微服务编写集成测试,除了常规的单元测试,将帮助你编写更稳定的代码,并确保所有服务在集成时能良好工作。

集成测试

集成测试是自动化的测试,用于验证服务各个单元与自身之间的集成正确性。在本节中,您将学习如何编写集成测试以及如何在其中构建逻辑,同时获得一些有用的提示,这将帮助您在未来编写自己的集成测试。

与测试单个代码片段(如函数和结构)的单元测试不同,集成测试有助于确保各个代码片段的组合仍然能够良好地协同工作。

让我们以我们的评级服务为例,提供一个集成测试的例子。我们的服务集成测试将实例化服务实例及其客户端,并确保客户端请求会产生预期的结果。如您所记,我们的评级服务提供了两个 API 端点:

  • PutRating:将评级写入数据库

  • GetAggregatedRating:检索提供的记录(如电影)的评级并返回聚合值

我们对评级服务的集成测试可能包含以下调用序列:

  • 使用PutRating端点写入一些数据

  • 使用GetAggregatedRating端点验证数据

  • 使用PutRating端点写入新的数据

  • 调用GetAggregatedRating端点并检查聚合值是否反映了最新的评级更新

在微服务开发中,集成测试通常测试单个服务或它们的组合——开发者可以编写针对任意数量服务的测试。

与通常与被测试代码一起存在并可以访问一些内部函数、结构、常量和变量的单元测试不同,集成测试通常将测试的组件视为黑盒。黑盒是逻辑块,其实现细节未知,只能通过公开的 API 或用户界面访问。这种测试方式被称为黑盒测试——使用公共接口(如 API)测试系统,而不是调用单个内部函数或访问系统的内部组件。

微服务集成测试通常通过实例化服务实例并执行请求来执行,这些请求可以通过调用服务 API 或通过异步事件(如果系统以异步方式处理请求)进行。集成测试的结构通常遵循类似的模式:

  • 设置测试:实例化被测试的组件和任何可以访问其接口的客户端

  • 执行测试操作并验证结果的正确性:运行任意数量的操作,并将测试系统(如微服务)的输出与预期值进行比较

  • 清理测试:通过清理设置中实例化的组件,优雅地终止测试,如有必要关闭任何客户端

为了说明如何编写集成测试,让我们从上一章中的三个微服务——元数据、电影和评分服务——中取三个。为了设置我们的测试,我们需要实例化六个组件——每个微服务的服务器和客户端。为了更容易运行测试,我们可以使用服务注册表和存储库的内存实现来实例化服务器。

在编写测试之前,通常很有帮助的是写下要测试的操作集以及每个步骤的预期输出。让我们写下我们的集成测试计划:

  1. 使用元数据服务 API(PutMetadata端点)为示例电影编写元数据,并检查操作没有返回任何错误。

  2. 使用元数据服务 API(GetMetadata端点)检索相同电影的元数据,并检查它是否与我们之前提交的记录匹配。

  3. 使用电影服务 API(GetMovieDetails端点)获取我们示例电影的详细信息(应仅包含元数据),并确保结果与之前提交的数据匹配。

  4. 使用评分服务 API(PutRating端点)为我们的示例电影写入第一个评分,并检查操作没有返回任何错误。

  5. 使用评分服务 API(GetAggregatedRating端点)检索我们电影的初始聚合评分,并检查该值是否与我们之前步骤中提交的值匹配。

  6. 使用评分服务 API 为我们的示例电影写入第二个评分,并检查操作没有返回任何错误。

  7. 使用评分服务 API 检索我们电影的最新聚合评分,并检查该值反映了最后一个评分。

  8. 获取我们示例电影的详细信息,并检查结果是否包含更新的评分。

有这样的计划使得编写集成测试的代码更容易,并带我们来到最后一步——实际实现它:

  1. 创建一个名为test/integration的目录,并添加一个名为main.go的文件,其中包含以下代码:

    package main
    
    import (
    
        "context"
    
        "log"
    
        "net"
    
        "github.com/google/go-cmp/cmp"
    
        "github.com/google/go-cmp/cmp/cmpopts"
    
        "google.golang.org/grpc"
    
        "movieexample.com/gen"
    
        metadatatest "movieexample.com/metadata/pkg/testutil"
    
        movietest "movieexample.com/movie/pkg/testutil"
    
        "movieexample.com/pkg/discovery"
    
        "movieexample.com/pkg/discovery/memory"
    
        ratingtest "movieexample.com/rating/pkg/testutil"
    
        "google.golang.org/grpc/credentials/insecure"
    
    )
    
  2. 让我们在文件中添加一些具有服务名称和地址的常量,我们可以在测试中稍后使用:

    const (
    
        metadataServiceName = "metadata"
    
        ratingServiceName   = "rating"
    
        movieServiceName    = "movie"
    
        metadataServiceAddr = "localhost:8081"
    
        ratingServiceAddr   = "localhost:8082"
    
        movieServiceAddr    = "localhost:8083"
    
    )
    
  3. 下一步是实现设置代码以实例化我们的服务服务器:

    func main() {
    
        log.Println("Starting the integration test")
    
        ctx := context.Background()
    
        registry := memory.NewRegistry()
    
        log.Println("Setting up service handlers and clients")
    
        metadataSrv := startMetadataService(ctx, registry)
    
        defer metadataSrv.GracefulStop()
    
        ratingSrv := startRatingService(ctx, registry)
    
        defer ratingSrv.GracefulStop()
    
        movieSrv := startMovieService(ctx, registry)
    
        defer movieSrv.GracefulStop()
    

注意对每个服务器的GracefulStop函数的defer调用——这段代码是我们测试的拆除逻辑的一部分,用于优雅地终止所有服务器。

  1. 现在,让我们设置我们的服务测试客户端:

        opts := grpc.WithTransportCredentials(insecure.NewCredentials())
    
        metadataConn, err := grpc.Dial(metadataServiceAddr, opts)
    
        if err != nil {
    
            panic(err)
    
        }
    
        defer metadataConn.Close()
    
        metadataClient := gen.NewMetadataServiceClient(metadataConn)
    
        ratingConn, err := grpc.Dial(ratingServiceAddr, opts)
    
        if err != nil {
    
            panic(err)
    
        }
    
        defer ratingConn.Close()
    
        ratingClient := gen.NewRatingServiceClient(ratingConn)
    
        movieConn, err := grpc.Dial(movieServiceAddr, opts)
    
        if err != nil {
    
            panic(err)
    
        }
    
        defer movieConn.Close()
    
        movieClient := gen.NewMovieServiceClient(movieConn)
    

现在,我们准备好实现测试命令的序列。第一步是测试、编写和读取元数据服务的操作:

    log.Println("Saving test metadata via metadata service")
    m := &gen.Metadata{
        Id:          "the-movie",
        Title:       "The Movie",
        Description: "The Movie, the one and only",
        Director:    "Mr. D",
    }
    if _, err := metadataClient.PutMetadata(ctx, &gen.PutMetadataRequest{Metadata: m}); err != nil {
        log.Fatalf("put metadata: %v", err)
    }
    log.Println("Retrieving test metadata via metadata service")
    getMetadataResp, err := metadataClient.GetMetadata(ctx, &gen.GetMetadataRequest{MovieId: m.Id})
    if err != nil {
        log.Fatalf("get metadata: %v", err)
    }
    if diff := cmp.Diff(getMetadataResp.Metadata, m, cmpopts.IgnoreUnexported(gen.Metadata{})); diff != "" {
        log.Fatalf("get metadata after put mismatch: %v", diff)
    }

你可能注意到我们在调用cmp.Diff函数时使用了cmpopts.IgnoreUnexported(gen.Metadata{})选项——这告诉cmp库忽略gen.Metadata结构中的未导出字段。我们添加此选项是因为由 Protocol Buffers 代码生成器生成的gen.Metadata结构包括一些我们希望在比较中忽略的私有字段。

我们序列中的下一个测试将是检索电影详情并检查元数据是否与我们之前提交的记录匹配:

    log.Println("Getting movie details via movie service")
    wantMovieDetails := &gen.MovieDetails{
        Metadata: m,
    }
    getMovieDetailsResp, err := movieClient.GetMovieDetails(ctx, &gen.GetMovieDetailsRequest{MovieId: m.Id})
    if err != nil {
        log.Fatalf("get movie details: %v", err)
    }
    if diff := cmp.Diff(getMovieDetailsResp.MovieDetails, wantMovieDetails, cmpopts.IgnoreUnexported(gen.MovieDetails{}, gen.Metadata{})); diff != "" {
        log.Fatalf("get movie details after put mismatch: %v", err)
    }

现在,我们准备好测试评分服务了。

让我们实现两个测试——一个用于写入评分,另一个用于检索初始聚合值,它应该与第一个评分匹配:

    log.Println("Saving first rating via rating service")
    const userID = "user0"
    const recordTypeMovie = "movie"
    firstRating := int32(5)
    if _, err = ratingClient.PutRating(ctx, &gen.PutRatingRequest{
        UserId:      userID,
        RecordId:    m.Id,
        RecordType:  recordTypeMovie,
        RatingValue: firstRating,
    }); err != nil {
        log.Fatalf("put rating: %v", err)
    }
    log.Println("Retrieving initial aggregated rating via rating service")
    getAggregatedRatingResp, err := ratingClient.GetAggregatedRating(ctx, &gen.GetAggregatedRatingRequest{
        RecordId:   m.Id,
        RecordType: recordTypeMovie,
    })
    if err != nil {
        log.Fatalf("get aggreggated rating: %v", err)
    }
    if got, want := getAggregatedRatingResp.RatingValue, float64(5); got != want {
        log.Fatalf("rating mismatch: got %v want %v", got, want)
    }

测试的下一部分将是提交第二个评分并检查聚合值是否已更改:

    log.Println("Saving second rating via rating service")
    secondRating := int32(1)
    if _, err = ratingClient.PutRating(ctx, &gen.PutRatingRequest{
        UserId:      userID,
        RecordId:    m.Id,
        RecordType:  recordTypeMovie,
        RatingValue: secondRating,
    }); err != nil {
        log.Fatalf("put rating: %v", err)
    }
    log.Println("Saving new aggregated rating via rating service")
    getAggregatedRatingResp, err = ratingClient.GetAggregatedRating(ctx, &gen.GetAggregatedRatingRequest{
        RecordId:   m.Id,
        RecordType: recordTypeMovie,
    })
    if err != nil {
        log.Fatalf("get aggreggated rating: %v", err)
    }
    wantRating := float64((firstRating + secondRating) / 2)
    if got, want := getAggregatedRatingResp.RatingValue, wantRating; got != want {
        log.Fatalf("rating mismatch: got %v want %v", got, want)
    }

我们几乎完成了main函数的实现——让我们实现最后的检查:

    log.Println("Getting updated movie details via movie service")
    getMovieDetailsResp, err = movieClient.GetMovieDetails(ctx, &gen.GetMovieDetailsRequest{MovieId: m.Id})
    if err != nil {
        log.Fatalf("get movie details: %v", err)
    }
    wantMovieDetails.Rating = wantRating
    if diff := cmp.Diff(getMovieDetailsResp.MovieDetails, wantMovieDetails, cmpopts.IgnoreUnexported(gen.MovieDetails{}, gen.Metadata{})); diff != "" {
        log.Fatalf("get movie details after update mismatch: %v", err)
    }
    log.Println("Integration test execution successful")
}

我们的集成测试几乎准备好了。让我们在main函数下方添加初始化我们服务服务器的函数。首先,添加创建元数据服务服务器的函数:

func startMetadataService(ctx context.Context, registry discovery.Registry) *grpc.Server {
    log.Println("Starting metadata service on " + metadataServiceAddr)
    h := metadatatest.NewTestMetadataGRPCServer()
    l, err := net.Listen("tcp", metadataServiceAddr)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    srv := grpc.NewServer()
    gen.RegisterMetadataServiceServer(srv, h)
    go func() {
        if err := srv.Serve(l); err != nil {
            panic(err)
        }
    }()
    id := discovery.GenerateInstanceID(metadataServiceName)
    if err := registry.Register(ctx, id, metadataServiceName, metadataServiceAddr); err != nil {
        panic(err)
    }
    return srv
}

你可能注意到我们在 goroutine 内部调用了srv.Serve函数——这样它就不会阻塞执行,并允许我们立即从函数返回。

让我们在同一文件中添加与评分服务服务器类似的实现:

func startRatingService(ctx context.Context, registry discovery.Registry) *grpc.Server {
    log.Println("Starting rating service on " + ratingServiceAddr)
    h := ratingtest.NewTestRatingGRPCServer()
    l, err := net.Listen("tcp", ratingServiceAddr)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    srv := grpc.NewServer()
    gen.RegisterRatingServiceServer(srv, h)
    go func() {
        if err := srv.Serve(l); err != nil {
            panic(err)
        }
    }()
    id := discovery.GenerateInstanceID(ratingServiceName)
    if err := registry.Register(ctx, id, ratingServiceName, ratingServiceAddr); err != nil {
        panic(err)
    }
    return srv
}

最后,让我们添加一个初始化电影服务器的函数:

func startMovieService(ctx context.Context, registry discovery.Registry) *grpc.Server {
    log.Println("Starting movie service on " + movieServiceAddr)
    h := movietest.NewTestMovieGRPCServer(registry)
    l, err := net.Listen("tcp", movieServiceAddr)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    srv := grpc.NewServer()
    gen.RegisterMovieServiceServer(srv, h)
    go func() {
        if err := srv.Serve(l); err != nil {
            panic(err)
        }
    }()
    id := discovery.GenerateInstanceID(movieServiceName)
    if err := registry.Register(ctx, id, movieServiceName, movieServiceAddr); err != nil {
        panic(err)
    }
    return srv
}

我们的集成测试已经准备好了!你可以通过执行以下命令来运行它:

go run test/integration/*.go

如果一切正确,你应该看到以下输出:

2022/07/16 16:20:46 Starting the integration test
2022/07/16 16:20:46 Setting up service handlers and clients
2022/07/16 16:20:46 Starting metadata service on localhost:8081
2022/07/16 16:20:46 Starting rating service on localhost:8082
2022/07/16 16:20:46 Starting movie service on localhost:8083
2022/07/16 16:20:46 Saving test metadata via metadata service
2022/07/16 16:20:46 Retrieving test metadata via metadata service
2022/07/16 16:20:46 Getting movie details via movie service
2022/07/16 16:20:46 Saving first rating via rating service
2022/07/16 16:20:46 Retrieving initial aggregated rating via rating service
2022/07/16 16:20:46 Saving second rating via rating service
2022/07/16 16:20:46 Saving new aggregated rating via rating service
2022/07/16 16:20:46 Getting updated movie details via movie service
2022/07/16 16:20:46 Integration test execution successful

如你所注意到的,我们的集成测试的结构与之前定义的测试操作序列精确匹配。我们将集成测试实现为一个可执行命令,并添加了足够的日志消息以帮助您进行调试——如果任何步骤失败,因此更容易理解失败发生在哪个步骤以及哪些操作先于该步骤。

重要的是要注意,我们在集成测试中使用了元数据和评分存储库的内存版本。另一种方法是设置一个将数据存储在某些持久数据库(如 MySQL)中的集成测试。然而,在集成测试中使用现有的持久数据库存在一些挑战:

  • 集成测试数据不应干扰用户数据。否则,它可能对现有服务用户产生意外影响。

  • 理想情况下,测试执行后应该清理测试数据,以免数据库被不必要的临时数据填满。

为了避免与现有用户数据发生干扰,我建议在非生产环境中运行集成测试,例如在预发布环境中。此外,我建议始终为你的测试记录生成随机标识符,以确保单个测试执行不会相互影响。例如,你可以使用github.com/google/uuid库通过uuid.New()函数生成新的标识符。最后,我建议在可能的情况下,始终在每个使用持久数据存储的集成测试结束时包含清理代码,以清理创建的记录。

现在,问题是我们在什么时候应该编写集成测试。这始终取决于你;然而,我确实有一些一般性的建议:

  • 测试关键流程:确保你测试整个流程,例如用户注册和登录

  • 测试关键端点:执行对你用户提供的最关键端点的测试

此外,你可能有一些在每次代码更改后执行的集成测试。像 Jenkins 这样的系统提供了这些功能,并允许你将任何自定义逻辑插入到代码的每次更新中。本书不会涵盖 Jenkins 的设置,但你可以在官方网站(https://www.jenkins.io)上熟悉其文档。

如我们所展示的如何编写单元测试和集成测试,让我们继续到本书的下一部分,描述一些 Go 测试的最佳实践。

测试最佳实践

在本节中,我们将列出一些额外的有用测试技巧,这些技巧将帮助你提高测试质量。

使用有帮助的消息

编写测试最重要的方面之一是在错误日志中提供足够的信息,以便容易理解到底发生了什么错误,以及哪个测试用例触发了失败。考虑以下测试用例代码:

if got, want := Process(tt.in), tt.want; got != want {
  t.Errorf("Result mismatch")
}

错误日志没有包括从被测试的函数接收到的预期和实际值,这使得理解函数返回的内容以及它与预期值的不同变得更加困难。

更好的日志行应该是这样的:

t.Errorf("got %v, want %v", got, want)

这条日志行包含了函数预期的和实际返回的值,并在调试测试时为你提供了更多的上下文。

重要提示

注意,在我们的测试日志中,首先记录实际值,然后是预期值。这种顺序是由 Go 团队推荐的测试中记录值的传统方式,并且被所有库和包遵循。在你的日志中遵循相同的顺序以保持一致性。

一个更好的错误消息如下:

t.Errorf("YourFunc(%v) = %v, want %v", tt.in, got, want)

这个错误日志消息包括一些额外的信息——被调用的函数以及传递给它的输入参数。

为了标准化测试用例的代码,你可以使用 github.com/stretchr/testify 库。以下示例说明了如何比较预期值和实际值,并记录正在测试的函数名称以及传递给它的参数:

assert.Equal(t, want, got, fmt.Sprintf("YourFunc(%v)", tt.in))

github.com/stretchr/testify 库的 assert 包会打印测试结果的预期值和实际值,并提供关于测试用例的详细信息(在我们的例子中是 fmt.Sprintf 的结果)。

避免在日志中使用 Fatal

内置的 Go 测试库包含了用于记录错误的多个函数,包括 ErrorErrorfFatalFatalf。后两个函数会打印日志并中断测试的执行。考虑以下测试代码:

if err := Process(tt.in); err != nil {
  t.Fatalf("Process(%v): %v, want nil", err)
} 

调用 Fatalf 函数会中断测试执行。中断测试执行通常不是最佳选择,因为它会导致执行的测试较少。执行较少的测试会使开发者对剩余的失败测试用例的信息更少。对于许多开发者来说,修复一个错误并重新运行所有测试可能是一个次优体验,并且尽可能继续测试执行通常更好。

以下示例可以重写如下:

if err := Process(tt.in); err != nil {
  t.Errorf("Process(%v): %v, want nil", err)
} 

如果你在一个循环中使用此代码,你可以在 Errorf 调用之后添加 continue 以继续下一个测试用例。

使用 cmp 库进行比较

假设你有一个测试,它比较我们在 第二章 中定义的 Metadata 结构:

want := &model.Metadata{ID: "123", Title: "Some title"}
id := "123"
if got := GetMetadata(ctx, "123"); got != want {
  t.Errorf("GetMetadata(%v): %v, want %v", id, got, want)
}

此代码对于结构引用将不起作用——在我们的代码中,want 变量持有 model.Metadata 结构的指针,因此即使这些结构具有相同的字段值,!= 操作符也会返回 true,前提是这些结构是分别创建的。

在 Go 中,可以使用 reflect.DeepEqual 函数比较结构指针:

if !reflect.DeepEqual(GetMetadata(ctx, "123"), want); {
  t.Errorf("GetMetadata(%v): %v, want %v", id, *got, *want)
}

然而,测试的输出可能不易阅读。考虑一下,Metadata 结构内部有很多字段——如果只有一个字段不同,你需要扫描两个结构以找到差异。有一个方便的库可以简化测试中的比较,称为 cmphttps://pkg.go.dev/github.com/google/go-cmp/cmp)。

cmp 库允许你以与 reflect.DeepEqual 相同的方式比较任意的 Go 结构,但它还提供了可读性强的输出。以下是一个使用该函数的示例:

if diff := cmp.Diff(want, got); diff != "" {
  t.Errorf("GetMetadata(%v): mismatch (-want +got):\n%s", tt.in, diff)
}

如果结构不匹配,diff 变量将是一个非空字符串,包括它们之间差异的可打印表示。以下是一个此类输出的示例:

GetMetadata(123) mismatch (-want +got):
  model.Metadata{
      ID:      "123",
-     Tiitle: s"Title",
+     IPAddress: s"The Title",
  }

注意 cmp 库如何使用 + 前缀突出显示两个结构之间的差异。现在,阅读测试输出并注意结构之间的差异变得容易——这种优化将在调试过程中为你节省大量时间。

这总结了我们的 Go 测试最佳实践简编——您可以通过阅读进一步阅读部分中提到的文档来找到更多提示。请确保熟悉官方推荐和testing包的注释,以了解如何以传统方式编写测试并利用内置的 Go 测试库提供的所有功能。

摘要

在本章中,我们介绍了与 Go 测试相关的多个主题,包括 Go 测试库的常见特性和编写单元和集成测试的基础。您已经学会了如何向您的微服务添加测试,如何在各种情况下优化测试执行,创建测试模拟,并通过遵循最佳测试实践来最大化测试质量。您从阅读本章中获得的知识应该有助于提高您测试逻辑的效率,并提高您微服务的可靠性。

在下一章中,我们将转向一个新的主题,该主题将涵盖服务可靠性的主要方面,并描述各种使您的服务能够应对各种类型故障的技术。

进一步阅读

第三部分:维护

本部分涵盖了 Go 微服务开发的一些高级主题,例如可靠性、可观察性、警报、所有权和安全。您将学习如何处理不同类型的微服务相关问题,如何收集和分析服务性能数据,如何设置自动服务事件警报,以及如何确保微服务之间的通信安全。本部分包括许多最佳实践和示例,这些示例将帮助您将新获得的知识应用到您的微服务中。

这包括以下章节:

  • 第十章,可靠性概述

  • 第十一章,收集服务遥测数据

  • 第十二章,设置服务警报

  • 第十三章,高级主题

第十章:可靠性概述

我们已经走过了本书所有前面的章节,完成了关于微服务开发基础的章节。到目前为止,您已经学习了如何启动微服务、编写测试、设置服务发现、在微服务之间使用同步和异步通信,以及使用不同的格式在它们之间序列化数据,以及如何部署服务并验证它们的 API 是否正常工作。

本章开始本书的第三部分,这部分内容专注于微服务开发的更高级概念,包括可靠性、可观察性、可维护性和可伸缩性。在本章中,我们将探讨微服务开发的一些实用方面,这些方面对于确保您的服务能够在多种条件下良好运行至关重要,包括故障场景、网络流量变化和意外服务关闭。

在本章中,我们将介绍各种技术和流程,可以帮助您提高服务的可靠性。我们将涵盖以下主题:

  • 可靠性基础

  • 通过自动化实现可靠性

  • 通过开发流程和文化实现可靠性

让我们继续本章的第一部分,这将帮助您更好地理解服务可靠性概念。

技术要求

要完成本章,您需要 Go 1.11 或更高版本。

您可以在此处找到本章的 GitHub 代码:

github.com/PacktPublishing/microservices-with-go/tree/main/Chapter10

可靠性基础

在实现新应用程序、服务或功能时,工程师通常会首先关注满足各种系统要求,例如实现特定的应用程序功能。这种工作的初步结果通常是某些可以正确执行其任务的代码,例如处理某些数据处理任务或作为 API 端点处理网络请求。我们可以这样说,这样的代码最初在独立运行时表现良好——实现的代码为我们提供的输入产生预期的输出。

当我们向系统中添加更多组件时,事情通常会变得更加复杂。让我们以第二章中的电影服务为例,并假设其 API 被某个拥有数百万用户的第三方服务使用。我们的服务可以完美实现并针对各种测试输入产生正确的结果。然而,一旦我们从外部服务收到请求,我们可能会注意到各种问题。其中之一被称为拒绝服务DoS)——外部服务可以通过请求处理过多的请求来超载我们的服务,以至于我们的服务停止处理新的请求。这种问题的后果可能从轻微的系统性能下降到由于达到 CPU、文件或内存限制而导致的服务崩溃。

DoS 只是微服务环境中可能出现问题的例子之一。假设您实施了一个限制传入服务请求数量的修复,但这个修复破坏了调用您的 API 的服务,因为它们没有预料到请求会突然遭受 DoS 攻击。另一种情况是服务 API 的变化引入了向后不兼容的更改。这种更改与您的服务 API 的一个或多个先前发布的版本不兼容。结果,调用您的 API 的服务可能会经历各种负面效果,甚至无法处理任何请求。

让我们定义一种服务在面对意外故障时仍能保持弹性的质量为可靠性——即按照预期运行并具有明确定义的限制。在我们对可靠性的定义中,最后一句话对它的含义产生了重大影响——仅仅做好某个功能是不够的。同样重要的是要明确服务的限制以及当这些限制被违反时会发生什么。

在我们的电影服务示例中,我们需要明确多个方面,如下所示:

  • 系统吞吐量:服务可以处理多少请求(例如,每秒最大请求量)

  • 拥堵策略:当我们的服务过载时我们将如何处理场景

例如,如果我们的服务每个实例无法处理超过 100 个并发请求,我们可以在 API 文档中明确指出这一点,并通过返回特殊错误代码(如HTTP 429 Too Many Requests)来拒绝所有额外的传入请求。这种对系统限制的指示和明确沟通拥堵问题将是一个巨大的步骤,有助于通过使行为更加确定性和可靠性来提高整体系统可靠性。

通常情况下,实现高度的可靠性是一个持续的过程,需要不断在以下三个类别中持续改进:

  • 预防:在可能的情况下防止可能出现的问题

  • 检测:尽可能早地发现可能出现的问题

  • 缓解:尽可能早地减轻任何问题

通过执行两种类型的操作可以改进预防、检测和缓解:

  • 自动化服务对各种类型故障的响应

  • 改变和改进服务开发流程

我们将把本章的其余部分分为两个部分,描述这两种类型的操作。让我们先进行第一部分,涵盖与自动化相关的可靠性工作。

通过自动化实现可靠性

在本节中,我们将讨论各种自动化技术,这些技术可以帮助您提高服务的可靠性。

首先,让我们回到通信错误处理,这是我们之前在第五章中简要介绍过的。拥有正确的通信错误处理逻辑是实现服务更高可靠性的第一步,因此我们将关注在微服务开发中同样重要的错误处理的多个方面。

通信错误处理

正如我们在本书第五章中讨论的那样,当两个组件(例如客户端和服务器)相互通信时,有三种可能的结果场景:

  • 成功响应:服务器接收并成功处理了一个请求。

  • 客户端错误:发生错误,并非由服务器引起(例如,客户端发送无效请求)。

  • 服务器错误:发生错误,是由服务器引起的(例如,由于应用程序崩溃或服务器端意外错误)。

从客户端的角度来看,存在两类不同的错误:

  • 可重试错误:客户端可以重试原始请求(例如,当服务器暂时不可用)。

  • 非可重试错误:客户端不应重试请求(例如,当请求本身由于验证失败而不正确)。

区分可重试和非可重试错误是客户端的责任。然而,在可能的情况下明确指出这一点是一个好习惯。例如,服务器可以返回特定的代码,指示错误类型(例如HTTP 404 Not Found),以便客户端可以识别可重试错误并执行重试。区分客户端和服务器错误还有助于确保不会对非可重试错误进行重试。从服务器的角度来看,这很重要,因为处理重复的、无效的请求会增加其负载。

让我们通过实现客户端请求重试来展示如何处理可重试的通信错误。设置对潜在问题的自动响应,例如通信错误,有助于使系统对瞬时故障更具弹性,从而为系统中的所有组件提供更好的体验。

实现请求重试

让我们通过在微服务代码中实现请求重试来展示如何操作。为此,让我们回顾一下我们在第五章中实现的元数据 gRPC 网关代码。Get函数包括对metadata服务的实际调用:

    resp, err := client.GetMetadata(ctx, &gen.GetMetadataRequest{MovieId: id})
    if err != nil {
        return nil, err
    }

现在,让我们看看在元数据服务 gRPC 处理器中实现GetMetadata端点的实现。GetMetadata函数包括以下代码:

func (h *Handler) GetMetadata(ctx context.Context, req *gen.GetMetadataRequest) (*gen.GetMetadataResponse, error) {
    if req == nil || req.MovieId == "" {
        return nil, status.Errorf(codes.InvalidArgument, "nil req or empty id")
    }
    m, err := h.ctrl.Get(ctx, req.MovieId)
    if err != nil && errors.Is(err, metadata.ErrNotFound) {
        return nil, status.Errorf(codes.NotFound, err.Error())
    } else if err != nil {
        return nil, status.Errorf(codes.Internal, err.Error())
    }
    return &gen.GetMetadataResponse{Metadata: model.MetadataToProto(m)}, nil
}

如我们所见,GetMetadata端点的实现包括三个错误情况,每个都有自己的 gRPC 错误代码:

  • InvalidArgument:传入的请求未通过验证。

  • NotFound:未找到带有提供标识符的记录。

  • Internal:内部服务器错误。

InvalidArgumentNotFound错误是不可重试的——重试验证失败的请求或尝试检索未找到的记录是没有意义的。"Internal"错误可能表明一系列问题,例如服务代码中的错误,因此我们无法肯定地说你应该对它们进行重试。

然而,还有一些其他类型的 gRPC 错误代码表示可能可重试的错误。让我们列出其中一些:

  • DeadlineExceeded:表示在配置的时间间隔内处理请求存在问题。

  • ResourceExhausted:处理请求的服务已耗尽。这可能表明可用资源不足的问题(例如,CPU、内存或磁盘达到其限制)或客户端达到访问服务的配额(例如,当服务不允许超过一定数量的并行请求时)。

  • Unavailable:服务当前不可用。

让我们首先在元数据 gRPC 网关内部实现一些简单的重试逻辑,通过替换Get函数为以下代码:

// Get returns movie metadata by a movie id.
func (g *Gateway) Get(ctx context.Context, id string) (*model.Metadata, error) {
    conn, err := grpcutil.ServiceConnection(ctx, "metadata", g.registry)
    if err != nil {
        return nil, err
    }
    defer conn.Close()
    client := gen.NewMetadataServiceClient(conn)
    var resp *model.Metadata
    const maxRetries = 5
    for i := 0; i < maxRetries; i++ {
        resp, err = client.GetMetadata(ctx, &gen.GetMetadataRequest{MovieId: id})
        if err != nil {
            if shouldRetry(err) {
                continue
            }
            return nil, err
        }
        return model.MetadataFromProto(resp.Metadata), nil
    }
    return nil, err
}

添加一个函数,帮助我们检查通信错误是否可重试:

func shouldRetry(err error) bool {
    e, ok := status.FromError(err)
    if !ok {
        return false
    }
    return e.Code() == codes.DeadlineExceeded || e.Code() == codes.ResourceExhausted || e.Code() == codes.Unavailable
}

注意,我们还需要导入两个额外的包来检查特定的 gRPC 错误代码——google.golang.org/grpc/codes用于访问错误代码列表,google.golang.org/grpc/status用于检查通信错误是否为有效的 gRPC 错误。

现在,我们的元数据 gRPC 网关可以对元数据服务的请求进行最多五次的重试。我们刚刚添加的重试逻辑应该有助于我们最小化偶尔的错误影响,例如临时服务器不可用(例如,在意外中断或临时网络问题期间)。然而,这也引入了一些额外的挑战:

  • Get函数,元数据服务 gRPC 网关现在对于可重试的错误进行最多五次调用,而不是一次。

  • 请求突发:元数据 gRPC 网关在出现错误时立即重试,这将生成对服务器的请求突发。

后者场景可能对服务器特别具有挑战性,因为负载分布不均。想象一下,你正在做一些工作,同时接到一些带有额外任务的电话。如果你回应这样的电话并说你在忙,你不想立即再次被叫去执行同样的任务——相反,你希望呼叫者在一段时间后再回电。同样,立即重试对于经历拥塞问题的服务器来说也是不理想的,因此我们需要对我们的重试逻辑进行额外的修改,在重试之间引入额外的延迟,以便我们的服务器不会因为立即重试而超载。

在客户端请求重试之间添加额外延迟的技术称为退避。通过在重试请求之间使用不同的延迟间隔来实现不同的退避类型:

  • 恒定退避:每次重试都在一个恒定的延迟之后执行。

  • 指数退避:每次重试都是在比前一次指数级更高的延迟后进行的。

指数退避的一个例子是一系列调用,其中第一次重试会在 100 毫秒的延迟后进行,第二次将需要 400 毫秒的等待,第三次重试延迟将是 900 毫秒。指数退避通常比恒定退避更好,因为它使下一次重试比前一次慢得多,允许服务器在过载的情况下恢复。一个流行的 Go 库github.com/cenkalti/backoff提供了指数退避和其他类型退避算法的实现。

通过引入对持续时间的小随机变化,退避延迟也可以被修改。例如,每一步的重试延迟值可以增加或减少多达 10%,以更好地分散服务器上的负载。这种优化称为抖动。为了说明抖动的有用性,假设多个客户端同时开始调用服务器。如果每个客户端的重试都使用相同的延迟,它们将同时不断调用服务器,生成服务器请求的突发。向重试延迟间隔添加伪随机偏移有助于更均匀地分配服务器上的负载,防止请求重试可能产生的流量突发。

截止日期和超时

现在我们来谈谈与时间相关的另一类通信问题。当客户端向服务器发起请求时,多种可能的失败可能导致客户端或服务器接收到的数据不足以认为请求成功。可能的失败场景包括以下几种:

  • 客户端请求因网络问题未能到达服务器。

  • 服务器过载,响应客户端需要更长的时间。

  • 服务器处理了请求,但由于网络问题,响应未能到达客户端。

这些失败可能导致客户端的等待时间更长。想象一下,你正在给你的亲戚写信,但没有收到回复。没有额外的信息,你会继续等待,不知道信是否在某个步骤中丢失,或者亲戚只是还没有回复。

对于同步请求,有一种方法可以通过设置请求超时来提高客户端体验——即在未收到成功响应的情况下,经过一定时间间隔后,请求被视为失败。由于多个原因,设置请求超时是一种良好的实践:

  • 消除意外等待:如果请求花费了意外长的时间,客户端可以提前停止它并执行可选的重试。

  • 估计最大请求处理时间的能力:当使用显式超时执行请求时,更容易计算操作返回响应或错误给调用者需要多长时间。

  • 能够为长时间运行的操作设置更长的超时时间:用于执行网络调用的库通常设置默认请求超时时间(例如,30 秒)。有时客户端希望设置一个更高的值,因为他们知道请求可能需要更长的时间才能完成(例如,当将大文件上传到服务器时)。显式地设置一个更高的超时时间有助于防止请求因超过默认超时而被取消的情况。

在 Go 中,超时通常通过context.Context对象传播。正如我们在第一章中提到的,每个 I/O 操作,如网络调用,都接受context对象作为参数,我们可以通过调用context.WithTimeout函数来设置超时,如下面的代码片段所示:

func TimeoutExample(ctx context.Context, args Args) {
    const timeout = 10 * time.Second
    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()
    resp, err := SomeOperation(ctx, args)
}

在前面的示例中,我们将SomeOperation函数的超时时间设置为10秒,因此完成操作不应超过 10 秒。

设置超时并不是限制请求处理时间的唯一方法。对此的另一种解决方案是设置一个time.Duration结构(例如,值为 10 秒),一个截止时间表示确切的时间点(例如,2074 年 1 月 1 日,00:00:00)。以下是一个使用截止时间的代码示例,与之前的代码示例中的相同操作:

deadline := time.Parse(time.RFC3339, "2074-01-01T00:00:00Z")
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
resp, err := SomeOperation(ctx, args)

技术上,超时和截止时间都帮助我们实现相同的目标——为特定操作设置时间限制。你可以根据自己的喜好选择使用任一格式。

回退

现在我们来讨论另一个客户端-服务器通信失败场景——当客户端尝试操作,即使在多次重试后也没有收到成功的响应。在这种情况下,客户端有三个可能的选择:

  • 如果有调用者,向其返回错误

  • 如果错误对系统是致命的,则引发恐慌

  • 如果可能,执行替代的备份操作

最后一个选项被称为回退——一种在某些操作无法按预期执行时可以执行的替代逻辑。

让我们以我们的评分服务为例。在我们的服务中,我们通过从评分存储库中读取提供的记录的所有评分来实现GetAggregatedRating端点。现在,让我们考虑一个由于某些问题(例如 MySQL 数据库不可用)而无法检索评分的失败场景。如果没有回退逻辑,我们就无法处理传入的请求,并需要向我们的调用者返回错误。

回退的一个例子是使用map结构,并在数据库读取错误时返回它们。以下代码片段提供了一个这样的回退逻辑示例:

    ratings, err := c.repo.Get(ctx, recordID, recordType)
    if err != nil && err == repository.ErrNotFound {
        return 0, ErrNotFound
    } else if err != nil {
        log.Printf("Failed to get ratings for %v %v: %v", recordID, recordType, err)
        log.Printf("Fallback: returning locally cached ratings for %v %v", recordID, recordType)
        return c.getCachedRatings(recordID, recordType)
    }

使用回退是优雅降级的一个例子——这是一种处理应用程序故障的方式,即使应用程序仍然以有限模式执行其操作。在我们的例子中,即使推荐功能不可用,电影服务也会继续处理获取电影详情的请求,为用户提供有限但正常的功能。

当设计新的服务或功能时,请自问哪些操作在出现故障时可以被回退操作所替代。此外,检查哪些特性和操作是绝对必要的,哪些可以在任何故障发生时关闭,例如系统过载或由于故障而丢失系统的一部分。另外,一个好的做法是发出与故障相关的额外有用信息,例如日志和指标,并在代码中明确指出回退是故意的,就像前面的例子一样。

速率限制和节流

正如我们在本章开头讨论的那样,可能存在一种情况,即微服务过载并且无法再处理传入的请求。我们如何防止或减轻此类问题?

防止此类问题的流行方法是在并行处理请求数量上设置一个硬限制。这种技术被称为速率限制,可以在多个级别上应用:

  • 客户端级别:客户端限制同时发出的请求数量。

  • 服务器级别:服务器限制同时传入的请求数量。

  • 网络/中间级别:服务器和其客户端之间的请求数量由它们之间的某些逻辑或中间组件(例如,由负载均衡器)控制。

当客户端或服务器超过配置的请求数量时,请求的结果将是一个错误,该错误应包括一个特殊代码或消息,指示请求已被速率限制。

HTTP 协议中速率限制的一个例子是内置的状态码,429 Too Many Requests。当客户端收到带有此代码的响应时,它应该通过减少调用速率或等待一段时间直到服务器可以再次处理请求来考虑这一点。

客户端和服务器级别的速率限制通常由每个服务实例单独执行:每个实例跟踪当前发出的或传入的请求数量。这些模型的缺点是无法在全局服务级别配置限制。如果你将每个服务客户端实例配置为每秒不超过 100 个请求,那么如果有 1000 个客户端实例,你仍然可能会接收到 10 万个同时请求。如此高的同时请求数量很容易使你的服务过载。

网络级别的速率限制可能解决此问题:如果以集中方式(例如,通过处理服务之间请求的负载均衡器)执行速率限制,执行速率限制的组件可以跟踪所有服务实例的总请求数量。

虽然网络级别的速率限制器提供了更多的配置灵活性,但它们通常需要额外的集中式组件(例如负载均衡器)。因此,我们将演示如何使用基于客户端的更简单的方法。

有一个流行的 Go 语言包实现了速率限制,称为golang.org/x/time/rate。该包实现了b,每次请求时减 1,并以每秒配置的速率r个元素进行补充。例如,对于 b = 100 和 r = 50,令牌桶算法创建一个容量为 100 的桶,并以每秒 50 的速率补充。在任何时刻,它不允许超过 100 个并发请求(最大数量由当前桶的大小控制)。

下面是一个在 Go 中使用基于令牌桶的速率限制器的示例:

package main
import (
    "fmt"
    "golang.org/x/time/rate"
)
func main() {
    limit := 3
    burst := 3
    limiter := rate.NewLimiter(rate.Limit(limit), burst)
    for i := 0; i < 100; i++ {
        if limiter.Allow() {
            fmt.Println("allowed")
        } else {
            fmt.Println("not allowed")
        }
    }
}

此代码会打印allowed三次,然后持续打印not allowed 97 次,除非执行时间超过 1 秒。

让我们通过第五章中实现的 gRPC API 处理器来展示如何使用这种速率限制器。gRPC 协议允许我们定义拦截器——在每个请求上执行的操作,可以修改 gRPC 服务器对该请求的响应。要将 gRPC 速率限制器添加到电影服务的 gRPC 处理器中,执行以下步骤:

  1. 打开movie/cmd/main.go文件,并在其导入中添加以下代码:

    “github.com/grpc-ecosystem/go-grpc-middleware/ratelimit"
    
  2. 将带有grpc.NewServer调用的行替换为以下代码:

        const limit = 100
    
        const burst = 100
    
        l := newLimiter(100, 100)
    
        srv := grpc.NewServer(grpc.UnaryInterceptor(ratelimit.UnaryServerInterceptor(l)))
    
  3. 然后,将以下结构定义添加到文件中:

    type limiter struct {
    
        l *rate.Limiter
    
    }
    
    func newLimiter(limit int, burst int) *limiter {
    
        return &limiter{rate.NewLimiter(rate.Limit(limit), burst)}
    
    }
    
    func (l *limiter) Limit() bool {
    
        return l.l.Allow()
    
    }
    

我们的速率限制器使用来自github.com/grpc-ecosystem/go-grpc-middleware/ratelimit包的速率限制 gRPC 服务器拦截器。其接口与我们来自golang.org/x/time/rate的限制器略有不同,因此我们添加了一个结构来将它们连接起来。现在,我们的 gRPC 服务器允许每秒最多 100 个请求,并在超过限制时返回一个带有codes.ResourceExhausted特殊代码的错误。这确保了服务不会因为大量请求的突然增加而超载——如果有人一次性请求 100 万部电影详情,我们不会对元数据服务进行 100 万次调用并超载其数据库。

请记住,速率限制是一种强大的技术;然而,它需要谨慎使用,因为设置限制过低会使系统对用户过于限制,拒绝过多的请求。为了计算服务的公平速率限制设置,您需要定期进行基准测试,了解其逻辑的最大吞吐量。

让我们转到基于自动化的可靠性技术的下一个主题,描述如何优雅地终止服务的执行。

优雅关闭

在本节中,我们将讨论服务关闭事件的优雅处理。服务关闭可以由多个事件触发:

  • 手动中断执行(例如,当用户在运行服务进程的终端中输入Ctrl + C/Cmd + C时,进程从操作系统接收SIGINT信号)

  • 操作系统通过(例如,通过SIGTERMSIGKILL信号)终止执行

  • 服务代码中的 panic

通常,服务的执行突然终止可能会导致以下负面后果:

  • 丢弃请求:入站 API 请求可能在完全处理之前被丢弃,从而导致服务调用者的错误。

  • 连接问题:在关闭过程中,服务网络连接可能无法正确关闭,从而导致多种负面影响。例如,未关闭数据库连接可能导致所谓的连接泄漏情况,此时数据库会保留连接以供服务使用,而不是允许其他实例重用该连接。

为了防止这些问题,您需要确保您的服务通过执行一系列操作来优雅地关闭,以最大限度地减少对服务和其组件的负面影响。执行优雅关闭时,服务在终止前会运行一些额外的逻辑,例如以下内容:

  • 尽可能完成尽可能多的未完成操作,例如未处理请求

  • 关闭所有打开的网络连接并释放任何共享资源,例如网络套接字

Go 服务的优雅关闭逻辑通常按以下方式实现:

  1. 服务通过调用os/signal包的Notify函数订阅关闭事件。

  2. 当服务从操作系统接收到SIGINTSIGTERM事件,表明服务即将被终止时,它执行一系列必要的操作来关闭所有打开的连接并完成所有挂起的任务。

  3. 一旦所有操作完成,服务将结束执行。

这里是一个您可以添加到任何 Go 服务main函数中的代码示例,例如我们在第二章中实现的服务:

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        s := <-sigChan
        log.Printf("Received signal %v, attempting graceful shutdown", s)
        // Graceful shutdown logic.
    }()
    wg.Wait()

通过使用内置的recover函数,也有一种优雅地处理 Go 代码中 panic 的方法。以下代码片段演示了如何在main函数内部处理 panic 并执行任何自定义逻辑,例如关闭任何打开的连接:

func main() {
    defer func() {
        if err := recover(); err != nil {
            log.Printf("Panic occurred, attempting graceful shutdown")
            // Graceful shutdown logic.
        }
    }()
    panic("panic example")
}

在我们的代码中,我们通过调用recover函数并检查它是否返回非空错误来检查是否存在服务 panic。在 panic 的情况下,我们可以执行任何额外的操作,例如保存任何未保存的数据或终止任何打开的连接。

要优雅地终止 Go gRPC 服务器的执行,你需要调用 GracefulStop 函数而不是 Stop。与 Stop 函数不同,GracefulStop 会等待所有请求处理完毕,从而帮助减少关闭对客户端的负面影响。

如果你有一些长时间运行的组件,例如 Kafka 消费者或执行长时间运行任务的任何后台 goroutine,你可以使用内置的 context.Context 结构来传达服务终止信号。context.Context 结构提供了一种名为 上下文取消 的功能——通过发送与上下文关联的通道中的特定事件来通知不同组件关于执行取消的能力。

让我们更新我们的评分服务代码,以说明如何实现上下文取消和 gRPC 服务的优雅关闭:

  1. 打开评分服务的 main.go 文件,找到执行 context.Background() 函数调用的行。将其替换为以下代码:
ctx, cancel := context.WithCancel(context.Background())

我们创建了一个上下文实例和 cancel 函数,我们将在服务关闭时调用它来通知我们的组件,例如服务注册表,关于即将到来的终止。

  1. 在调用 srv.Serve 函数之前立即添加以下代码:
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        s := <-sigChan
        cancel()
        log.Printf("Received signal %v, attempting graceful shutdown", s)
        srv.GracefulStop()
        log.Println("Gracefully stopped the gRPC server")
    }()

在我们的代码中,我们让评分服务监听进程中断和终止信号,并启动后台 goroutine,持续监听相关通知。一旦它收到任一信号,它就会调用我们在上一步获得的 cancel 函数。调用此函数的结果将是一个通知,该通知将被发送到使用我们的上下文初始化的组件,例如服务注册表。

  1. 让我们在 main 函数的末尾添加以下行来完成最后的润色:
wg.Wait()

现在我们来测试我们刚刚实现的代码。运行评分服务,然后通过按 Ctrl + C/Cmd + C(取决于你的操作系统)来终止它。你应该会看到以下消息:

2022/10/13 08:55:05 Received signal interrupt, attempting graceful shutdown
2022/10/13 08:55:05 Gracefully stopped the gRPC server

在 Go 微服务开发中,传达终止和中断事件是一种常见做法,并且是实现优雅关闭逻辑的一种优雅方式。在设计和服务实现时,提前考虑在服务终止时需要关闭或反初始化的可能资源,例如任何网络客户端和连接。优雅关闭逻辑可以防止服务突然终止的负面影响。它还可以减少服务中可能出现的错误数量,并提高你的操作体验。

到目前为止,我们已经审查了一些自动化技术,以提高我们服务的可靠性并减少各种故障场景的症状。现在,我们可以继续本章的下一节,涵盖与开发流程和文化相关的另一个可靠性工作方面。改进你的开发流程对于长期实现高可靠性至关重要,本节将为你提供一些有价值的技巧和想法,你可以在微服务开发中利用它们。

通过开发流程和文化实现可靠性

在本节中,我们将描述一些基于开发流程和文化变革提高服务可靠性的技术。你将学习如何建立改进和审查服务可靠性的流程,如何高效地从任何服务相关的问题和事件中学习,以及如何衡量你的服务可靠性。我们将涵盖整个行业广泛使用的流程和实践,概述每个流程最重要的思想。本节将比前一节更具理论性;然而,它应该同样有用。

首先,我们将提供一个概述,介绍设置监控服务问题的机制所必需的值班流程。

值班流程

当你的服务开始处理生产流量或开始响应用户请求时,你的第一个可靠性目标应该是尽早发现任何问题或事件。高效的检测应该是自动的——程序在检测大多数问题时总是比人类更有效率。每个自动检测都应该通知一个或多个工程师关于事件的信息,以便工程师可以采取措施减轻事件的影响。

建立这种通知工程师关于服务事件机制的流程被称为值班。此流程有助于确保在任何时刻,服务事件都由负责该服务的工程师承认和解决。

值班流程背后的主要思想如下:

  • 工程师可以被分组到值班轮换中。每个参与值班流程的工程师都会反复被分配一个连续的班次(通常持续 1 周),在此期间,他们负责定期处理有关服务级别事件的通知。

  • 值班轮换可以有一个升级策略——在事件未解决的情况下升级事件的过程。首先,事件报告给轮换的主要值班工程师。如果主要工程师不可用,事件报告给次要工程师,依此类推。

  • 可以有一个影子角色,通常分配给新工程师。这个角色不需要对事件做出任何响应,但可以用来熟悉值班流程并订阅实时事件通知。

  • 每个事件都会触发一个或多个通知,通知值班工程师关于问题。除非事件自行解决(例如,如果服务停止接收过多请求并开始正常运作),否则每个通知都必须由负责的值班工程师确认。

  • 您还可以为轮换设置一个升级策略——如果负责的值班工程师在配置的时间内未确认事件通知,则触发事件升级的机制。通常,升级策略遵循工程层级报告链——如果没有任何工程师确认事件,事件首先触发通知给最近的工程经理,然后是经理报告的人,依此类推,直到达到最高级别(这甚至可能是某些公司的 CTO)。

值班流程对于大多数科技公司和团队来说是常见的,而且大多数公司之间的值班流程都很相似。一些流行的解决方案提供了触发各种类型通知的机制,例如短信、电子邮件,甚至电话。您还可以配置值班轮换并将它们分配给不同的服务。最流行的值班管理解决方案之一是PagerDuty——一个提供一系列自动化值班操作工具的平台,以及与数百个服务的集成,包括 Slack、Zoom 等。PagerDuty 提供了我们之前列出的所有功能,允许工程师为他们的服务配置值班轮换,并以不同的方式通知他们关于事件的信息。此外,它提供了一个 API,可以用于访问事件数据和从代码中触发新事件。

我们不会深入探讨本章中 PagerDuty 的功能和集成细节——我建议您查看他们网站上的官方 PagerDuty 文档,developer.pagerduty.com/docs。我还建议您在为您的服务建立值班流程之前阅读第十二章。这将帮助您了解可能的故障检测机制以及您可以在项目中利用的工具。

让我们讨论在微服务环境中建立值班流程的常见挑战:

  • 轮换所有权:不同的服务可能由不同的团队维护,因此一个公司内部可能有多个值班轮换。一个好的做法是在每个生产服务和相关的值班轮换之间建立明确的映射,以便清楚地知道每个事件应该报告给哪个轮换。在第十三章中,我们将介绍这个方面的所有权问题。

  • 跨服务问题:一些问题,如数据库或网络故障,可能跨越多个服务,因此拥有一些能够帮助解决跨越单个服务边界的任何问题的集中式团队变得很重要。

一些公司可能有数千个微服务,因此集中式的事件响应团队变得至关重要。例如,Uber 有一个名为Ring0的专用工程师团队,能够处理任何广泛的事件,并协调跨多个团队的问题缓解。拥有这样的团队有助于显著减少事件缓解时间。

为了更好地了解在工程师检测和确认事件后会发生什么,我们现在将转到下一个主题:事件管理。

事件管理

一旦事件被工程师检测和确认,还有两种其他类型的工作对于提高服务或系统可靠性是必要的——缓解和预防。除非问题自行解决或由于某些外部变化(例如,外部 API 由拥有团队修复),否则需要缓解来解决开放问题。预防工作对于确保问题不再发生是有用的。如果没有适当的预防响应来应对事件,你可能会反复修复相同的问题,浪费时间和影响系统用户的使用体验。

为了使事件缓解过程快速高效,尤其是在一个大型团队中,工程师可能对系统的理解程度不同,应该有足够的文档描述在发生事件时应采取哪些行动。这种文档称为运行手册,应该为尽可能多的可检测事件类型准备。每当值班工程师收到事件通知时,运行手册应该清楚地说明缓解事件的步骤。

一个好的运行手册应该简短、简洁,并提供任何工程师都容易理解的明确可执行步骤。让我们以这个例子为例:

rating_service_fd_limit_reached:
  mitigation: Restart the service

如果事件缓解需要进一步调查,请包括任何有用的链接,例如指向相关应用程序日志和仪表板的链接。你应该争取尽可能低的缓解时间——也称为修复时间TTR)——以提高服务的可用性并改善其整体健康状况。

一旦事件得到缓解,关注预防工作以确保您采取所有行动消除其原因,以及必要时改进检测和缓解机制。跨行业中的多家公司使用编写文档的过程称为 事件后分析 来组织围绕事件的学习,并确保每个事件都涉及足够的相关未来预防工作。事件后分析通常包括以下数据:

  • 事件标题和摘要

  • 作者

  • 何时以及如何检测和缓解事件

  • 事件背景,以文本或一组图表的形式呈现,有助于理解

  • 根本原因

  • 事件影响

  • 事件时间线

  • 经验教训

  • 行动项

在著名的谷歌 《站点可靠性工程(SRE)》 书籍中提供了一个优秀的故障分析文档示例,您可以在以下链接中熟悉它:sre.google/sre-book/example-postmortem/.

要找到事件的根本原因,您可以使用称为 五问法 的技术。该技术的理念是持续询问导致先前问题的原因,直到找到根本原因。让我们以下面的 根本原因分析RCA)为例来理解这个技术:

事件:评级服务向其 API 调用者返回内部错误

根本原因分析

  1. 评级服务开始向其 API 调用者返回内部错误,因为评级数据库不可用。

  2. 评级数据库因意外的高请求负载而变得不可用。

  3. 评级服务意外的高请求负载是由电影服务中的应用程序错误引起的。

在这个例子中,我们通过使用五问法,逐步找到每个先前问题的根本原因,直到在仅仅三步中找到事件的根本原因。这个技术非常强大且易于使用,并且可以帮助您快速找到复杂问题的根本原因。

确保您包括并跟踪您事件中的行动项。仅捕获事件细节和识别原因不足以确保防止事件发生。优先处理行动项有助于确保最关键的行动项尽早得到解决。

现在,让我们转向基于定期测试您可能的服务故障场景的下一个可靠性流程。

可靠性演练

如许多系统管理员所知,仅对数据进行备份以保证其持久性是不够的。您还需要确保在发生任何故障的情况下能够从备份中恢复数据。同样的原则适用于您服务的任何部分——为了知道您的服务对特定故障具有弹性,您需要定期进行练习,称为 演练

你可以执行许多可能类型的演练。例如,在数据库备份的例子中,如果你在数据库中存储了任何持久数据,你可以定期测试备份和恢复数据的能力,以验证你的服务是否能够容忍数据库可用性问题。另一个例子是网络演练。你可以通过更新服务路由配置或任何其他网络设置来模拟网络问题,例如连接丢失,以检查你的服务在网络不可用的情况下会如何表现。

执行可靠性演练有多个好处:

  • 检测意外的服务故障:通过执行故障演练,你可以检测到一些在常规模式下不会发生的意外服务错误和恐慌。这些问题将在一个受控环境中呈现,工程师可以随时停止演练,并尽早解决检测到的错误。

  • 检测意外的服务依赖性:可靠性演练经常揭示服务之间意外的依赖关系,例如传递依赖(服务 A 依赖于服务 B,而服务 B 依赖于服务 C)或甚至循环依赖(两个服务需要彼此才能运行)。

  • 更快地减轻未来事件的能力:通过了解服务在故障情况下的运行方式和如何解决相关问题,你投资于提高未来事件缓解的能力。

演练通常作为计划中的事件进行——这些事件提前宣布,并遵循常规事件管理流程,包括编写事后报告。演练的事后报告应包括与常规事件相同的条目,重点在于改进缓解和预防体验。此外,工程师应专注于审查和更新服务操作手册,确保事件缓解说明准确且最新。

到目前为止,我们已经讨论了最重要的服务可靠性技术。还有许多与服务可靠性相关的话题值得探讨——其中一些与事件检测相关的内容,我们将在本书的第十二章中进行介绍。如果你对这个话题感兴趣,我强烈建议你阅读谷歌的《站点可靠性工程(SRE)》一书,它提供了各种可靠性相关技术的全面指南。你可以通过以下链接找到这本书的在线版本:sre.google/sre-book/table-of-contents。书中描述的实践适用于任何微服务,因此你可以在构建任何类型的系统时始终将其作为参考。

摘要

在本章中,我们讨论了可靠性的主题,描述了一系列可以帮助你使你的微服务对各种类型的故障更具弹性的技术和实践。你学习了一些有用的技术来自动化服务的错误响应,并减少各种类型问题(如服务过载和意外的服务关闭)的负面影响。

在本章的最后部分,我们讨论了基于工程流程和文化变化的可靠性技术,例如引入值班和事件管理流程,以及进行定期的可靠性演练。从阅读本章中你获得的知识应该有助于你为编写可靠的微服务建立一个坚实的基础。

在下一章中,我们将继续我们的可靠性主题之旅,重点关注收集服务遥测数据,如日志、指标和跟踪。服务遥测数据是设置服务事件检测的主要工具,我们将说明如何在你的微服务代码中处理每种类型的遥测数据。

进一步阅读

如果你想了解更多,请参考以下资源:

第十一章:收集服务遥测数据

在上一章中,我们探讨了服务可靠性的主题,并描述了各种使您的服务更能抵御不同类型错误的技术。您了解到与可靠性相关的工作包括在事件检测、缓解和预防技术方面进行持续改进。

在本章中,我们将更深入地探讨各种类型的服务性能数据,这对于设置服务健康监控、调试和自动化服务事件检测至关重要。您将学习如何收集服务日志、指标和跟踪信息,以及如何使用分布式跟踪技术可视化并调试微服务之间的通信。

我们将涵盖以下主题:

  • 遥测概述

  • 收集服务日志

  • 收集服务指标

  • 收集服务跟踪

现在,让我们继续概述本章将要描述的所有技术。

技术要求

要完成本章,您需要 Go 1.11 或更高版本。您还需要以下工具:

您可以在此处找到本章的 GitHub 代码:github.com/PacktPublishing/microservices-with-go/tree/main/Chapter11

遥测概述

在本章的介绍中,我们提到了存在不同类型的服务性能数据,所有这些对于服务健康监控和故障排除都是必不可少的。这些类型的数据被称为遥测数据,包括以下内容:

  • 日志:由您的服务记录的消息,提供了对它们执行的操作或遇到的错误的洞察

  • 指标:由您的服务产生的性能数据,例如注册用户数量、API 请求错误率或可用磁盘空间的百分比

  • 跟踪:显示您的服务执行各种操作的数据,例如 API 请求,它们调用了哪些其他服务,它们执行了哪些内部操作,以及这些操作花费了多长时间

遥测数据是不可变的:它捕获了服务已经发生的事件,并提供了各种测量结果,例如服务 API 响应延迟。当不同类型的遥测数据结合在一起时,它们成为了解服务行为的有力信息来源。

在本章中,我们将描述如何收集服务遥测数据以监控服务的健康状态。有两种类型的服务健康和性能监控:

  • 白盒监控:在可以访问不同类型内部生成数据的情况下监控服务。例如,您可以通过系统监控应用程序查看服务器的 CPU 利用率来监控它。

  • 黑盒监控:仅使用外部可用的数据和指标进行服务监控。在这种情况下,您不知道或无法访问与它们的结构或内部行为相关的数据。例如,如果一个服务有一个公开可用的健康检查 API,外部系统可以通过调用该 API 来监控其健康状态,而不需要访问内部服务数据。

这两种类型的监控都是通过收集和持续分析服务性能数据来实现的。一般来说,您从应用程序中收集的数据类型越多,您获得有关其健康和行为的信息类型就越多。让我们列出一些您可以使用有关服务性能信息的方法:

  • 趋势分析:检测您的服务性能数据中的任何趋势:

    • 您的服务健康状况随着时间的推移是变好还是变差?

    • 您的 API 成功率是如何变化的?

    • 与前一天/月/年相比,您获得了多少新用户?

  • 语义图捕获:捕获有关您的服务如何相互通信以及与任何其他组件(如数据库、外部 API 和消息代理)通信的数据。

  • 异常检测:自动检测服务行为中的异常,例如 API 请求的突然下降。

  • 事件关联:检测各种类型事件之间的关系,例如失败的部署和服务崩溃。

虽然可观察性提供了很多机会,但也伴随着以下挑战:

  • 收集大量数据集:实时性能数据通常需要大量的存储空间,尤其是如果您有很多服务或您的服务产生了大量数据。

  • 需要特定工具:要收集、处理和可视化不同类型的数据,例如日志、指标和跟踪,您需要一些额外的工具。这些工具通常需要付费。

  • 复杂配置:可观察性工具和基础设施通常很难配置。要访问来自多个服务的所有数据,您需要设置适当的数据收集、聚合、数据保留策略以及许多其他策略。

我们将描述如何处理您在微服务中可以收集的每种类型的遥测数据。对于每种类型的数据,我们将提供一些使用示例,并描述设置工具以处理它的常见方式。首先,让我们继续查看服务日志收集。

收集服务日志

日志记录是一种涉及收集以时间顺序排列的消息集合的形式的实时应用程序性能数据的技术,称为日志。以下是一个服务日志的示例:

2022/06/06 23:00:00 Service started
2022/06/06 23:00:01 Connecting to the database
2022/06/06 23:00:11 Unable to connect to the database: timeout error

日志可以帮助我们了解在特定时间点应用程序中发生了什么。正如您在前面的示例中看到的那样,该服务在晚上 11 点开始启动,并在一秒后开始连接到数据库,最终在 10 秒后记录了一个超时错误。

日志可以提供关于发出它们的组件的大量有价值的信息,例如以下内容:

  • 操作顺序:日志可以帮助我们通过显示每个操作发生的时间来理解服务执行的操作的逻辑顺序。

  • 失败的操作:日志最有用的应用之一是能够看到服务记录的错误列表。

  • 恐慌:如果一个服务由于恐慌而意外关闭,日志可以提供相关信息,帮助排查问题。

  • 调试信息:开发人员可以记录各种类型的附加信息,例如请求参数或头信息,这有助于调试各种问题。

  • 警告:日志可以指示各种系统级警告,例如磁盘空间不足,可以用作防止各种类型错误的警报机制。

我们在我们创建的服务中使用了日志第二章 – 我们的服务已经通过内置日志库记录了一些重要的状态消息。以下是一个示例:

log.Printf("Starting the metadata service on port %d", port)

内置日志库提供了记录任意文本消息和恐慌的功能。前述操作的输出如下:

2022/07/01 13:05:21 Starting the metadata service on port 8081

默认情况下,日志库将所有日志记录到与当前进程关联的stdout流。但可以通过调用SetOutput函数来设置输出目的地。这样,你可以将日志写入文件或将它们通过网络发送。

日志库提供了两种类型的函数,如果服务遇到意外或不可恢复的错误,可以使用:

  • 致命:带有此前缀的函数在记录消息后立即停止进程的执行。

  • panic:在记录消息后,它们会调用 Go 的panic函数,写入相关错误的输出。以下是一个调用panic函数的示例输出:

    2022/11/10 23:00:00 network unavailable
    
    panic: network unavailable
    

虽然内置日志库提供了记录任意文本消息的简单方法,但它缺少一些有用的功能,使得收集和处理服务日志更容易。其中缺失的功能包括记录流行序列化格式(如 JSON)中的事件的能力,这将简化消息数据的解析。另一个问题是它缺少ErrorErrorf函数,这些函数可以用于显式记录错误。由于内置日志库只提供Print函数,默认情况下无法确定记录的消息表示错误、警告还是两者都不是。

然而,内置日志库中最大的缺失部分是执行结构化日志记录的能力。结构化日志记录是一种涉及以序列化结构的形式收集日志消息的技术,例如 JSON 记录。与任意文本字符串相比,此类结构的独特之处在于它们可以包含以字段形式存在的附加元数据 – 键值记录。这使得服务能够以任何支持的类型表示消息元数据,例如数字、字符串或序列化记录。

下面的代码片段包括一个 JSON 编码的日志结构示例:

{"level":"info", "time":"2022-09-04T20:10:10+1:00","message":"Service started", "service":"metadata"}

如您可能已注意到的,除了使用 JSON 作为输出格式外,先前的日志格式还有两个附加功能:

  • level 指定日志消息的类型。

  • service,用于指示发出消息的服务。

之前描述的输出格式使我们能够更容易地解码日志消息。它还帮助我们根据日志级别和额外的消息字段来解释其内容。还可以使用附加元数据进行搜索:例如,我们可以搜索具有特定 service 字段值的消息。

现在,让我们专注于上一个示例中的日志级别元数据。首先,让我们回顾一些常见的日志级别:

  • 信息:不指示任何错误的 informational 消息。此类消息的例子是日志记录表明服务成功连接到数据库。

  • 错误:指示错误的消息,例如网络超时。

  • 警告:指示一些潜在问题的消息,例如打开的文件太多。

  • 致命:指示关键或不可恢复的错误,例如内存不足,使得进一步执行服务变得不可能。

  • Debug 消息通常默认禁用,因为它们通常会生成大量数据。

日志级别也帮助我们解释日志消息。考虑以下由内置日志库生成的无结构消息,它不包含任何级别信息:

2022/06/06 23:00:00 Connection terminated

您能否判断这是一条表示常规行为(例如,服务在执行一些工作后有意终止了连接)的常规信息性消息,还是警告或错误?如果是错误,它是关键的还是非关键的?如果没有日志级别提供额外的上下文,很难解释这条消息。

显式使用日志级别的另一个优点是能够启用或禁用记录特定类型级别的功能。例如,在正常服务条件下可以禁用 Debug 消息的记录,并在故障排除期间启用。Debug 消息通常包含比常规消息更多的信息,需要更多的磁盘空间,并使其他类型的日志导航更困难。不同的日志库允许我们启用或禁用特定级别,例如 Debug 或甚至 Info,只留下表示警告、错误、致命错误和恐慌的日志。

log15 (github.com/inconshreveable/log15):

选择日志库

流行 Go 日志库的列表包括以下内容:

在本节中,我们将描述一些现有的 Go 日志库并回顾它们的功能。本节应有助于您选择将在您的微服务中使用的日志库。

  • 结构化日志:支持记录结构化消息,可能包括键值格式中的额外字段。

  • 首先,让我们列出我们希望从日志库中获得的功能:

  • 由 Go 开发团队官方支持,包含在 Go SDK 中

以下是一些额外且令人愉悦的功能:

  • 日志级别支持:强制在消息元数据中包含日志级别。

现在,让我们回顾一些最受欢迎的 Go 日志库。在评估库性能时,我们将使用日志库基准数据:github.com/uber-go/zap#performance

功能丰富,支持快速的最小化日志记录器,以及带有额外功能的稍慢版本

  • 内置 Go 日志包 (pkg.go.dev/log):

    • 简洁优雅的 API

    • 不支持结构化日志,也没有内置对日志级别的支持

  • zap (github.com/uber-go/zap):

    • 在所有已审查的日志库中性能最快

    • 类似于 Printf 的格式(例如,支持 Errorf 函数来记录格式化的错误)。

  • zerolog (github.com/rs/zerolog):

    • 快速性能:写入日志消息不应对服务性能产生明显影响。

    • 让我们回顾一些流行的 Go 日志库,并专注于选择我们将在微服务中使用的库。

  • go-kit 微服务开发工具包

  • zerologzap略慢,但比其他日志库快

  • apex/log (github.com/apex/log):

    • 内置支持各种日志存储,如 Elasticsearch、Graylog 和 AWS Kinesis

    • 功能丰富的日志工具包

    • 比其他已审查的日志库慢得多

上述列表提供了一些关于一些流行 Go 日志库的高级细节,以帮助您为您的服务选择正确的库。所有库(除了内置的 log 包)都提供了我们需要的功能,包括结构化日志和日志级别。现在,问题是如何从它们中选出最佳的一个?

我个人的观点是,zap 库提供了最灵活且性能最出色的服务日志解决方案。它允许我们使用两个独立的日志记录器,分别称为 LoggerSugaredLoggerLogger 可用于高性能应用,而 SugaredLogger 可用于需要额外功能时;我们将在下一节中回顾这些功能。

使用日志功能

让我们开始练习并展示如何使用我们在上一节中选择的 zap 日志库的一些功能。首先,让我们从基础知识开始,说明如何记录一个具有 Info 级别和一个名为 serviceName 的附加元数据字段的简单消息。本例的完整 Go 代码如下:

package main
import "go.uber.org/zap"
func main() {
    logger, _ := zap.NewProduction()
    logger.Info("Started the service", zap.String("serviceName", "metadata"))
}

我们通过调用 zap.NewProduction 函数初始化 logger 变量,该函数返回一个生产配置的日志记录器。这个日志记录器省略了调试消息,使用 JSON 作为输出格式,并在日志中包含堆栈跟踪。然后,我们通过使用 zap.String 函数包含一个 serviceName 字段来创建一个结构化日志消息,该函数可以用于记录字符串数据。

上一例子的输出如下:

{"level":"info","ts":1257894000,"caller":"sandbox1575103092/prog.go:11","msg":"Started the service","serviceName":"metadata"}

zap 库为其他类型的 Go 原始数据提供支持,例如 intlongbool 以及更多。创建日志字段名的相应函数遵循相同的命名格式,例如 IntLongBool。此外,zap 包含了一组用于其他内置 Go 类型的函数,例如 time.Duration。以下代码展示了 time.Duration 字段的示例:

logger.Info("Request timed out", zap.Duration("timeout", 10*time.Second))

让我们说明如何记录任意对象,例如结构。在 第二章 中,我们定义了 Metadata 结构:

// Metadata defines the movie metadata.
type Metadata struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Director    string `json:"director"`
}

假设我们想要为了调试目的记录整个结构。这样做的一种方法是通过使用 zap.Stringer 字段。该字段允许我们使用 String() 函数记录任何结构或接口。我们可以为我们的 Metadata 结构定义一个 String 函数,如下所示:

func (m *Metadata) String() string {
    return fmt.Sprintf("Metadata{id=%s, title=%s, description=%s, director=%s}", m.ID, m.Title, m.Description, m.Director)
}

现在,我们可以将 Metadata 结构作为日志字段进行记录:

logger.Debug("Retrieved movie metadata", zap.Stringer("metadata", metadata))

输出将如下所示:

{"level":"debug","msg":"Retrieved movie metadata","metadata":"Metadata{id=id, title=title, description=description, director=director}"}

现在,让我们再展示一个使用 zap 库的有用技巧。如果你想在多个消息中包含相同的字段,你可以通过使用 With 函数重新初始化日志记录器,如下面的例子所示:

logger = logger.With(zap.String("endpoint", "PutRating"), zap.String("ratingId", ratingID))
logger.Debug("Received a PutRating request")
// endpoint logic
logger.Debug("Processed a PutRating request")

现在两次调用 Debug 函数的结果将包括 endpointratingId 字段。

你也可以在创建代码中的新服务组件时使用这种技巧。在下面的例子中,我们在 New 函数内部创建了一个子日志记录器:

func New(logger *zap.Logger, ctrl *rating.Controller) *Handler{ 
    return &Handler{logger.With("component": "ratingController"), ctrl}
}

这样,新创建的 Handler 结构实例将使用包含每个消息中 component 字段具有 ratingController 值的日志记录器进行初始化。

现在我们已经覆盖了一些主要的服务日志用例,让我们讨论如何在微服务环境中存储日志。

存储微服务日志

默认情况下,每个服务实例的日志会被写入运行该实例的进程的输出流。这种日志收集机制允许我们通过持续读取运行服务实例的主机上相关流(大多数情况下是stdout)中的数据来监控服务操作。然而,如果没有额外的软件,日志数据将不会被持久化,因此在服务重启或突然崩溃后,您将无法读取之前记录的日志。

各种软件解决方案允许我们在多服务环境中存储和查询日志数据。它们帮助解决多个其他问题:

  • 分布式日志收集:如果您有多个服务在不同的主机上运行,您必须独立收集每个主机上的服务日志并将它们发送进行进一步聚合。

  • 集中式日志存储:为了能够查询不同服务发出的数据,您需要以集中化的方式存储它 – 在查询执行期间,所有服务的所有日志都应该是可访问的。

  • 数据保留:日志数据通常占用大量磁盘空间,并且通常存储所有服务的日志变得过于昂贵。为了解决这个问题,您需要为您的服务建立合适的数据保留策略,这将允许您配置每个服务可以存储数据的时间长度。

  • 高效索引:为了能够快速查询您的日志数据,日志需要被高效地索引和存储。现代索引软件可以帮助您在不到 10 毫秒的时间内查询 TB 级的日志数据。

不同的工具有助于简化此类日志操作,例如 Elasticsearch 和 Graylog。让我们简要回顾 Elasticsearch,以提供一个端到端的日志管理解决方案的示例。

Elasticsearch是一个流行的开源搜索引擎,它于 2010 年创建,并迅速成为索引和查询不同类型结构化数据的可扩展系统的热门选择。虽然 Elasticsearch 的主要用例是全文搜索,但它可以有效地用于存储和查询各种类型的结构化数据,例如服务日志。Elasticsearch 也是名为Elastic Stack(也称为ELK)的工具包的一部分,它包括一些其他系统:

  • Logstash:一个数据处理管道,可以收集、聚合和转换各种类型的数据,例如服务日志

  • Kibana:一个用于访问 Elasticsearch 中数据的用户界面,提供便捷的可视化和查询功能

Elastic Stack 中的日志收集管道看起来是这样的:

图 11.1 – Elastic Stack 中的日志管道

图 11.1 – Elastic Stack 中的日志管道

在这个流程图中,服务日志由 Logstash 收集并发送到 Elasticsearch 进行索引和存储。然后,用户可以使用 Kibana 界面访问日志和其他在 Elasticsearch 中索引的数据。

Elastic Stack 的一个关键优势是,其大多数工具都是免费且开源的。它维护良好,在开发者社区中极为流行,这使得搜索相关文档、获取额外支持或找到一些额外工具变得更加容易。它还提供了一套适用于所有流行语言的库,使我们能够对管道的所有组件执行各种类型的查询和 API 调用。

用于使用 Elasticsearch API 的 Go 库称为 go-elasticsearch,可以在 GitHub 上找到:github.com/elastic/go-elasticsearch

我们不会详细讨论 Elastic Stack,因为它超出了本章的范围,但您可以通过阅读其官方文档(https://www.elastic.co/guide/index.html)来了解更多关于 Elastic Stack 的信息。

在介绍了一些流行的日志软件的高级细节之后,让我们继续下一个主题:描述日志的最佳实践。

日志最佳实践

到目前为止,我们已经涵盖了日志最重要的方面,并描述了如何选择日志库,以及如何建立用于收集和分析数据的日志基础设施。让我们描述一些日志服务数据的最佳实践:

  • 避免使用插值字符串。

  • 标准化您的日志消息。

  • 定期审查您的日志数据。

  • 设置适当的日志保留。

  • 在日志中识别消息来源。

现在,让我们详细说明每个实践。

避免使用插值字符串

最常见的日志反模式之一是使用 插值字符串 – 在文本字段中嵌入元数据的消息。以下代码片段可以作为例子:

logger.Infof("User %s successfully registered", userID)

这段代码的问题在于它将两种类型的数据合并到一个单独的文本消息中:一个操作名称(用户注册)和一个用户标识符。这样的消息使得搜索和处理日志元数据变得更加困难:每次您需要从日志消息中提取 userID 时,您都需要解析包含它的字符串。

让我们通过遵循结构化日志记录方法来更新我们的示例,其中我们将额外的元数据作为消息字段进行记录:

logger.Infof("User successfully registered", zap.String("userId", userID))

当您想要查询数据时,更新后的版本会带来很大的差异。现在,您可以查询所有包含 User successfully registered 文本消息的日志事件,并轻松访问与之相关的所有用户标识符。避免使用插值消息有助于保持您的日志数据易于查询和解析,简化所有与之相关的操作。

标准化您的日志消息

在本节中,我们介绍了日志集中化的好处以及跨多个服务查询数据的优势。但我想强调,在微服务环境中标准化日志消息的格式是多么重要。有时,执行跨越多个服务、API 端点处理程序或其他组件的日志查询是有用的。例如,您可能需要在您的日志数据上执行以下类型的查询:

  • 获取所有服务中超时错误的分布情况。

  • 获取每个 API 端点的错误每日计数。

  • 获取所有数据库存储库中独特的错误消息。

如果您的服务使用不同的字段名记录数据,您将无法轻松地使用通用查询函数收集此类数据。相反,建立通用字段名有助于确保日志消息遵循相同的命名约定,简化您编写的任何查询。

为了确保所有服务和所有组件以相同的方式发出日志,您可以遵循以下提示:

  • 创建一个包含日志字段名作为常量的共享包;以下是一个示例:

    package logging
    
    const (
    
      FieldService  = "service"
    
      FieldEndpoint = "endpoint"
    
    ...
    
    )
    
  • 为了避免忘记在某个结构、函数或函数集中包含某些重要字段,请尽早通过设置字段来重新初始化记录器;以下是一个示例:

    func (h *Handler) PutRating(ctx context.Context, req *PutRatingRequest) (*PutRatingResponse, error) {
    
        logger := h.logger.With(logging.FieldEndpoint, "putRating")
    
        // Now we can make sure the endpoint field is set across all handler logic.
    
    ...
    
    }
    
  • 此外,确保您的服务根记录器设置了服务名称,以便所有服务组件将默认自动收集此字段:

    func main() {
    
        logger, _ := zap.NewProduction()
    
        logger = logger.With(logging.FieldService, "rating")
    
        // Pass the initialized logger to all service components.
    

我们刚刚提供的提示应该有助于您在所有服务组件中标准化常用字段的用法,使查询记录的数据和以不同方式聚合数据变得更加容易。

定期审查您的日志数据

一旦开始收集您的服务日志,定期审查它们就很重要。注意以下情况:

  • 确保日志中没有 PII 数据个人身份信息(PII),例如全名和 SSN,受到许多法规的约束,通常不应存储在日志中。请确保没有任何组件,例如 API 处理程序或存储库组件,发出任何此类数据,即使是出于调试目的。

  • 检查您的服务是否未发出额外的调试数据:有时,开发者为了调试各种问题,会在日志中记录一些额外的数据,例如请求字段。请确保没有服务在长时间内持续发出过多的调试消息,污染日志并占用过多磁盘空间。

设置适当的日志保留策略

日志数据通常需要占用大量的存储空间。如果它不断增长而没有采取任何额外措施,你可能会用完所有磁盘空间,并不得不紧急清理旧记录。为了防止这种情况,各种日志存储解决方案允许你为你的数据配置保留策略。例如,你可以配置你的日志存储,将某些服务的日志保留几年,同时将其他一些服务的保留时间限制为几天,具体取决于需求。此外,你还可以设置一些大小限制,以确保你的服务日志不超过预定义的大小阈值。

确保为所有类型的日志设置保留策略,避免需要手动清理不需要的日志记录的情况。

识别日志中的消息来源

假设你正在查看系统日志,并注意到以下错误事件:

{"level":"error", "time":"2022-09-04T20:10:10+1:00","message":"Request timed out"}

你能理解这个事件中描述的问题吗?日志记录包括请求超时错误消息,并且具有error级别,但它没有为我们提供任何有意义的上下文。没有额外的上下文,我们无法轻易理解导致日志事件的根本问题。

提供任何日志消息的上下文对于轻松处理日志至关重要。这在微服务环境中尤为重要,因为类似操作可以由多个服务或组件执行。始终应该能够理解每条消息,并有一些关于其来源组件的参考。在本节中,我们已经提到了在日志事件中包含一些额外信息的做法,例如组件名称。此类元数据通常包括以下内容:

  • 服务的名称

  • 发出事件的组件名称(例如,端点名称)

  • 文件名称(可选)

上一条日志消息的更详细版本如下所示:

{"level":"error", "time":"2022-09-04T20:10:10+1:00","message":"Request timed out", "service":"rating", "component": "handler", "endpoint": "putRating", "file": "handler.go"}

到目前为止,我们已经讨论了与日志记录相关的主要主题,可以继续到下一节,该节描述另一种类型的遥测数据——指标。

收集服务指标

在本节中,我们将描述另一种类型的服务遥测数据:指标。为了理解指标是什么以及它们与日志数据的区别,让我们从一个例子开始。假设你有一组为用户提供 API 的服务,你想要知道每个 API 端点每秒被调用多少次。你将如何做?

解决这个问题的可能方法之一是使用日志。我们可以为每个请求创建一个日志事件,然后我们就能按端点计数事件的数量,按秒、分钟或任何其他可能的方式聚合它们。这种解决方案会一直有效,直到每个端点的请求太多,无法再独立记录每个请求。让我们假设有一个每秒处理超过一百万个请求的服务。如果我们使用日志来衡量其性能,我们每秒就需要生成超过一百万个日志事件,这将产生大量数据。

解决这个问题的更优解决方案是使用某种基于值的聚合。而不是单独存储表示每个请求的数据,我们可以总结每秒、每分钟或每小时的请求计数,使数据更适合存储。

我们刚才描述的问题非常适合使用指标——对系统性能进行实时定量测量,如请求速率、延迟或累积计数。像日志一样,指标是基于时间的——每条记录都包含一个时间戳,代表过去某个独特的时间点。然而,与日志事件不同,指标主要用于存储单个值。在我们的例子中,端点请求速率指标的价值将是每秒请求的数量。

指标通常表示为时间序列——包含以下数据的对象集合,称为数据点

  • 时间戳

  • 值(最常见的是数值)

  • 可选的标签集合,定义为键值对,包含任何附加元数据

为了帮助您更好地理解使用指标的使用案例,让我们定义一些常见的指标类型:

  • 计数器:这些是表示随时间累积计数器值的时序。一个例子是服务请求计数器——每个数据点将包括一个时间戳和在该特定时刻的请求计数。

  • 仪表:这些是表示单个标量值随时间变化的时序。仪表的一个例子是包含服务器在不同时刻的空闲磁盘空间量的数据集:每个数据点包含一个单一的数值。

  • 直方图:这些是表示某些值相对于预定义的值范围分布的时序,称为。直方图指标的一个例子是包含不同年龄段用户数量的数据集。

让我们专注于每种指标类型,以帮助您了解它们之间的差异以及每种类型的常见用例。

计数器指标通常用于衡量两种类型的数据:

  • 随时间累积的值(例如,错误总数)

  • 随时间累积值的改变(例如,每小时新注册用户数)

第二个用例在技术上是对第一个用例的不同表示——如果您知道在每一个时间点有多少用户,您可以看到这个值是如何变化的。正因为如此,计数器通常用于测量各种事件(如 API 请求)随时间的变化率。

以下代码片段提供了一个Counter接口在tally指标库中的示例(我们将在本章后面回顾这个库):

type Counter interface {
    // Inc increments the counter by a delta.
    Inc(delta int64)
}

与计数器不同,仪表用于存储测量的唯一值,例如服务随时间可用的内存。以下是从tally库中的一个仪表示例:

type Gauge interface {
    // Update sets the gauges absolute value.
    Update(value float64)
}

一些其他的仪表用例包括以下内容:

  • 由服务实例运行的 goroutine 数量

  • 活跃连接数

  • 打开文件数

直方图与计数器和仪表略有不同。它们要求我们定义一组范围,这些范围将用于存储记录数据的子集。以下是一些使用直方图指标的示例:

  • 延迟跟踪:您可以通过创建代表各种持续时间范围的桶来跟踪执行特定服务操作所需的时间。例如,您的桶可以是 0-100 毫秒、100-200 毫秒、200-300 毫秒,依此类推。

  • 群体跟踪:您可以跟踪统计数据,例如每个值组的记录数。例如,您可以跟踪每个年龄段有多少用户订阅了您的服务。

现在我们已经介绍了一些指标的高级基础知识,让我们概述一下存储指标的方法。

存储指标

与日志类似,在微服务环境中存储指标会带来一些常见的挑战:

  • 收集和聚合:需要从所有服务实例收集指标,并进一步聚合和存储。

  • 聚合:收集的数据需要被聚合,因此各种类型的指标,如计数器,将包含来自所有服务实例的数据。例如,测量总请求数的计数器应该汇总所有服务实例的数据。

让我们回顾一些提供此类功能的流行工具。

Prometheus

Prometheus 是一个流行的开源监控解决方案,它提供了收集和查询服务指标以及设置自动警报以检测各种类型事件的机制。由于其简单的数据模型和非常灵活的数据摄取模型,Prometheus 在开发社区中获得了流行,我们将在本节中介绍这些内容。

注意

您知道 Prometheus 是用 Go 编写的吗?您可以在其 GitHub 页面上查看其源代码:github.com/prometheus/prometheus

Prometheus 支持三种类型的指标——计数器、仪表和直方图。它将每个指标存储为时间序列,类似于我们在“收集服务指标”部分开头描述的模型。每个指标包含值、额外的标签,称为标签,以及一个用于识别它的名称。

当数据进入 Prometheus 时间序列存储后,它可以通过其查询语言PromQL进行查询。PromQL 允许我们使用各种函数来检索时间序列数据,这些函数使我们能够轻松地过滤或排除某些名称和标签组合。以下是一个 PromQL 查询的示例:

http_requests_total{environment="production",method!="GET"}

在这个例子中,查询检索了名为http_requests_total的时间序列,包含环境键和产品值的标签,以及任何不等于GET的方法标签的值。

GitHub 上有一个官方的 Prometheus Go 客户端,它提供了将指标数据导入 Prometheus 以及执行 PromQL 查询的各种机制。您可以通过以下链接访问它:github.com/prometheus/client_golang

关于为使用 Prometheus 对 Go 应用程序进行度量的文档,可以在以下链接找到:prometheus.io/docs/guides/go-application

Graphite

Graphite 是另一个流行的监控工具,它提供了与 Prometheus 类似的指标收集、聚合和查询功能。尽管它是行业中最古老的服务监控工具之一,但它仍然是处理服务指标数据的一个极其强大的工具。

一个典型的 Graphite 安装包括三个主要组件:

  • Carbon:一个监听时间序列数据的服务

  • Whisper:一个时间序列数据库

  • Graphite-web:一个用于访问指标数据的 Web 界面和 API

Graphite 提供了一个快速的数据可视化工具Grafana的集成,我们将在本书的第十三章中介绍。您可以在 Graphite 的网站上了解更多关于 Graphite 的详细信息:graphiteapp.org

现在,让我们继续下一节,我们将描述发布服务指标的一些流行库。

流行的 Go 指标库

有一些流行的 Go 库可以帮助您处理指标,从而导入和查询您的时序指标数据。以下是一些简要概述:

我们将把选择服务指标库的决定留给你,因为每个库都提供了一些有用的功能,你可以在开发微服务时利用这些功能。我将在本章的示例中使用 tally 库,因为它提供了一个简单且最小化的 API,可以帮助说明常见的指标用例。在下一节中,我们将回顾一些在 Go 微服务代码中使用指标用例。

发射服务指标

在本节中,我们将提供一些发射和收集服务指标的示例,同时涵盖一些常见场景,例如测量 API 请求速率、操作延迟以及发射仪表值。我们将使用 tally 库作为示例,但你也可以使用所有其他流行的指标库来实现此逻辑。

首先,让我们提供一个示例,说明如何初始化 tally 库,以便你可以在服务代码中使用它。在以下示例中,我们使用 StatsD 客户端来初始化它(你可以使用任何其他工具来收集指标):

statter, err := statsd.NewBufferedClient("127.0.0.1:8125","stats", time.Second, 1440)
if err != nil {
    panic(err)
}
reporter := tallystatsd.NewReporter(statter, tallystatsd.Options{
    SampleRate: 1.0,
})
scope, closer := tally.NewRootScope(tally.ScopeOptions{
    Tags:     map[string]string{"service": "rating"},
    Reporter: reporter,
}, time.Second)

在这个示例中,我们创建了一个 tally 报告器,它将提交指标到数据收集器(在我们的用例中是 StatsD),并创建一个作用域——一个用于报告指标数据的接口,它会自动提交它们以供收集。

Tally 作用域是分层的:当我们初始化库时,我们创建一个根作用域,它包含以键值标签形式存在的初始元数据。所有从它创建的作用域都会包含父级元数据,从而防止在指标发射过程中出现缺少标签的情况。

一旦确定了范围,你就可以开始报告指标。以下示例说明了如何通过测量 API 请求次数来增加计数器指标,这些请求将由 tally 自动报告:

counter := scope.Counter("request_count")
counter.Inc(1)

Inc操作通过1增加计数器的值,并且更新的指标值将由 tally 在后台自动收集。这不会影响执行提供的操作的功能的性能。

如果你想要向指标添加一些额外的标签,你可以使用Tagged函数:

counter := scope.Tagged(map[string]string{"operation": "put"}).Counter("request_count")

以下示例说明了如何更新仪表值。假设我们有一个计算系统中活跃用户数量的函数,并且我们希望将此值报告到指标存储。我们可以通过以下方式使用仪表指标来实现:

gauge := scope.Gauge("active_user_count")
gauge.Update(userCount)

现在,让我们提供一个报告时间段的示例。这种用法的一个常见场景是报告各种操作的延迟。在以下示例中,我们报告执行我们的函数所需的时间:

func latencyTrackingExample(scope tally.Scope) {
    timer := scope.Timer("operation_latency")
    stopwatch := timer.Start()
    defer stopwatch.Stop()
    // Function logic.
}

在我们的例子中,我们初始化了operation_latency计时器,并调用其Start函数以开始测量操作延迟。Start函数返回一个Stopwatch接口的实例,该实例包括Stop函数。这报告了自计时器开始时间以来的时间。

在报告延迟度量时,tally 库使用默认的桶,除非您提供它们的精确值。例如,当将度量报告给 Prometheus 时,tally 正在使用以下桶配置:

func DefaultHistogramBuckets() []float64 {
    return []float64{
        ms,
        2 * ms,
        5 * ms,
        10 * ms,
        20 * ms,
        50 * ms,
        100 * ms,
        200 * ms,
        500 * ms,
        1000 * ms,
        2000 * ms,
        5000 * ms,
        10000 * ms,
    }
}

让我们提供一个使用一组预定义数值桶的直方图的例子:

histogram := scope.Histogram("user_age_distribution", tally.MustMakeLinearValueBuckets(0, 1, 130))
histogram.RecordValue(userAgeInYears)

在我们的例子中,我们使用从 0 到 130 的一系列预定义的桶来初始化直方图度量,并使用它记录与用户年龄匹配的值。然后,直方图的每个桶将包含值的总和。

现在我们已经提供了一些发出服务度量的基本示例,让我们看看处理度量数据的最佳实践。

度量最佳实践

在本节中,我们将描述一些与度量数据收集相关的最佳实践。这个列表不是详尽的,但仍然对在您的服务中设置度量收集逻辑很有用。

注意标签基数

当您发出度量并添加额外的标签到时间序列数据时,请记住,大多数时间序列数据库都不是为存储高基数数据而设计的,这些数据可能包含许多可能的标签值。例如,以下类型的数据通常不应包含在服务度量中:

  • 对象标识符,例如电影或评分 ID

  • 随机生成数据,例如 UUID(例如,请求 UUID)

这种情况的原因是索引,因为每个标签键值组合都必须被索引以使时间序列可搜索,当存在大量不同的标签值时,执行此操作变得昂贵。

您仍然可以在度量标签中使用一些低基数数据。以下是一些可能的例子:

  • 城市 ID

  • 服务名称

  • 端点名称

记住这个技巧以避免降低度量管道的吞吐量,并确保您的服务不发出用户标识和其他类型的高基数元数据。

标准化度量标签名称

想象一下,你有数百个服务,每个服务遵循不同的度量命名约定。例如,一个服务可能使用api_errors作为 API 错误计数器度量的名称,而另一个可能使用api_request_errors名称。如果您想比较此类服务的度量,您需要记住每个服务使用的是哪种命名约定。这种度量发现总是需要时间,这会降低您分析数据的能力。

一个更好的解决方案是在所有服务中标准化常见指标和标签的名称。这样,你可以轻松搜索和比较各种性能指标,例如服务客户端和服务器错误率、API 吞吐量和请求延迟。在第十二章中,我们将回顾一些你可以用来监控服务健康状况的常见性能指标。

设置适当的保留时间

由于高效的聚合,大多数时间序列数据库能够存储大量指标数据集。与需要独立存储每条记录的日志不同,指标可以聚合到更小的数据集中。例如,如果你存储计数器数据,你可以存储值的总和而不是单独存储每个值。即使有这些优化,时间序列数据仍然需要大量的磁盘空间来存储。大型公司可以存储数以 TB 计的指标数据,因此管理其大小并设置数据保留策略变得很重要,类似于日志和其他类型的遥测数据。

指标存储,如 Prometheus,默认保留时间为 15 天,允许你在设置中更改它。例如,要将 Prometheus 中的数据保留时间设置为 60 天,你可以使用以下标志:

--storage.tsdb.retention.time=60d

限制存储保留时间有助于控制时间序列数据集的大小,从而更容易管理存储容量并规划数据存储的基础设施支出。

既然我们已经讨论了指标数据,让我们继续到下一节,该节涵盖了一个强大的技术:跟踪。

跟踪

到目前为止,我们已经涵盖了两种常见的可观察性数据类型——日志和指标。拥有日志和指标数据通常足以进行服务调试和故障排除。然而,还有一种类型的数据对于深入了解微服务通信和数据流非常有用。

在本节中,我们将讨论分布式跟踪——一种涉及记录和分析不同服务和服务组件之间交互的技术。分布式跟踪背后的主要思想是自动记录所有此类交互并提供一种方便的方式来可视化它们。让我们看看以下示例,它说明了称为调用分析的分布式跟踪用例:

![Figure 11.2 – Tracing visualization example]

![img/Figure_11.2_B18865.jpg]

图 11.2 – 跟踪可视化示例

在这里,你可以看到对我们电影服务的单个GetMovieDetails请求的执行。这些数据提供了一些关于操作执行的见解:

  • 请求开始不久后,电影服务会发起两个并行调用;一个调用元数据服务,另一个调用评分服务。

  • 调用元数据服务的完成时间为 100 毫秒。

  • 调用评分服务的完成时间为 1,100 毫秒,几乎涵盖了整个请求处理时间。

我们刚刚提取的数据为我们提供了分析电影服务性能的大量有价值信息。首先,它帮助我们了解单个请求是如何被电影服务处理的,以及它执行了哪些子操作。我们还可以看到每个操作的持续时间,并找出哪个操作减慢了整个请求。通过使用这些数据,我们可以排查端点性能问题,找出对请求处理有重大影响的组件。

在我们的例子中,我们展示了仅三个服务之间的交互,但跟踪工具使我们能够分析同时使用数十甚至数百个服务的系统的行为。以下是一些其他用例,这些用例使跟踪成为生产调试的有力工具:

  • 错误分析:跟踪使我们能够可视化复杂调用路径上的错误,例如跨越许多不同服务的调用链。

  • 调用路径分析:有时,您可能需要调查您不太熟悉的系统中的问题。跟踪数据有助于您可视化各种操作的调用路径,帮助您理解服务的逻辑,而无需分析它们的代码。

  • 操作性能分解:跟踪使我们能够看到长时间运行的操作的各个步骤的持续时间。

让我们描述跟踪数据模型,以便您熟悉其常用术语。跟踪的核心元素是一个span——表示某些逻辑操作的记录,例如服务端点调用。每个 span 都具有以下属性:

  • 操作名称

  • 开始时间

  • 结束时间

  • 一个可选的标签集,提供与相关操作执行相关的某些附加元数据

  • 一个可选的关联日志集

可以将 span 分组到层次结构中,以表示不同操作之间的关系。例如,在图 11.2中,我们的GetMovieDetails span 包含两个子 span,分别代表GetMetadataGetAggregatedRating操作。

让我们探索如何在我们的 Go 应用程序中收集和使用跟踪数据。

跟踪工具

在微服务环境中,有各种分布式跟踪工具。其中最受欢迎的是 Jaeger,我们将在本节中对其进行回顾。

注意

开发者可以使用多种可观察性库和工具,包括跟踪收集库。为了标准化数据模型并使数据可交换,有一个包含跟踪数据模型规范的 OpenTelemetry 项目。您可以在其网站上了解该项目:opentelemetry.io

Jaeger 是一款开源的分布式跟踪工具,它提供了收集、聚合和可视化跟踪数据的机制。它提供了一个简单但高度灵活的设置,以及一个优秀的用户界面来访问跟踪数据。这也是它迅速成为行业内最受欢迎的可观察性工具之一的原因。

Jaeger 与 OpenTelemetry 规范兼容,因此它可以与任何实现跟踪规范的客户端一起使用,例如 Go SDK(https://opentelemetry.io/docs/instrumentation/go/)。目前,OpenTelemetry SDK 是从应用程序中发出跟踪数据的首选方式,因此在本节的示例中我们将使用它。

使用 Jaeger 的服务的一般数据流看起来像这样:

![图 11.3 – Jaeger 数据流]

![img/Figure_11.3_B18865.jpg]

图 11.3 – Jaeger 数据流

在前面的图中,使用与 Jaeger 兼容库的服务正在向 Jaeger 后端发出跟踪,后端将它们存储在跨度存储中。数据可以通过 Jaeger UI 进行查询和可视化。

注意

Jaeger 是用 Go 编写的可观察性工具的另一个例子。您可以在其官方 GitHub 页面上查看 Jaeger 的源代码:github.com/jaegertracing/jaeger

您可以在其网站上找到有关 Jaeger 项目的更多信息:www.jaegertracing.io

让我们看看一些为 Go 服务添加跟踪数据的功能示例。在我们的示例中,我们将使用 OpenTelemetry SDK 来使我们的代码与不同的跟踪软件兼容。

使用 OpenTelemetry SDK 收集跟踪数据

在本节中,我们将向您展示如何在服务代码中发出跟踪数据。

如我们在“跟踪”部分的开始所述,分布式跟踪的核心优势是能够自动捕获显示服务和其他网络组件之间如何相互通信的数据。与衡量单个操作性能的指标不同,跟踪有助于收集关于每个请求或操作在整个报告此数据的节点网络中如何处理的详细信息。为了报告跟踪,服务实例需要被仪器化,以便它们执行两个不同的角色:

  • 报告分布式操作的数据:对于每个可跟踪操作——跨越多个组件的操作,例如网络请求或数据库查询——被仪器化的服务应报告跨度。报告应包含操作名称、开始时间和结束时间。

  • 促进上下文传播:服务应明确在整个执行过程中传播上下文(如果您对此感到困惑,请阅读以下段落——这是分布式跟踪背后的主要技巧!)。

我们在本章中已经定义了一个 span,现在让我们转向服务仪表化的第二个要求。上下文传播是什么,以及我们如何在 Go 微服务代码中执行它?

上下文传播是一种技术,它涉及将一个称为 上下文 的对象显式地以参数的形式传递给其他函数。上下文可能包含任意元数据,因此将其传递给另一个函数有助于进一步传播。也就是说,流中的每个函数都可以向上下文中添加新的元数据或访问其中已存在的元数据。

让我们通过一个图表来阐述上下文传播:

Figure 11.4 – Context propagation example

img/Figure_11.4_B18865.jpg

图 11.4 – 上下文传播示例

在之前的流程图中,有一个来自 ctx.reqId 的 HTTP 请求,用于传递请求标识符。ctx.reqId 标头进一步传递到 服务 C,以便所有服务都能记录请求的标识符,并针对该标识符执行操作。

我们刚才提供的例子说明了三个服务之间的上下文传播。这是通过在请求中包含特定的 HTTP 标头来实现的,这些标头为请求处理提供了额外的元数据。在执行各种操作时,有多种传播数据的方式。我们将首先查看常规的 Go 函数调用。

我们在 第二章 中介绍了 Go 上下文传播,并提到了 context 包,它提供了一个名为 context.Context 的类型。在单个服务中的两个 Go 函数之间传递上下文就像调用另一个函数并添加一个额外的参数一样简单,如下所示:

func ProcessRequest(ctx context.Context, ...) {
    return ProcessAnotherRequest(ctx, ...)
}

在我们的例子中,我们将我们在函数中接收到的上下文传递给另一个函数,在整个执行链中传播它。我们可以通过使用 WithValue 函数将额外的元数据附加到上下文中,如下面的代码块所示:

func ProcessRequest(ctx context.Context, ...) {
    newCtx = context.WithValue(ctx, someKey, someValue)
    return ProcessAnotherRequest(newCtx, ...)
}

在这个更新的例子中,我们将修改后的上下文传递给另一个函数,该函数将包括一些额外的跟踪元数据。

现在,让我们将这个知识与跟踪的核心概念——span——联系起来。span 代表一个单独的操作,例如网络请求,它可以与其他操作相关联,例如在请求执行期间做出的其他网络调用。在我们的 getMovieDetails 示例中,原始请求将表示为 getMovieDetails 请求处理。为了建立子 span 和父 span 之间的关系,我们需要将父 span 的标识符传递给其子 span。我们可以通过在每次函数调用中传播它来实现这一点,就像我们之前所展示的那样。为了使这一点更容易理解,让我们总结一下收集 Go 函数跟踪数据的步骤:

  1. 为原始的跟踪函数生成一个新的父 span 对象。

  2. 当函数调用任何需要包含在跟踪中的其他函数(例如,网络调用或数据库请求)时,我们将父跨度数据作为 Go context参数的一部分传递给它们。

  3. 当一个函数接收一个包含一些父跨度元数据的上下文时,我们将父跨度 ID 包含在函数关联的跨度数据中。

  4. 链中的所有函数都应该遵循相同的步骤,并在每次执行的末尾报告捕获的跨度数据。

现在,让我们演示如何在 Go 应用程序中使用这项技术。在我们的示例中,我们将使用 OpenTelemetry Go SDK,并使用 Jaeger 作为跟踪数据的数据源:

  1. 让我们从配置更改开始。在每个服务目录内部,更新cmd/config.go文件到以下内容:

    package main
    
    type config struct {
    
        API    apiConfig    `yaml:"api"`
    
        Jaeger jaegerConfig `yaml:"jaeger"`
    
    }
    
    type apiConfig struct {
    
        Port int `yaml:"port"`
    
    }
    
    type jaegerConfig struct {
    
        URL string `yaml:"url"`
    
    }
    

我们刚刚添加的配置将帮助我们设置提交跟踪数据的 Jaeger URL。

  1. 下一步是更新每个服务的configs/base.yaml文件,以便它包含 Jaeger API URL 属性。我们可以通过在末尾添加以下代码来完成:

    jaeger:
    
      url: http://localhost:14268/api/traces
    
  2. 让我们创建一个可以在每个服务中使用的共享函数,用于初始化跟踪数据提供者。这将提交我们的跟踪到 Jaeger。在我们的根pkg目录中,创建一个名为tracing的目录,并添加一个tracing.go文件,内容如下:

    package tracing
    
    import (
    
        "go.opentelemetry.io/otel/exporters/jaeger"
    
        "go.opentelemetry.io/otel/sdk/resource"
    
        tracesdk "go.opentelemetry.io/otel/sdk/trace"
    
        semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
    
    )
    
    // NewJaegerProvider returns a new jaeger-based tracing provider.
    
    func NewJaegerProvider(url string, serviceName string) (*tracesdk.TracerProvider, error) {
    
        exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(url)))
    
        if err != nil {
    
            return nil, err
    
        }
    
        tp := tracesdk.NewTracerProvider(
    
            tracesdk.WithBatcher(exp),
    
            tracesdk.WithResource(resource.NewWithAttributes(
    
                semconv.SchemaURL,
    
                semconv.ServiceNameKey.String(serviceName),
    
            )),
    
        )
    
        return tp, nil
    
    }
    

在这里,我们初始化了 Jaeger 客户端,并使用它来创建 OpenTelemetry 跟踪数据提供者。该提供者将自动提交我们在服务执行过程中收集的跟踪数据。

  1. 下一步是更新每个服务的main.go文件。将go.opentelemetry.io/otel导入添加到每个服务的main.go文件的导入块中,并在第一个log.Printf调用之后添加以下代码块:

        tp, err := tracing.NewJaegerProvider(cfg.Jaeger.URL, serviceName)
    
        if err != nil {
    
            log.Fatal(err)
    
        }
    
        defer func() {
    
            if err := tp.Shutdown(ctx); err != nil {
    
                log.Fatal(err)
    
            }
    
        }()
    
        otel.SetTracerProvider(tp)
    
        otel.SetTextMapPropagator(propagation.TraceContext{})
    

代码的最后两行将全局 OpenTelemetry 跟踪提供者设置为基于 Jaeger 的版本。这些行还启用了上下文传播,这将允许我们在服务之间传输跟踪数据。

  1. 要启用客户端上下文传播,更新internal/grpcutil/grpcutil.go文件到以下内容:

    package grpcutil
    
    import (
    
        "context"
    
        "math/rand"
    
        "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
    
        "google.golang.org/grpc"
    
        "google.golang.org/grpc/credentials/insecure"
    
        "movieexample.com/pkg/discovery"
    
    )
    
    // ServiceConnection attempts to select a random service // instance and returns a gRPC connection to it.
    
    func ServiceConnection(ctx context.Context, serviceName string, registry discovery.Registry) (*grpc.ClientConn, error) {
    
        addrs, err := registry.ServiceAddresses(ctx, serviceName)
    
        if err != nil {
    
            return nil, err
    
        }
    
        return grpc.Dial(
    
            addrs[rand.Intn(len(addrs))],
    
            grpc.WithTransportCredentials(insecure.NewCredentials()),
    
            grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
    
        )
    
    }
    

在这里,我们添加了一个基于 OpenTelemetry 的拦截器,它将跟踪数据注入到每个请求中。

  1. 在每个服务的main.go文件中,更改包含grpc.NewServer()调用的行,如下所示,以启用服务器端上下文传播:

    srv := grpc.NewServer(grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()))
    

我们刚刚做出的更改与上一步类似,只是针对服务器端处理。

  1. 最后一步是确保所有新库都包含在我们的项目中,通过运行以下命令来完成:

    go mod tidy
    

这样,我们的服务已经通过跟踪代码进行了配置,并在每个 API 请求上发出跨度数据。

让我们通过运行我们的服务和向它们发出一些请求来测试我们新添加的代码:

  1. 要能够收集跟踪数据,您需要在本地运行 Jaeger。您可以通过运行以下命令来完成:

    docker run -d --name jaeger \
    
      -e COLLECTOR_OTLP_ENABLED=true \
    
      -p 6831:6831/udp \
    
      -p 6832:6832/udp \
    
      -p 5778:5778 \
    
      -p 16686:16686 \
    
      -p 4317:4317 \
    
      -p 4318:4318 \
    
      -p 14250:14250 \
    
      -p 14268:14268 \
    
      -p 14269:14269 \
    
      -p 9411:9411 \
    
      jaegertracing/all-in-one:1.37
    
  2. 现在,我们可以通过在每个 cmd 目录中执行 go run *.go 命令来本地启动所有服务。

  3. 让我们对我们的电影服务进行一些请求。在 第五章 中,我们提到了 grpcurl 工具。让我们再次使用它来手动进行 gRPC 查询:

    grpcurl -plaintext -d '{"movie_id":"1"}' localhost:8083 MovieService/GetMovieDetails
    

如果一切正常,我们应该在 Jaeger 中看到我们的跟踪。让我们通过转到 http://localhost:16686/ 来在 Jaeger UI 中检查它。你应该会看到一个类似的页面,如下所示:

![Figure 11.5 – Jaeger UI图片

Figure 11.5 – Jaeger UI

  1. 服务 字段中选择 movie 服务,然后点击 查找跟踪。你应该会看到一些跟踪结果,如下所示:

![Figure 11.6 – 电影服务的 Jaeger 跟踪图片

Figure 11.6 – 电影服务的 Jaeger 跟踪

  1. 如果你点击跟踪,你会看到其可视化的视图,如下所示:

![Figure 11.7 – GetMovieDetails 端点调用的 Jaeger 跟踪视图图片

Figure 11.7 – GetMovieDetails 端点调用的 Jaeger 跟踪视图

在左侧面板中,你可以看到请求作为一个跨度的树,其中根跨度代表 MovieService/GetMovieDetails 操作,它包括对 MetadataService/GetMetadataRatingService/GetAggregatedRating 端点的调用。恭喜你,你已经使用 OpenTracing SDK 为你的微服务设置了分布式跟踪!我们所有的 gRPC 调用现在都自动跟踪,无需添加任何额外的服务逻辑。这为我们提供了一个方便的机制来收集有关服务通信的有价值数据。

作为额外的一步,让我们说明如何为我们的数据库操作添加跟踪。如前一个屏幕截图中的跟踪视图所示,我们目前在我们的图表上没有任何数据库相关的跨度。这是因为我们的数据库逻辑还没有被度量。让我们演示如何手动进行此操作:

  1. 打开 metadata/internal/repository/memory/memory.go 文件,并将 go.opentelemetry.io/otel 添加到其导入中。

  2. 在同一文件中添加以下常量:

    const tracerID = "metadata-repository-memory"
    
  3. Get 函数的开始处添加以下代码:

    _, span := otel.Tracer(tracerID).Start(ctx, "Repository/Get")
    
    defer span.End()
    
  4. Put 函数的开始处添加一个类似的代码块:

    _, span := otel.Tracer(tracerID).Start(ctx, "Repository/Put")
    
    defer span.End()
    

我们刚刚手动度量了我们的内存元数据存储库,以便在其主要操作 GetPut 上发出跟踪数据。现在,对这些函数的每次调用都应该在捕获的跟踪中创建一个跨度,使我们能够看到每个操作何时以及执行了多长时间。

让我们测试我们新添加的代码。重新启动元数据服务,并使用之前提供的电影服务进行新的 grpcurl 请求。如果你在 Jaeger 中检查新的跟踪,你应该会看到新的一个,并增加一个跨度:

![Figure 11.8 – Jaeger 跟踪视图,包含额外的存储库跨度图片

Figure 11.8 – 包含额外存储库跨度的 Jaeger 跟踪视图

注意追踪视图中最后一个跨度,代表Repository/Get操作。这是我们更改的结果。现在,我们可以在我们的追踪中看到数据库操作。你可以继续更新评分服务仓库,包括类似的逻辑——遵循前面的说明,你应该能够以我们刚刚为元数据服务所做的方式使其工作。

你应该在何时手动将跨度数据添加到你的函数中?我建议对于涉及网络调用、I/O 操作(如读写文件)、数据库读写以及其他可能花费大量时间的调用,都进行这样的操作。我个人认为,任何执行时间超过 50 毫秒的函数都是进行追踪的好候选。

到目前为止,我们已经提供了 Go 追踪技术的概述,这也标志着我们探索遥测数据的旅程的结束。在接下来的几章中,我们将继续探索其他领域,例如仪表盘、系统级性能分析和一些高级可观察性技术。

摘要

在本章中,我们通过描述分析 Go 微服务实时性能的各种技术,并涵盖服务遥测数据的主要类型,如日志、指标和追踪,来介绍可观察性。你学习了执行日志记录、指标收集和分布式追踪的一些最佳实践。我们展示了如何对你的 Go 服务进行配置以收集遥测数据,以及如何设置分布式追踪的工具。我们还提供了追踪本书早期实现的服务中跨越三个服务的请求的示例。

本章中获得的知识应该有助于你调试微服务的各种性能问题,并使各种类型的遥测数据监控成为可能。在第十二章中,我们将展示如何使用收集到的遥测数据来设置服务警报,以便尽可能快地检测与服务相关的事件。

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:

第十二章:设置服务警报

在上一章中,我们描述了各种类型的服务遥测数据,例如日志、指标和跟踪,并说明了如何收集它们以解决服务性能问题。

在本章中,我们将说明如何通过为我们的微服务设置警报来使用遥测数据自动检测事件。您将学习收集哪些类型的服务指标,如何定义各种事件的条件,以及如何使用流行的监控和警报工具 Prometheus 为您的微服务建立完整的警报管道。

我们将涵盖以下主题:

  • 警报基础

  • Prometheus 简介

  • 为我们的微服务设置 Prometheus 警报

  • 警报最佳实践

现在,我们将继续概述警报基础。

技术要求

为了完成本章,您需要 Go 1.11 或更高版本。您还需要 Docker 工具,您可以在 https://www.docker.com/下载。

您可以在 GitHub 上找到本章的代码示例:github.com/PacktPublishing/microservices-with-go/tree/main/Chapter12

警报基础

没有微服务可以不发生事件;即使您有一个稳定、高度测试和良好维护的服务,它仍然可能遇到各种类型的问题,例如以下:

  • 资源限制:运行服务的宿主可能会遇到高 CPU 利用率或内存或磁盘空间不足。

  • 网络拥塞:服务可能会在其依赖项中突然增加负载或降低性能。这可能会限制其处理传入请求或以预期性能水平运行的能力。

  • 依赖失败:您的服务所依赖的其他服务或库可能会遇到各种问题,影响您的服务执行。

这些问题可能是自解决的。例如,较慢的网络吞吐量可能是由于临时维护或网络设备重启而引起的暂时性问题。许多其他类型的问题,我们称之为事件,需要工程师采取一些行动来减轻。

为了减轻事件,首先,我们需要检测它。一旦问题已知,我们可以通知工程师或执行自动化操作,例如自动部署回滚或应用程序重启。在本章中,我们将描述结合事件检测和通知的警报技术。这项技术可用于自动化对各种类型微服务问题的响应。

警报背后的关键原则非常简单,可以总结如下:

  • 要设置警报,开发者定义警报条件

  • 警报条件基于遥测数据(最常见的是指标)并以查询的形式定义。

  • 每个定义的警报条件都会定期评估,例如每分钟一次。

  • 如果满足警报条件,将执行相关的操作(例如,向工程师发送电子邮件或短信)。

为了说明警报是如何工作的,想象一下,你的某个服务正在发出一个名为 active_user_count 的指标,该指标报告了特定时刻的活跃用户数量。让我们假设,如果我们想得到通知,活跃用户数量突然降至零,我们会收到通知。这种情况很可能会表明我们的服务发生了某些事件,除非我们用户太少(为了简单起见,我们将假设我们的系统应该始终有一些活跃用户)。

使用伪代码,我们可以以下述方式定义我们的用例的警报条件:

active_user_count == 0

一旦满足警报条件,警报软件将根据其配置检查应触发的操作。假设我们已经配置了我们的警报以触发电子邮件通知,它将发送电子邮件并包含任何必要的元数据。元数据将包括有关刚刚发生的事件的信息,以及如果提供,减轻该事件的步骤。

我们将在本章后面提供一些警报配置的例子。现在,我们将专注于一些实际用例,为你提供一些为你的服务设置警报的想法。

警报用例

有许多用例,你需要设置自动警报。在本节中,我们将提供一些常见的例子,这些例子可以作为你的参考点。

在我们之前提到的 Google SRE 书籍中,在 第十章 中,有一个关于监控的四个黄金信号的定义,这些信号可以用来监控各种类型的应用,从微服务到数据处理管道。这些信号为服务警报提供了很好的基础,因此让我们来回顾一下它们,并描述如何使用每个信号来提高你的服务可靠性:

  • 延迟:延迟是处理时间的衡量,例如处理 API 请求、Kafka 消息或任何其他操作的持续时间。它是系统性能的主要指标——当它变得过高时,系统开始影响其调用者,造成网络拥塞。你应该通常跟踪你的主要操作(如提供关键功能的 API 端点)的延迟。

  • 流量:流量衡量的是你的系统负载,例如你的微服务在当前时刻接收到的请求数量。基于流量的指标示例是 API 请求速率,以每秒请求数量来衡量。测量流量对于确保你有足够的容量来处理系统请求至关重要。

  • 错误:错误通常被测量为错误率,即失败操作与总操作之间的比率。测量错误率对于确保你的服务保持运行至关重要。

  • 饱和度:饱和度通常衡量您资源的利用率,如 RAM 或磁盘使用率、CPU 或 I/O 负载。您应该跟踪饱和度,以确保您的服务不会因资源不足而意外失败。

这四个黄金信号可以帮助您为您的服务和关键操作(如您的 API 主端点)建立监控和警报。让我们提供一些实际例子来帮助您理解一些常见的警报用例。

首先,让我们从可以跨所有端点或按端点测量的 API 警报的常见信号开始:

  • API 客户端错误率:由于客户端错误而失败的请求与所有请求的比率

  • API 服务器错误率:由于服务器错误而失败的请求与所有请求的比率

  • API 延迟:处理请求所需的时间

现在,让我们提供一些测量系统饱和度的信号例子:

  • CPU 利用率:您的 CPU 在 0%(未使用/空闲)到 100%(完全使用,无额外容量)的范围内被使用的程度。

  • 内存利用率:已使用内存与总内存的比率。

  • 磁盘利用率:已使用磁盘空间的百分比。

  • 打开文件描述符:文件描述符通常用于处理网络请求、文件读写和其他 I/O 操作。每个进程通常都有打开文件描述符的数量限制,所以如果您的服务达到一个关键限制(基于您的操作系统设置),您的服务可能无法处理请求。

让我们也提供一些其他要监控的信号的例子:

  • 服务崩溃:一般建议不要容忍任何服务崩溃,因为它们通常表明应用程序错误或问题,如内存不足错误。

  • 失败的部署:您可以使用它来自动检测失败的部署并发出表示失败的指标,从而创建自动警报。

现在我们已经覆盖了一些常见的警报用例,让我们继续介绍 Prometheus 的概述,我们将使用它来设置我们的微服务警报。

Prometheus 简介

第十一章中,我们提到了一个流行的开源警报和监控工具 Prometheus,它可以收集服务指标并根据指标数据设置自动警报。在本节中,我们将演示如何使用 Prometheus 为我们的微服务设置警报。

让我们总结一下我们从第十一章学到的关于 Prometheus 的知识:

  • Prometheus 允许我们以时间序列的形式收集和存储服务指标。

  • 有三种类型的指标——计数器、直方图和仪表。

  • 要查询指标数据,Prometheus 提供了一种名为 PromQL 的查询语言。

  • 可以使用名为 Alertmanager 的工具配置服务警报。

指标可以通过两种不同的方式从服务实例导入 Prometheus:

  • 抓取:Prometheus 从服务实例中读取指标。

  • 推送:服务实例通过一个专用服务,即 Prometheus Pushgateway,将指标发送到 Prometheus。

抓取是设置 Prometheus 指标数据摄取的推荐方式。每个服务实例都需要暴露一个端点以提供指标,Prometheus 负责拉取数据并将其存储以供进一步查询,如下所示:

![图 12.1 – Prometheus 抓取模型图 12.1 – Prometheus 抓取模型

图 12.1 – Prometheus 抓取模型

让我们提供一个服务实例对 Prometheus 抓取请求的响应示例。假设你添加了一个名为/metrics的单独 HTTP API 端点,并以下列格式返回最新的服务实例指标:

active_user_count 755
api_requests_total_count 18900
api_requests_getuser_count 500

在此示例中,服务实例以键值对的形式报告了三个指标,其中键定义了时间序列名称,值定义了当前时刻时间序列的值。一旦 Prometheus 调用/metrics端点,服务实例应提供一个只包含之前响应中未包含的时间序列的新数据集。

一旦 Prometheus 收集了指标,它们就可以使用一种称为 PromQL 的 Prometheus 特定语言进行查询。基于 PromQL 的查询可以用于通过 Prometheus UI 分析时间序列数据,或者使用 Alertmanager 设置自动警报。例如,以下查询返回所有active_user_count时间序列的值及其标签:

active_user_count

你可以使用额外的查询过滤器,例如active_user_count指标,你只能请求具有特定标签值的时间序列:

active_user_count{service="rating-ui"}

警报条件通常定义为返回布尔结果的表达式。例如,要定义活动用户计数降至零时的警报条件,你会使用以下带有==运算符的 PromQL 查询:

active_user_count == 0

PromQL 语言提供了一些其他类型的时间序列匹配器,例如quantile,可用于执行各种聚合。以下查询示例可以用来检查中值api_request_latency值是否超过1

api_request_latency{quantile="0.5"} > 1

你可以通过阅读其网站上的官方文档来熟悉 PromQL 语言的其它方面:prometheus.io/docs/prometheus/latest/querying/basics/。现在,让我们探讨如何使用 Prometheus 警报工具 Alertmanager 设置警报。

Alertmanager 是 Prometheus 的一个独立组件,允许我们配置警报和通知以检测各种类型的事件。Alertmanager 通过读取提供的配置并定期查询 Prometheus 时间序列数据来运行。以下是一个 Alertmanager 配置的示例:

groups:
- name: Availability alerts
  rules:
  - alert: Rating service down
    expr: service_availability{service="rating"} == 0
    for: 3m
    labels:
      severity: page
    annotations:
      title: Rating service availability down
      description: No available instance of the rating service.

在我们的配置示例中,我们设置了一个警报,当具有 service="rating" 标签的 service_availability 指标值等于 0 并且持续 3 分钟或更长时间时,将触发一个 PagerDuty 事件来通知值班工程师有关该问题。

Alertmanager 的其他一些功能包括通知分组、通知重试和警报抑制。为了说明 Prometheus 和 Alertmanager 在实际中的应用,让我们描述如何为上一章中提到的示例微服务设置它们。

为我们的微服务设置 Prometheus 警报。

在本节中,我们将说明如何使用 Prometheus 和其警报扩展 Alertmanager 为我们在上一章中创建的服务设置服务警报。你将学习如何暴露服务指标以供收集,如何设置 Prometheus 和 Alertmanager 以聚合和存储多个服务的指标,以及如何定义和处理服务警报。

我们的高级方法如下:

  1. 为我们的服务设置 Prometheus 指标报告。

  2. 安装 Prometheus 并配置它从我们在上一章中创建的三个示例服务中抓取数据。

  3. 使用 Alertmanager 配置服务可用性警报。

  4. 通过触发警报条件并运行 Alertmanager 来测试我们的警报。

让我们首先说明如何将我们的服务与 Prometheus 集成。为此,我们需要通过暴露一个将提供最新指标给 Prometheus 的端点来向我们的服务添加一个指标收集。

首先,我们需要将 Prometheus 配置添加到我们的服务中。在每个服务目录中,更新 cmd/config.go 文件为以下内容:

package main
type config struct {
    API        apiConfig        `yaml:"api"`
    Jaeger     jaegerConfig     `yaml:"jaeger"`
    Prometheus prometheusConfig `yaml:"prometheus"`
}
type apiConfig struct {
    Port int `yaml:"port"`
}
type jaegerConfig struct {
    URL string `yaml:"url"`
}
type prometheusConfig struct {
    MetricsPort int `yaml:"metricsPort"`
}

我们的新配置允许我们指定指标收集端点的服务端口。在每一个 configs/base.yaml 文件中,添加以下块:

prometheus:
  metricsPort: 8091

我们已准备好更新我们的服务,以便它们可以开始报告指标。通过在每个服务的 main.go 文件中添加以下导入来更新每个服务的 main.go 文件:

    "github.com/uber-go/tally"
    "github.com/uber-go/tally/prometheus"

main 函数的任何部分,添加以下代码:

    reporter := prometheus.NewReporter(prometheus.Options{})
    _, closer := tally.NewRootScope(tally.ScopeOptions{
        Tags:           map[string]string{"service": "metadata"},
        CachedReporter: reporter,
    }, 10*time.Second)
    defer closer.Close()
    http.Handle("/metrics", reporter.HTTPHandler())
    go func() {
        if err := http.ListenAndServe(fmt.Sprintf(":%d", cfg.Prometheus.MetricsPort), nil); err != nil {
            logger.Fatal("Failed to start the metrics handler", zap.Error(err))
        }
    }()
    counter := scope.Tagged(map[string]string{
        "service": "metadata",
    }).Counter("service_started")
    counter.Inc(1)

在我们刚刚添加的代码中,我们初始化了 tally 库来收集和报告我们提到的指标数据,这些数据在本书的第十一章中有所提及。我们使用了一个内置的 Prometheus 报告器,该报告器使用 Prometheus 时间序列格式实现指标数据收集,并暴露了一个 HTTP 端点以允许 Prometheus 收集我们的数据。

让我们测试新添加的端点。重启元数据服务,并在浏览器中打开 http://localhost:8091/metrics 来尝试访问新端点。你应该会得到类似的响应:

# HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 0
go_gc_duration_seconds{quantile="0.25"} 0
...

指标处理程序的响应包括 Go 运行时数据,例如当前时刻的 goroutine 数量、Go 库版本以及许多其他有用的指标。

现在,我们已准备好设置 Prometheus 警报。在我们的项目 src 目录内,创建一个名为 configs 的目录,并添加一个包含以下内容的 prometheus.yaml 文件:

global:
  scrape_interval: 15s
  scrape_timeout: 10s
  evaluation_interval: 15s
alerting:
  alertmanagers:
  - follow_redirects: true
    enable_http2: true
    scheme: http
    timeout: 10s
    api_version: v2
    static_configs:
    - targets:
      - host.docker.internal:9093

此外,将以下配置添加到文件中:

rule_files:
- alerts.rules
scrape_configs:
- job_name: prometheus
  honor_timestamps: true
  scrape_interval: 15s
  scrape_timeout: 10s
  metrics_path: /metrics
  scheme: http
  follow_redirects: true
  enable_http2: true
  static_configs:
  - targets:
    - localhost:9090
  - targets:
    - host.docker.internal:8091
    labels:
      service: metadata
  - targets:
    - host.docker.internal:8092
    labels:
      service: rating
  - targets:
    - host.docker.internal:8093
    labels:
      service: movie

让我们描述我们刚刚添加的配置。我们将抓取间隔设置为15秒,并提供了一组抓取指标数据的目标,包括我们每个服务的地址。你可能注意到我们在每个目标定义中使用的是host.docker.internal网络地址——我们将使用 Docker 运行 Prometheus,而host.docker.internal地址将允许它访问我们新添加的运行在 Docker 之外的端点。

注意,我们在static_configs块内提供了一个静态的服务地址列表。我们故意这样做是为了说明最简单的抓取方法,即当 Prometheus 知道每个服务实例的地址时。在一个动态环境中,服务实例可以被添加或删除,你需要使用 Prometheus 与一个服务注册表,如 Consul 一起使用。Prometheus 为 Consul 注册的服务提供了内置的抓取指标支持:你可以在 Consul 抓取配置中定义,而不是使用static_configs

    consul_sd_configs:
    - server:  host.docker.internal:8500
      services:
        - <SERVICE_NAME>

接下来,我们将演示如何抓取静态的服务实例列表;在阅读本章后,你可以尝试设置基于 Consul 的 Prometheus 抓取作为额外的练习。让我们为我们的服务添加警报规则。在新建的configs目录内,创建一个名为alerts.rules的文件,并将以下内容添加到其中:

groups:
- name: Service availability
  rules:
  - alert: Metadata service down
    expr: up{service="metadata"} == 0
    labels:
      severity: warning
    annotations:
      title: Metadata service is down
      description: Failed to scrape {{ $labels.service }}. Service possibly down.
  - alert: Rating service down
    expr: up{service="rating"} == 0
    labels:
      severity: warning
    annotations:
      title: Metadata service is down
      description: Failed to scrape {{ $labels.service }} service on {{ $labels.instance }}. Service possibly down.
  - alert: Movie service down
    expr: up{service="movie"} == 0
    labels:
      severity: warning
    annotations:
      title: Metadata service is down
      description: Failed to scrape {{ $labels.service }} service on {{ $labels.instance }}. Service possibly down.

我们刚刚添加的文件包含了我们每个服务的警报定义。每个警报定义都包括 Prometheus 将检查的表达式,以评估是否应该触发相关的警报。

现在,我们已经准备好安装并运行 Prometheus 来测试我们的警报功能。在我们的项目src目录内,运行以下命令以使用新创建的配置运行 Prometheus:

docker run \
    -p 9090:9090 \
    -v configs:/etc/prometheus \
    prom/prometheus

如果一切顺利,你应该可以通过打开http://localhost:9090/来访问 Prometheus UI。在初始屏幕上,你会看到一个搜索输入框,你可以使用它来访问我们服务发出的 Prometheus 指标。在搜索输入框中输入up并点击执行以访问指标:

图 12.2 – Prometheus 指标搜索

图 12.2 – Prometheus 指标搜索

你可以前往alerts.rules文件:

图 12.3 – Prometheus 警报视图

图 12.3 – Prometheus 警报视图

如果所有三个服务都在运行,所有三个相关的警报都应该被标记为不活跃。我们很快就会回到警报页面;现在,让我们继续并设置 Alertmanager,以便我们可以为我们的服务触发一些警报。

在我们的configs目录内,包括 Prometheus 配置,添加一个名为alertmanager.yml的文件,内容如下:

global:
  resolve_timeout: 5m
route:
  repeat_interval: 1m
  receiver: 'email'
receivers:
- name: 'email'
  email_configs:
  - to: 'your_email@gmail.com'
    from: 'your_email@gmail.com'
    smarthost: smtp.gmail.com:587
    auth_username: 'your_email@gmail.com'
    auth_identity: 'your_email@gmail.com'
    auth_password: 'your_password'

更新我们刚刚创建的文件中的电子邮件配置,以便 Alertmanager 可以为我们的警报发送一些电子邮件。

现在,运行以下命令以启动 Alertmanager:

docker run -p 9093:9093 -v <PATH_TO_CONFIGS_DIR>:/etc/alertmanager prom/alertmanager --config.file=/etc/alertmanager/alertmanager.yml

不要忘记将<PATH_TO_CONFIGS_DIR>占位符替换为包含新添加的alertmanager.yml文件的configs目录的完整本地路径。

现在,让我们通过手动停止评分和电影服务来模拟警报条件。一旦您这样做,打开 Prometheus UI 中的警报页面;您应该看到两个警报都是触发的:

图 12.4 – 触发 Prometheus 警报

图 12.4 – 触发 Prometheus 警报

您可以通过访问http://localhost:9093来访问 Alertmanager UI。

如果在 Prometheus 中触发警报,您也应该在 Alertmanager UI 中看到它们:

图 12.5 – Alertmanager UI

图 12.5 – Alertmanager UI

如果您正确配置了 Alertmanager,您应该会收到一封电子邮件,地址是您在配置中提供的。如果您没有收到电子邮件,请检查 Alertmanager 的 Docker 日志 – 使用双因素电子邮件认证的用户可能会收到启用通知的额外说明。

如果一切顺利 – 恭喜你,你已经设置了服务警报!我们故意没有涵盖 Alertmanager 的许多功能 – 它包括许多超出本章范围的配置设置。如果您想了解更多关于它的信息,请查看官方文档prometheus.io/docs

现在,让我们进入下一节,我们将提供一些设置服务警报的最佳实践,这应该有助于提高您的服务可靠性。

警报最佳实践

通过阅读本节,您将获得的知识应该有助于建立您服务的新的警报流程。如果您正在使用一些既定的警报流程,它还将帮助您改进现有的警报。

在最有价值的最佳实践中,我会强调以下几项:

  • (规则配置中的for值)。

  • 包含运行手册引用:对于每个警报,确保您有一个运行手册,为接收警报的值班工程师提供明确的指示。为每个警报拥有准确且最新的运行手册有助于减少事件缓解时间,并在所有工程师之间共享相关知识。

  • 确保定期审查警报配置:确保警报配置准确的最佳解决方案是使其易于访问和审查。其中一种最简单的方法是将警报配置作为您代码库的一部分,这样所有警报配置都很容易审查。定期检查您的警报,以确保所有重要场景都得到覆盖,以及确保没有过时的警报。

此列表仅包含一些最佳实践,旨在提高你的服务警报能力。如果你对这个主题感兴趣,我强烈建议你阅读《Google SRE》书的相关章节,包括来自 sre.google/sre-book/monitoring-distributed-systems/监控分布式系统 章节。

这总结了服务警报的简要概述。现在,让我们总结本章内容。

摘要

在本章中,我们讨论了服务可靠性工作中最重要的方面之一——警报。你学习了如何使用 Prometheus 工具和 tally 库设置服务指标收集,使用 Alertmanager 工具设置服务警报,以及将这些组件连接起来创建一个端到端的服务警报管道。

本章内容总结了我们从可靠性和服务遥测主题中学习到的内容,来自 第十章第十一章。通过收集遥测数据并使用警报工具建立通知机制,我们可以快速检测各种服务问题,并在需要缓解这些问题时及时收到通知。

在下一章中,我们将继续介绍 Go 开发的某些高级方面,包括系统分析和仪表板。

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:

)

)

)

)

第十三章:高级主题

如果你正在阅读这一章——恭喜你,你已经到达了这本书的最后一部分!我们已经讨论了许多与微服务开发相关的话题,但还有一些重要的话题需要覆盖。本章涵盖的主题范围很广,从可观察性和调试到服务所有权和安全。你可能会在各个时间点发现这些主题很有用:其中一些在你有正在运行的服务处理生产流量时会有所帮助,而其他一些则在你服务仍在积极开发时会有所帮助。

在本章中,我们将涵盖以下主题:

  • 分析 Go 服务

  • 创建微服务仪表板

  • 框架

  • 存储微服务所有权数据

  • 使用 JWT 保护微服务通信

让我们进入本章的第一部分,它涵盖了服务分析。

技术要求

要完成本章,你需要 Go 1.11 或更高版本。此外,你还需要以下工具:

你可以在 GitHub 上找到本章的代码示例:https://github.com/PacktPublishing/microservices-with-go/tree/main/Chapter13。

分析 Go 服务

在本节中,我们将回顾一种称为分析的技术,它涉及收集运行进程的实时性能数据,例如 Go 服务。分析是一种强大的技术,可以帮助你分析各种类型的服务性能数据:

  • CPU 使用率:哪些操作使用了最多的 CPU 功率,它们之间的 CPU 使用率分布是怎样的?

  • 堆分配:哪些操作使用了堆(Go 应用程序中分配的动态内存)以及使用了多少内存?

  • 调用图:服务函数的执行顺序是怎样的?

分析可以在不同的情况下帮助你:

  • 识别 CPU 密集型逻辑:在某个时刻,你可能会注意到你的服务消耗了大部分的 CPU 功率。为了理解这个问题,你可以收集 CPU 分析——一个显示各种服务组件(如单个函数)CPU 使用的图表。消耗过多 CPU 功率的组件可能表明各种问题,如实现效率低下或代码错误。

  • 捕获服务的内存占用:类似于高 CPU 消耗,你的服务可能使用了过多的内存(例如,为堆分配过多的数据),这可能导致服务偶尔因内存不足而崩溃。进行内存分析可以帮助你分析服务各个部分的内存使用情况,并找到意外占用内存过多的组件。

让我们通过使用 Go SDK 的一部分 pprof 工具来展示如何分析 Go 服务,为了可视化工具的结果,你需要安装 Graphviz 库:graphviz.org/

我们将以我们在第二章中实现的元数据服务为例。打开 metadata/cmd/main.go 文件,并将 flag 包添加到 imports 块中。然后,在主函数的开始处,紧接在日志初始化之后,添加以下代码:

simulateCPULoad := flag.Bool("simulatecpuload",
    false,"simulate CPU load for profiling")
    flag.Parse()
    if *simulateCPULoad {
        go heavyOperation()
    }
go func() {
    if err := http.ListenAndServe("localhost:6060", nil);
    err != nil {
        logger.Fatal("Failed to start profiler handler",
            zap.Error(err))
    }
}()

在我们刚刚添加的代码中,我们引入了一个额外的标志 simulatecpuload,它将允许我们模拟一个 CPU 密集型操作以进行分析。我们还启动了一个 HTTP 处理器,我们将使用它从命令行访问分析器数据。

现在,让我们向同一文件添加另一个函数,该函数将运行一个连续循环并执行一些 CPU 密集型操作。我们将生成随机的 1,024 字节数组并计算它们的 md5 哈希(你可以在其 Go 包的注释中阅读有关 md5 操作的内容,网址为 https://pkg.go.dev/crypto/md5)。我们选择这种逻辑是完全随机的:我们很容易选择任何其他会消耗 CPU 负载可见部分的操作。

将以下代码添加到我们刚刚更新的 main.go 文件中:

func heavyOperation() {
    for {
        token := make([]byte, 1024)
        rand.Read(token)
        md5.New().Write(token)
    }
}

现在,我们已经准备好测试我们的分析逻辑。使用 --simulatecpuload 参数运行服务:

go run *.go --simulatecpuload

现在,执行以下命令:

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=5

命令应该需要 5 秒才能完成。如果执行成功,pprof 工具将运行,如下所示:

Type: cpu
Time: Sep 13, 2022 at 5:37pm (+05)
Duration: 5.14s, Total samples = 4.42s (85.92%)
Entering interactive mode (type "help" for commands,
    "o" for options)
(pprof)

在工具的命令提示符中键入 web 并按 Enter。如果一切正常,你将被重定向到一个包含 CPU 配置文件图的浏览器窗口:

图 13.1 – Go CPU 配置文件示例

图 13.1 – Go CPU 配置文件示例

让我们遍历图中的数据,了解如何解释它。图中的每个节点都包含以下数据:

  • 包名称

  • 函数名称

  • 运行时间和执行总时间

例如,heavyOperation 函数仅花费了 0.01 秒,但其中执行的所有操作(包括其内部的所有函数调用)共花费了 4.39 秒,占去大部分的运行时间。

如果你遍历这个图,你会看到子操作的时间分布。在我们的例子中,heavyOperation 执行了两个被 CPU 分析器记录的函数:md5.Writerand.Readmd5.Write 函数总共花费了 2.78 秒,而 rand.Read 花费了 1.59 秒的执行时间。逐层分析,你可以找到 CPU 密集型函数。

当处理 CPU 分析器数据时,注意那些花费最多处理时间的函数。这些函数被表示为更大的矩形,以帮助你找到它们。如果你注意到某些函数的处理时间异常高,花些时间分析它们的代码,看看是否有任何机会进行优化。

现在,让我们通过另一个分析器数据的示例。这次,我们将捕获一个堆分析 – 一个显示 Go 进程动态内存分配的分析。运行以下命令:

go tool pprof http://localhost:6060/debug/pprof/heap

与前面的例子类似,成功执行此命令应运行 pprof 工具,在那里我们可以执行 web 命令。结果将包含以下图表:

图 13.2 – Go 堆配置文件示例

图 13.2 – Go 堆配置文件示例

此图与 CPU 配置文件类似。每个节点内的最后一行显示了函数使用的内存与进程分配的总堆内存之间的比率。

在我们的例子中,有三个高级操作正在消耗堆内存:

  • api.serviceRegister:一个通过 Consul API 注册服务的函数

  • zap

  • trace.init:初始化跟踪逻辑

  1. 观察堆配置文件数据,很容易找到分配意外大量堆内存的函数。与 CPU 配置文件图类似,堆配置文件显示具有最高堆分配的函数以更大的矩形显示,这使得可视化最消耗内存的函数更容易。

  2. 我建议您使用 pprof 工具进行练习,并尝试它提供的其他操作。能够对 Go 应用程序进行性能分析是生产调试中非常宝贵的技能,这有助于您优化服务并解决不同的性能相关问题。以下是一些其他有用的 Go 服务性能分析技巧:

    • 您可以在不向代码添加任何额外逻辑的情况下对 Go 测试进行性能分析。使用带有-cpuprofile-memprofile标志的go test命令将分别捕获您的逻辑的 CPU 和内存配置文件。

    • pprof 工具的top命令是一种方便显示顶级内存消费者的方式。还有一个top10命令,它显示了前 10 个内存消费者。

    • 使用 pprof 工具的goroutine模式,您可以获取所有使用 goroutine 的配置文件,以及它们的堆栈跟踪。

现在我们已经介绍了 Go 性能分析的基础知识,让我们继续本章的下一个主题:服务仪表板化。

创建微服务仪表板

在前两章中,我们回顾了与服务指标一起工作的各种方法。在第十一章中,我们演示了如何收集服务指标,而在第十二章中,我们展示了如何使用 Prometheus 工具聚合和查询它们。在本节中,我们将描述另一种访问指标数据的方法,这可以帮助您探索指标并将它们作为图表绘制。我们将介绍的技术称为仪表板化,它对于可视化各种服务指标非常有用。

让我们提供一个仪表板的例子——一组代表不同指标的图表。以下图显示了包含一些系统级指标(如 goroutine 数量、Go 线程数量和分配的内存大小)的 Go 服务仪表板:

![图 13.3 – 使用 Grafana 工具的 Go 进程仪表板示例图 13.3 – Go 进程仪表板示例

图 13.3 – Grafana 工具的 Go 进程仪表板示例

仪表板有助于可视化各种类型的数据,例如时间序列数据集,使我们能够分析服务性能。以下是一些使用仪表板的其它用例:

  • 调试:能够可视化各种服务性能指标有助于我们识别服务问题并注意系统活动中的任何异常

  • 数据相关性:多个服务性能图表并排显示有助于我们找到相关事件,例如服务器错误增加或可用内存突然下降

为每个服务设置一个仪表板,以及一些跨越所有服务的仪表板,以获取一些高级系统性能数据,例如活动服务实例的数量、网络吞吐量等等,这是一个很好的实践。

让我们演示如何为我们在 第十二章 中收集的 Prometheus 数据设置一个示例仪表板。为此,我们将使用名为 Grafana 的开源工具,它内置了对各种类型的时间序列数据的支持,并提供了一个方便的用户界面来设置不同的仪表板。按照以下说明设置 Grafana 仪表板:

  1. 执行以下命令以运行 Grafana Docker 镜像:

    docker run -d -p 3000:3000 grafana/grafana-oss
    

此命令应获取并运行 Grafana 的开源版本(Grafana 还有一个企业版本,我们将在本章中不涉及)并暴露端口 3000,以便我们可以通过 HTTP 访问。

注意

与 Prometheus 类似,Grafana 也是用 Go 编写的,它是广泛用于软件开发行业的流行开源 Go 项目的另一个例子。

  1. 执行上述命令后,在您的浏览器中打开 localhost:3000。这将带您到 Grafana 登录页面。默认情况下,基于 Docker 的 Grafana 版本包括一个用户,其用户名和密码都是 admin,因此您可以使用这些凭据登录。

  2. 从侧边菜单中选择 配置

图 13.4 – Grafana 数据源配置菜单

图 13.4 – Grafana 数据源配置菜单

  1. 配置 页面上,点击 数据源 菜单项,然后点击 添加数据源 并从可用数据源列表中选择 Prometheus。这样做将打开一个新页面,显示 Prometheus 设置。在 HTTP 部分,将 URL 设置为 host.docker.internal:9090,如下截图所示:

图 13.5 – Grafana Prometheus 数据源配置

图 13.5 – Grafana Prometheus 数据源配置

  1. 现在,您可以在页面底部点击 保存并测试 按钮,这将通知您操作是否成功。如果您一切都做得很好,Grafana 应该准备好显示来自 Prometheus 的指标。

  2. 从侧边菜单中,点击新建仪表板

![图 13.6 – Grafana 的“新建仪表板”菜单项用于创建仪表板

![img/Figure_13.6_B18865.jpg]

图 13.6 – Grafana 新建仪表板菜单项用于创建仪表板

  1. 这应该打开一个空白的仪表板页面。

  2. 在此仪表板页面上点击添加新面板按钮;您将被重定向到面板创建页面。

面板是 Grafana 仪表板的核心元素,其目的是可视化提供的数据集。为了说明如何使用它,让我们选择我们的 Prometheus 数据源以及它已经拥有的某些指标。在面板视图中,选择process_open_fds元素并选择它。现在,点击运行查询按钮;您应该看到以下视图:

![图 13.7 – Grafana 面板视图

![img/Figure_13.7_B18865.jpg]

图 13.7 – Grafana 面板视图

我们刚刚配置了仪表板面板以显示存储在 Prometheus 中的process_open_fds时间序列。图表上的每个数据点都显示了时间序列在不同时间点的值,显示在图表下方。在右侧面板中,您可以设置面板标题为打开文件描述符计数。现在,通过点击顶部菜单中提供的应用按钮保存仪表板。您将被重定向到仪表板页面。

在顶部菜单中,您将找到go_gc_duration_seconds指标,您将添加一个新的面板到仪表板,用于可视化 Prometheus 中的go_gc_duration_seconds时间序列。

生成的仪表板应该看起来像这样:

![图 13.8 – 示例 Grafana 仪表板

![img/Figure_13.8_B18865.jpg]

图 13.8 – 示例 Grafana 仪表板

我们刚刚创建了一个示例仪表板,它包含两个面板,用于显示一些现有的 Prometheus 指标。您可以使用相同的方法为您的服务创建任何仪表板,以及显示系统全局指标的高级仪表板,例如 API 请求总数、网络吞吐量或所有服务实例的总数。

让我们提供一些有用的指标示例,这些指标可以用于为单个服务设置仪表板。这包括我们提到的第十二章中的四个黄金信号

  • 客户端错误率:客户端错误(如无效或未经认证的请求)与对服务的所有请求之间的比率

  • 服务器错误率:服务器错误(如数据库写入错误)与对服务的所有请求之间的比率

  • API 吞吐量:每秒/每分钟的 API 请求数量

  • API 延迟:API 请求处理延迟,通常以百分位数衡量,如 p90/p95/p99(您可以通过阅读这篇博客了解百分位数:https://www.elastic.co/blog/averages-can-dangerous-use-percentile)

  • CPU 利用率:CPU 当前的使用情况(100%表示所有 CPU 都处于完全负载状态)

  • 内存利用率:所有服务实例中已用内存与总内存之间的比率

  • 网络吞吐量:每秒/每分钟的网络读写流量总量

根据您的服务执行的操作(例如,数据库写入或读取、缓存使用、Kafka 消费或生产),您可能希望包含额外的面板,以帮助您可视化服务性能。确保您涵盖了服务的高级所有功能,这样您就可以在仪表板上直观地注意到任何服务故障。

我们在示例中使用的 Grafana 工具也支持许多不同的可视化选项,例如显示表格、热图、数值等。我们不会在本章中介绍这些功能,但您可以通过阅读官方文档来熟悉它们:https://grafana.com/docs/。利用 Grafana 的全部功能将帮助您为您的服务设置出色的仪表板,简化您的调试和性能分析。

现在,让我们进入下一节,我们将描述 Go 框架。

框架

第二章中,我们讨论了 Go 项目结构的话题,以及一些组织 Go 代码的常见模式。我们描述的代码组织原则通常基于约定——书面协议或声明,用于定义命名和放置 Go 文件的特定规则。我们遵循的一些约定是由 Go 语言的作者提出的,而其他则是被各种 Go 库的作者广泛使用并提出的。

虽然约定在建立组织 Go 代码的共同原则方面发挥着重要作用,但还有其他方法可以强制执行特定的代码结构。其中一种方法就是使用框架,我们将在本节中介绍。

通常来说,框架是用于建立代码各种组件结构的工具。以下代码片段作为例子:

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/echo",
        func(w http.ResponseWriter, _ *http.Request) {
            fmt.Fprintf(w, "Hi!")
        })
    if err := http.ListenAndServe(":8080", nil);
    err != nil {
        panic(err)
    }
}

在这里,我们正在注册一个 HTTP 处理函数,并让它处理localhost:8080/echo端点的 HTTP 请求。我们示例中的代码非常简单,但它做了大量的后台工作(您可以检查net/http包的源代码,以了解 HTTP 处理逻辑的内部部分是多么复杂),以启动 HTTP 服务器、接受所有传入的请求并通过执行我们提供的函数来响应它们。最重要的是,我们的代码允许我们通过遵循调用http.HandleFunc函数和向其传递处理函数的相同格式来添加额外的 HTTP 处理程序。我们示例中使用的net/http库为处理各种端点的 HTTP 调用建立了结构,充当了我们 Go 应用程序的框架。

net/http包的作者能够通过遵循名为net/http包的模式添加额外的 HTTP 端点处理器(由http.HandleFunc函数提供),该模式通过调用其其他组件(在我们的情况下,是作为http.HandleFunc参数提供的函数)来控制执行流程。在我们的例子中,当我们调用http.ListenAndServe函数时,net/http包就控制了 HTTP 处理器函数的执行:每当 HTTP 服务器收到传入的请求时,我们的函数会自动被调用。

IaC(基础设施即代码)是大多数框架的主要机制,它使它们能够为应用程序代码的各个部分建立基础。一般来说,大多数框架通过控制应用程序或其一部分,并处理一些路由操作来工作,例如资源管理(打开和关闭传入连接、读写文件等)、序列化和反序列化,以及更多。

使用 Go 框架的主要用例有哪些?我们可以列出一些最常见的用例:

  • 编写网络服务器:类似于我们关于 HTTP 服务器的例子,可以有其他类型的网络服务器,它们使用不同的协议处理对不同端点的请求,例如 Apache Thrift 或 gRPC。

  • 异步事件处理:有各种异步通信工具的库,如 Apache Kafka,它们通过传递各种类型事件(例如属于不同主题的 Kafka 消息)的处理函数来以 IoC(控制反转)方式组织代码,这些处理函数会在每次有新的未处理消息时自动调用。

需要注意的是,框架有一些显著的缺点:

在决定使用特定的框架时,您应该做一些分析,比较它为您提供的优势与它带来的劣势,尤其是在长期内。许多开发者低估了框架给他们或他们组织中的其他开发者带来的复杂性:大多数框架都进行了一定程度的“魔法”操作,为应用开发者提供方便的代码结构。一般来说,您应该始终从一个更简单的选项(在我们的案例中,即不使用特定的框架)开始,并且只有在框架的好处超过其劣势时才决定使用框架。

现在我们已经讨论了框架的话题,接下来让我们进入下一节,我们将描述微服务所有权的不同方面。

存储微服务所有权数据

使用微服务架构的一个关键好处是能够分散其开发:每个服务可以由一个独立的团队开发和维护,并且团队可以分布在全球各地。虽然分布式开发模型有助于不同团队独立构建其系统的各个部分,但它也带来了一些新的挑战,例如服务所有权。

为了说明服务所有权的问题,想象一下您正在一家拥有数千个微服务的公司工作。有一天,您的公司的安全工程师发现,在大多数公司服务中使用的流行 Go 库中存在一个关键的安全漏洞。您如何与正确的团队沟通,找出谁将负责对每个服务进行更改?

有许多公司拥有数千个微服务。在这样的公司中,记住哪个团队和哪些开发者负责每个服务变得不可能。在这样的公司中,找到解决服务所有权问题的解决方案变得至关重要。

注意

当我们在讨论微服务的所有权问题时,同样的原则也适用于许多其他类型的科技资产,例如 Kafka 主题和数据库表。

我们如何定义服务所有权?

有许多不同的方法来做这件事,每种方法对于某些特定的用例来说都同等重要:

  • 问责制:哪个人/实体对服务负责,谁可以充当其主要联系点或主要权威?

  • 支持:谁将提供对服务的支持,例如服务中的 bug、功能请求或用户问题?

  • 值班:目前谁负责该服务的值班?在紧急情况下我们可以联系谁?

如您所见,根据使用场景的不同,对“所有权”一词的解释有很多种。让我们看看定义每个角色的几种方法,从问责制开始:谁应该对服务负责,或者说谁应该对服务承担责任?

在大多数组织中,责任归咎于工程经理:每位工程经理都作为某个独特领域的责任个人。如果您在您的服务和负责它们的工程经理之间定义一个映射,您可以通过让他们轻松找到相关联系人点来解决服务责任问题,例如负责该服务的工程经理。

定义服务责任的另一种方法是将其与团队关联。然而,这可能会出现多个问题:

  • 共享责任并不总是有效:如果您有多个对服务负责的人,那么在他们中间谁是最终权威就变得不清楚了。

  • 在许多组织中,团队是一个松散定义的概念:除非您在公司中有一个单一、明确定义的团队注册,否则最好避免在您的系统中引用团队名称。

现在,让我们来讨论所有权的支持方面。理想情况下,每个服务都应该有一个机制来报告任何问题或错误。这样的机制可以采取以下形式之一:

  • 支持渠道:用于留下支持请求的消息渠道的标识符或 URL,例如相关 Google 群组、Slack 频道或任何其他类似工具的链接。

  • 票据系统 URL:允许您创建支持请求票据的系统/页面的 URL。开发者通常使用 Atlassian Jira 来完成这项任务。

如果您为所有服务提供此类元数据,您将显著简化用户支持:所有服务用户,如其他开发者,都将始终知道如何为他们请求支持或报告任何错误或其他问题。

让我们继续讨论待命的所有权元数据。解决这个问题的简单方法是将每个服务与其待命轮换关联起来。如果您使用 PagerDuty,您可以存储服务名称与其对应的 PagerDuty 轮换标识符之间的关系。

我们刚才描述的所有权元数据的示例如下:

ownership:
    rating-service:
        accountable: example@somecompany.com
            support:
                slack: rating-service-support-group
                    oncall:
                        pagerduty_rotation:SOME_ROTATION_ID

我们的示例定义在 YAML 格式中,尽管将此数据存储在允许我们通过 API 查询或修改它的系统中可能更可取。这样,您可以自动提交新的所有权更改(例如,当人们离开公司并且您希望自动重新分配所有权时)。我还建议将所有权数据对所有服务强制为必填项。为了强制执行此操作,您可以建立一个服务创建流程,在开发者在提供新服务之前要求提供所有权数据。

现在我们已经讨论了服务所有权,接下来让我们进入下一节,我们将描述 Go 微服务安全的基础知识。

使用 JWT 保护微服务通信

在本节中,我们将回顾一些微服务安全的基本概念,例如身份验证和授权。您将学习如何使用流行的JSON Web Token(JWT)协议在 Go 中实现此类逻辑。

让我们从安全的一个主要方面开始:认证。认证是验证某人身份的过程,例如通过用户凭证。当你登录到某个系统,如 Gmail 时,你通常通过提供登录详细信息(用户名和密码)来完成认证过程。执行认证的系统通过将提供的数据与它存储的现有记录进行比较来进行验证。验证可以是一步或多步:某些类型的认证,如双因素认证,需要一些额外的操作,例如通过短信验证对电话号码的访问。

成功的认证通常会导致授予调用者访问某些资源的权限,例如用户数据(例如,Gmail 中的用户电子邮件)。此外,执行此认证的服务器可能还会向调用者提供一个安全令牌,该令牌可以在后续调用中使用以跳过验证过程。

另一种形式的访问控制,称为授权,涉及指定对各种资源的访问权限。授权通常是为了检查用户是否有权执行某些操作,例如查看特定的管理员页面。授权通常是通过使用在认证期间获得的安全令牌来执行的,如下面的图所示:

图 13.9 – 提供令牌的授权请求

图 13.9 – 提供令牌的授权请求

在微服务中实现认证和授权有许多不同的方法。其中最受欢迎的协议之一是 JWT,这是一个提议的互联网标准,用于创建可以包含关于调用者身份的任何数量事实的安全令牌,例如他们是否是管理员。让我们回顾一下该协议的基本知识,以帮助您了解如何在您的服务中使用它。

JWT 基础

JWT 由执行认证或授权的组件生成。每个令牌由三部分组成:一个头部、一个有效载荷和一个签名。有效载荷是令牌的主要部分,它包含一组声明——关于调用者身份的陈述,例如用户标识符或系统中的角色。以下代码显示了一个令牌有效载荷的示例:

{
    "name": "Alexander",
    "role": "admin",
    "iat": 1663880774
}

我们的示例有效载荷包含三个声明:用户的姓名、角色(在我们的示例中为admin)和令牌发行时间(iat是一个标准字段名,它是 JWT 协议的一部分)。这些声明可以在各种流程中使用——例如,当检查用户是否有admin角色以访问系统仪表板时。

作为防止修改的保护机制,每个令牌都包含一个签名——其有效载荷的加密函数、一个头部和一个称为密钥的特殊值,这个密钥只有认证服务器才知道。以下伪代码提供了一个令牌签名计算的示例:

HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret,
)

用于创建令牌签名的算法在令牌头中定义。以下 JSON 记录提供了一个头部的示例:

{
    "alg": "HS256",
    "typ": "JWT"
}

在我们的示例中,令牌使用HMAC-SHA256,这是一种常用的 JWT 签名加密算法。我们选择HMAC-SHA256主要是由于其流行度;如果您想了解其他签名算法,可以在本章的进一步阅读部分找到它们的概述。

生成的 JWT 是令牌的头、有效载荷和签名的串联,使用Base64uri协议编码。例如,以下值是一个 JWT,它通过结合我们的代码片段中的头部和有效载荷,并使用名为our-secret的密钥字符串签名而创建:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWxleGFuZGVyIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjYzODgwNzc0fQ.FqogLyrV28wR5po6SMouJ7qs2Y3m6gmpaPg6MUthWpQ

为了练习 JWT 的创建,我建议使用位于jwt.io的 JWT 工具尝试编码任意的 JWT 并查看生成的令牌值。

现在我们已经讨论了 JWT 的高级细节,让我们继续本节的实际部分——使用 JWT 在 Go 微服务中实现基本的身份验证和授权。

使用 JWT 实现身份验证和授权

在本节中,我们将提供一些使用 Go 实现基本访问控制(通过身份验证和授权)的示例。

让我们从认证过程开始。一个基于凭证的简单认证流程可以总结如下:

  • 初始化认证的客户端会调用一个指定的端点(例如,HTTPS POST /auth),同时提供用户凭证,如用户名和密码。

  • 处理认证的服务器会验证凭证并执行以下两个动作之一:

    • 如果凭证无效,则返回错误(例如,带有401代码的 HTTP 错误)。

    • 返回一个包含 JWT 的成功响应,该 JWT 由服务器的密钥签名。

  • 如果认证成功,客户端可以存储收到的令牌,以便在后续请求中使用。

让我们说明如何实现我们刚才描述的认证流程的服务器逻辑。在我们的 Go 代码中生成 JWT,我们将使用https://github.com/golang-jwt/jwt库。

以下代码提供了一个处理 HTTP 认证请求的示例。它执行凭证验证,如果验证通过,则返回一个包含已签名 JWT 的成功响应:

const secret = "our-secret"
func Authenticate(w http.ResponseWriter, req *http.Request) {
    username := req.FormValue("username")
    password := req.FormValue("password")
    if !validCredentials(username, password) {
        http.Error(w, "invalid credentials", http.StatusUnauthorized)
        return
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "username": username,
        "iat": time.Now().Unix(),
    })
    tokenString, err := token.SignedString(secret)
    if err != nil {
        http.Error(w, "failed to create a token", http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, tokenString)
}
func validCredentials(username, password string) bool {
    // Implement your credential verification here.
    return false
}

在前面的代码中,我们使用jwt.NewWithClaims函数创建了一个令牌。该令牌包括两个字段:

  • username:已认证用户的名称

  • iat:令牌创建时间

我们刚才创建的服务器代码正在使用密钥值来签名令牌。不知道密钥的情况下,任何修改令牌的尝试都是不可能的:令牌签名允许我们检查令牌是否正确。

现在,让我们说明客户端如何使用在成功认证后收到的令牌进行请求:

func authorizationExample(
    token string, operationURL string) error {
        req, err := http.NewRequest(
            http.MethodPost, operationURL, nil)
            if err != nil {
                return err
            }
        req.Header.Set("Authorization", "Bearer "+token)
        resp, err := http.DefaultClient.Do(req)
        // Handle response.
    }

在我们的授权操作示例中,我们在使用带有 Bearer 前缀的令牌值的同时,向请求中添加了 Authorization 头。Bearer 前缀定义了一个 携带令牌 – 一个意图赋予其携带者访问权限的令牌。

同时,我们也提供处理此类授权请求并验证提供的令牌是否正确的服务器处理器的逻辑:

func AuthorizedOperationExample(w http.ResponseWriter,
    req *http.Request) {
        authHeaderValue := req.Header.Get("Authorization")
        const bearerPrefix = "Bearer "
        if !strings.HasPrefix(authHeaderValue,
            bearerPrefix) {
                http.Error(w,
                    "request does not contain an Authorization Bearer token", http.StatusUnauthorized)
                return
            }
        tokenString := strings.TrimPrefix(authHeaderValue,
            bearerPrefix)
        // Validate token.
        token, err := jwt.Parse(tokenString,
            func(token *jwt.Token) (interface{}, error) {
                if _, ok := token.Method.(
                    *jwt.SigningMethodHMAC); !ok {
                        return nil,
                        fmt.Errorf(
                            "unexpected signing method:
                                %v", token.Header["alg"])
            }
            return secret, nil
        })
        if err != nil {
            http.Error(w, "invalid token",
                http.StatusUnauthorized)
        }
        claims, ok := token.Claims.(jwt.MapClaims)
        if !ok || !token.Valid {
            http.Error(w, "invalid token",
                http.StatusUnauthorized)
            return
        }
        username := claims["username"]
        fmt.Fprintf(w, "Hello, "+username.(string))
    }

让我们描述一下提供的示例的一些亮点:

  • 我们使用 jwt.Parse 函数来解析令牌并验证它。如果签名算法与我们之前使用的 HMAC-SHA256 不匹配,我们返回一个错误。

  • 解析后的令牌包含 Claims 字段,它包含令牌有效载荷中的声明。

  • 我们在我们的函数中使用令牌有效载荷中的 username 声明。一旦我们成功解析令牌并验证它是有效的,我们可以假设其有效载荷中的信息已经安全地传递给我们,并且可以信任它。

现在我们已经提供了使用 JWTs 进行 Go 认证和授权的示例,让我们列出一些使用 JWTs 保护微服务通信的最佳实践:

  • (exp JWT 声明字段) 以避免用户使用旧的授权记录的情况。通过在令牌有效载荷中设置过期时间,你可以将其与授权请求进行验证。例如,当用户以系统管理员身份进行认证时,你可以设置一个短的令牌过期时间(例如,几个小时),以避免前管理员仍然可以在系统中执行关键操作的情况。

  • (ist JWT 声明字段),在许多实际情况下可能很有用。例如,如果你确定在某个时间点发生的安全漏洞,你可以通过使用令牌发行时间元数据来使在该时刻之前发行的所有的访问令牌失效。

  • 使用 HTTPS 而不是 HTTP 来使用 JWTs:HTTPS 协议加密请求元数据,例如授权请求头,防止各种类型的网络安全攻击。这类安全攻击的一个例子是 中间人攻击,即某个第三方(例如试图获取用户访问令牌的黑客)捕获网络流量以从请求头中提取 JWTs。

  • 优先使用标准 JWT 声明字段而不是自定义字段:当在 JWT 有效载荷中包含元数据时,请确保没有相同目的的标准字段。你可以在 https://en.wikipedia.org/wiki/JSON_Web_Token#Standard_fields 找到标准 JWT 声明字段列表。

jwt.io/ 网站包含一些关于使用 JWTs 的额外提示,以及一个用于编码和解码 JWTs 的在线工具,你可以使用它来调试你的服务通信。

摘要

通过回顾许多未在前几章中包含的微服务开发主题,我们完成了这本书的最后一章。你学习了如何分析 Go 服务,创建微服务仪表板以便监控其性能,定义和存储微服务所有权数据,以及使用 JWT 保护微服务通信。我希望你在这一章中找到了许多有用的提示,这将帮助你构建可扩展、高性能和安全的微服务。

Go 语言及其工具集不断进化。每天,开发者都会发布新的库和工具来解决我们在本书中描述的各种微服务开发问题。虽然本书为您提供了许多关于 Go 微服务开发的提示,但您应该不断改进您的技能,并使您的服务更简单、更易于维护。

我还想感谢您阅读这本书。希望您喜欢阅读它,并从中获得了许多有用的经验,这将帮助您掌握 Go 微服务开发的技艺。让您的 Go 微服务具有高性能、安全且易于维护!

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:

我们建议您使用以下资源来了解与 Go 微服务开发相关的最新新闻:

posted @ 2025-09-06 13:44  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报