gRPC-启动指南-全-

gRPC 启动指南(全)

原文:zh.annas-archive.org/md5/a7098600fff8a5faa7a647c913167ab9

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

如今,软件应用程序通常通过计算机网络连接到彼此,使用进程间通信技术。gRPC 是一种基于高性能 RPC(远程过程调用)的现代进程间通信风格,用于构建分布式应用程序和微服务。随着微服务和云原生应用程序的出现,gRPC 的采用正以指数级增长。

我们为什么要写这本书?

随着 gRPC 的日益普及,我们感到开发人员需要一本全面的关于 gRPC 的书籍,一本可以在您的 gRPC 应用程序开发周期的每个阶段中作为最终参考指南的书籍。关于 gRPC 的资源和代码示例遍布各处(文档、博客、文章、会议演讲等),但没有一本单一的资源可以用来构建 gRPC 应用程序。此外,也没有关于 gRPC 协议内部工作原理及其运行机制的资源。

我们写这本书的目的是克服这些挑战,并帮助您全面了解 gRPC 的基础知识,它与传统的进程间通信技术有何不同,真实世界中的 gRPC 通信模式,如何使用 Go 和 Java 构建 gRPC 应用程序,它的内部工作原理,如何在生产环境中运行 gRPC 应用程序,以及 gRPC 如何与 Kubernetes 及其余的生态系统配合工作。

本书适合谁?

本书最直接适合正在使用不同进程间通信技术构建分布式应用程序和微服务的开发人员。在构建此类应用程序和服务时,开发人员需要学习 gRPC 的基础知识,以及何时以及如何在服务间通信中使用它,生产环境中运行 gRPC 服务的最佳实践等等。此外,正在采用微服务或云原生架构并设计服务间通信方式的架构师将从本书中获得深刻的见解,因为它将 gRPC 与其他技术进行了比较,并提供了在何时使用和避免 gRPC 的指南。

我们假设开发人员和架构师都具备分布式计算的基本理解,例如进程间通信技术、面向服务的架构(SOA)和微服务。

本书的组织方式

本书以实际用例解释理论概念。在整本书中,我们广泛使用了涉及 Go 和 Java 的代码示例,让读者亲身体验所学概念。我们将书分为八章。

第一章,gRPC 简介

本章将帮助您基本了解 gRPC 的基础知识,并将其与 REST、GraphQL 及其他 RPC 技术等类似的进程间通信风格进行比较。

第二章,开始使用 gRPC

这一章节让您第一次亲手构建完整的 gRPC 应用程序,使用 Go 或 Java 语言。

第三章,《gRPC 通信模式》

在这一章节中,您将通过实际示例探索 gRPC 的通信模式。

第四章,《gRPC: 深入了解》

如果您是一个对 gRPC 内部机制感兴趣的高级用户,这是学习的章节。本章教授您 gRPC 服务器与客户端之间的每一个通信步骤,以及它如何在网络上运作。

第五章,《gRPC: 进阶》

这一章节教会您一些 gRPC 最重要的高级特性,例如拦截器、截止时间、元数据、多路复用、负载均衡等等。

第六章,《安全的 gRPC》

这一章节全面介绍了如何保护通信渠道,以及我们如何对 gRPC 应用程序的用户进行认证和访问控制。

第七章,《在生产环境中运行 gRPC》

这一章节将带您贯穿整个 gRPC 应用程序的开发生命周期。我们涵盖了测试 gRPC 应用程序、与 CI/CD 的集成、在 Docker 和 Kubernetes 上部署和运行,以及监控 gRPC 应用程序。

第八章,《gRPC 生态系统》

在这一章节中,我们讨论了一些围绕 gRPC 构建的有用支持组件。在使用 gRPC 构建真实世界应用程序时,大多数这些项目都非常有用。

使用代码示例

本书的所有代码示例和补充材料都可以在 https://grpc-up-and-running.github.io 上下载。我们强烈建议您在阅读书籍时尝试这个存储库中提供的示例。这将帮助您更好地理解正在学习的概念。

这些代码示例是维护并与最新版本的库、依赖项和开发工具保持同步的。偶尔您可能会发现文本中的代码示例与存储库中的示例略有不同。如果您在代码示例中遇到任何问题或改进意见,我们强烈建议您发送一个拉取请求(PR)。

您可以在自己的程序和文档中使用本书的示例代码,无需征得我们的许可,除非您复制了大量代码。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发 O'Reilly 图书中的示例代码需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书的大量示例代码整合到产品文档中需要许可。

我们欣赏但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN 号。例如:“gRPC: Up and Running by Kasun Indrasiri and Danesh Kuruppu (O’Reilly)。版权 2020 年 Kasun Indrasiri 和 Danesh Kuruppu,978-1-492-05833-5。”

如果您认为您使用的代码示例超出了合理使用或上述授权,请随时联系我们:permissions@oreilly.com

本书中使用的约定

本书使用以下印刷约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及在段落内用来引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示应由用户按照字面意思输入的命令或其他文本。

常量宽度斜体

显示应用程序列表以及在段落中引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

提示

此元素表示提示或建议。

注释

此元素表示一般注释。

警告

此元素指示警告或注意事项。

O’Reilly 在线学习

注释

超过 40 年来,O’Reilly Media一直为企业提供技术和商业培训、知识和见解,帮助其取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专长。O'Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自 O'Reilly 和 200 多家其他出版商的广泛的文本和视频集合。欲了解更多信息,请访问:http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(在美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们有这本书的网页,其中列出了勘误、示例和任何额外信息。您可以访问此页面:https://oreil.ly/gRPC_Up_and_Running

发送电子邮件至bookquestions@oreilly.com 以评论或提出关于本书的技术问题。

欲了解更多关于我们的图书、课程、会议和新闻的信息,请访问我们的网站:http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

观看我们的 YouTube 频道:http://www.youtube.com/oreillymedia

致谢

我们衷心感谢本书的技术审阅者,Julien Andrieux、Tim Raymond 和 Ryan Michela。同时,我们要感谢开发编辑 Melissa Potter 在指导和支持上的帮助,以及采编编辑 Ryan Shaw 的大力支持。最后,我们要感谢整个 gRPC 社区为创造如此优秀的开源项目所做的贡献。

第一章:gRPC 简介

现代软件应用很少独立运行。相反,它们通过计算机网络相互连接,并通过传递消息来通信和协调它们的操作。因此,现代软件系统是运行在不同网络位置的分布式软件应用集合,并使用不同的通信协议通过消息传递来彼此通信。例如,一个在线零售软件系统包括多个分布式应用程序,如订单管理应用程序、目录应用程序、数据库等等。为了实现在线零售系统的业务功能,需要这些分布式应用程序之间的互联互通。

注意

微服务架构

微服务架构是将软件应用程序构建为独立、自治(独立开发、部署和扩展)、面向业务能力的松散耦合服务集合的过程。¹

随着微服务架构云原生架构的出现,为多个业务能力构建的传统软件应用程序进一步分割成一组细粒度、自治和业务能力导向的实体,称为微服务。因此,基于微服务的软件系统也要求微服务通过网络使用进程间(或服务间或应用间)通信技术进行连接。例如,如果我们考虑使用微服务架构实现的同一在线零售系统,您会发现多个相互连接的微服务,如订单管理、搜索、结账、运输等等。与传统应用程序不同,由于微服务的细粒度特性,网络通信链接的数量大大增加。因此,无论您使用的是传统架构还是微服务架构,进程间通信技术都是现代分布式软件应用程序中最重要的方面之一。

进程间通信通常采用同步请求-响应风格或异步事件驱动风格进行实现。在同步通信风格中,客户端进程通过网络向服务器进程发送请求消息,并等待响应消息。在异步事件驱动消息传递中,进程通过使用称为事件代理的中介进行异步消息传递进行通信。根据您的业务用例,可以选择要实现的通信模式。

当涉及构建现代云原生应用程序和微服务的同步请求-响应式通信时,最常见和传统的方法是将它们构建为 RESTful 服务,其中您将应用程序或服务建模为可以通过网络调用访问并通过 HTTP 协议改变其状态的资源集合。然而,对于大多数用例来说,RESTful 服务相当笨重、效率低下,并且易于出错,用于构建进程间通信。通常需要一种高度可扩展、松耦合的进程间通信技术,其效率比 RESTful 服务更高。这就是现代进程间通信样式 gRPC 出现的地方(我们将在本章后期将 gRPC 与 RESTful 通信进行比较)。gRPC 主要使用同步请求-响应式样式进行通信,但一旦建立了初始通信,它也可以以完全异步或流模式运行。

在本章中,我们将探讨 gRPC 是什么,以及发明这种进程间通信协议背后的主要动机。我们将通过一些真实的用例来深入了解 gRPC 协议的关键构建块。此外,了解进程间通信技术的基本原理及其随时间演变的过程非常重要,这样您就可以理解 gRPC 试图解决的关键问题。因此,我们将详细介绍这些技术,并对比每一种。让我们从 gRPC 是什么开始讨论。

gRPC 是什么?

gRPC(“g”在每个 gRPC 发布版本中都有不同的含义)是一种进程间通信技术,允许您像调用本地函数一样连接、调用、操作和调试分布式异构应用程序。

当你开发 gRPC 应用程序时,首先要做的是定义一个服务接口。服务接口定义包含了有关你的服务如何被消费者消费的信息,允许消费者远程调用哪些方法,调用这些方法时使用的方法参数和消息格式等信息。我们在服务定义中指定的语言被称为接口定义语言(IDL)。

使用该服务定义,您可以生成称为服务器骨架的服务器端代码,它通过提供低级通信抽象简化了服务器端逻辑。同时,您还可以生成客户端代码,称为客户端存根,它通过抽象隐藏不同编程语言的低级通信,简化了客户端通信。在服务接口定义中指定的方法可以被客户端远程调用,就像调用本地函数一样简单。底层的 gRPC 框架处理了通常与强制执行严格服务合同、数据序列化、网络通信、身份验证、访问控制、可观测性等相关的所有复杂性。

要理解 gRPC 的基本概念,让我们看一个使用 gRPC 实现的微服务的真实用例。假设我们正在构建一个由多个微服务组成的在线零售应用程序。如 图 1-1 所示,假设我们想要构建一个微服务,提供在线零售应用程序中可用产品的详细信息(我们将在 第二章 中从头开始实现此用例)。ProductInfo 服务被建模为以 gRPC 服务形式通过网络公开。

基于 gRPC 的微服务和消费者

图 1-1. 基于 gRPC 的微服务和消费者

服务定义在ProductInfo.proto文件中指定,该文件被服务器和客户端使用以生成代码。在本例中,我们假设服务使用 Go 语言实现,消费者使用 Java 实现。服务和消费者之间的网络通信通过 HTTP/2 进行。

现在让我们深入了解这种 gRPC 通信的详细信息。构建 gRPC 服务的第一步是创建服务接口定义,包括该服务暴露的方法以及输入参数和返回类型。让我们继续详细了解服务定义的内容。

服务定义

gRPC 使用 协议缓冲区 作为接口定义语言(IDL)来定义服务接口。协议缓冲区是一种语言无关、平台中立、可扩展的机制,用于序列化结构化数据(我们将在 第四章 中详细介绍协议缓冲区的一些基础知识,但目前您可以将其视为一种数据序列化机制)。服务接口定义以 .proto 扩展名的普通文本文件的形式指定。您以普通的协议缓冲区格式定义 gRPC 服务,包括指定 RPC 方法参数和返回类型作为协议缓冲区消息。由于服务定义是协议缓冲区规范的扩展,需要使用特殊的 gRPC 插件从您的 proto 文件生成代码。

在我们的示例用例中,可以使用协议缓冲定义 ProductInfo 服务的接口,如 示例 1-1 所示。ProductInfo 的服务定义由服务接口定义组成,我们在其中指定远程方法、它们的输入和输出参数以及这些参数的类型定义(或消息格式)。

示例 1-1. 使用协议缓冲定义 ProductInfo 服务的 gRPC 服务定义
// ProductInfo.proto syntax = "proto3"; ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
package ecommerce; ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

service ProductInfo { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    rpc addProduct(Product) returns (ProductID); ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    rpc getProduct(ProductID) returns (Product); ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
}

message Product { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
    string id = 1; ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
    string name = 2;
    string description = 3;
}

message ProductID { ![8](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/8.png)
    string value = 1;
}

1

服务定义以指定我们使用的协议缓冲版本(proto3)开始。

2

包名称用于防止协议消息类型之间的名称冲突,也将用于生成代码。

3

定义 gRPC 服务的服务接口。

4

用于添加产品并返回产品 ID 作为响应的远程方法。

5

用于根据产品 ID 获取产品的远程方法。

6

定义 Product 的消息格式/类型。

7

字段(名称-值对),用于保存具有唯一字段号的产品 ID,在消息二进制格式中用于标识您的字段。

8

产品识别号的用户定义类型。

服务是一组可以远程调用的方法(例如,addProductgetProduct)的集合。每个方法都有我们定义的输入参数和返回类型,可以作为服务的一部分定义,或者可以导入到协议缓冲定义中。

输入和返回参数可以是用户定义的类型(例如 ProductProductID 类型)或在服务定义中定义的协议缓冲器知名类型。这些类型被结构化为消息,其中每个消息是包含一系列名称-值对字段的小逻辑记录。这些字段是具有唯一字段号的名称-值对(例如 string id = 1),用于在消息二进制格式中标识您的字段。

此服务定义用于构建您的 gRPC 应用程序的服务器和客户端。在下一节中,我们将详细介绍 gRPC 服务器的实现。

gRPC 服务器

一旦您有了服务定义,您可以使用协议缓冲编译器 protoc 生成服务器端或客户端代码。使用协议缓冲器的 gRPC 插件,您可以生成 gRPC 服务器端和客户端代码,以及用于填充、序列化和检索消息类型的常规协议缓冲器代码。

在服务器端,服务器实现该服务定义并运行 gRPC 服务器来处理客户端调用。因此,在服务器端,要使ProductInfo服务发挥作用,您需要做以下几点:

  1. 通过覆盖服务基类实现生成的服务框架的服务逻辑。

  2. 运行 gRPC 服务器以监听来自客户端的请求并返回服务响应。

在实现服务逻辑时,首先要做的是从服务定义生成服务框架。例如,在 示例 1-2 的代码片段中,您可以找到使用 Go 构建的ProductInfo服务的生成远程函数。在这些远程函数的主体中,您可以实现每个函数的逻辑。

示例 1-2. 使用 Go 实现 ProductInfo 服务的 gRPC 服务器端
import (
  ...
  "context"
  pb "github.com/grpc-up-and-running/samples/ch02/productinfo/go/proto"
  "google.golang.org/grpc"
  ...
)

// ProductInfo implementation with Go

// Add product remote method
func (s *server) AddProduct(ctx context.Context, in *pb.Product) (
      *pb.ProductID, error) {
   // business logic
}

// Get product remote method
func (s *server) GetProduct(ctx context.Context, in *pb.ProductID) (
     *pb.Product, error) {
   // business logic
}

一旦您准备好服务实现,您需要运行 gRPC 服务器来监听来自客户端的请求,将这些请求分派给服务实现,并将服务响应返回给客户端。在 示例 1-3 中的代码片段中,显示了使用 Go 为ProductInfo服务用例实现的 gRPC 服务器。在此,我们打开一个 TCP 端口,启动 gRPC 服务器,并在该服务器上注册ProductInfo服务。

示例 1-3. 使用 Go 运行 ProductInfo 服务的 gRPC 服务器
func main() {
  lis, _ := net.Listen("tcp", port)
  s := grpc.NewServer()
  pb.RegisterProductInfoServer(s, &server{})
  if err := s.Serve(lis); err != nil {
    log.Fatalf("failed to serve: %v", err)
  }
}

这就是在服务器端需要做的全部。现在让我们转向 gRPC 客户端实现。

gRPC 客户端

类似于服务器端,我们可以使用服务定义生成客户端存根。客户端存根提供与服务器相同的方法,您的客户端代码可以调用这些方法;客户端存根将它们转换为远程函数调用网络调用,这些调用转到服务器端。由于 gRPC 服务定义是与语言无关的,您可以为您选择的任何支持的语言生成客户端和服务器(通过您选择的语言的 第三方实现)。因此,对于ProductInfo服务用例,我们可以为 Java 生成客户端存根,而我们的服务器端使用 Go 实现。在 示例 1-4 中的代码片段中,您可以找到 Java 的代码。尽管我们使用的编程语言不同,但客户端实现涉及的简单步骤包括建立与远程服务器的连接、将客户端存根附加到该连接,并使用客户端存根调用远程方法。

示例 1-4. gRPC 客户端调用服务的远程方法
// Create a channel using remote server address
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080)
   .usePlaintext(true)
   .build();

// Initialize blocking stub using the channel
ProductInfoGrpc.ProductInfoBlockingStub stub =
       ProductInfoGrpc.newBlockingStub(channel);

// Call remote method using the blocking stub
StringValue productID = stub.addProduct(
       Product.newBuilder()
       .setName("Apple iPhone 11")
       .setDescription("Meet Apple iPhone 11." +
            "All-new dual-camera system with " +
            "Ultra Wide and Night mode.")
       .build());

现在您已经对 gRPC 的关键概念有了很好的了解,让我们详细了解 gRPC 客户端与服务器之间的消息流程。

客户端-服务器消息流

当 gRPC 客户端调用 gRPC 服务时,客户端 gRPC 库使用协议缓冲区并封装远程过程调用协议缓冲区格式,然后通过 HTTP/2 发送。在服务器端,请求被解封装,并使用协议缓冲区执行相应的过程调用。响应从服务器到客户端的执行流程类似。作为传输协议,gRPC 使用 HTTP/2,这是一种高性能的二进制消息协议,支持双向消息传递。我们将进一步讨论 gRPC 客户端和服务器之间的消息流低级细节,以及协议缓冲区以及 gRPC 如何在 第四章 中使用 HTTP/2。

注意

封装 是将参数和远程函数打包到消息数据包中,然后通过网络发送的过程,而 解封装 则将消息数据包解包到相应的方法调用中。

在深入讨论 gRPC 协议之前,了解不同的进程间通信技术及其随时间演变的重要性尤为重要。

进程间通信的演变

随着时间的推移,进程间通信技术发生了显著的演变。出现了各种这样的技术,以解决现代需求并提供更好、更高效的开发体验。因此,了解进程间通信技术的演变以及它们如何走向 gRPC 是非常重要的。让我们看一些最常用的进程间通信技术,并尝试与 gRPC 进行比较和对比。

传统 RPC

RPC 是一种流行的进程间通信技术,用于构建客户端-服务端应用程序。使用 RPC,客户端可以远程调用方法或函数,就像调用本地方法一样。早期有一些流行的 RPC 实现,如公共对象请求代理体系结构(CORBA)和 Java 远程方法调用(RMI),用于构建和连接服务或应用程序。然而,大多数传统的 RPC 实现非常复杂,因为它们建立在诸如 TCP 等通信协议之上,这些协议阻碍了互操作性,并且基于臃肿的规范。

SOAP

由于诸如 CORBA 等传统 RPC 实现的局限性,简单对象访问协议(SOAP)被设计出来,并得到微软、IBM 等大型企业的大力推广。SOAP 是服务导向架构(SOA)中的标准通信技术,用于在服务之间交换基于 XML 结构化数据(通常在 SOA 上下文中称为 Web 服务),并可以通过任何底层通信协议(如 HTTP,最常用)进行通信。

使用 SOAP 使用 SOAP 可以定义服务接口、该服务的操作以及用于调用这些操作的关联 XML 消息格式。SOAP 曾经是一项相当流行的技术,但消息格式的复杂性以及围绕 SOAP 构建的规范的复杂性阻碍了构建分布式应用程序的灵活性。因此,在现代分布式应用程序开发的背景下,SOAP Web 服务被视为一项传统技术。现在,大多数现有的分布式应用程序都是使用 REST 架构风格进行开发,而不是使用 SOAP。

REST

表述性状态转移(REST)是一种架构风格,起源于罗伊·菲尔丁(Roy Fielding)的博士论文。菲尔丁是 HTTP 规范的主要作者之一,也是 REST 架构风格的创始人。REST 是资源导向架构(ROA)的基础,其中你将分布式应用程序建模为一组资源,访问这些资源的客户端可以更改这些资源的状态(创建、读取、更新或删除)。

REST 的事实实现是 HTTP,在 HTTP 中,你可以将 RESTful Web 应用程序建模为一组使用唯一标识符(URL)可访问的资源集合。状态更改操作以 HTTP 动词(GET、POST、PUT、DELETE、PATCH 等)的形式应用于这些资源之上。资源状态以文本格式表示,例如 JSON、XML、HTML、YAML 等等。

使用 REST 架构风格和 HTTP、JSON 构建应用程序已成为构建微服务的事实标准方法。然而,随着微服务数量的增加以及它们的网络交互,RESTful 服务无法满足预期的现代要求。RESTful 服务存在几个关键限制,阻碍了将它们用作现代基于微服务的应用程序的消息传递协议。

低效的基于文本的消息协议

本质上,RESTful 服务是建立在诸如 HTTP 1.x 之类的基于文本的传输协议之上的,并利用 JSON 等人类可读的文本格式。当涉及到服务之间的通信时,使用 JSON 等文本格式非常低效,因为通信的双方都不需要使用这种人类可读的文本格式。

客户端应用程序(源)生成要发送到服务器的二进制内容,然后将二进制结构转换为文本(因为在 HTTP 1.x 中必须发送文本消息),并通过网络以文本形式(通过 HTTP)发送到解析并将其转换回服务(目标)端的机器。而实际上,我们本可以轻松地发送一种可以映射到服务和消费者业务逻辑的二进制格式。使用 JSON 的一个流行论点是它更易于使用,因为它是“人类可读的”。这更多是一个工具问题,而不是二进制协议的问题。

缺乏应用程序之间的强类型接口

随着使用不同多语言技术构建的服务在网络上交互的数量增加,缺乏明确定义和强类型服务定义是一个重大挫折。我们在 RESTful 服务中拥有的大多数现有服务定义技术,如 OpenAPI/Swagger,都是事后想法,与底层架构风格或消息协议的紧密集成不强。

这导致在构建这种分散式应用程序时出现许多不兼容性、运行时错误和互操作性问题。例如,当您开发 RESTful 服务时,无需拥有服务定义和信息类型定义,这些信息在应用程序之间共享。相反,您可以根据线上的文本格式或第三方 API 定义技术(如 OpenAPI)开发您的 RESTful 应用程序。因此,拥有现代强类型服务定义技术以及为多语言技术生成服务器和客户端核心代码的框架是至关重要的。

REST 架构风格难以强制执行

作为一种架构风格,REST 有许多“良好实践”需要遵循才能创建真正的 RESTful 服务。但是,它们并不作为实现协议(如 HTTP)的一部分强制执行,这使得在实施阶段强制执行它们变得困难。因此,在实践中,大多数声称为 RESTful 的服务并未正确遵循 REST 风格的基础。因此,大多数所谓的 RESTful 服务只是通过网络公开的 HTTP 服务。因此,开发团队必须花费大量时间维护 RESTful 服务的一致性和纯度。

随着现代云原生应用程序构建中进程间通信技术的所有这些限制,追求发明一种更好的消息协议的旅程开始了。

gRPC 的创世

Google 一直在使用名为 Stubby 的通用 RPC 框架来连接跨多个数据中心运行的数千个微服务,并构建具有不同技术的微服务。其核心 RPC 层设计用于处理每秒数十亿次请求的互联网规模。Stubby 具有许多出色的特性,但由于过于紧密耦合到 Google 的内部基础设施,因此不能标准化用作通用框架。

2015 年,Google 发布了 gRPC 作为开源的 RPC 框架;它是一个标准化的、通用的、跨平台的 RPC 基础设施。gRPC 旨在为社区提供与 Stubby 提供的相同的可扩展性、性能和功能。

此后,gRPC 在过去几年中以及来自 Netflix、Square、Lyft、Docker、Cisco 和 CoreOS 等主要公司的大规模采用下急剧增长。后来,gRPC 加入了 云原生计算基金会(CNCF),这是致力于使云原生计算普遍和可持续的最流行的开源软件基金会之一;gRPC 从 CNCF 生态系统项目中获得了很多推动力。

现在让我们看看使用 gRPC 而不是传统进程间通信协议的一些关键原因。

为什么选择 gRPC?

gRPC 设计为一种可以克服传统进程间通信技术大部分缺点的互联网规模的技术。由于 gRPC 的优势,大多数现代应用程序和服务器正越来越将其进程间通信协议转换为 gRPC。那么,在有这么多其他选择可用时,为什么会有人选择 gRPC 作为通信协议呢?让我们更仔细地看看 gRPC 带来的一些关键优势。

gRPC 的优势

gRPC 带来的优势是 gRPC 日益增长的原因。这些优势包括以下内容:

它对进程间通信非常高效。

gRPC 不像使用 JSON 或 XML 等文本格式,而是使用基于协议缓冲区的二进制协议与 gRPC 服务和客户端通信。此外,gRPC 在 HTTP/2 之上实现了协议缓冲区,使其成为更快的进程间通信技术。这使得 gRPC 成为最高效的进程间通信技术之一。

它具有简单明确的服务接口和模式。

gRPC 促进了一种基于契约的应用程序开发方法。您首先定义服务接口,然后再处理实现细节。因此,与 RESTful 服务定义的 OpenAPI/Swagger 和 SOAP web 服务的 WSDL 不同,gRPC 提供了一种简单而一致、可靠和可扩展的应用程序开发体验。

它是强类型的。

由于我们使用协议缓冲区来定义 gRPC 服务,因此 gRPC 服务契约清晰地定义了在应用程序之间进行通信时将使用的类型。这使得分布式应用程序开发更加稳定,因为静态类型有助于克服在构建跨多个团队和技术的云原生应用程序时可能遇到的大多数运行时和互操作性错误。

它是多语言的。

gRPC 设计用于与多种编程语言一起工作。使用协议缓冲区定义的 gRPC 服务是与语言无关的。因此,您可以选择您喜欢的语言,但可以与任何现有的 gRPC 服务或客户端进行互操作。

它具有双工流功能。

gRPC 原生支持客户端或服务器端流,这一特性已经融入到服务定义中。这使得开发流式服务或流式客户端变得更加容易。而且,能够构建传统的请求-响应式消息传递以及客户端和服务器端流式传输是传统 RESTful 消息传递风格的重要优势之一。

它具有内建的通用功能。

gRPC 提供了内建的支持,如身份验证、加密、弹性(截止日期和超时)、元数据交换、压缩、负载平衡、服务发现等功能(我们将在 第五章 中探讨这些功能)。

它与云原生生态系统集成。

gRPC 是 CNCF 的一部分,大多数现代框架和技术都原生支持 gRPC。例如,CNCF 下的许多项目,如 Envoy,支持 gRPC 作为通信协议;对于跨切面功能,如度量和监视,大多数这类工具(例如使用 Prometheus 监视 gRPC 应用程序)都支持 gRPC。

它是成熟的,并且已被广泛采用。

gRPC 经过 Google 等公司的大量战斗测试,已经成熟,许多其他主要技术公司如 Square、Lyft、Netflix、Docker、Cisco 和 CoreOS 也采用了它。

就像任何技术一样,gRPC 也有一定的缺点。在应用程序开发过程中了解这些缺点非常有用。因此,让我们来看看 gRPC 的一些局限性。

gRPC 的缺点

在选择 gRPC 构建应用程序时,需要注意一些缺点。这些包括以下内容:

它可能不适用于面向外部服务。

当您希望通过互联网向外部客户公开应用程序或服务时,gRPC 可能不是最合适的协议,因为大多数外部消费者对 gRPC 和 REST/HTTP 都比较陌生。gRPC 服务的合同驱动、强类型的特性可能会限制您向外部方公开服务的灵活性,并且消费者会获得较少的控制权(不像 GraphQL 等协议那样)。gRPC 网关被设计为解决这个问题的一种方法。我们将在第八章中详细讨论。

重大服务定义变更是一个复杂的开发过程。

现代服务间通信中,模式修改非常普遍。当 gRPC 服务定义发生重大变化时,通常需要重新生成客户端和服务器端的代码。这需要融入现有的持续集成流程中,并可能使整体开发生命周期变得复杂。然而,大多数 gRPC 服务定义的变更可以在不破坏服务合同的情况下进行,只要没有引入破坏性变更,gRPC 就能与使用不同版本 proto 的客户端和服务器愉快地互操作。因此,在大多数情况下并不需要重新生成代码。

生态系统相对较小。

与传统的 REST/HTTP 协议相比,gRPC 生态系统仍然相对较小。在浏览器和移动应用程序中支持 gRPC 的情况仍处于初级阶段。

在开发应用程序时,你必须注意这些限制。显然,gRPC 并不是你应该用于所有进程间通信需求的技术。相反,你需要评估业务用例和需求,并选择适合的消息传递协议。我们将在第八章中探讨一些指南。

正如我们在之前的章节中讨论的那样,市场上存在许多现有和新兴的进程间通信技术。了解我们如何比较 gRPC 与其他类似的技术,这将帮助您找到最适合您服务的协议,这一点非常重要。

gRPC 与其他协议的比较:GraphQL 和 Thrift

我们详细讨论了 REST 的一些关键限制,这为 gRPC 的诞生奠定了基础。同样,有许多正在出现的进程间通信技术来满足相同的需求。因此,让我们看看一些流行的技术,并将它们与 gRPC 进行比较。

Apache Thrift

Apache Thrift是一个 RPC 框架(最初在 Facebook 开发,后来捐赠给 Apache),类似于 gRPC。它使用自己的接口定义语言,并支持广泛的编程语言。Thrift 允许您在定义文件中定义数据类型和服务接口。通过将服务定义作为输入,Thrift 编译器生成客户端和服务器端的代码。Thrift 传输层提供了网络 I/O 的抽象,并将 Thrift 与系统的其余部分解耦,这意味着它可以在任何传输实现(如 TCP、HTTP 等)上运行。

如果你将 Thrift 与 gRPC 进行比较,你会发现它们基本上遵循相同的设计和使用目标。然而,两者之间有几个重要的区别:

Transport

gRPC 比 Thrift 更具有见解,并为 HTTP/2 提供了一流的支持。它在 HTTP/2 上的实现利用协议的能力实现效率,并支持诸如流式传输等消息模式。

Streaming

gRPC 服务定义本身原生支持双向流式传输(客户端和服务器)作为服务定义的一部分。

采纳和社区

当涉及到采纳时,gRPC 似乎具有相当好的动力,并成功地在 CNCF 项目周围建立了一个良好的生态系统。此外,诸如良好的文档、外部演示和示例用例等社区资源在 gRPC 中非常普遍,这使得采纳过程与 Thrift 相比更加顺畅。

Performance

虽然没有官方结果比较 gRPC 与 Thrift,但有一些在线资源进行了性能比较,显示 Thrift 的数据更好。然而,几乎每个发布版本都对 gRPC 进行了大量性能基准测试。因此,在选择 Thrift 或 gRPC 时,性能不太可能成为决定性因素。此外,还有其他 RPC 框架提供类似的功能,但 gRPC 目前作为最标准化、可互操作性和广泛采用的 RPC 技术领先一步。

GraphQL

GraphQL是另一种技术(由 Facebook 发明并作为开放技术标准化),用于构建进程间通信,正在变得越来越流行。它是一个 API 的查询语言,以及用现有数据满足这些查询的运行时。GraphQL 通过允许客户端确定他们想要什么数据、以什么方式和以什么格式来提供对传统客户端-服务器通信的基本不同方法。另一方面,gRPC 通过具有固定契约的远程方法来使客户端和服务器之间的通信成为可能。

GraphQL 更适合于面向直接暴露给消费者的外部服务或 API,在这些服务中,客户端需要更多控制所消费的数据。例如,在我们的在线零售应用场景中,假设ProductInfo服务的消费者仅需要产品的特定信息,而不需要产品的所有属性集,并且消费者还需要一种指定所需信息的方法。使用 GraphQL,您可以建模一个服务,使消费者能够使用 GraphQL 查询语言查询服务并获取所需的信息。

在大多数实际的 GraphQL 和 gRPC 用例中,GraphQL 用于面向外部的服务/API,而支持 API 的内部服务则使用 gRPC 实现。

现在让我们看看一些实际采用 gRPC 的公司及其使用案例。

gRPC 在现实世界中的应用

任何进程间通信协议的成功在很大程度上取决于行业广泛的采纳以及该项目背后的用户和开发者社区。gRPC 已被广泛采用于构建微服务和云原生应用程序。让我们看看 gRPC 的一些关键成功案例。

Netflix

Netflix,一个基于订阅的视频流媒体公司,是在大规模实践微服务架构中的先驱之一。它的所有视频流媒体能力都通过面向外部的托管服务(或 API)提供给消费者,并且有数百个后端服务支持其 API。因此,进程间(或服务间)通信是其用例的最重要方面之一。在微服务实施的初始阶段,Netflix 开发了自己的技术堆栈,使用基于 HTTP/1.1 的 RESTful 服务进行服务间通信,这支持了 Netflix 产品几乎 98% 的业务用例。

然而,Netflix 在运营互联网规模时观察到 RESTful 服务方法的几个限制。RESTful 微服务的消费者经常通过检查资源和所需消息格式来自行编写,这非常耗时,阻碍了开发者的生产力,并增加了编码出错的风险。由于缺乏全面定义服务接口的技术,服务的实施和消费也变得具有挑战性。因此,Netflix 最初尝试通过构建内部 RPC 框架来克服大部分这些限制,但在评估了可用的技术堆栈后,选择了 gRPC 作为其服务间通信技术。在评估过程中,Netflix 发现 gRPC 在封装所有必需的责任方面表现出色,并提供了一个易于消费的整体解决方案。

采用 gRPC 后,Netflix 的开发人员生产力大幅提升。例如,对于每个客户端,数百行自定义代码被 proto 中的两到三行配置所替代。使用 gRPC 可以在几分钟内创建客户端,而这可能需要两到三周的时间。平台的整体稳定性也有了显著改善,因为不再需要大多数常见功能的手写代码,并且有了一种全面且安全的定义服务接口的方式。由于 gRPC 提供的性能提升,Netflix 整个平台的总体延迟也减少了。由于其已经在大多数进程间通信用例中采用了 gRPC,因此 Netflix 似乎已经将一些使用 REST 和 HTTP 协议进行进程间通信的自制项目(例如 Ribbon)置于维护模式(不再积极开发),并改用了 gRPC。

etcd 是一个分布式可靠的键值存储,用于分布式系统的最关键数据。它是 CNCF 中最受欢迎的开源项目之一,并且被许多其他开源项目(如 Kubernetes)广泛采用。

etcd 使用 gRPC 用户面向 API 来充分利用 gRPC 的全部功能。

Dropbox

Dropbox 是一个文件托管服务,提供云存储、文件同步、个人云和客户端软件。Dropbox 运行数百个多语言微服务,每秒交换数百万个请求。最初它使用了多个 RPC 框架,包括一个使用自定义协议进行手动序列化和反序列化的自制 RPC 框架,Apache Thrift,以及一个基于 HTTP/1.1 的传统 RPC 框架,使用 protobuf 编码的消息。

Dropbox 不再使用上述任何框架,而是转向了 gRPC(这也允许其重用一些现有的协议缓冲区定义其消息格式)。它创建了 Courier,一个基于 gRPC 的 RPC 框架。Courier 不是一个新的 RPC 协议,而是一个将 gRPC 与 Dropbox 现有基础设施集成的项目。Dropbox 已经扩展了 gRPC,以满足其与认证、授权、服务发现、服务统计、事件日志记录和跟踪工具相关的特定要求。

gRPC 的成功案例告诉我们,它是一个简单、提高生产力和可靠性的进程间通信协议,并且在互联网规模上进行扩展和操作。这些都是 gRPC 的早期知名早期采用者,但 gRPC 的用例和采用情况正在日益增长。

总结

现代软件应用或服务很少独立存在,连接它们的进程间通信技术是现代分布式软件应用中最重要的方面之一。gRPC 是一种可扩展、松耦合和类型安全的解决方案,比传统的 REST/HTTP 基础通信更高效。它使您能够像调用本地方法一样简单地连接、调用、操作和调试分布式异构应用,使用的网络传输协议如 HTTP/2。

gRPC 也可以被视为传统 RPC 的进化,并成功地克服了它们的局限性。gRPC 正被各种互联网规模的公司广泛采用,用于它们的进程间通信需求,并且最常用于构建内部服务到服务的通信。

从本章节获得的知识将是后续章节的良好入门点,您将深入研究 gRPC 通信的不同方面。这些知识将在下一章中得以实践,我们将从零开始构建一个真实的 gRPC 应用程序。

¹ K. Indrasiri 和 P. Siriwardena,《企业微服务》(Apress,2018)。

第二章:gRPC 入门

关于 gRPC 的理论就到此为止;让我们应用您在第一章学到的知识来从头开始构建一个真实的 gRPC 应用程序。在本章中,您将使用 Go 和 Java 构建一个简单的 gRPC 服务和一个调用您开发的服务的客户端应用程序。在此过程中,您将学习如何使用协议缓冲区指定 gRPC 服务定义,生成服务器框架和客户端存根,实现服务的业务逻辑,运行具有您实现的服务的 gRPC 服务器,并通过 gRPC 客户端应用程序调用服务。

让我们使用与第一章相同的在线零售系统,我们需要构建一个负责管理零售店产品的服务。该服务可以远程访问,服务的消费者可以通过提供产品 ID 来向系统中添加新产品,并从系统中检索产品详细信息。我们将使用 gRPC 对这个服务和消费者进行建模。您可以选择任意编程语言来实现这一点,但在本章中,我们将使用 Go 和 Java 语言来实现这个示例。

注意

您可以在本书的源代码存储库中尝试这个示例的 Go 和 Java 实现。

在图 2-1 中,我们说明了ProductInfo服务的客户端-服务器通信模式,每个方法调用的方式。服务器托管一个 gRPC 服务,提供两个远程方法:addProduct(product)getProduct(productId)。客户端可以调用这两个远程方法之一。

产品信息服务的客户端-服务器交互

图 2-1. 产品信息服务的客户端-服务器交互

让我们从创建ProductInfo gRPC 服务的服务定义开始构建这个示例。

创建服务定义

正如您在第一章中学到的,当您开发 gRPC 应用程序时,首先要做的是定义服务接口。该接口包含允许消费者远程调用的方法,调用这些方法时要使用的方法参数和消息格式等。所有这些服务定义都记录在协议缓冲区的定义中,这是 gRPC 中使用的接口定义语言(IDL)。

注意

我们将在第三章中进一步探讨不同消息模式的服务定义技术。我们还将在第四章中详细介绍协议缓冲区和 gRPC 实现细节。

一旦确定了服务的业务能力,就可以定义服务接口以满足业务需求。在我们的示例中,我们可以识别出ProductInfo服务中的两个远程方法(addProduct(product)getProduct(productId))以及两个消息类型(ProductProductID),这两个方法都接受和返回。

下一步是将这些服务定义指定为协议缓冲定义。使用协议缓冲,我们可以定义服务和消息类型。服务由其方法组成,每个方法由其类型、输入和输出参数定义。消息由其字段组成,每个字段由其类型和唯一索引值定义。让我们深入了解定义消息结构的详细信息。

定义消息

消息是在客户端和服务之间交换的数据结构。正如您在 图 2-1 中看到的那样,我们的ProductInfo用例有两种消息类型。一种是产品信息(Product),在向系统中添加新产品时需要,并在检索特定产品时返回。另一种是产品的唯一标识(ProductID),在从系统中检索特定产品时需要,并在添加新产品时返回:

ProductID

ProductID是产品的唯一标识符,可以是字符串值。我们可以定义自己的消息类型,其中包含一个字符串字段,或者使用由 Protocol Buffer 库提供的google.protobuf.StringValue这种已知消息类型。在这个示例中,我们将定义自己的消息类型,其中包含一个字符串字段。ProductID消息类型的定义如 示例 2-1 所示。

示例 2-1. ProductID消息类型的 Protocol Buffer 定义。
message ProductID {
   string value = 1;
}

Product

Product是我们在线零售应用中表示产品应该存在的数据的自定义消息类型。它可以包含一组字段,每个字段表示与每个产品相关联的数据。假设Product消息类型具有以下字段:

ID

产品的唯一标识符

名称

产品名称

描述

产品描述

价格

产品价格

然后我们可以使用协议缓冲定义我们的自定义消息类型,如 示例 2-2 所示。

示例 2-2. Product消息类型的 Protocol Buffer 定义
message Product {
   string id = 1;
   string name = 2;
   string description = 3;
   float price = 4;
}

这里分配给每个消息字段的数字用于唯一标识消息中的字段。因此,在同一消息定义中,我们不能使用相同的数字来标识两个不同的字段。当我们定义协议缓冲消息时,我们将进一步深入探讨消息定义技术的细节,并解释为什么需要为每个字段提供一个唯一的数字,在 第四章 中说明这一点。目前,您可以将其视为定义协议缓冲消息时的一个规则。

注意

protobuf 库为一组已知类型提供了协议缓冲区消息类型。因此,我们可以重用它们,而不是在服务定义中再次定义这些类型。您可以在 协议缓冲区文档 中获取有关这些已知类型的更多详细信息。

由于我们已经完成了 ProductInfo 服务的消息类型定义,现在可以继续进行服务接口的定义。

定义服务

服务 是向客户端暴露的一组远程方法。在我们的示例中,ProductInfo 服务有两个远程方法:addProduct(product)getProduct(productId)。根据协议缓冲区规则,远程方法中只能有一个输入参数,并且只能返回一个值。如果需要像 addProduct 方法中那样传递多个值给方法,我们需要定义一个消息类型,并像我们在 Product 消息类型中所做的那样将所有值分组:

addProduct

在系统中创建一个新的 Product。它需要产品的详细信息作为输入,并且如果操作成功完成,则返回新添加产品的产品标识号。Example 2-3 展示了 addProduct 方法的定义。

示例 2-3. addProduct 方法的协议缓冲区定义
rpc addProduct(Product) returns (google.protobuf.StringValue);

getProduct

检索产品信息。它需要 ProductID 作为输入,并且如果系统中存在特定产品,则返回 Product 的详细信息。Example 2-4 展示了 getProduct 方法的定义。

示例 2-4. getProduct 方法的协议缓冲区定义
rpc getProduct(google.protobuf.StringValue) returns (Product);

结合所有消息和服务,我们现在有了一个完整的协议缓冲区定义,用于我们的 ProductInfo 使用案例,如 Example 2-5 所示。

示例 2-5. 使用协议缓冲区的 ProductInfo 服务的 gRPC 服务定义
syntax = "proto3"; ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
package ecommerce; ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

service ProductInfo { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    rpc addProduct(Product) returns (ProductID); ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    rpc getProduct(ProductID) returns (Product); ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
}

message Product { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
    string id = 1; ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
    string name = 2;
    string description = 3;
}

message ProductID { ![8](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/8.png)
    string value = 1;
}

1

服务定义始于指定的协议缓冲区版本(proto3)的说明。

2

包名用于避免协议消息类型之间的名称冲突,并且将用于生成代码。

3

服务接口的定义。

4

远程方法添加产品,返回产品 ID 作为响应。

5

基于产品 ID 获取产品的远程方法。

6

Product 的消息格式/类型定义。

7

包含产品 ID 的字段(名称-值对),其唯一字段编号用于在消息二进制格式中标识您的字段。

8

ProductID 的消息格式/类型定义。

在协议缓冲定义中,我们可以指定一个包名(例如 ecommerce),这有助于防止不同项目之间的命名冲突。当我们使用带有包的服务定义生成我们的服务或客户端的代码时(除非我们显式指定不同的包用于代码生成),相同的包(当然,只有在语言支持 的概念时)将在相应的编程语言中创建。我们还可以使用带有版本号的包名,如 ecommerce.v1ecommerce.v2。因此,API 的未来主要更改可以共存于同一代码库中。

注意

像 IntelliJ IDEA、Eclipse、VSCode 等常用的集成开发环境现在都有支持协议缓冲的插件。您可以将插件安装到您的 IDE 中,并轻松为您的服务创建协议缓冲定义。

这里还应提及另一个过程,即从另一个 proto 文件导入。如果我们需要使用其他 proto 文件中定义的消息类型,我们可以将它们导入到我们的协议缓冲定义中。例如,如果我们想要使用在 wrappers.proto 文件中定义的 StringValue 类型(google.protobuf.StringValue),我们可以在我们的定义中导入 google/protobuf/wrappers.proto 文件如下所示:

syntax = "proto3";

import "google/protobuf/wrappers.proto";

package ecommerce;
...

完成服务定义的规范后,您可以继续实现 gRPC 服务和客户端。

实现

让我们实现一个 gRPC 服务,该服务包含我们在服务定义中指定的一组远程方法。这些远程方法由服务器公开,gRPC 客户端连接到服务器并调用这些远程方法。

如图 Figure 2-2 所示,我们首先需要编译 ProductInfo 服务定义并为所选语言生成源代码。gRPC 默认支持所有流行语言如 Java、Go、Python、Ruby、C、C++、Node 等。您可以选择在实现服务或客户端时使用哪种语言。gRPC 还跨越多种语言和平台工作,这意味着您可以在应用程序中将服务器编写为一种语言,将客户端编写为另一种语言。在我们的示例中,我们将使用 Go 和 Java 两种语言开发我们的客户端和服务器,因此您可以选择使用您偏好的实现。

为了从服务定义中生成源代码,我们可以手动编译 proto 文件使用 协议缓冲编译器,或者我们可以使用像 Bazel、Maven 或 Gradle 这样的构建自动化工具。这些自动化工具已经定义了一组规则,在构建项目时生成代码。通常,集成现有的构建工具来生成 gRPC 服务和客户端的源代码更加容易。

产品信息服务的客户端-服务器交互

图 2-2. 基于服务定义的微服务和消费者

在此示例中,我们将使用 Gradle 构建 Java 应用程序,并使用 Gradle 协议缓冲插件生成服务和客户端代码。对于 Go 应用程序,我们将使用协议缓冲编译器生成代码。

让我们开始实现 Go 和 Java 中的 gRPC 服务器和客户端。在此之前,请确保您的本地机器上已安装了 Java 7 或更高版本以及 Go 1.11 或更高版本。

开发一个服务

当您生成服务骨架代码时,您将获得建立 gRPC 通信所需的低级代码,相关的消息类型和接口。服务实现的任务就是实现与代码生成步骤一起生成的接口。让我们从实现 Go 服务开始,然后我们将看看如何在 Java 语言中实现相同的服务。

使用 Go 实现 gRPC 服务

实现 Go 服务有三个步骤。首先,我们需要为服务定义生成存根,然后实现服务所有远程方法的业务逻辑,最后创建一个在指定端口监听并注册服务以接受客户端请求的服务器。让我们从创建一个新的 Go 模块开始。在这里,我们将创建一个模块和模块内的一个子目录;模块 productinfo/service 用于保持服务代码,而子目录 (ecommerce) 用于保存自动生成的存根文件。在 productinfo 目录中创建一个名为 service 的目录。进入 service 目录并执行以下命令以创建模块 productinfo/service

go mod init productinfo/service

一旦创建模块并在模块内部创建一个子目录,您将获得以下模块结构:

└─ productinfo
           └─ service
                 ├── go.mod
                 ├ . . .
                 └── ecommerce
                         └── . . .

我们还需要使用 go.mod 文件更新特定版本的依赖项,如下所示:

module productinfo/service

require (
  github.com/gofrs/uuid v3.2.0
  github.com/golang/protobuf v1.3.2
  github.com/google/uuid v1.1.1
  google.golang.org/grpc v1.24.0
)
注意

从 Go 1.11 开始,引入了一个称为 模块 的新概念,允许开发人员在 GOPATH 之外的任何位置创建和构建 Go 项目。要创建一个 Go 模块,我们需要在 $GOPATH/src 之外的任何位置创建一个新目录,并在该目录内执行以下命令,以模块名称初始化模块:

go mod init <module_name>

一旦初始化模块,将在模块根目录内创建一个 go.mod 文件。然后我们可以在模块内部创建我们的 Go 源文件并构建它。Go 通过使用 go.mod 中列出的特定依赖模块版本解析导入。

生成客户端/服务器存根

现在我们将手动生成客户端/服务器存根,使用协议缓冲编译器。为此,我们需要满足以下一系列先决条件:

注意

在下载编译器时,需要选择适合您平台的编译器。例如,如果您使用的是 64 位 Linux 机器,并且需要获取协议缓冲区编译器版本 x.x.x,您需要下载 protoc-x.x.x-linux-x86_64.zip 文件。

  • 使用以下命令安装 gRPC 库:
go get -u google.golang.org/grpc
  • 使用以下命令安装 Go 的 protoc 插件:
go get -u github.com/golang/protobuf/protoc-gen-go

当我们满足所有先决条件后,可以按照以下示例执行 protoc 命令来生成服务定义的代码:

protoc -I ecommerce \ ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  ecommerce/product_info.proto \ ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  --go_out=plugins=grpc:<module_dir_path>/ecommerce ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

1

指定源 proto 文件和依赖 proto 文件所在的目录路径(使用 --proto_path-I 命令行标志指定)。如果不指定值,则使用当前目录作为源目录。在目录内,我们需要按照包名排列依赖的 proto 文件。

2

指定要编译的 proto 文件路径。编译器将读取文件并生成输出的 Go 文件。

3

指定您希望生成的代码去的目标目录。

当我们执行命令时,会在模块中给定的子目录(ecommerce)内生成一个存根文件(product_info.pb.go)。现在我们已生成了存根,需要使用生成的代码实现我们的业务逻辑。

实现业务逻辑

首先,让我们在 Go 模块(productinfo/service)内创建一个名为 productinfo_service.go 的新 Go 文件,并按 示例 2-6 中显示的方式实现远程方法。

示例 2-6. 在 Go 中实现 ProductInfo 服务的 gRPC 服务实现
package main

import (
  "context"
  "errors"
  "log"

"github.com/gofrs/uuid"
pb "productinfo/service/ecommerce" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

)

// server is used to implement ecommerce/product_info. type server struct{ ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  productMap map[string]*pb.Product
}

// AddProduct implements ecommerce.AddProduct func (s *server) AddProduct(ctx context.Context,
                    in *pb.Product) (*pb.ProductID, error) { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
  out, err := uuid.NewV4()
  if err != nil {
     return nil, status.Errorf(codes.Internal,
         "Error while generating Product ID", err)
  }
  in.Id = out.String()
  if s.productMap == nil {
     s.productMap = make(map[string]*pb.Product)
  }
  s.productMap[in.Id] = in
  return &pb.ProductID{Value: in.Id}, status.New(codes.OK, "").Err()

}

// GetProduct implements ecommerce.GetProduct func (s *server) GetProduct(ctx context.Context, in *pb.ProductID)
                              (*pb.Product, error) { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
  value, exists := s.productMap[in.Value]
  if exists {
    return value, status.New(codes.OK, "").Err()
  }
  return nil, status.Errorf(codes.NotFound, "Product does not exist.", in.Value)

}

1

导入包含从 protobuf 编译器生成的代码的包。

2

server 结构体是服务器的抽象。它允许将服务方法附加到服务器上。

3

AddProduct 方法以 Product 作为参数,并返回一个 ProductIDProductProductID 结构在 product_info.pb.go 文件中定义,该文件是从 product_info.proto 定义自动生成的。

4

GetProduct 方法以 ProductID 作为参数,并返回一个 Product

5

这两个方法都有一个 Context 参数。Context 对象包含元数据,例如最终用户身份验证令牌的标识和请求的截止时间,并且在请求的生命周期内存在。

6

这两种方法除了远程方法的返回值之外,还返回一个错误(方法有多种返回类型)。这些错误会传播到消费者,并可用于在消费者端进行错误处理。

这就是您必须为 ProductInfo 服务实现业务逻辑的所有内容。然后,我们可以创建一个简单的服务器来托管服务并接受来自客户端的请求。

创建一个 Go 服务器

要在 Go 中创建服务器,让我们在同一个 Go 包(productinfo/service)中创建一个名为 main.go 的新 Go 文件,并按照示例 2-7 中显示的方式实现 main 方法。

示例 2-7. 在 Go 中实现 gRPC 服务器以托管 ProductInfo 服务
package main

import (
  "log"
  "net"

  pb "productinfo/service/ecommerce" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "google.golang.org/grpc"
)

const (
  port = ":50051"
)

func main() {
  lis, err := net.Listen("tcp", port) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  if err != nil {
     log.Fatalf("failed to listen: %v", err)
  }
  s := grpc.NewServer() ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  pb.RegisterProductInfoServer(s, &server{}) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

  log.Printf("Starting gRPC listener on port " + port)
  if err := s.Serve(lis); err != nil { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
     log.Fatalf("failed to serve: %v", err)
  }
}

1

导入包含从 protobuf 编译器刚刚创建的生成代码的包。

2

我们希望 gRPC 服务器绑定到的 TCP 监听器创建在端口(50051)上。

3

通过调用 gRPC Go API 创建新的 gRPC 服务器实例。

4

先前实现的服务通过调用生成的 API 注册到新创建的 gRPC 服务器上。

5

开始监听端口(50051)传入的消息。

现在,我们已经完成了在 Go 语言中为我们的业务用例构建 gRPC 服务。同时,我们还创建了一个简单的服务器,用于公开服务方法并接受来自 gRPC 客户端的消息。

如果您喜欢使用 Java 构建服务,我们可以使用 Java 实现相同的服务。实现过程与 Go 非常相似。因此,让我们使用 Java 语言创建相同的服务。但是,如果您有兴趣在 Go 中构建客户端应用程序,请直接转到“开发 gRPC 客户端”。

使用 Java 实现 gRPC 服务

创建 Java gRPC 项目时,最佳方法是使用像 Gradle、Maven 或 Bazel 这样的现有构建工具,因为它管理所有依赖项和代码生成等。在我们的示例中,我们将使用 Gradle 管理项目,并讨论如何使用 Gradle 创建 Java 项目以及如何实现服务的所有远程方法的业务逻辑。最后,我们将创建一个服务器并注册服务以接受客户端请求。

注意

Gradle 是一个支持多种语言(包括 Java、Scala、Android、C/C++ 和 Groovy)的构建自动化工具,并与 Eclipse 和 IntelliJ IDEA 等开发工具紧密集成。您可以按照官方页面上给出的步骤在您的机器上安装 Gradle。

设置一个 Java 项目

让我们首先创建一个 Gradle Java 项目(product-info-service)。创建项目后,您将获得以下项目结构:

 product-info-service

  ├── build.gradle
  ├ . . .
  └── src
      ├── main
      │   ├── java
      │   └── resources
      └── test
          ├── java
          └── resources

src/main目录下创建一个proto目录,并将我们的ProductInfo服务定义文件(.proto文件)添加到proto目录中。

接下来,您需要更新build.gradle文件并添加依赖项和 Gradle 的 protobuf 插件。根据 Example 2-8 更新build.gradle文件。

Example 2-8. gRPC Java 项目的 Gradle 配置
apply plugin: 'java'
apply plugin: 'com.google.protobuf'

repositories {
   mavenCentral()
}

def grpcVersion = '1.24.1' ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

dependencies { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
   compile "io.grpc:grpc-netty:${grpcVersion}"
   compile "io.grpc:grpc-protobuf:${grpcVersion}"
   compile "io.grpc:grpc-stub:${grpcVersion}"
   compile 'com.google.protobuf:protobuf-java:3.9.2'
}

buildscript {
   repositories {
       mavenCentral()
   }
   dependencies { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

       classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
   }
}

protobuf { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
   protoc {
       artifact = 'com.google.protobuf:protoc:3.9.2'
   }
   plugins {
       grpc {
           artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
       }
   }
   generateProtoTasks {
       all()*.plugins {
           grpc {}
       }
   }
}

sourceSets { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
   main {
       java {
           srcDirs 'build/generated/source/proto/main/grpc'
           srcDirs 'build/generated/source/proto/main/java'
       }
   }
}

jar { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
   manifest {
       attributes "Main-Class": "ecommerce.ProductInfoServer"
   }
   from {
       configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
   }
}

apply plugin: 'application'

startScripts.enabled = false

1

在 Gradle 项目中使用的 gRPC Java 库版本。

2

我们在此项目中需要使用的外部依赖项。

3

我们在项目中使用的 Gradle protobuf 插件版本。如果您的 Gradle 版本低于 2.12,请使用插件版本 0.7.5。

4

在 protobuf 插件中,我们需要指定 protobuf 编译器版本和 protobuf Java 可执行版本。

5

此内容用于通知像 IntelliJ IDEA、Eclipse 或 NetBeans 等 IDE 有关生成代码的信息。

6

配置在运行应用程序时使用的主类。

然后运行以下命令来构建库并从 protobuf 构建插件生成存根代码:

$ ./gradle build

现在我们已经准备好了带有自动生成代码的 Java 项目。让我们实现服务接口,并向远程方法添加业务逻辑。

实现业务逻辑

首先,让我们在src/main/java源目录下创建 Java 包(ecommerce),并在包内创建一个 Java 类(ProductInfoImpl.java)。然后,根据 Example 2-9 实现远程方法。

Example 2-9. Java 中ProductInfo服务的 gRPC 服务实现
package ecommerce;

import io.grpc.Status;
import io.grpc.StatusException;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public class ProductInfoImpl extends ProductInfoGrpc.ProductInfoImplBase { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

   private Map productMap = new HashMap<String, ProductInfoOuterClass.Product>();

   @Override
  public void addProduct(
       ProductInfoOuterClass.Product request,
      io.grpc.stub.StreamObserver
           <ProductInfoOuterClass.ProductID> responseObserver ) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
       UUID uuid = UUID.randomUUID();
       String randomUUIDString = uuid.toString();
       productMap.put(randomUUIDString, request);
       ProductInfoOuterClass.ProductID id =
           ProductInfoOuterClass.ProductID.newBuilder()
           .setValue(randomUUIDString).build();
       responseObserver.onNext(id); ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
       responseObserver.onCompleted(); ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
   }

   @Override
  public void getProduct(
       ProductInfoOuterClass.ProductID request,
       io.grpc.stub.StreamObserver
            <ProductInfoOuterClass.Product> responseObserver ) { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
       String id = request.getValue();
       if (productMap.containsKey(id)) {
           responseObserver.onNext(
                (ProductInfoOuterClass.Product) productMap.get(id)); ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
           responseObserver.onCompleted(); ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
       } else {
           responseObserver.onError(new StatusException(Status.NOT_FOUND)); ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
       }
   }
}

1

扩展自插件生成的抽象类(ProductInfoGrpc.ProductInfoImplBase)。这将允许在服务定义中定义的AddProductGetProduct方法中添加业务逻辑。

2

AddProduct方法以Product(ProductInfoOuterClass.Product)作为参数。Product类定义在ProductInfoOuterClass类中,该类是从服务定义生成的。

3

GetProduct方法以ProductID(ProductInfoOuterClass.ProductID)作为参数。ProductID类定义在ProductInfoOuterClass类中,该类是从服务定义生成的。

4

responseObserver对象用于将响应发送回客户端并关闭流。

5

将响应发送回客户端。

6

通过关闭流来结束客户端调用。

7

向客户端发送一个错误。

这就是您需要在 Java 中实现 ProductInfo 服务的业务逻辑的全部内容。然后,我们可以创建一个简单的服务器来托管服务并接受来自客户端的请求。

创建一个 Java 服务器

为了将我们的服务暴露给外部,我们需要创建一个 gRPC 服务器实例,并将我们的 ProductInfo 服务注册到服务器中。服务器将在指定的端口上监听,并将所有请求分派到相关的服务上。让我们在包内创建一个主类(ProductInfoServer.java),如 Example 2-10 中所示。

Example 2-10. 在 Java 中托管 ProductInfo 服务的 gRPC 服务器实现
package ecommerce;

import io.grpc.Server;
import io.grpc.ServerBuilder;

import java.io.IOException;

public class ProductInfoServer {

   public static void main(String[] args)
           throws IOException, InterruptedException {
       int port = 50051;
       Server server = ServerBuilder.forPort(port) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
               .addService(new ProductInfoImpl())
               .build()
               .start();
       System.out.println("Server started, listening on " + port);
       Runtime.getRuntime().addShutdownHook(new Thread(() -> { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
           System.err.println("Shutting down gRPC server since JVM is " +
                "shutting down");
           if (server != null) {
               server.shutdown();
           }
           System.err.println(“Server shut down");
       }));
       server.awaitTermination(); ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
   }
}

1

服务器实例创建在端口 50051 上。这是我们希望服务器绑定并监听传入消息的端口。我们的 ProductInfo 服务实现添加到了服务器中。

2

当 JVM 关闭时,添加运行时关闭钩子以关闭 gRPC 服务器。

3

在方法结束时,保持服务器线程直到服务器终止。

现在我们已经完成了两种语言中的 gRPC 服务的实现。接下来,我们可以进行 gRPC 客户端的实现。

开发 gRPC 客户端

就像我们实现 gRPC 服务一样,现在我们可以讨论如何创建一个应用程序来与服务器通信。让我们从服务定义中生成客户端存根开始。在生成的客户端存根之上,我们可以创建一个简单的 gRPC 客户端来连接我们的 gRPC 服务器,并调用其提供的远程方法。

在这个示例中,我们将使用 Java 和 Go 两种语言编写客户端应用程序。但是您不需要在同一种语言或平台上创建您的服务器和客户端。由于 gRPC 跨语言和跨平台工作,您可以在任何支持的语言中创建它们。让我们先讨论 Go 的实现。如果您对 Java 实现感兴趣,可以跳过下一节,直接进入 Java 客户端。

实现 gRPC Go 客户端

让我们首先创建一个新的 Go 模块 (productinfo/client),并在模块内部创建一个子目录 (ecommerce)。为了实现 Go 客户端应用程序,我们还需要生成存根,就像我们在实现 Go 服务时所做的那样。由于我们需要创建相同的文件 (product_info.pb.go) 并需要遵循生成存根的相同步骤,我们不会在此处详述。请参考 “生成客户端/服务器存根” 以生成存根文件。

让我们在 Go 模块 (productinfo/client) 中创建一个名为 productinfo_client.go 的新文件,并实现主方法来调用远程方法,如 Example 2-11 中所示。

示例 2-11. Go 中的 gRPC 客户端应用程序
package main

import (
  "context"
  "log"
  "time"

  pb "productinfo/client/ecommerce" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "google.golang.org/grpc"

)

const (
  address = "localhost:50051"
)

func main() {

  conn, err := grpc.Dial(address, grpc.WithInsecure()) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  if err != nil {
     log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close() ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
  c := pb.NewProductInfoClient(conn) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

  name := "Apple iPhone 11"
  description := `Meet Apple iPhone 11\. All-new dual-camera system with
              Ultra Wide and Night mode.`
  price := float32(1000.0)
  ctx, cancel := context.WithTimeout(context.Background(), time.Second) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
  defer cancel()
  r, err := c.AddProduct(ctx,
         &pb.Product{Name: name, Description: description, Price: price})  ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
  if err != nil {
     log.Fatalf("Could not add product: %v", err)
  }
  log.Printf("Product ID: %s added successfully", r.Value)

  product, err := c.GetProduct(ctx, &pb.ProductID{Value: r.Value}) ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
  if err != nil {
    log.Fatalf("Could not get product: %v", err)
  }
  log.Printf("Product: ", product.String())

}

1

导入包含我们从 protobuf 编译器生成的代码的包。

2

使用提供的地址(“localhost:50051”)建立与服务器的连接。在这里,我们创建了客户端和服务器之间的非安全连接。

3

传递连接并创建存根。此存根实例包含调用服务器的所有远程方法。

4

创建一个 Context 对象以传递远程调用。这里的 Context 对象包含元数据,如最终用户的身份、授权令牌和请求的截止日期,并且在请求的生命周期内都存在。

5

使用产品详细信息调用 addProduct 方法。如果操作成功完成,则返回产品 ID。否则返回错误。

6

使用产品 ID 调用 getProduct 方法。如果操作成功完成,则返回产品详情。否则返回错误。

7

当所有操作完成后关闭连接。

现在我们已经完成了使用 Go 语言构建 gRPC 客户端的过程。接下来让我们使用 Java 语言创建一个客户端。这不是强制性的步骤。如果您也有兴趣在 Java 中构建 gRPC 客户端,可以继续;否则,您可以跳过下一部分,直接去 “构建和运行”。

实现 Java 客户端

要创建 Java 客户端应用程序,我们还需要设置一个 Gradle 项目(product-info-client)并使用 Gradle 插件生成类,就像我们在实现 Java 服务时所做的那样。请按照 “设置 Java 项目” 中的步骤设置 Java 客户端项目。

一旦通过 Gradle 构建工具为您的项目生成了客户端存根代码,让我们在 ecommerce 包内创建一个名为 ProductInfoClient 的新类,并添加 示例 2-12 中的内容。

示例 2-12. Java 中的 gRPC 客户端应用程序
package ecommerce;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;

import java.util.logging.Logger;

/**
* gRPC client sample for productInfo service.
*/
public class ProductInfoClient {

   public static void main(String[] args) throws InterruptedException {
       ManagedChannel channel = ManagedChannelBuilder
           .forAddress("localhost", 50051) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
           .usePlaintext()
           .build();

       ProductInfoGrpc.ProductInfoBlockingStub stub =
              ProductInfoGrpc.newBlockingStub(channel); ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

       ProductInfoOuterClass.ProductID productID = stub.addProduct(    ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
               ProductInfoOuterClass.Product.newBuilder()
                       .setName("Apple iPhone 11")
                       .setDescription("Meet Apple iPhone 11\. " +
                            All-new dual-camera system with " +
                            "Ultra Wide and Night mode.");
                       .setPrice(1000.0f)
                       .build());
       System.out.println(productID.getValue());

       ProductInfoOuterClass.Product product = stub.getProduct(productID);  ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
       System.out.println(product.toString());
       channel.shutdown(); ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
   }
}

1

创建一个 gRPC 通道,指定我们要连接到的服务器地址和端口。在这里,我们尝试连接运行在同一台机器上并监听端口 50051 的服务器。我们还启用了明文传输,这意味着我们正在设置客户端和服务器之间的非安全连接。

2

使用新创建的通道创建客户端存根。我们可以创建两种类型的存根。一种是 BlockingStub,它会等待接收服务器响应。另一种是 NonBlockingStub,它不会等待服务器响应,而是注册一个观察者来接收响应。在本例中,我们使用 BlockingStub 来简化客户端。

3

使用产品详细信息调用 addProduct 方法。如果操作成功完成,将返回产品 ID。

4

使用产品 ID 调用 getProduct。如果操作成功完成,将返回产品详细信息。

5

当一切都完成后关闭连接,以确保我们应用程序中使用的网络资源在我们完成后安全地返回。

现在我们已经完成了开发 gRPC 客户端的工作。让我们让客户端和服务器相互通信。

构建和运行

现在是时候构建和运行我们创建的 gRPC 服务器和客户端应用程序了。您可以在本地机器、虚拟机、Docker 或 Kubernetes 上部署和运行 gRPC 应用程序。在本节中,我们将讨论如何在本地机器上构建和运行 gRPC 服务器和客户端应用程序。

注意

我们将在 第七章 中介绍如何在 Docker 和 Kubernetes 环境中部署和运行 gRPC 应用程序。

让我们在本地运行我们刚刚开发的 gRPC 服务器和客户端应用程序。由于我们的服务器和客户端应用程序使用两种语言编写,我们将分别构建服务器应用程序。

构建 Go 服务器

当我们实现 Go 服务时,工作空间中的最终包结构如下所示:

└─ productinfo
           └─ service
                 ├─ go.mod
                 ├─ main.go
                 ├─ productinfo_service.go
                 └─ ecommerce
                    └── product_info.pb.go

我们可以构建我们的服务以生成一个服务二进制文件(bin/server)。为了构建,首先进入 Go 模块根目录位置(productinfo/service),然后执行以下 shell 命令:

$ go build -i -v -o bin/server

构建成功后,在 bin 目录下创建一个可执行文件(bin/server)。

接下来,让我们设置 Go 客户端!

构建 Go 客户端

当我们实现 Go 客户端时,工作空间中的包结构如下所示:

└─ productinfo
           └─ client
                ├─ go.mod
                ├──main.go
                └─ ecommerce
                       └── product_info.pb.go

我们可以使用以下 shell 命令像构建 Go 服务一样构建客户端代码:

$ go build -i -v -o bin/client

构建成功后,在 bin 目录下创建一个可执行文件(bin/client)。下一步是运行这些文件!

运行 Go 服务器和客户端

我们刚刚构建了一个客户端和一个服务器。让我们在不同的终端上运行它们并让它们相互通信:

// Running Server
$ bin/server
2019/08/08 10:17:58 Starting gRPC listener on port :50051

// Running Client
$ bin/client
2019/08/08 11:20:01 Product ID: 5d0e7cdc-b9a0-11e9-93a4-6c96cfe0687d
added successfully
2019/08/08 11:20:01 Product: id:"5d0e7cdc-b9a0-11e9-93a4-6c96cfe0687d"
        name:"Apple iPhone 11"
        description:"Meet Apple iPhone 11\. All-new dual-camera system with
 Ultra Wide and Night mode."
        price:1000

接下来我们将构建一个 Java 服务器。

构建 Java 服务器

由于我们将 Java 服务实现为 Gradle 项目,我们可以使用以下命令轻松构建项目:

$ gradle build

构建成功后,可执行的 JAR 文件(server.jar)将创建在 build/libs 目录下。

构建 Java 客户端

与服务一样,我们可以轻松地使用以下命令构建项目:

$ gradle build

一旦构建成功,可执行的 JAR 文件(client.jar)将创建在 build/libs 目录下。

运行 Java 服务器和客户端

我们现在已经用 Java 语言分别构建了客户端和服务器。让我们来运行它们:

$ java -jar build/libs/server.jar
INFO: Server started, listening on 50051

$ java -jar build/libs/client.jar
INFO: Product ID: a143af20-12e6-483e-a28f-15a38b757ea8 added successfully.
INFO: Product: name: "Apple iPhone 11"
description: "Meet Apple iPhone 11\. All-new dual-camera system with
Ultra Wide and Night mode."
price: 1000.0

现在我们已经在本地机器上成功构建并运行了示例。一旦成功运行客户端和服务器,客户端应用程序首先使用addProduct方法调用产品详细信息,并接收新添加产品的产品标识符作为响应。然后,通过使用产品标识符调用getProduct方法来检索新添加的产品详细信息。正如本章前面提到的,我们不需要用相同的语言编写客户端与服务器通信。我们可以运行一个 gRPC Java 服务器和 Go 客户端,它将毫无问题地工作。

这就是本章的结束!

摘要

当您开发 gRPC 应用程序时,首先使用协议缓冲区定义服务接口。协议缓冲区是一种语言无关、平台中立、可扩展的机制,用于序列化结构化数据。接下来,为您选择的编程语言生成服务器端和客户端代码,这简化了服务器端和客户端逻辑,提供了低级通信抽象。在服务器端,您实现远程公开的方法的逻辑,并运行绑定服务的 gRPC 服务器。在客户端,您连接到远程 gRPC 服务器,并使用生成的客户端代码调用远程方法。

本章主要介绍如何动手开发和运行 gRPC 服务器和客户端应用程序的经验。按照本节的步骤获得的经验在构建真实的 gRPC 应用程序时非常有用,因为无论使用哪种语言,构建 gRPC 应用程序都需要类似的步骤。因此,在下一章中,我们将进一步扩展您学到的概念和技术,以构建实际的使用案例。

第三章:gRPC 通信模式

在前几章中,您学习了 gRPC 的进程间通信技术的基础知识,并获得了构建基于 gRPC 的简单应用程序的实践经验。到目前为止,我们已经定义了一个服务接口,实现了一个服务,运行了一个 gRPC 服务器,并通过 gRPC 客户端应用程序远程调用服务操作。客户端和服务器之间的通信模式是简单的请求-响应式通信,您发送一个请求就会得到一个响应。但是,使用 gRPC,您可以利用除简单请求-响应模式之外的不同进程间通信模式(或 RPC 样式)。

在本章中,我们将探讨 gRPC 应用程序中使用的四种基本通信模式:一元 RPC(简单 RPC)、服务器端流式传输、客户端端流式传输和双向流式传输。我们将使用一些真实用例展示每种模式,使用 gRPC IDL 定义服务定义,并使用 Go 在服务端和客户端实现服务。

注意

Go 和 Java 代码示例

为了保持一致性,本章中的所有代码示例均使用 Go 编写。但如果您是 Java 开发人员,您也可以在本书的源代码存储库中找到相同用例的完整 Java 代码示例。

简单 RPC(一元 RPC)

让我们从最简单的 RPC 样式,简单 RPC,也称为一元 RPC开始讨论 gRPC 通信模式。在简单 RPC 中,当客户端调用服务器的远程函数时,客户端向服务器发送一个请求,并获得一个包含状态详细信息和尾部元数据的单个响应。实际上,这正是您在第一章和第二章学到的通信模式。让我们通过一个真实的用例进一步了解简单 RPC 模式。

假设我们需要为基于 gRPC 的在线零售应用程序构建一个OrderManagement服务。作为该服务的一部分,我们必须实现一个getOrder方法,客户端可以通过提供订单 ID 检索现有订单。如 图 3-1 所示,客户端发送一个带有订单 ID 的单个请求,服务端响应一个包含订单信息的单个响应。因此,它遵循简单 RPC 模式。

简单/一元 RPC

图 3-1. 简单/一元 RPC

现在让我们继续实现这个模式。第一步是为 OrderManagement 服务创建服务定义,其中包含 getOrder 方法。如 Example 3-1 中的代码片段所示,我们可以使用协议缓冲定义服务定义,并且 getOrder 远程方法接收一个单一的请求订单 ID,并响应一个单一的响应,其中包含 Order 消息。Order 消息具有在此用例中表示订单所需的结构。

Example 3-1. 使用简单 RPC 模式的 OrderManagement 的 getOrder 方法的服务定义
syntax = "proto3";

import "google/protobuf/wrappers.proto"; ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

package ecommerce;

service OrderManagement {
    rpc getOrder(google.protobuf.StringValue) returns (Order); ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
}

message Order { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    string id = 1;
    repeated string items = 2; ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    string description = 3;
    float price = 4;
    string destination = 5;
}

1

使用此包以利用诸如 StringValue 等的知名类型。

2

检索订单的远程方法。

3

定义 Order 类型。

4

repeated 用于表示消息中可以重复任意次数(包括零)的字段。这里一个订单消息可以包含任意数量的条目。

然后,使用 gRPC 服务定义 proto 文件,您可以生成服务器框架代码并实现 getOrder 方法的逻辑。在 Example 3-2 的代码片段中,我们展示了 OrderManagement 服务的 Go 实现。作为 getOrder 方法的输入,您将获得一个单一的订单 ID(String)作为请求,并且您可以简单地从服务器端查找订单并响应一个 Order 消息(Order 结构)。可以返回带有 nil 错误的 Order 消息,告诉 gRPC 我们已经完成了对 RPC 的处理,并且可以将 Order 返回给客户端。

Example 3-2. 使用 Go 实现 OrderManagement 的 getOrder 服务
// server/main.go
func (s *server) GetOrder(ctx context.Context,
	orderId *wrapper.StringValue) (*pb.Order, error) {
     // Service Implementation.
	ord := orderMap[orderId.Value]
	return &ord, nil
}
注意

详细说明了 gRPC 服务器和客户端完整消息流的低级细节,这些在 Chapter 4 中有解释。除了我们在您的服务定义中为 getOrder 方法指定的方法参数外,您可以观察到在 OrderManagement 服务的前述 Go 实现中,方法还传递了另一个 Context 参数。Context 携带一些如截止时间和取消等构造,这些用于控制 gRPC 的行为。我们将在 Chapter 5 中详细讨论这些概念。

现在让我们实现客户端逻辑,远程调用getOrder方法。与服务器端实现一样,你可以生成首选语言的代码以创建客户端存根,然后使用该存根调用服务。在示例 3-3 中,我们使用了 Go gRPC 客户端来调用OrderManagement服务。当然,首先要建立与服务器的连接,并启动客户端存根来调用服务。然后,你可以简单地调用客户端存根的getOrder方法来远程调用方法。作为响应,你会得到一个包含我们在服务定义中使用协议缓冲区定义的订单信息的Order消息。

示例 3-3. 使用 Go 实现的客户端调用远程方法getOrder
// Setting up a connection to the server.
...
orderMgtClient := pb.NewOrderManagementClient(conn)
...

// Get Order
retrievedOrder , err := orderMgtClient.GetOrder(ctx,
       &wrapper.StringValue{Value: "106"})
log.Print("GetOrder Response -> : ", retrievedOrder)

简单 RPC 模式非常直接,适合大多数进程间通信用例。该实现在多种编程语言中非常相似,你可以在该书的示例源代码仓库中找到 Go 和 Java 的源代码。

现在,既然你已经对简单 RPC 通信模式有了很好的理解,让我们继续服务器端流式 RPC

服务器端流式 RPC

在简单 RPC 中,你总是在 gRPC 服务器和 gRPC 客户端之间的通信中有一个请求和一个响应。在服务器端流式 RPC 中,服务器在收到客户端的请求消息后会返回一系列响应。这一系列的多个响应被称为“流”。在发送所有服务器响应之后,服务器通过向客户端发送服务器状态详细信息作为尾随元数据来标记流的结束。

让我们来看一个真实的用例,进一步理解服务器端流式。在我们的OrderManagement服务中,假设我们需要构建一个订单搜索功能,我们可以提供搜索词并获取匹配的结果(图 3-2)。与其一次性发送所有匹配订单,OrderManagement服务可以在找到时即时发送订单。这意味着订单服务客户端将会为其发送的单个请求接收到多个响应消息。

服务器端流式 RPC

图 3-2. 服务器端流式 RPC

现在让我们在OrderManagement服务的 gRPC 服务定义中包含一个searchOrder方法。如示例 3-4 所示,方法定义与简单 RPC 非常相似,但作为返回参数,你必须在服务定义的 proto 文件中使用returns (stream Order)指定一组订单的stream

示例 3-4. 带有服务器端流式 RPC 的服务定义
syntax = "proto3";

import "google/protobuf/wrappers.proto";

package ecommerce;

service OrderManagement {
    ...
    rpc searchOrders(google.protobuf.StringValue) returns (stream Order); ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
    ...
}

message Order {
    string id = 1;
    repeated string items = 2;
    string description = 3;
    float price = 4;
    string destination = 5;
}

1

定义服务器端流式传输,通过返回stream类型的Order消息。

根据服务定义,你可以生成服务端代码,然后通过实现生成的接口来构建 OrderManagement gRPC 服务中 searchOrder 方法的逻辑。在示例 3-5 中展示的 Go 实现中,SearchOrders 方法有两个参数:searchQuery,一个字符串值,和一个特殊的参数 OrderManagement_SearchOrdersServer,用于向其写入我们的响应。OrderManagement_SearchOrdersServer 充当流的引用对象,我们可以使用 Send(…) 方法向流中写入多个响应。业务逻辑在于查找匹配的订单,并逐个通过流发送它们。一旦所有响应写入流中,你可以通过返回 nil 来标记流的结束,服务器状态和其他尾随元数据将发送到客户端。

示例 3-5. 使用 Go 实现的 OrderManagement 的服务端实现,带有 searchOrders
func (s *server) SearchOrders(searchQuery *wrappers.StringValue,
	stream pb.OrderManagement_SearchOrdersServer) error {

	for key, order := range orderMap {
		log.Print(key, order)
		for _, itemStr := range order.Items {
			log.Print(itemStr)
			if strings.Contains(
				itemStr, searchQuery.Value) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
				// Send the matching orders in a stream 				err := stream.Send(&order) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
				if err != nil {
				   return fmt.Errorf(
					    "error sending message to stream : %v",
						    err) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
				}
				log.Print("Matching Order Found : " + key)
				break
			}
		}
	}
	return nil
}

1

查找匹配的订单。

2

发送匹配订单通过流。

3

检查可能在向客户端流式传输消息时发生的错误。

客户端从客户端发起的远程方法调用与简单的 RPC 非常相似。然而,在这里,你必须处理多个响应,因为服务器将多个响应写入流中。因此,在 gRPC 客户端的 Go 实现中(示例 3-6),我们使用 Recv() 方法从客户端流中检索消息,并持续执行,直到流结束。

示例 3-6. 使用 Go 实现的 OrderManagement 的客户端实现,带有 searchOrders
// Setting up a connection to the server. ...
	c := pb.NewOrderManagementClient(conn)
...
     searchStream, _ := c.SearchOrders(ctx,
     	&wrapper.StringValue{Value: "Google"}) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

	for {
		searchOrder, err := searchStream.Recv() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
		if err == io.EOF { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
			break
		}
           // handle other possible errors 		log.Print("Search Result : ", searchOrder)
	}

1

SearchOrders 函数返回一个 OrderManagement_SearchOrders​Client 的客户端流,其中包含一个 Recv 方法。

2

调用客户端流的 Recv() 方法逐个检索 Order 响应。

3

当找到流的结束时,Recv 返回一个 io.EOF

现在让我们看看客户端流式 RPC,这在逻辑上与服务器流式 RPC 正好相反。

客户端流式 RPC

在客户端流式 RPC 中,客户端发送多个消息到服务器,而不是单个请求。服务器向客户端发送单个响应。然而,服务器不一定需要等到接收到所有来自客户端的消息后才发送响应。根据此逻辑,你可以在从流中读取一个或几个消息后或者读取所有消息后发送响应。

让我们进一步扩展我们的 OrderManagement 服务,以理解客户端流式 RPC。假设您想在 OrderManagement 服务中包含一个新方法 updateOrders,以更新一组订单(图 3-3)。在这里,我们希望将订单列表作为消息流发送到服务器,并且服务器将处理该流并发送带有订单状态的消息。

客户端流式 RPC

图 3-3. 客户端流式 RPC

然后,我们可以在 OrderManagement 服务的服务定义中包含 updateOrders 方法,如示例 3-7 所示。您可以简单地将 stream order 作为 updateOrders 的方法参数,以表示 updateOrders 将从客户端接收多条消息作为输入。由于服务器只发送单个响应,返回值是单个字符串消息。

示例 3-7. 带有客户端流式 RPC 的服务定义。
syntax = "proto3";

import "google/protobuf/wrappers.proto";

package ecommerce;

service OrderManagement {
...
    rpc updateOrders(stream Order) returns (google.protobuf.StringValue);
...
}

message Order {
    string id = 1;
    repeated string items = 2;
    string description = 3;
    float price = 4;
    string destination = 5;
}

一旦我们更新了服务定义,我们可以生成服务器端和客户端代码。在服务器端,您需要实现 OrderManagement 服务的生成方法接口中的 UpdateOrders 方法。在示例 3-8 中显示的 Go 实现中,UpdateOrders 方法具有 OrderManagement_UpdateOrdersServer 参数,这是从客户端传入消息流的引用对象。因此,您可以通过调用 Recv() 方法从该对象中读取消息。根据业务逻辑,您可以读取少量或所有消息,直到流的结束。服务可以通过调用 OrderManagement_UpdateOrdersServer 对象的 SendAndClose 方法简单地发送其响应,这也标记了服务端消息流的结束。如果服务器决定过早停止从客户端的流中读取消息,则服务器应取消客户端流,以便客户端知道停止生成消息。

示例 3-8. 在 Go 中实现的 OrderManagement 服务,包含了 updateOrders 方法的服务实现。
func (s *server) UpdateOrders(stream pb.OrderManagement_UpdateOrdersServer) error {

	ordersStr := "Updated Order IDs : "
	for {
		order, err := stream.Recv() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
		if err == io.EOF { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
			// Finished reading the order stream. 			return stream.SendAndClose(
				&wrapper.StringValue{Value: "Orders processed "
				+ ordersStr})
		}
		// Update order 		orderMap[order.Id] = *order

		log.Printf("Order ID ", order.Id, ": Updated")
		ordersStr += order.Id + ", "
	}
}

1

从客户端流中读取消息。

2

检查流的结束。

现在让我们看看客户端流式 RPC 用例的客户端实现。如下 Go 实现所示(示例 3-9),客户端可以通过客户端流引用使用 updateStream.Send 方法发送多条消息。一旦所有消息流式传输完毕,客户端可以通过流引用的 CloseAndRecv 方法标记流的结束并接收来自服务的响应。

示例 3-9. 在 Go 中实现的 OrderManagement 的客户端实现,包含了 updateOrders 方法。
// Setting up a connection to the server. ...
	c := pb.NewOrderManagementClient(conn)
...
     updateStream, err := client.UpdateOrders(ctx) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

	if err != nil { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
		log.Fatalf("%v.UpdateOrders(_) = _, %v", client, err)
	}

	// Updating order 1 	if err := updateStream.Send(&updOrder1); err != nil { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
		log.Fatalf("%v.Send(%v) = %v",
			updateStream, updOrder1, err) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
	}

	// Updating order 2 	if err := updateStream.Send(&updOrder2); err != nil {
		log.Fatalf("%v.Send(%v) = %v",
			updateStream, updOrder2, err)
	}

	// Updating order 3 	if err := updateStream.Send(&updOrder3); err != nil {
		log.Fatalf("%v.Send(%v) = %v",
			updateStream, updOrder3, err)
	}

	updateRes, err := updateStream.CloseAndRecv() ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
	if err != nil {
		log.Fatalf("%v.CloseAndRecv() got error %v, want %v",
			updateStream, err, nil)
	}
	log.Printf("Update Orders Res : %s", updateRes)

1

调用 UpdateOrders 远程方法。

2

处理与 UpdateOrders 相关的错误。

3

通过客户端流发送订单更新。

4

处理向流发送消息时的错误。

5

关闭流并接收响应。

作为此功能调用的结果,您会收到服务的响应消息。既然现在您已经很好地理解了服务器流式和客户端流式 RPC,让我们继续讨论双向流式 RPC,这种 RPC 是我们讨论过的 RPC 风格的一种组合。

双向流式 RPC

在双向流式 RPC 中,客户端作为一系列消息的流向服务器发送请求。服务器也以消息流的形式响应。调用必须由客户端端发起,但之后的通信完全基于 gRPC 客户端和服务器的应用逻辑。让我们通过一个示例详细了解双向流式 RPC。如图 Figure 3-4 所示,在我们的 OrderManagement 服务用例中,假设我们需要订单处理功能,您可以发送连续的订单集(订单流),并根据交付位置将其处理成组合装运(即根据交付目的地将订单组织成装运)。

双向流式 RPC

图 3-4. 双向流式 RPC

我们可以确定此业务用例的以下关键步骤:

  • 客户端应用程序通过与服务器建立连接并发送调用元数据(头部)来启动业务用例。

  • 连接设置完成后,客户端应用程序发送一组连续的订单 ID,这些订单需要由 OrderManagement 服务处理。

  • 每个订单 ID 都作为单独的 gRPC 消息发送到服务器。

  • 服务为每个指定的订单 ID 处理每个订单,并根据订单的交付位置将其组织成组合装运。

  • 组合装运可能包含多个订单,这些订单应该发送到相同的目的地。

  • 订单按批次处理。当达到批次大小时,所有当前创建的组合装运将发送回客户端。

  • 例如,一个包含四个订单的有序流,其中两个订单寄送到位置 X,两个寄送到位置 Y,可以表示为 X, Y, X, Y。如果批次大小为三,那么创建的组合订单应该是装运 [X, X],装运 [Y],装运 [Y]。这些组合装运也作为流发送回客户端。

此业务用例背后的关键思想是一旦调用 RPC 方法,无论是客户端还是服务端都可以在任意时间发送消息。(这还包括来自任一方的流结束标记。)

现在,让我们继续进行前面用例的服务定义。如示例 3-10 所示,我们可以定义一个processOrders方法,使其将字符串流作为方法参数来表示订单 ID 流,并将CombinedShipments流作为方法的返回参数。因此,通过将方法参数和返回参数都声明为stream,可以定义双向流式 RPC 方法。组合发货消息也在服务定义中声明,并包含订单元素列表。

示例 3-10. 双向流式 RPC 的服务定义
syntax = "proto3";

import "google/protobuf/wrappers.proto";

package ecommerce;

service OrderManagement {
    ...
    rpc processOrders(stream google.protobuf.StringValue)
        returns (stream CombinedShipment); ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
}

message Order { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
    string id = 1;
    repeated string items = 2;
    string description = 3;
    float price = 4;
    string destination = 5;
}

message CombinedShipment { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    string id = 1;
    string status = 2;
    repeated Order ordersList = 3;
}

1

在双向 RPC 中,方法参数和返回参数均声明为流。

2

Order消息的结构。

3

CombinedShipment消息的结构。

然后我们可以从更新的服务定义生成服务端代码。服务应该实现OrderManagement服务的processOrders方法。在示例 3-11 中显示的 Go 实现中,processOrders方法具有OrderManagement_ProcessOrdersServer参数,这是客户端和服务之间消息流的引用对象。使用这个流对象,服务可以读取流到服务器的客户端消息,并将流服务器的消息写回客户端。使用该流引用对象,可以使用Recv()方法读取传入消息流。在processOrders方法中,服务可以继续读取传入的消息流,同时使用Send写入同一流。

注意

为了简化演示,未显示示例 3-10 的部分逻辑。您可以在本书的源代码库中找到完整的代码示例。

示例 3-11. 使用 Go 实现的 OrderManagement 服务,包含 processOrders 方法
func (s *server) ProcessOrders(
	stream pb.OrderManagement_ProcessOrdersServer) error {
	...
	for {
		orderId, err := stream.Recv() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
		if err == io.EOF {            ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
			...
			for _, comb := range combinedShipmentMap {
				stream.Send(&comb) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
			}
			return nil               ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
		}
		if err != nil {
			return err
		}

		// Logic to organize orders into shipments, 		// based on the destination. 		...
		// 
		if batchMarker == orderBatchSize { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
			// Stream combined orders to the client in batches 			for _, comb := range combinedShipmentMap {
				// Send combined shipment to the client 				stream.Send(&comb)      ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
			}
			batchMarker = 0
			combinedShipmentMap = make(
				map[string]pb.CombinedShipment)
		} else {
			batchMarker++
		}
	}
}

1

从传入流中读取订单 ID。

2

一直读取,直到找到流的结尾。

3

当找到流的结尾时,将所有剩余的组合发货发送到客户端。

4

返回nil标记了服务端流的结束。

5

订单按批次处理。当达到批处理大小时,所有创建的组合发货将流式传输到客户端。

6

将组合发货写入流。

在这里,我们根据 ID 处理传入的订单,当创建新的组合装运时,服务端将其写入同一流(与客户端流式 RPC 不同,其中我们使用SendAndClose来写入并关闭流)。在服务器端,当我们返回nil以找到客户端流的末尾时,流的末尾被标记。

客户端实现(示例 3-12)与之前的示例非常相似。当客户端通过OrderManagement客户端对象调用processOrders方法时,它会得到一个流引用(streamProcOrder),用于向服务器发送消息以及从服务器读取消息。

示例 3-12. 使用 Go 实现的 OrderManagement 客户端,包含了在 processOrders 方法中的实现。
// Process Order streamProcOrder, _ := c.ProcessOrders(ctx) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"102"}); err != nil { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
		log.Fatalf("%v.Send(%v) = %v", client, "102", err)
	}

	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"103"}); err != nil {
		log.Fatalf("%v.Send(%v) = %v", client, "103", err)
	}

	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"104"}); err != nil {
		log.Fatalf("%v.Send(%v) = %v", client, "104", err)
	}

	channel := make(chan struct{})  ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    go asncClientBidirectionalRPC(streamProcOrder, channel) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    time.Sleep(time.Millisecond * 1000)     ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)

	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"101"}); err != nil {
		log.Fatalf("%v.Send(%v) = %v", client, "101", err)
	}

	if err := streamProcOrder.CloseSend(); err != nil { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
		log.Fatal(err)
	}

<- channel

func asncClientBidirectionalRPC (
	    streamProcOrder pb.OrderManagement_ProcessOrdersClient,
	    c chan struct{}) {
	for {
		combinedShipment, errProcOrder := streamProcOrder.Recv() ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
		if errProcOrder == io.EOF { ![8](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/8.png)
			break
		}
		log.Printf("Combined shipment : ", combinedShipment.OrdersList)
	}
	<-c
}

1

调用远程方法并获取用于从客户端写入和读取的流引用。

2

向服务端发送消息。

3

创建一个用于 Goroutines 的通道。

4

使用 Goroutines 调用函数以并行方式从服务端读取消息。

5

模拟发送某些消息到服务端时的延迟。

6

标记客户端流(订单 ID)的流的末尾。

7

在客户端上读取服务端的消息。

8

检测流的结束条件。

客户端可以随时向服务端发送消息并关闭流。读取也是如此。在前面的例子中,我们使用 Go 语言的Goroutines术语,使用两个并发线程执行客户端消息写入和消息读取逻辑。

Goroutines

在 Go 语言中,Goroutines 是并行运行的函数或方法,它们可以与其他函数或方法同时运行。可以将它们视为轻量级线程。

因此,客户端可以并行读写同一流,入站和出站流操作完全独立。我们展示的是一个相对复杂的用例,展示了双向 RPC 的强大功能。重要的是要理解,客户端和服务端可以按任意顺序读写——流操作完全独立。因此,一旦建立初始连接,客户端和服务端可以完全自行决定之间的通信模式。

到此为止,我们已经涵盖了用于构建与基于 gRPC 的应用程序交互的所有可能通信模式。在选择通信模式时没有硬性规定,但始终建议分析业务用例,然后选择最合适的模式。

在我们结束对 gRPC 通信模式的讨论之前,重要的是要看一看 gRPC 如何用于微服务通信。

使用 gRPC 进行微服务通信

gRPC 的主要用途之一是实现微服务及其服务间通信。在微服务的服务间通信中,gRPC 与其他通信协议一起使用,通常 gRPC 服务被实现为多语言服务(使用不同的编程语言实现)。为了进一步理解这一点,让我们看一个真实世界的场景(见图 3-5),这是在线零售系统的扩展版本,它是我们迄今所讨论的内容的延伸。

在这种场景下,我们有多个微服务来提供在线关系系统的特定业务能力。有一些像Product服务这样的服务,它被实现为一个 gRPC 服务,还有一些复合服务如Catalog服务,它调用多个下游服务来构建其业务能力。正如我们在第一章中讨论的那样,对于大多数同步消息传递的场景,我们可以使用 gRPC。当您有某些可能需要持久消息传递的异步消息传递场景时,可以使用事件代理或消息代理,如KafkaActive MQRabbitMQ,以及NATS。当您必须将某些业务功能暴露给外部世界时,可以使用传统的 REST/OpenAPI 服务或 GraphQL 服务。因此,诸如CatalogCheckout之类的服务正在消费基于 gRPC 的后端服务,并且还暴露 RESTful 或基于 GraphQL 的外部接口。

一个使用 gRPC 和其他协议的常见微服务部署模式

图 3-5. 使用 gRPC 和其他协议的常见微服务部署模式

在大多数实际应用案例中,这些面向外部的服务通过 API 网关暴露。这是您应用各种非功能性功能(如安全性、节流、版本控制等)的地方。大多数这样的 API 利用诸如 REST 或 GraphQL 等协议。虽然这并不常见,但只要 API 网关支持暴露 gRPC 接口,您也可以将 gRPC 暴露为面向外部的服务。API 网关实现了诸如身份验证、日志记录、版本控制、节流和负载均衡等横切功能。通过在 gRPC API 中使用 API 网关,您能够将此功能部署到核心 gRPC 服务之外。此架构的另一个重要方面是我们可以利用多种编程语言,但共享相同的服务契约(即从同一 gRPC 服务定义生成的代码)。这使我们能够根据服务的业务能力选择适当的实现技术。

摘要

gRPC 提供了多种 RPC 通信样式,用于构建基于 gRPC 的应用程序之间的进程间通信。在本章中,我们探讨了四种主要的通信模式。简单 RPC 是最基本的一种,基本上是一种简单的请求-响应式远程过程调用样式。服务器流式 RPC 允许在远程方法第一次调用后,从服务向消费者发送多个消息,而客户端流式允许从客户端向服务发送多个消息。我们深入探讨了如何使用一些真实的用例来实现每个模式的细节。

本章中获得的知识对于实施任何 gRPC 使用案例都非常有用,因此您可以选择最适合您业务的最合适的通信模式。尽管本章为您提供了对 gRPC 通信模式的深入理解,但用户透明的低级通信细节在本章中没有涵盖。在下一章中,我们将深入探讨基于 gRPC 的进程间通信时的低级通信细节。

第四章:gRPC: 深入解析

正如您在之前的章节中所学到的,gRPC 应用程序使用 RPC 在网络上进行通信。作为 gRPC 应用程序开发者,您不需要担心 RPC 的具体实现细节,消息编码技术的使用方式,以及 RPC 在网络上的工作原理。您可以使用服务定义为所选择的语言生成服务器端或客户端代码。所有的低级通信细节都在生成的代码中实现,并且您会获得一些高级抽象来进行操作。然而,在构建复杂的基于 gRPC 的系统并将其投入生产时,了解 gRPC 在底层是如何工作的是非常重要的。

在本章中,我们将探讨 gRPC 通信流程的实现方式,使用的编码技术,gRPC 如何利用底层网络通信技术等。我们将为您讲解消息流程,客户端如何调用特定 RPC,然后讨论它如何编组为一个 gRPC 调用并在网络上传输,以及服务器如何进行反编组,并调用相应的服务和远程函数等等。

我们还将看看如何使用协议缓冲作为编码技术和 HTTP/2 作为 gRPC 的通信协议。最后,我们将深入探讨 gRPC 的实现架构以及围绕其构建的语言支持堆栈。尽管我们将在此讨论的低级细节在大多数 gRPC 应用程序中可能用处不大,但如果您正在设计复杂的 gRPC 应用程序或尝试调试现有应用程序,了解低级通信细节是非常有帮助的。

RPC 流程

在 RPC 系统中,服务器实现一组可远程调用的函数。客户端应用程序可以生成一个提供这些函数抽象的存根,以便客户端应用程序可以直接调用存根函数,从而调用服务器应用程序的远程函数。

让我们看看我们在 第二章 中讨论的 ProductInfo 服务,以了解远程过程调用在网络上的工作原理。作为 ProductInfo 服务的一部分,我们实现的其中一个函数是 getProduct,客户端可以通过提供产品 ID 来检索产品详细信息。图 4-1 描绘了客户端调用远程函数时涉及的操作。

远程过程调用在网络上的工作原理

图 4-1. 远程过程调用在网络上的工作原理

如 图 4-1 所示,当客户端调用生成的存根中的 getProduct 函数时,我们可以识别以下关键步骤:

1

客户端进程调用生成的存根中的 getProduct 函数。

2

客户端存根创建一个带有编码消息的 HTTP POST 请求。在 gRPC 中,所有请求都是带有以 application/grpc 为前缀的内容类型的 HTTP POST 请求。它调用的远程函数(/ProductInfo/getProduct)作为单独的 HTTP 头发送。

3

HTTP 请求消息被发送到服务器机器上的网络。

4

当消息在服务器端接收时,服务器检查消息头以查看需要调用哪个服务函数,并将消息交给服务存根。

5

服务存根将消息字节解析为特定语言的数据结构。

6

然后,使用解析后的消息,服务调用本地的 getProduct 函数。

7

服务函数的响应被编码并发送回客户端。响应消息遵循我们在客户端端观察到的相同过程(响应→编码→通过 HTTP 发送的响应);消息被解包并其值返回给等待的客户端进程。

这些步骤与大多数 RPC 系统(如 CORBA、Java RMI 等)非常相似。gRPC 在这里的主要区别是它编码消息的方式,正如我们在 Figure 4-1 中看到的。对于消息的编码,gRPC 使用协议缓冲区。协议缓冲区 是一种语言无关、平台中立、可扩展的机制,用于序列化结构化数据。您定义数据结构的方式一次,然后可以使用专门生成的源代码轻松地将结构化数据写入和从各种数据流中读取。

让我们深入了解 gRPC 如何使用协议缓冲区对消息进行编码。

使用协议缓冲区对消息进行编码

正如我们在前几章中讨论的那样,gRPC 使用协议缓冲区为 gRPC 服务编写服务定义。使用协议缓冲区定义服务包括在服务中定义远程方法和定义我们想要通过网络发送的消息。例如,让我们看一下 ProductInfo 服务中的 getProduct 方法。getProduct 方法接受 ProductID 消息作为输入参数,并返回 Product 消息。我们可以像 Example 4-1 中所示使用协议缓冲区定义这些输入和输出消息结构。

Example 4-1. 使用 getProduct 函数定义 ProductInfo 服务的服务定义
syntax = "proto3";

package ecommerce;

service ProductInfo {
   rpc getProduct(ProductID) returns (Product);
}

message Product {
   string id = 1;
   string name = 2;
   string description = 3;
   float price = 4;
}

message ProductID {
    string value = 1;
}

根据 Example 4-1,ProductID 消息携带唯一的产品 ID。因此它只有一个字符串类型的字段。Product 消息具有表示产品所需的结构。正确定义消息非常重要,因为消息的定义方式决定了消息如何被编码。我们将在本节稍后讨论消息定义在编码消息时的使用方式。

现在我们有了消息定义,让我们看看如何对消息进行编码并生成等效的字节内容。通常这是由消息定义的生成源代码处理的。所有支持的语言都有自己的编译器来生成源代码。作为应用开发者,您需要传递消息定义并生成源代码以读取和写入消息。

假设我们需要获取产品 ID 为15的产品详细信息;我们创建一个值为 15 的消息对象,并将其传递给getProduct函数。以下代码片段展示了如何创建一个ProductID消息,其值为15,并将其传递给getProduct函数以检索产品详细信息:

product, err := c.GetProduct(ctx, &pb.ProductID{Value: “15”})

此代码片段是用 Go 语言编写的。在这里,ProductID消息定义在生成的源代码中。我们创建一个ProductID实例,并将值设置为15。在 Java 语言中,我们使用生成的方法创建ProductID实例,如下所示的代码片段:

ProductInfoOuterClass.Product product = stub.getProduct(
       ProductInfoOuterClass.ProductID.newBuilder()
               .setValue("15").build());

在接下来的ProductID消息结构中,有一个名为value的字段,其字段索引为 1。当我们创建一个带有value等于15的消息实例时,等效的字节内容包含了value字段的字段标识符,后跟其编码值。此字段标识符也称为标签

message ProductID {
    string value = 1;
}

这个字节内容结构类似于图 4-2,其中每个消息字段都包含字段标识符和其编码值。

协议缓冲编码字节流

图 4-2. 协议缓冲编码字节流

此标记构建了两个值:字段索引和线路类型。字段索引是我们在 proto 文件中定义消息时分配给每个消息字段的唯一编号。线路类型基于字段类型,即可以输入字段的数据类型。此线路类型提供信息以查找值的长度。表 4-1 展示了线路类型如何映射到字段类型。这是线路类型和字段类型的预定义映射。您可以参考官方协议缓冲编码文档以获取更多关于映射的洞察。

表 4-1. 可用的线路类型及其对应的字段类型

线路类型 类别 字段类型
0 变长整数 int32、int64、uint32、uint64、sint32、sint64、bool、enum
1 64 位 fixed64、sfixed64、double
2 长度限定 string、bytes、嵌入式消息、打包的重复字段
3 开始组 组(已弃用)
4 结束组 组(已弃用)
5 32 位 fixed32、sfixed32、float

一旦我们知道某个字段的字段索引和线类型,我们就可以使用以下方程式确定字段的标签值。在此处,我们将字段索引的二进制表示左移三位,并与线类型值的二进制表示进行按位或运算:

Tag value = (field_index << 3) | wire_type

图 4-3 展示了字段索引和线类型在标签值中的排列方式。

标签值的结构

图 4-3. 标签值的结构

让我们尝试使用先前使用的示例来理解这些术语。ProductID 消息具有一个字符串字段,字段索引为 1,字符串的线类型为 2. 当我们将它们转换为二进制表示时,字段索引看起来像 00000001,线类型看起来像 00000010. 当我们将这些值放入前面的方程式中时,标签值 10 派生如下:

Tag value = (00000001 << 3) | 00000010
          = 000 1010

下一步是对消息字段的值进行编码。协议缓冲使用不同的编码技术来编码不同类型的数据。例如,如果它是字符串值,则协议缓冲使用 UTF-8 对该值进行编码;如果它是具有 int32 字段类型的整数值,则使用一种称为 varints 的编码技术。我们将在下一节详细讨论不同的编码技术以及何时应用这些技术。现在,我们将讨论如何编码字符串值以完成示例。

在协议缓冲编码中,字符串值使用 UTF-8 编码技术进行编码。UTF-8(Unicode 转换格式)使用 8 位块来表示一个字符。它是一种变长字符编码技术,也是网页和电子邮件中首选的编码技术。

在我们的示例中,ProductID 消息中 value 字段的值为 15,并且 15 的 UTF-8 编码值是 \x31 \x35。在 UTF-8 编码中,编码值的长度是不固定的。换句话说,表示编码值所需的 8 位块数是不固定的。它取决于消息字段的值。在我们的示例中,它是两个块。因此,在编码值之前,我们需要传递编码值的长度(编码值跨越的块数)。15 的十六进制表示如下:

A 02 31 35

这里的两个右手字节是 15 的 UTF-8 编码值。值 0x02 表示编码的字符串值的长度,以 8 位块为单位。

当消息被编码时,它的标签和值被连接成字节流。图 4-2 说明了当消息具有多个字段时,如何将字段值排列成字节流。流的结尾通过发送标签值为 0 来标记。

我们已经使用协议缓冲完成了对带有字符串字段的简单消息的编码。协议缓冲支持各种字段类型,并且一些字段类型具有不同的编码机制。让我们快速浏览一下协议缓冲使用的编码技术。

编码技术

协议缓冲支持许多编码技术。不同的编码技术根据数据类型应用不同的方法。例如,字符串值使用 UTF-8 字符编码进行编码,而 int32 值使用称为 Varints 的技术进行编码。了解每种数据类型在编码时如何处理数据是设计消息定义时非常重要的,因为这样可以为每个消息字段设置最合适的数据类型,从而在运行时高效地编码消息。

在协议缓冲中,支持的字段类型被分类为不同的组,并且每个组使用不同的技术来编码值。在下一节中列出了协议缓冲中一些常用的编码技术。

变长整数

变长整数(Varints)是一种使用一个或多个字节序列化整数的方法。它们基于大多数数字不是均匀分布的观念。因此,为每个值分配的字节数并不固定,而是取决于值本身。根据表 4-1,如 int32、int64、uint32、uint64、sint32、sint64、bool 和 enum 等字段类型被分组为 Varints 并编码为 Varints。表 4-2 显示了哪些字段类型被归类为 Varints,并说明了每种类型的用途。

表 4-2. 字段类型定义

字段类型 定义
int32 一个表示带有值范围从负 2,147,483,648 到正 2,147,483,647 的有符号整数的值类型。注意,这种类型不适合编码负数。
int64 一个表示带有值范围从负 9,223,372,036,854,775,808 到正 9,223,372,036,854,775,807 的有符号整数的值类型。注意,这种类型不适合编码负数。
uint32 一个表示无符号整数的值类型,其取值范围从 0 到 4,294,967,295。
uint64 一个表示无符号整数的值类型,其取值范围从 0 到 18,446,744,073,709,551,615。
sint32 一个表示带有值范围从负 2,147,483,648 到正 2,147,483,647 的有符号整数的值类型。相比普通的 int32,这种类型更有效地编码负数。
sint64 一个表示带有值范围从负 9,223,372,036,854,775,808 到正 9,223,372,036,854,775,807 的有符号整数的值类型。相比普通的 int64,这种类型更有效地编码负数。
bool 一个表示两种可能值的值类型,通常表示为 true 或 false。
enum 一个表示一组命名值的值类型。

在变长整数中,除了最后一个字节外,每个字节的最高位(MSB)都被设置为指示后续字节的存在。每个字节的低 7 位用于存储该数字的二进制补码表示。此外,最不重要的组先出现,这意味着我们应在低阶组中添加一个继续位。

有符号整数

签名整数是表示正负整数值的类型。像 sint32 和 sint64 这样的字段类型被视为有符号整数。对于有符号类型,使用锯齿编码将有符号整数转换为无符号整数。然后使用先前提到的变长整数编码无符号整数。

在锯齿编码中,有符号整数通过负整数和正整数以锯齿方式映射为无符号整数。表 4-3 显示了锯齿编码中映射工作的方式。

表 4-3。用于有符号整数的锯齿编码

原始值 映射值
0 0
-1 1
1 2
-2 3
2 4

如表 4-3 所示,零值映射到原始零值,其他值以锯齿方式映射为正数。负的原始值映射为奇数正数,正的原始值映射为偶数正数。经过锯齿编码后,我们获得一个与原始值的符号无关的正数。一旦我们有了一个正数,我们执行变长整数编码来编码该值。

对于负整数值,建议使用有符号整数类型,如 sint32 和 sint64,因为如果使用普通类型如 int32 或 int64,负值将使用变长整数编码转换为二进制值。变长整数编码负整数值需要更多字节来表示等效的二进制值,比正整数值更多。因此,编码负值的有效方式是将负值转换为正数,然后编码正数。在像 sint32 这样的有符号整数类型中,负值首先使用锯齿编码转换为正值,然后使用变长整数编码。

非变长整数

非变长整数类型正好与变长整数类型相反。它们分配固定数量的字节,不考虑实际值。协议缓冲区使用两种线路类型来分类为非变长整数。一种是用于 64 位数据类型,如 fixed64、sfixed64 和 double。另一种是用于 32 位数据类型,如 fixed32、sfixed32 和 float。

字符串类型

在协议缓冲区中,字符串类型属于长度限定的线路类型,这意味着值是变长整数编码的长度,后跟指定数量的数据字节。字符串值使用 UTF-8 字符编码进行编码。

我们刚刚总结了编码常用数据类型的技术。你可以在 官方页面 上找到有关协议缓冲区编码的详细说明。

现在我们已经使用协议缓冲区对消息进行了编码,下一步是在将其发送到服务器之前对消息进行帧化。

长度前缀消息帧

通常术语中,消息帧方法构建信息和通信,以便预期的受众可以轻松提取信息。gRPC 通信也适用相同原理。一旦我们有编码后的数据要发送给对方,我们需要以其他方便提取信息的方式打包数据。为了将消息打包以便在网络上传输,gRPC 使用一种称为长度前缀帧的消息帧技术。

长度前缀是一种消息帧方法,它在写入消息本身之前写入每个消息的大小。正如你可以在 图 4-4 中看到的那样,在编码的二进制消息之前,有 4 个字节用于指定消息的大小。在 gRPC 通信中,每个消息额外分配了 4 个字节来设置其大小。消息的大小是一个有限的数字,分配 4 个字节来表示消息大小意味着 gRPC 通信可以处理大小达到 4 GB 的所有消息。

gRPC 消息编码和帧

图 4-4. gRPC 消息帧使用长度前缀帧的示意图

如 图 4-4 所示,当使用协议缓冲区对消息进行编码时,我们得到二进制格式的消息。然后,我们计算二进制内容的大小,并以大端格式将其添加到二进制内容之前。

注意

大端法是一种在系统或消息中对二进制数据排序的方式。在大端格式中,序列中最重要的值(最大的二的幂)存储在最低的存储地址。

除了消息大小之外,帧还有一个 1 字节的无符号整数,用于指示数据是否已压缩。压缩标志值为 1 表示使用消息编码头中声明的机制对二进制数据进行了压缩,该头是在 HTTP 传输中声明的头之一。值 0 表示未对消息字节进行编码。我们将在下一节详细讨论 gRPC 通信中支持的 HTTP 头。

现在消息已经被分帧并准备好通过网络发送给接收者。对于客户端请求消息,接收者是服务器。对于响应消息,接收者是客户端。在接收方,一旦接收到消息,首先需要读取第一个字节来检查消息是否已压缩。然后,接收者读取接下来的四个字节以获取编码二进制消息的大小。一旦知道大小,就可以从流中读取确切长度的字节。对于一元/简单消息,我们只有一个长度前缀的消息,而对于流式消息,我们将有多个长度前缀的消息需要处理。

现在您已经很好地理解了如何准备消息并通过网络发送到接收者。在下一节中,我们将讨论 gRPC 如何通过网络发送这些长度前缀的消息。目前,gRPC 核心支持三种传输实现方式:HTTP/2、Cronetin-process。其中,用于发送消息的最常见的传输方式是 HTTP/2。让我们讨论 gRPC 如何利用 HTTP/2 网络高效地发送消息。

gRPC 在 HTTP/2 上

HTTP/2 是互联网协议 HTTP 的第二个主要版本。它被引入以解决之前版本(HTTP/1.1)中遇到的一些安全性、速度等问题。HTTP/2 支持 HTTP/1.1 的所有核心功能,但以更高效的方式实现。因此,使用 HTTP/2 编写的应用程序更快、更简单、更健壮。

gRPC 使用 HTTP/2 作为其传输协议,在网络上发送消息。这是 gRPC 成为高性能 RPC 框架的原因之一。让我们探讨 gRPC 和 HTTP/2 之间的关系。

注意

在 HTTP/2 中,客户端和服务器之间的所有通信都通过单个 TCP 连接进行,该连接可以承载任意数量的双向字节流。要理解 HTTP/2 的过程,您应该熟悉以下重要术语:

  • 流(Stream): 在已建立的连接中的双向字节流。一个流可以承载一个或多个消息。

  • 帧(Frame): HTTP/2 中通信的最小单位。每个帧包含一个帧头,至少标识帧所属的流。

  • 消息(Message): 映射到逻辑 HTTP 消息的完整帧序列,由一个或多个帧组成。这使得消息可以进行多路复用,客户端和服务器可以将消息分解为独立的帧、交错它们,然后在另一端重新组装它们。

如您在图 Figure 4-5 中所见,gRPC 通道代表与端点的连接,这是一个 HTTP/2 连接。当客户端应用程序创建 gRPC 通道时,它在后台与服务器创建了一个 HTTP/2 连接。一旦通道创建完成,我们可以重用它来向服务器发送多个远程调用。这些远程调用在 HTTP/2 中被映射为流。发送到远程调用的消息作为 HTTP/2 帧发送。一个帧可以携带一个 gRPC 长度前缀消息,或者如果 gRPC 消息非常大,它可能跨越多个数据帧。

gRPC 语义与 HTTP/2 的关系

图 4-5. gRPC 语义与 HTTP/2 的关系

在前面的部分中,我们讨论了如何将消息框架化为长度前缀消息。当我们将它们作为请求或响应消息通过网络发送时,我们需要发送附加的头部以及消息。让我们在接下来的部分讨论如何构造请求/响应消息以及每个消息需要传递哪些头部。

请求消息

请求消息是发起远程调用的消息。在 gRPC 中,请求消息始终由客户端应用程序触发,它由三个主要组件组成:请求头,长度前缀消息以及流结束标志,如图 Figure 4-6 所示。一旦客户端发送请求头,远程调用就被初始化。然后,在调用中发送长度前缀消息。最后,发送 EOS(流结束)标志通知接收方,我们完成了请求消息的发送。

请求消息中消息元素的顺序

图 4-6. 请求消息中消息元素的顺序

让我们使用 ProductInfo 服务中相同的 getProduct 函数来解释如何在 HTTP/2 帧中发送请求消息。当我们调用 getProduct 函数时,客户端通过发送如下的请求头来发起调用:

HEADERS (flags = END_HEADERS)
:method = POST ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
:scheme = http ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
:path = /ProductInfo/getProduct ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
:authority = abc.com ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
te = trailers ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
grpc-timeout = 1S ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
content-type = application/grpc ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
grpc-encoding = gzip ![8](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/8.png)
authorization = Bearer xxxxxx ![9](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/9.png)

1

定义 HTTP 方法。对于 gRPC,:method 头部始终为 POST

2

定义 HTTP 方案。如果启用了传输层安全(TLS),则方案设置为 “https”,否则为 “http”。

3

定义端点路径。对于 gRPC,该值构造为 “/” {服务名称} “/” {方法名称}。

4

定义目标 URI 的虚拟主机名。

5

定义不兼容代理的检测。对于 gRPC,该值必须为 “trailers”。

6

定义调用超时时间。如果未指定,服务器应假定超时时间为无限。

7

定义内容类型。对于 gRPC,内容类型应以application/grpc开头。否则,gRPC 服务器将以 HTTP 415(不支持的媒体类型)状态响应。

8

定义消息压缩类型。可能的值为identitygzipdeflatesnappy{custom}

9

这是可选的元数据。authorization元数据用于访问安全端点。

注意

关于此示例的一些其他注意事项:

  • 以“:”开头的标头称为保留标头,HTTP/2 要求保留标头出现在其他标头之前。

  • gRPC 通信中传递的标头分为两种类型:调用定义标头和自定义元数据。

  • 调用定义标头是 HTTP/2 支持的预定义标头。这些标头应在自定义元数据之前发送。

  • 自定义元数据是应用层定义的一组任意键值对。在定义自定义元数据时,请确保不使用以grpc-开头的标头名称,因为这在 gRPC 核心中列为保留名称。

一旦客户端与服务器发起调用,客户端将作为 HTTP/2 数据帧发送长度前缀消息。如果长度前缀消息不适合一个数据帧,则可以跨多个数据帧。请求消息的结束通过在最后一个DATA帧上添加END_STREAM标志来指示。当没有剩余数据需要发送但需要关闭请求流时,实现必须发送带有END_STREAM标志的空数据帧:

DATA (flags = END_STREAM)
<Length-Prefixed Message>

这只是 gRPC 请求消息结构的概述。您可以在官方 gRPC GitHub 存储库找到更多细节。

与请求消息类似,响应消息也有其自己的结构。让我们看看响应消息及其相关标头的结构。

响应消息

服务器响应消息是响应客户端请求生成的。与请求消息类似,在大多数情况下,响应消息也由三个主要组件组成:响应标头、长度前缀消息和尾部。当没有长度前缀消息作为响应发送给客户端时,响应消息仅由标头和尾部组成,如图 4-7 所示。

响应消息中消息元素的顺序

图 4-7. 响应消息中消息元素的顺序

让我们以同样的示例来解释响应消息的 HTTP/2 帧序列。当服务器向客户端发送响应时,首先发送响应标头,如下所示:

HEADERS (flags = END_HEADERS)
:status = 200 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
grpc-encoding = gzip ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
content-type = application/grpc ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

1

指示 HTTP 请求的状态。

2

定义消息压缩类型。可能的值包括identitygzipdeflatesnappy{custom}

3

定义了 content-type。对于 gRPC,content-type 应以 application/grpc 开头。

注意

与请求头类似,响应头中可以设置包含应用层定义的任意键值对的自定义元数据。

一旦服务器发送了响应头,长度前缀消息将作为 HTTP/2 数据帧发送。与请求消息类似,如果长度前缀消息不适合一个数据帧,它可以跨越多个数据帧。如下所示,END_STREAM 标志不会与数据帧一起发送,而是作为称为尾部的单独头部发送:

DATA
<Length-Prefixed Message>

最后,尾部被发送以通知客户端我们已完成发送响应消息。尾部还携带请求的状态码和状态消息:

HEADERS (flags = END_STREAM, END_HEADERS)
grpc-status = 0 # OK ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
grpc-message = xxxxxx ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

1

定义了 gRPC 的状态码。gRPC 使用一组明确定义的状态码。你可以在 官方 gRPC 文档 中找到状态码的定义。

2

定义了错误的描述。这是可选的。只有在处理请求时出现错误时才设置。

注意

尾部也作为 HTTP/2 头部帧传递,但位于响应消息的末尾。通过在尾部头部设置 END_STREAM 标志来指示响应流的结束。此外,它包含 grpc-statusgrpc-message 头部。

在某些场景中,请求调用可能会立即失败。在这些情况下,服务器需要发送仅包含尾部的响应。这些尾部也作为 HTTP/2 头部帧传递,并包含 END_STREAM 标志。此外,尾部还包含以下头部:

  • HTTP 状态 → :status

  • 内容类型 → content-type

  • 状态 → grpc-status

  • 状态消息 → grpc-message

现在我们知道 gRPC 消息如何在 HTTP/2 连接上传输,让我们尝试理解 gRPC 中不同通信模式的消息流动。

在理解 gRPC 通信模式的消息流程时

在前一章中,我们讨论了 gRPC 支持的四种通信模式。它们是简单 RPC、服务器流式 RPC、客户端流式 RPC 和双向流式 RPC。我们还讨论了如何使用真实用例来解释这些通信模式的工作原理。在本节中,我们将从不同角度再次审视这些模式,讨论如何在传输层级别上使用我们在本章中收集的知识来解释每种模式的工作方式。

简单 RPC

在简单 RPC 中,通信中 gRPC 服务器和 gRPC 客户端始终有一个单一请求和单一响应。如 图 4-8 所示,请求消息包含头部,后跟一个长度前缀消息,该消息可以跨越一个或多个数据帧。在消息末尾添加了流结束 (EOS) 标志,以半关闭客户端侧的连接并标记请求消息的结束。这里的“半关闭连接”意味着客户端关闭了其侧的连接,因此客户端无法再向服务器发送消息,但仍然可以接收服务器传入的消息。服务器只有在收到完整的请求消息后才创建响应消息。响应消息包含一个头部帧,后跟一个长度前缀消息。一旦服务器发送带有状态详细信息的尾部头,通信即告结束。

简单 RPC:消息流

图 4-8. 简单 RPC:消息流

这是最简单的通信模式。让我们继续看一个稍微复杂一点的服务器端流式 RPC 场景。

服务器端流式 RPC

从客户端的角度看,简单 RPC 和服务器端流式 RPC 的请求消息流是相同的。在两种情况下,我们发送一个请求消息。主要区别在于服务器端。服务器不会只向客户端发送一个响应消息,而是发送多个消息。服务器等待接收完整的请求消息后,发送响应头和多个长度前缀消息,如 图 4-9 所示。一旦服务器发送带有状态详细信息的尾部头,通信即告结束。

服务器端流式 RPC:消息流

图 4-9. 服务器端流式 RPC:消息流

现在让我们来看看客户端流式 RPC,这与服务器端流式 RPC 几乎完全相反。

客户端流式 RPC

在客户端流式 RPC 中,客户端向服务器发送多个消息,服务器则以一个响应消息回复。客户端首先通过发送头帧与服务器建立连接。一旦建立连接,客户端将多个长度前缀消息作为数据帧发送到服务器,如 图 4-10 所示。最后,客户端通过在最后一个数据帧中发送 EOS 标志来半关闭连接。与此同时,服务器读取从客户端接收到的消息。一旦接收到所有消息,服务器将发送一个响应消息以及尾部头,并关闭连接。

客户端流式 RPC:消息流

图 4-10. 客户端流式 RPC:消息流

现在让我们转向最后一种通信模式,双向 RPC,在这种模式下,客户端和服务器都会彼此发送多个消息,直到关闭连接。

双向流式 RPC

在这种模式中,客户端通过发送头帧建立连接。一旦连接建立,客户端和服务器都可以发送长度前缀的消息,而无需等待对方完成。如图 4-11 所示,客户端和服务器同时发送消息。两者都可以在各自的一侧结束连接,这意味着它们不能再发送任何消息。

双向流式 RPC:消息流

图 4-11. 双向流式 RPC:消息流

到此,我们已经结束了关于 gRPC 通信的深入介绍。通信中的网络和传输相关操作通常在 gRPC 核心层处理,作为 gRPC 应用程序开发人员,您不需要了解这些细节。

在结束本章之前,让我们来看看 gRPC 实现架构和语言堆栈。

gRPC 实现架构

如图 4-12 所示,gRPC 实现可以分为多个层次。基础层是 gRPC 核心层。这是一个薄层,并且将所有网络操作从上层抽象出来,以便应用开发人员可以轻松地通过网络进行 RPC 调用。核心层还提供了对核心功能的扩展。一些扩展点是用于处理调用安全性的身份验证过滤器和用于实现调用截止时间的截止时间过滤器等。

gRPC 受到 C/C++、Go 和 Java 语言的原生支持。gRPC 还提供了许多流行语言的语言绑定,如 Python、Ruby、PHP 等。这些语言绑定是低级 C API 的封装器。

最后,应用程序代码位于语言绑定的顶部。此应用层处理应用逻辑和数据编码逻辑。通常开发人员使用不同语言提供的编译器生成数据编码逻辑的源代码。例如,如果我们使用协议缓冲区来编码数据,可以使用协议缓冲区编译器生成源代码。因此,开发人员可以通过调用生成的源代码的方法来编写其应用程序逻辑。

gRPC 原生实现架构

图 4-12. gRPC 原生实现架构

到此,我们已经涵盖了基于 gRPC 的应用程序的大部分低级实现和执行细节。作为应用程序开发人员,了解即将在应用程序中使用的技术的低级细节总是更好的。这不仅有助于设计健壮的应用程序,还有助于轻松解决应用程序问题。

总结

gRPC 建立在两个快速高效的协议之上,称为协议缓冲区和 HTTP/2。协议缓冲区是一种数据序列化协议,它是一种与语言无关、平台中立且可扩展的机制,用于序列化结构化数据。一旦序列化,该协议生成的二进制载荷比普通 JSON 载荷更小,并且是强类型的。这个序列化的二进制载荷然后通过称为 HTTP/2 的二进制传输协议进行传输。

HTTP/2 是互联网协议 HTTP 的下一个主要版本。HTTP/2 是完全多路复用的,这意味着 HTTP/2 可以在单个 TCP 连接上并行发送多个数据请求。这使得使用 HTTP/2 编写的应用程序比其他应用程序更快、更简单和更可靠。

所有这些因素使得 gRPC 成为一个高性能的 RPC 框架。

在本章中,我们讨论了关于 gRPC 通信的低级细节。这些细节对于开发 gRPC 应用可能并非必不可少,因为它们已经被库处理了,但是在使用 gRPC 进行生产时,理解低级 gRPC 消息流对于解决与 gRPC 通信相关的问题是绝对必要的。在下一章中,我们将讨论 gRPC 提供的一些高级能力,以满足实际需求。

第五章:gRPC:超越基础知识

构建实际的 gRPC 应用程序时,您可能需要增加各种功能以满足需求,如拦截进出的 RPC、弹性地处理网络延迟、处理错误、在服务和消费者之间共享元数据等。

注意

为了保持一致性,本章中的所有示例都是用 Go 语言解释的。如果您更熟悉 Java,可以参考源代码存储库中的 Java 示例,以实现相同的用例。

在本章中,您将了解一些关键的高级 gRPC 能力,包括使用 gRPC 拦截器在服务器和客户端上拦截 RPC、使用截止时间指定 RPC 完成的等待时间、服务器和客户端上的错误处理最佳实践、使用多路复用在同一服务器上运行多个服务、在调用其他服务时共享自定义元数据、使用负载均衡和名称解析技术、压缩 RPC 调用以有效利用网络带宽。

让我们从 gRPC 拦截器开始讨论。

拦截器

在构建 gRPC 应用程序时,您可能希望在远程函数执行前或执行后执行一些常见逻辑,无论是客户端还是服务器应用程序。在 gRPC 中,您可以拦截该 RPC 的执行,以满足诸如日志记录、身份验证、度量等要求,使用一种称为拦截器的扩展机制。gRPC 提供了简单的 API 来在客户端和服务器 gRPC 应用程序中实现和安装拦截器。它们是 gRPC 中的关键扩展机制之一,在日志记录、身份验证、授权、度量、跟踪以及其他客户需求等用例中非常有用。

注意

并非所有支持 gRPC 的语言都支持拦截器,并且每种语言中拦截器的实现可能有所不同。在本书中,我们只涵盖了 Go 和 Java 两种语言。

gRPC 拦截器可以根据它们拦截的 RPC 调用类型分为两种类型。对于一元 RPC,您可以使用一元拦截器,而对于流式 RPC,您可以使用流式拦截器。这些拦截器可以在 gRPC 服务器端或 gRPC 客户端端使用。首先,让我们从在服务器端使用拦截器开始。

服务器端拦截器

当客户端调用 gRPC 服务的远程方法时,您可以通过使用服务器端拦截器在调用远程方法之前执行一些常见逻辑。当您需要在调用远程方法之前应用某些功能(例如身份验证)时,这将非常有帮助。如图 5-1 所示,您可以将一个或多个拦截器插入到您开发的任何 gRPC 服务器中。例如,要将一个新的服务器端拦截器插入到您的 OrderManagement gRPC 服务中,您可以实现该拦截器,并在创建 gRPC 服务器时进行注册。

服务器端拦截器

图 5-1. 服务器端拦截器

在服务器端,一元拦截器允许你拦截一元 RPC 调用,而流拦截器则拦截流式 RPC。让我们先讨论服务器端的一元拦截器。

一元拦截器

如果你想在服务器端拦截你的 gRPC 服务的一元 RPC,你需要为你的 gRPC 服务器实现一个一元拦截器。如 Go 代码片段中所示的 示例 5-1,你可以通过实现一个类型为 UnaryServerInterceptor 的函数并在创建 gRPC 服务器时注册该函数来实现这一点。UnaryServerInterceptor 是具有以下签名的服务器端一元拦截器类型:

func(ctx context.Context, req interface{}, info *UnaryServerInfo,
	                     handler UnaryHandler) (resp interface{}, err error)

在这个函数内部,你可以完全控制所有发送到你的 gRPC 服务器的一元 RPC 调用。

示例 5-1. gRPC 服务器端一元拦截器
// Server - Unary Interceptor func orderUnaryServerInterceptor(ctx context.Context, req interface{},
                             info *grpc.UnaryServerInfo, handler grpc.UnaryHandler)
                             (interface{}, error) {

	// Preprocessing logic 	// Gets info about the current RPC call by examining the args passed in 	log.Println("======= [Server Interceptor] ", info.FullMethod) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

	// Invoking the handler to complete the normal execution of a unary RPC. 	m, err := handler(ctx, req) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

	// Post processing logic 	log.Printf(" Post Proc Message : %s", m) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
	return m, err ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
}

// ... 
func main() {

...
     // Registering the Interceptor at the server-side. 	s := grpc.NewServer(
		grpc.UnaryInterceptor(orderUnaryServerInterceptor)) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
...

1

预处理阶段:这是你可以在调用相应 RPC 之前拦截消息的地方。

2

通过 UnaryHandler 调用 RPC 方法。

3

后处理阶段:您可以处理来自 RPC 调用的响应。

4

返回 RPC 响应。

5

使用 gRPC 服务器注册一元拦截器。

服务器端一元拦截器的实现通常可以分为三个部分:预处理、调用 RPC 方法和后处理。顾名思义,预处理阶段在调用 RPC 调用中指定的远程方法之前执行。在预处理阶段,用户可以通过检查传递的 args 获取有关当前 RPC 调用的信息,例如 RPC 上下文、RPC 请求和服务器信息。因此,在预处理阶段,甚至可以修改 RPC 调用。

然后,在调用程序阶段,您必须调用 gRPC UnaryHandler 来调用 RPC 方法。一旦调用 RPC,将执行后处理阶段。这意味着 RPC 调用的响应经过后处理阶段。在此阶段,您可以在需要时处理返回的回复和错误。完成后处理阶段后,您需要将消息和错误作为拦截器函数的返回参数返回。如果不需要后处理,则可以简单地返回处理程序调用(handler(ctx, req))。

接下来,让我们讨论流拦截器。

流拦截器

服务器端流拦截器拦截 gRPC 服务器处理的任何流式 RPC 调用。流拦截器包括预处理阶段和流操作拦截阶段。

如在示例 5-2 中的 Go 代码片段中所示,假设我们想拦截OrderManagement服务的流式 RPC 调用。StreamServerInterceptor是服务器端流拦截器的类型。orderServerStreamInterceptor是一个类型为StreamServerInterceptor的拦截器函数,其签名为:

func(srv interface{}, ss ServerStream, info *StreamServerInfo,
                                     handler StreamHandler) error

类似于一元拦截器,在预处理阶段,您可以在服务实现之前拦截流式 RPC 调用。在预处理阶段之后,您可以调用StreamHandler来完成远程方法的 RPC 调用执行。在预处理阶段之后,您可以通过使用实现grpc.ServerStream接口的包装器流来拦截流式 RPC 消息。当您使用handler(srv, newWrappedStream(ss))调用时,可以传递此包装器结构。grpc.ServerStream的包装器拦截 gRPC 服务发送或接收的流式消息。它实现了SendMsgRecvMsg函数,这些函数在服务接收或发送 RPC 流式消息时将被调用。

示例 5-2. gRPC 服务器端流式拦截器
// Server - Streaming Interceptor // wrappedStream wraps around the embedded grpc.ServerStream, // and intercepts the RecvMsg and SendMsg method call. 
type wrappedStream struct { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
	grpc.ServerStream
}

![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
func (w *wrappedStream) RecvMsg(m interface{}) error {
	log.Printf("====== [Server Stream Interceptor Wrapper] " +
		"Receive a message (Type: %T) at %s",
		m, time.Now().Format(time.RFC3339))
	return w.ServerStream.RecvMsg(m)
}

![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
func (w *wrappedStream) SendMsg(m interface{}) error {
	log.Printf("====== [Server Stream Interceptor Wrapper] " +
		"Send a message (Type: %T) at %v",
		m, time.Now().Format(time.RFC3339))
	return w.ServerStream.SendMsg(m)
}

![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
func newWrappedStream(s grpc.ServerStream) grpc.ServerStream {
	return &wrappedStream{s}
}

![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
func orderServerStreamInterceptor(srv interface{},
        ss grpc.ServerStream, info *grpc.StreamServerInfo,
        handler grpc.StreamHandler) error {
	log.Println("====== [Server Stream Interceptor] ",
		info.FullMethod) ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
	err := handler(srv, newWrappedStream(ss)) ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
	if err != nil {
		log.Printf("RPC failed with error %v", err)
	}
	return err
}

...
// Registering the interceptor s := grpc.NewServer(
		grpc.StreamInterceptor(orderServerStreamInterceptor)) ![8](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/8.png)

…

1

grpc.ServerStream的包装器流。

2

实现包装器的RecvMsg函数以处理使用流 RPC 接收的消息。

3

实现包装器的SendMsg函数以处理使用流 RPC 发送的消息。

4

创建新包装器流的实例。

5

流式拦截器实现。

6

预处理阶段。

7

使用包装器流调用流式 RPC。

8

注册拦截器。

要理解服务器端流式拦截器的行为,请查看 gRPC 服务器日志中的以下输出。根据每条日志消息打印的顺序,您可以识别流式拦截器的行为。我们在这里调用的流式远程方法是SearchOrders,这是一个服务器流式 RPC:

[Server Stream Interceptor]  /ecommerce.OrderManagement/searchOrders
[Server Stream Interceptor Wrapper] Receive a message

Matching Order Found : 102 -> Writing Order to the stream ...
[Server Stream Interceptor Wrapper] Send a message...
Matching Order Found : 104 -> Writing Order to the stream ...
[Server Stream Interceptor Wrapper] Send a message...

客户端拦截器术语与服务器端拦截器非常相似,但在接口和函数签名上有一些微妙的变化。让我们继续了解客户端拦截器的详细信息。

客户端拦截器

当客户端调用 RPC 调用来调用 gRPC 服务的远程方法时,您可以在客户端拦截这些 RPC 调用。如图 5-2 所示,使用客户端拦截器,您可以拦截一元 RPC 调用以及流式 RPC 调用。

客户端拦截器

图 5-2. 客户端拦截器

当你需要在客户端应用程序代码之外安全地调用 gRPC 服务时,这是特别有用的。

一元拦截器

用于拦截客户端单一 RPC 的客户端一元拦截器。UnaryClientInterceptor 是一个具有以下函数签名的客户端一元拦截器的类型:

func(ctx context.Context, method string, req, reply interface{},
         cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error

就像我们在服务器端一元拦截器中看到的那样,客户端一元拦截器具有不同的阶段。示例 5-3 展示了在客户端实现一元拦截器的基本 Go 代码。在预处理阶段,你可以在调用远程方法之前拦截 RPC 调用。在这里,你可以通过检查传入的参数(如 RPC 上下文、方法字符串、要发送的请求和配置的 CallOptions)来访问当前 RPC 调用的信息。因此,甚至可以修改原始 RPC 调用,然后使用 UnaryInvoker 参数调用实际的一元 RPC。在后处理阶段,你可以访问 RPC 调用的响应或错误结果。

示例 5-3. gRPC 客户端单一拦截器
func orderUnaryClientInterceptor(
	ctx context.Context, method string, req, reply interface{},
	cc *grpc.ClientConn,
	invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
	// Preprocessor phase 	log.Println("Method : " + method) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

	// Invoking the remote method 	err := invoker(ctx, method, req, reply, cc, opts...) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

	// Postprocessor phase 	log.Println(reply) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

	return err ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
}
...

func main() {
	// Setting up a connection to the server. 	conn, err := grpc.Dial(address, grpc.WithInsecure(),
		grpc.WithUnaryInterceptor(orderUnaryClientInterceptor)) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
...

1

在将 RPC 请求发送到服务器之前,预处理阶段可以访问 RPC 请求。

2

通过 UnaryInvoker 调用 RPC 方法。

3

后处理阶段可以处理响应或错误结果。

4

将错误返回到 gRPC 客户端应用程序,并将回复作为参数传递。

5

通过将一元拦截器作为拨号选项来建立与服务器的连接。

grpc.Dial 操作中通过 grpc.WithUnaryInterceptor 注册拦截器函数。

流拦截器

客户端流拦截器用于拦截 gRPC 客户端处理的任何流式 RPC 调用。客户端流拦截器的实现与服务器端非常相似。StreamClientInterceptor 是客户端流拦截器的类型,其函数类型如下:

func(ctx context.Context, desc *StreamDesc, cc *ClientConn,
                                      method string, streamer Streamer,
                                      opts ...CallOption) (ClientStream, error)

如示例 5-4 所示,客户端流拦截器的实现包括预处理和流操作拦截。

示例 5-4. gRPC 客户端流拦截器
func clientStreamInterceptor(
	ctx context.Context, desc *grpc.StreamDesc,
	cc *grpc.ClientConn, method string,
	streamer grpc.Streamer, opts ...grpc.CallOption)
        (grpc.ClientStream, error) {
	log.Println("======= [Client Interceptor] ", method) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
	s, err := streamer(ctx, desc, cc, method, opts...) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
	if err != nil {
		return nil, err
	}
	return newWrappedStream(s), nil ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
}

type wrappedStream struct { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
	grpc.ClientStream
}

func (w *wrappedStream) RecvMsg(m interface{}) error { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
	log.Printf("====== [Client Stream Interceptor] " +
		"Receive a message (Type: %T) at %v",
		m, time.Now().Format(time.RFC3339))
	return w.ClientStream.RecvMsg(m)
}

func (w *wrappedStream) SendMsg(m interface{}) error { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
	log.Printf("====== [Client Stream Interceptor] " +
		"Send a message (Type: %T) at %v",
		m, time.Now().Format(time.RFC3339))
	return w.ClientStream.SendMsg(m)
}

func newWrappedStream(s grpc.ClientStream) grpc.ClientStream {
	return &wrappedStream{s}
}

...

func main() {
	// Setting up a connection to the server. 	conn, err := grpc.Dial(address, grpc.WithInsecure(),
		grpc.WithStreamInterceptor(clientStreamInterceptor)) ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
...

1

在将 RPC 请求发送到服务器之前,预处理阶段可以访问 RPC 请求。

2

调用传入的流程以获取一个ClientStream

3

包装 ClientStream,通过拦截逻辑重载其方法,并将其返回给客户端应用程序。

4

grpc.ClientStream的包装流。

5

拦截从流式 RPC 接收到的消息的函数。

6

拦截从流式 RPC 发送的消息的函数。

7

注册流拦截器。

通过包装 grpc.ClientStream 的实现来拦截流操作是通过一个新结构来实现的,该结构包装了两个被拦截的流方法 RecvMsgSendMsg,这些方法可用于拦截从客户端发送或接收的流式消息。拦截器的注册与一元拦截器相同,并且在 grpc.Dial 操作中完成。

让我们来看看截止时间,这是在从客户端应用程序调用 gRPC 服务时经常需要应用的另一种能力。

截止时间

截止时间和超时是分布式计算中常用的两种模式。超时 允许您指定客户端应用程序在终止并返回错误之前可以等待 RPC 完成的时间。超时通常以持续时间指定,并且在每个客户端端本地应用。例如,单个请求可能由多个下游 RPC 组成,这些 RPC 将多个服务链在一起。因此,我们可以在每个服务调用处相对于每个 RPC 应用超时。因此,超时不能直接应用于请求的整个生命周期。这就是我们需要使用截止时间的地方。

截止时间 是从请求开始的绝对时间来表达的(即使 API 将它们表示为持续时间偏移),并且在多个服务调用之间应用。发起请求的应用程序设置截止时间,并且整个请求链需要在截止时间之前响应。gRPC API 支持在 RPC 中使用截止时间。出于多种原因,在您的 gRPC 应用程序中始终使用截止时间是个好习惯。gRPC 通信通过网络进行,因此在 RPC 调用和响应之间可能会存在延迟。此外,在某些情况下,根据服务的业务逻辑,gRPC 服务本身可能需要更长时间来响应。当客户端应用程序在不使用截止时间的情况下开发时,它们会无限期地等待发起的 RPC 请求的响应,并且资源将被保留用于所有正在进行的请求。这会使服务和客户端面临资源耗尽的风险,增加服务的延迟;甚至可能导致整个 gRPC 服务崩溃。

在 图 5-3 中展示的示例场景说明了一个 gRPC 客户端应用程序调用产品管理服务,该服务再次调用库存服务。

客户端应用程序设置了截止时间偏移量(即,截止时间 = 当前时间 + 偏移量),为 50 毫秒。客户端与ProductMgt服务之间的网络延迟为 0 毫秒,而ProductMgt服务的处理延迟为 20 毫秒。产品管理服务必须设置 30 毫秒的截止时间偏移量。由于库存服务需要 30 毫秒来响应,截止事件将在客户端双方发生(ProductMgt调用Inventory服务和客户端应用程序)。

ProductMgt服务的业务逻辑中添加的延迟为 20 毫秒。然后,ProductMgt服务的调用逻辑触发了超过截止日期的情况,并将其传播回客户端应用程序。因此,在使用截止日期时,请确保它们应用于所有服务。

调用服务时使用截止日期

图 5-3. 调用服务时使用截止日期

客户端应用程序在初始化与 gRPC 服务的连接时可以设置截止日期。一旦发起 RPC 调用,客户端应用程序将等待由截止日期指定的持续时间;如果未在该时间内收到 RPC 调用的响应,则以DEADLINE_EXCEEDED错误终止 RPC 调用。

让我们看一个使用 gRPC 服务调用截止日期的真实示例。在相同的OrderManagement服务用例中,假设AddOrder RPC 花费了大量时间来完成(我们通过在OrderManagement gRPC 服务的AddOrder方法中引入延迟来模拟这一点)。但是客户端应用程序只等待直到响应对其不再有用。例如,AddOrder响应所需的持续时间为两秒,而客户端只等待两秒钟以获取响应。为了实现这一点(如在示例 5-5 中所示的 Go 代码片段中),客户端应用程序可以使用context.WithDeadline操作设置两秒钟的超时时间。我们已使用status包来处理错误代码;我们将在错误处理部分详细讨论这一点。

示例 5-5. 客户端应用程序的 gRPC 截止日期
conn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
client := pb.NewOrderManagementClient(conn)

clientDeadline := time.Now().Add(
    time.Duration(2 * time.Second))
ctx, cancel := context.WithDeadline(
    context.Background(), clientDeadline) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

defer cancel()

// Add Order order1 := pb.Order{Id: "101",
    Items:[]string{"iPhone XS", "Mac Book Pro"},
    Destination:"San Jose, CA",
    Price:2300.00}
res, addErr := client.AddOrder(ctx, &order1) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

if addErr != nil {
    got := status.Code(addErr) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    log.Printf("Error Occured -> addOrder : , %v:", got) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
} else {
    log.Print("AddOrder Response -> ", res.Value)
}

1

在当前上下文中设置两秒钟的截止日期。

2

调用AddOrder远程方法,并将可能的任何错误捕获到addErr中。

3

使用status包来确定错误代码。

4

如果调用超过指定的截止时间,应返回类型为DEADLINE_EXCEEDED的错误。

那么,我们应该如何确定截止时间的理想值呢?对于这个问题没有单一的答案,但你需要考虑几个因素来做出选择;主要是我们调用的每个服务的端到端延迟,哪些 RPC 是串行的,哪些可以并行执行,底层网络的延迟以及下游服务的截止时间值。一旦你能够确定截止时间的初始值,根据 gRPC 应用程序的操作条件进行微调。

注意

设置在 Go 语言中 gRPC 的截止时间是通过 Go 的context完成的,其中WithDeadline是一个内置函数。在 Go 中,上下文经常用于传递可以被所有下游操作使用的共同数据。一旦这个函数从 gRPC 客户端应用程序调用,客户端侧的 gRPC 库将创建一个必需的 gRPC 头来表示客户端和服务器应用程序之间的截止时间。在 Java 中,这有些不同,因为实现直接来自io.grpc.stub.*包的存根实现,你可以使用blockingStub.withDeadlineAfter(long, java.util.concurrent.TimeUnit)来设置 gRPC 的截止时间。详细的 Java 实现请参考代码库。

当涉及到 gRPC 中的截止时间时,客户端和服务器都可以独立地进行关于 RPC 是否成功的本地决定;这意味着它们的结论可能不匹配。例如,在我们的例子中,当客户端遇到DEADLINE_EXCEEDED条件时,服务可能仍然尝试响应。因此,服务应用程序需要确定当前 RPC 是否仍然有效。从服务器端来看,你还可以检测客户端在调用 RPC 时已达到的截止时间。在AddOrder操作中,你可以检查ctx.Err() == context.DeadlineExceeded来判断客户端是否已经遇到截止时间过期状态,然后在服务器端放弃 RPC 并返回错误(这通常使用非阻塞的select结构在 Go 中实现)。

类似于截止时间,有些情况下你的客户端或服务器应用程序希望终止正在进行的 gRPC 通信。这时候 gRPC 的取消功能就变得非常有用。

取消

在客户端和服务器应用程序之间的 gRPC 连接中,客户端和服务器都会独立和本地地确定调用的成功情况。例如,服务器端可以成功完成一个 RPC,但客户端端可能失败。同样地,客户端和服务器可能会因为各种条件而对 RPC 的结果得出不同的结论。当客户端或服务器应用程序想要终止 RPC 时,可以通过 取消 RPC 来实现。一旦 RPC 被取消,将无法进行更多与 RPC 相关的消息传递,并且取消 RPC 的一方会向另一方传播这一事实。

在 Go 中,类似于截止日期,取消功能是通过 context 提供的,其中 WithCancel 是一个内置函数。一旦从 gRPC 应用程序中调用它,客户端的 gRPC 库会创建一个必要的 gRPC 头来表示客户端和服务器应用程序之间的 gRPC 终止。

让我们以客户端和服务器应用程序之间的双向流式处理为例。在所示的 Go 代码示例中 Example 5-6 中,您可以从 context.WithTimeout 调用中获取 cancel 函数。一旦获得了 cancel 的引用,您可以在任何想要终止 RPC 的地方调用它。

Example 5-6. gRPC 取消
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

streamProcOrder, _ := client.ProcessOrders(ctx) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
_ = streamProcOrder.Send(&wrapper.StringValue{Value:"102"}) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
_ = streamProcOrder.Send(&wrapper.StringValue{Value:"103"})
_ = streamProcOrder.Send(&wrapper.StringValue{Value:"104"})

channel := make(chan bool, 1)

go asncClientBidirectionalRPC(streamProcOrder, channel)
time.Sleep(time.Millisecond * 1000)

// Canceling the RPC cancel() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
log.Printf("RPC Status : %s", ctx.Err()) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)

_ = streamProcOrder.Send(&wrapper.StringValue{Value:"101"})
_ = streamProcOrder.CloseSend()

<- channel

func asncClientBidirectionalRPC (
    streamProcOrder pb.OrderManagement_ProcessOrdersClient, c chan bool) {
...
		combinedShipment, errProcOrder := streamProcOrder.Recv()
		if errProcOrder != nil {
			log.Printf("Error Receiving messages %v", errProcOrder) ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
...
}

1

获取取消的引用。

2

调用流式 RPC。

3

通过流向服务发送消息。

4

从客户端取消 RPC/终止 RPC。

5

当前上下文的状态。

6

当尝试从取消的上下文接收消息时返回上下文取消错误。

当一方取消 RPC 时,另一方可以通过检查上下文确定。在这个例子中,服务器应用程序可以通过 stream.Context().Err() == context.Canceled 来检查当前上下文是否已取消。

如你在截止日期和取消应用中所见,处理 RPC 错误是一个非常常见的要求。在接下来的章节中,我们将详细讨论 gRPC 错误处理技术。

错误处理

当我们调用 gRPC 时,客户端会收到一个带有成功状态或相应错误状态的响应。客户端应用程序需要编写成这样的方式,能够处理所有可能的错误和错误条件。服务器应用程序需要处理错误,并生成相应的状态码。

当发生错误时,gRPC 返回其错误状态码之一,并可选择性地提供错误消息,以提供更多错误条件的详细信息。状态对象由整数代码和字符串消息组成,这在所有不同语言的 gRPC 实现中都是通用的。

gRPC 使用一组明确定义的特定于 gRPC 的状态代码。这包括诸如以下状态代码:

成功

成功状态;不是错误。

已取消

操作通常由调用者取消。

超出期限

操作在完成之前已超出期限。

无效参数

客户端指定了无效参数。

表 5-1 显示了可用的 gRPC 错误代码及每个错误代码的描述。完整的错误代码列表可以在 gRPC 官方文档 中找到,或者在 GoJava 的文档中找到。

表 5-1. gRPC 错误代码

代码 编号 描述
成功 0 成功状态。
已取消 1 操作已被取消(由调用者)。
未知错误 2 未知错误。
无效参数 3 客户端指定了无效参数。
超出期限 4 操作在完成之前已超出期限。
未找到 5 未找到某些请求的实体。
已存在 6 客户端试图创建的实体已存在。
拒绝许可 7 调用者无权限执行指定的操作。
未经身份验证 16 请求没有为操作提供有效的身份验证凭据。
资源耗尽 8 某些资源已耗尽。
前提条件失败 9 操作被拒绝,因为系统不处于操作执行所需的状态。
已中止 10 操作已中止。
超出范围 11 尝试的操作超出了有效范围。
未实现 12 操作未实现或不受此服务支持/启用。
内部错误 13 内部错误。
服务不可用 14 当前服务不可用。
数据丢失 15 不可恢复的数据丢失或损坏。

gRPC 提供的默认错误模型相当有限,并且独立于底层 gRPC 数据格式(其中最常见的格式是协议缓冲区)。如果您正在使用协议缓冲区作为数据格式,则可以利用 Google API 在 google.rpc 包下提供的更丰富的错误模型。然而,该错误模型仅在 C++、Go、Java、Python 和 Ruby 库中受支持,因此如果您计划使用其他语言,请注意这一点。

让我们看看如何在实际的 gRPC 错误处理用例中使用这些概念。在我们的订单管理用例中,假设在AddOrder远程方法中处理请求时,我们需要处理一个具有无效订单 ID 的请求。如示例 5-7 所示,假设给定的订单 ID 等于-1,则需要生成一个错误并返回给消费者。

示例 5-7. 服务器端错误创建和传播
if orderReq.Id == "-1" { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
    log.Printf("Order ID is invalid! -> Received Order ID %s",
        orderReq.Id)

    errorStatus := status.New(codes.InvalidArgument,
        "Invalid information received") ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
    ds, err := errorStatus.WithDetails( ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
        &epb.BadRequest_FieldViolation{
            Field:"ID",
            Description: fmt.Sprintf(
                "Order ID received is not valid %s : %s",
                orderReq.Id, orderReq.Description),
        },
    )
    if err != nil {
        return nil, errorStatus.Err()
    }

    return nil, ds.Err() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    }
    ...

1

无效请求,需要生成错误并将其发送回客户端。

2

创建一个带有错误码InvalidArgument的新错误状态。

3

包含来自google.golang.org/genproto/googleapis/rpc/errdetails的错误类型为BadRequest_FieldViolation的任何错误详细信息。

4

返回生成的错误。

您可以简单地从grpc.status包创建一个错误状态,包括所需的错误码和详细信息。在这个例子中,我们使用了status.New(codes.InvalidArgument, "接收到无效信息")。您只需将此错误发送回客户端return nil, errorStatus.Err()。但是,如果需要包含更丰富的错误模型,您可以使用 Google API 的google.rpc包。在本例中,我们设置了来自google.golang.org/genproto/googleapis/rpc/errdetails的特定错误类型的错误详细信息。

对于客户端端错误处理,您只需处理作为 RPC 调用的一部分返回的错误。例如,在示例 5-8 中,您可以找到该订单管理用例的客户端应用程序的 Go 实现。在这里,我们调用了AddOrder方法并将返回的错误赋给addOrderError变量。因此,下一步是检查addOrderError的结果并优雅地处理错误。为此,您可以获取从服务器端设置的错误码和特定错误类型。

示例 5-8. 客户端端错误处理
order1 := pb.Order{Id: "-1",
	Items:[]string{"iPhone XS", "Mac Book Pro"},
	Destination:"San Jose, CA", Price:2300.00} ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
res, addOrderError := client.AddOrder(ctx, &order1) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

if addOrderError != nil {
	errorCode := status.Code(addOrderError) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
	if errorCode == codes.InvalidArgument { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
		log.Printf("Invalid Argument Error : %s", errorCode)
		errorStatus := status.Convert(addOrderError) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
		for _, d := range errorStatus.Details() {
			switch info := d.(type) {
			case *epb.BadRequest_FieldViolation: ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
				log.Printf("Request Field Invalid: %s", info)
			default:
				log.Printf("Unexpected error type: %s", info)
			}
		}
	} else {
		log.Printf("Unhandled error : %s ", errorCode)
	}
} else {
	log.Print("AddOrder Response -> ", res.Value)
}

1

这是一个无效的订单。

2

调用AddOrder远程方法并将错误赋给addOrderError

3

使用grpc/status包获取错误码。

4

检查InvalidArgument错误码。

5

从错误中获取错误状态。

6

检查BadRequest_FieldViolation错误类型。

在您的 gRPC 应用程序中,始终使用适当的 gRPC 错误码和更丰富的错误模型是一个良好的实践。gRPC 错误状态和详细信息通常通过传输协议级别的尾部标头发送。

现在让我们看看多路复用,这是在同一个 gRPC 服务器运行时的一种服务托管机制。

多路复用

就 gRPC 服务和客户端应用程序而言,我们已经看到了一个特定的 gRPC 服务器上注册了一个 gRPC 服务,并且一个 gRPC 客户端连接仅被单个客户端存根使用的情况。但是,gRPC 允许您在同一个 gRPC 服务器上运行多个 gRPC 服务(请参见 Figure 5-4),并且可以为多个 gRPC 客户端存根使用相同的 gRPC 客户端连接。这种能力被称为多路复用

在同一服务器应用程序中复用多个 gRPC 服务

图 5-4. 在同一服务器应用程序中复用多个 gRPC 服务

例如,在我们的 OrderManagement 服务示例中,假设您希望在同一个 gRPC 服务器上运行另一个服务,以便客户端应用程序可以重用同一连接来调用两个服务,那么您可以通过使用它们各自的服务器注册函数(即 ordermgt_pb.RegisterOrderManagementServerhello_pb.RegisterGreeterServer)来同时在同一个 gRPC 服务器上注册这两个服务。使用这种方法,您可以在同一个 gRPC 服务器上注册一个或多个 gRPC 服务(如示例 Example 5-9 所示)。

示例 5-9. 两个共享相同 grpc.Server 的 gRPC 服务
func main() {
	initSampleData()
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	grpcServer := grpc.NewServer() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

	// Register Order Management service on gRPC orderMgtServer 	ordermgt_pb.RegisterOrderManagementServer(grpcServer, &orderMgtServer{}) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

	// Register Greeter Service on gRPC orderMgtServer 	hello_pb.RegisterGreeterServer(grpcServer, &helloServer{}) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

      ...
}

1

创建 gRPC 服务器。

2

在 gRPC 服务器上注册 OrderManagement 服务。

3

在同一个 gRPC 服务器上注册 Hello 服务。

类似地,从客户端方面,您可以在两个 gRPC 客户端存根之间共享相同的 gRPC 连接。

如示例 Example 5-10 所示,由于两个 gRPC 服务在同一个 gRPC 服务器上运行,您可以创建一个 gRPC 连接,并在为不同服务创建 gRPC 客户端实例时使用它。

示例 5-10. 两个共享相同 grpc.ClientConn 的 gRPC 客户端存根
// Setting up a connection to the server. conn, err := grpc.Dial(address, grpc.WithInsecure()) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
...

orderManagementClient := pb.NewOrderManagementClient(conn) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

...

// Add Order RPC 	...
res, addErr := orderManagementClient.AddOrder(ctx, &order1)

...

helloClient := hwpb.NewGreeterClient(conn) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

	...
	// Say hello RPC helloResponse, err := helloClient.SayHello(hwcCtx,
	&hwpb.HelloRequest{Name: "gRPC Up and Running!"})
...

1

创建 gRPC 连接。

2

使用创建的 gRPC 连接创建 OrderManagement 客户端。

3

使用同一 gRPC 连接创建 Hello 服务客户端。

在多个服务运行或在多个存根之间使用相同连接是独立于 gRPC 概念的设计选择。在大多数日常用例中,如微服务,将同一 gRPC 服务器实例共享给两个服务是非常常见的。

注意

在微服务架构中,gRPC 多路复用的一个强大用途是在一个服务器进程中托管同一服务的多个主要版本。这样一来,在 API 变更后,服务可以为旧客户端提供支持。一旦不再使用旧版本的服务契约,就可以从服务器中删除。

在接下来的部分中,我们将讨论如何在客户端和服务应用程序之间交换不属于 RPC 参数和响应的数据。

元数据

gRPC 应用程序通常通过 RPC 调用在 gRPC 服务和消费者之间共享信息。在大多数情况下,与服务的业务逻辑和消费者直接相关的信息是远程方法调用参数的一部分。然而,在某些情况下,您可能希望共享与 RPC 调用不相关的关于 RPC 调用的信息,因此它们不应作为 RPC 参数的一部分。在这种情况下,您可以使用gRPC 元数据,您可以从 gRPC 服务或 gRPC 客户端发送或接收它们。正如在图 5-5 中所示,您在客户端或服务器端创建的元数据可以通过 gRPC 头在客户端和服务器应用程序之间进行交换。元数据的结构形式为键(字符串)/值对的列表。

元数据的最常见用途之一是在 gRPC 应用程序之间交换安全头信息。同样,您可以用它来在 gRPC 应用程序之间交换任何此类信息。通常,gRPC 元数据 API 在我们开发的拦截器中被大量使用。在接下来的部分中,我们将探讨 gRPC 如何支持在客户端和服务器之间发送元数据。

在客户端和服务器应用程序之间交换 gRPC 元数据。

图 5-5. 在客户端和服务器应用程序之间交换 gRPC 元数据

创建和检索元数据

从 gRPC 应用程序创建元数据非常简单。在以下 Go 代码片段中,您将找到创建元数据的两种方法。在 Go 中,元数据被表示为普通的映射,并且可以使用metadata.New(map[string]string{"key1": "val1", "key2": "val2"})格式创建。此外,您还可以使用metadata.Pairs以键值对的方式创建元数据,因此具有相同键的元数据将合并为列表:

// Metadata Creation : option I
md := metadata.New(map[string]string{"key1": "val1", "key2": "val2"})

// Metadata Creation : option II
md := metadata.Pairs(
    "key1", "val1",
    "key1", "val1-2", // "key1" will have map value []string{"val1", "val1-2"}
    "key2", "val2",
)

您还可以将二进制数据设置为元数据值。在发送之前,我们设置为元数据值的二进制数据将进行 base64 编码,并在传输后进行解码。

从客户端或服务器端读取元数据可以通过 RPC 调用的传入上下文和metadata.FromIncomingContext(ctx)来完成,该方法在 Go 语言中返回元数据映射:

func (s *server) AddOrder(ctx context.Context, orderReq *pb.Order)
    (*wrappers.StringValue, error) {

md, metadataAvailable := metadata.FromIncomingContext(ctx)
// read the required metadata from the ‘md’ metadata map.

现在让我们深入探讨客户端或服务器端不同一元和流式 RPC 样式中的元数据发送和接收过程。

发送和接收元数据:客户端端

您可以通过创建元数据并将其设置到 RPC 调用的上下文中,从客户端向 gRPC 服务发送元数据。在 Go 实现中,您可以通过两种不同的方式完成这个操作。如 示例 5-11 所示,您可以使用 NewOutgoingContext 创建带有新元数据的新上下文,或者只需将元数据附加到现有上下文中使用 AppendToOutgoingContext。但是,使用 NewOutgoingContext 将替换上下文中的任何现有元数据。一旦您创建了带有所需元数据的上下文,它可以用于一元或流式 RPC。正如您在 第四章 中学到的那样,您在上下文中设置的元数据会被转换为 gRPC 标头(在 HTTP/2 中)或者在传输层级别上的尾部。因此,当客户端发送这些标头时,接收方将其作为标头接收。

示例 5-11. 从 gRPC 客户端端发送元数据
md := metadata.Pairs(
	"timestamp", time.Now().Format(time.StampNano),
	"kn", "vn",
) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
mdCtx := metadata.NewOutgoingContext(context.Background(), md) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

ctxA := metadata.AppendToOutgoingContext(mdCtx,
      "k1", "v1", "k1", "v2", "k2", "v3") ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

// make unary RPC response, err := client.SomeRPC(ctxA, someRequest) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

// or make streaming RPC stream, err := client.SomeStreamingRPC(ctxA) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)

1

创建元数据。

2

使用新元数据创建一个新上下文。

3

将一些额外的元数据附加到现有上下文中。

4

使用新元数据的一元 RPC。

5

相同的上下文也可以用于流式 RPC。

因此,在从客户端接收元数据时,您需要将它们视为标头或尾部。在 示例 5-12 中,您可以找到有关一元和流式 RPC 样式接收元数据的 Go 代码示例。

示例 5-12. 在 gRPC 客户端端读取元数据
var header, trailer metadata.MD ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

// ***** Unary RPC ***** 
r, err := client.SomeRPC( ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
    ctx,
    someRequest,
    grpc.Header(&header),
    grpc.Trailer(&trailer),
)

// process header and trailer map here. 
// ***** Streaming RPC ***** 
stream, err := client.SomeStreamingRPC(ctx)

// retrieve header header, err := stream.Header() ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

// retrieve trailer trailer := stream.Trailer() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

// process header and trailer map here.

1

用于存储从 RPC 调用返回的标头和尾部的变量。

2

传递标头和尾部引用以存储一元 RPC 返回的值。

3

从流中获取标头。

4

从流中获取尾部。尾部用于发送状态码和状态消息。

一旦从各自的 RPC 操作中获取了值,您可以将它们作为通用映射处理并处理所需的元数据。

现在让我们转向服务器端的元数据处理。

发送和接收元数据:服务器端

在服务器端接收元数据非常简单。在 Go 中,您可以在远程方法实现中使用 metadata.FromIncomingContext(ctx) 简单获取元数据(参见 示例 5-13)。

示例 5-13. 在 gRPC 服务器端读取元数据
func (s *server) SomeRPC(ctx context.Context,
    in *pb.someRequest) (*pb.someResponse, error) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
    md, ok := metadata.FromIncomingContext(ctx) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
    // do something with metadata }

func (s *server) SomeStreamingRPC(
    stream pb.Service_SomeStreamingRPCServer) error { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    md, ok := metadata.FromIncomingContext(stream.Context()) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    // do something with metadata }

1

一元 RPC。

2

从远程方法的传入上下文中读取元数据映射。

3

流式 RPC。

4

从流中获取上下文并从中读取元数据。

要从服务器端发送元数据,请使用带有元数据的头部或设置带有元数据的尾部。元数据创建方法与我们在前一节中讨论的相同。在示例 5-14 中,您可以找到有关在服务器端实现一元和流式远程方法发送元数据的 Go 代码示例。

示例 5-14. 从 gRPC 服务器端发送元数据
func (s *server) SomeRPC(ctx context.Context,
    in *pb.someRequest) (*pb.someResponse, error) {
    // create and send header
    header := metadata.Pairs("header-key", "val")
    grpc.SendHeader(ctx, header) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
    // create and set trailer
    trailer := metadata.Pairs("trailer-key", "val")
    grpc.SetTrailer(ctx, trailer) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
}

func (s *server) SomeStreamingRPC(stream pb.Service_SomeStreamingRPCServer) error {
    // create and send header
    header := metadata.Pairs("header-key", "val")
    stream.SendHeader(header) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    // create and set trailer
    trailer := metadata.Pairs("trailer-key", "val")    stream.SetTrailer(trailer) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
}

1

在流的头部发送元数据。

2

在流的尾部发送元数据。

3

在流中作为头部发送元数据。

4

在流的尾部发送元数据。

无论是一元的还是流式的情况,你都可以使用grpc.SendHeader方法发送元数据。如果你希望将元数据作为尾部的一部分发送,则需要使用上下文的尾部设置元数据,使用相应流的grpc.SetTrailerSetTrailer方法。

现在让我们讨论调用 gRPC 应用程序时另一种常用的技术:名称解析。

名称解析器

名称解析器接受服务名称并返回后端的 IP 地址列表。在示例 5-15 中使用的解析器将lb.example.grpc.io解析为localhost:50051localhost:50052

示例 5-15. 在 Go 中实现的 gRPC 名称解析器
type exampleResolverBuilder struct{} ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

func (*exampleResolverBuilder) Build(target resolver.Target,
	cc resolver.ClientConn,
	opts resolver.BuildOption) (resolver.Resolver, error) {

	r := &exampleResolver{ ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
		target: target,
		cc:     cc,
		addrsStore: map[string][]string{
           exampleServiceName: addrs, ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
		},
	}
	r.start()
	return r, nil
}
func (*exampleResolverBuilder) Scheme() string { return exampleScheme } ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

type exampleResolver struct { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
	target     resolver.Target
	cc         resolver.ClientConn
	addrsStore map[string][]string
}

func (r *exampleResolver) start() {
	addrStrs := r.addrsStore[r.target.Endpoint]
	addrs := make([]resolver.Address, len(addrStrs))
	for i, s := range addrStrs {
		addrs[i] = resolver.Address{Addr: s}
	}
	r.cc.UpdateState(resolver.State{Addresses: addrs})
}
func (*exampleResolver) ResolveNow(o resolver.ResolveNowOption) {}
func (*exampleResolver) Close()                                 {}

func init() {
	resolver.Register(&exampleResolverBuilder{})
}

1

创建解析器的名称解析器构建器。

2

创建解析lb.example.grpc.io的示例解析器。

3

这将lb.example.grpc.io解析为localhost:50051localhost:50052

4

此解析器适用于 scheme example

5

名称解析器的结构。

因此,基于此名称解析器的实现,您可以为任何服务注册表(如ConsuletcdZookeeper)实现解析器。 gRPC 负载均衡需求可能与您使用的部署模式或用例密切相关。随着像 Kubernetes 这样的容器编排平台和更高级别的抽象(如服务网格)的日益普及,客户端端实现负载均衡逻辑的需求变得非常罕见。我们将探讨在容器和 Kubernetes 上本地部署 gRPC 应用程序的一些最佳实践,详见第七章。

现在让我们讨论你的 gRPC 应用程序中最常见的要求之一,即负载均衡,在某些情况下可以使用名称解析器。

负载均衡

在开发可用于生产的 gRPC 应用程序时,通常需要确保应用程序能够满足高可用性和可扩展性需求。因此,您始终会在生产环境中运行多个 gRPC 服务器。因此,需要有一个实体来负责在这些服务之间分发 RPC 调用。这就是负载均衡发挥作用的地方。gRPC 中通常使用两种主要的负载均衡机制:负载均衡(LB)代理客户端负载均衡。让我们先讨论 LB 代理。

负载均衡代理

在代理负载均衡中(图 5-6),客户端向 LB 代理发起 RPC 调用。然后 LB 代理将 RPC 调用分发给一个实现实际调用服务逻辑的可用后端 gRPC 服务器。LB 代理跟踪每个后端服务器的负载,并为在后端服务之间分发负载提供不同的负载均衡算法。

客户端应用调用一个负载均衡器,该负载均衡器前端有多个 gRPC 服务。

图 5-6. 客户端应用调用一个负载均衡器,该负载均衡器前端有多个 gRPC 服务

后端服务的拓扑对 gRPC 客户端不透明,它们只知道负载均衡器的终端节点。因此,在客户端,除了将负载均衡器的终端点作为所有 gRPC 连接的目的地外,不需要为负载均衡使用情况做任何其他更改。后端服务可以向负载均衡器报告负载状态,以便负载均衡器可以利用该信息进行负载均衡逻辑。

理论上,您可以选择任何支持 HTTP/2 的负载均衡器作为 gRPC 应用程序的 LB 代理。但是,它必须完全支持 HTTP/2。因此,明确选择支持 gRPC 的负载均衡器总是一个好主意。例如,您可以使用像 NginxEnvoy proxy 等负载均衡解决方案作为 gRPC 应用程序的 LB 代理。

如果您不使用 gRPC 负载均衡器,则可以将负载均衡逻辑实现为您编写的客户端应用程序的一部分。让我们更详细地了解客户端负载均衡。

客户端负载均衡

与具有负载均衡中间代理层不同,您可以在 gRPC 客户端级别实现负载均衡逻辑。在这种方法中,客户端知道多个后端 gRPC 服务器,并选择一个用于每个 RPC。正如在 图 5-7 中所示,负载均衡逻辑可以完全作为客户端应用程序的一部分开发(也称为厚客户端),或者可以在专用服务器上实现,称为外部负载均衡器。然后,客户端可以查询它以获取连接的最佳 gRPC 服务器。客户端直接连接到外部负载均衡器获取的 gRPC 服务器地址。

客户端负载均衡

图 5-7. 客户端负载均衡

要了解如何实现客户端负载均衡,让我们看一个使用 Go 实现的厚客户端的示例。在这个用例中,假设我们有两个后端 gRPC 服务,在 :50051 和 :50052 上运行 echo 服务器。这些 gRPC 服务将服务器地址作为 RPC 响应的一部分包括进去。因此,我们可以将这两个服务器视为 echo gRPC 服务集群的两个成员。现在,假设我们想要构建一个使用轮询(依次执行对每个其他的)算法选择 gRPC 服务器端点的 gRPC 客户端应用程序,另一个客户端使用服务器端点列表的第一个端点。示例 5-16 展示了厚客户端负载均衡的实现。在这里,你可以观察到客户端正在拨号 example:///lb.example.grpc.io。因此,我们使用 example 方案名和 lb.example.grpc.io 作为服务器名。基于此方案,它将查找名解析器以发现后端服务地址的绝对值。根据名解析器返回的值列表,gRPC 对这些服务器运行不同的负载均衡算法。该行为通过 grpc.WithBalancerName("round_robin") 进行配置。

示例 5-16. 带有厚客户端的客户端负载均衡
pickfirstConn, err := grpc.Dial(
		fmt.Sprintf("%s:///%s",
        // 	exampleScheme      = "example"
        //	exampleServiceName = "lb.example.grpc.io"
        exampleScheme, exampleServiceName), ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
        // "pick_first" is the default option. ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
		grpc.WithBalancerName("pick_first"),

		grpc.WithInsecure(),)
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
defer pickfirstConn.Close()

log.Println("==== Calling helloworld.Greeter/SayHello " +
	"with pick_first ====")
makeRPCs(pickfirstConn, 10)

// Make another ClientConn with round_robin policy. roundrobinConn, err := grpc.Dial(
    fmt.Sprintf("%s:///%s", exampleScheme, exampleServiceName),
    // "example:///lb.example.grpc.io"
    grpc.WithBalancerName("round_robin"), ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
    grpc.WithInsecure(),
)
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
defer roundrobinConn.Close()

log.Println("==== Calling helloworld.Greeter/SayHello " +
	"with round_robin ====")
makeRPCs(roundrobinConn, 10)

1

使用 scheme 和服务名称创建 gRPC 连接。该方案从方案解析器解析,该解析器是客户端应用程序的一部分。

2

指定选择服务器端点列表中的第一个服务器的负载均衡算法。

3

使用轮询负载均衡算法。

gRPC 默认支持两种负载均衡策略:pick_firstround_robinpick_first 尝试连接第一个地址,如果连接成功,则用于所有 RPC;如果失败,则尝试下一个地址。round_robin 连接它看到的所有地址,并依次向每个后端发送一个 RPC。

在客户端负载均衡的场景中,在示例 5-16 中,我们有一个名称解析器来解析包含发现端点 URL 实际值的逻辑的scheme example。现在让我们谈谈压缩,这是 gRPC 的另一个常用特性,用于在 RPC 上发送大量内容。

压缩

为了有效利用网络带宽,在客户端和服务之间执行 RPC 时,请使用压缩。在客户端使用 gRPC 压缩可以通过在进行 RPC 时设置压缩器来实现。例如,在 Go 中,可以简单地使用client.AddOrder(ctx, &order1, grpc.UseCompressor(gzip.Name))来实现,其中"google.golang.org/grpc/encoding/gzip"提供了 gzip 包。

从服务器端,注册的压缩器将自动用于解码请求消息和编码响应。在 Go 中,注册压缩器就像将"google.golang.org/grpc/encoding/gzip"导入到您的 gRPC 服务器应用程序中一样简单。服务器始终使用客户端指定的相同压缩方法进行响应。如果未注册相应的压缩器,则会向客户端返回Unimplemented状态。

摘要

构建可供生产使用的真实世界 gRPC 应用程序通常需要包括除定义服务接口、生成服务器和客户端代码以及实现业务逻辑之外的各种能力。正如您在本章中看到的,gRPC 提供了多种能力,在构建 gRPC 应用程序时将会用到,包括拦截器、截止时间、取消和错误处理。

然而,我们还没有讨论如何保护 gRPC 应用程序以及如何消费它们。因此,在下一章中,我们将详细讨论这个主题。

第六章:安全的 gRPC

基于 gRPC 的应用程序在网络上远程通信。这要求每个 gRPC 应用程序向需要与其通信的其他应用程序公开其入口点。从安全性的角度来看,这并不是一件好事。我们拥有的入口点越多,攻击面就越广,被攻击的风险就越高。因此,保护通信并保护入口点对于任何真实世界的使用案例都是至关重要的。每个 gRPC 应用程序必须能够处理加密消息,加密所有节点间的通信,并验证和签署所有消息等。

在本章中,我们将讨论一组安全基础知识和模式,以解决在启用应用级安全时面临的挑战。简单来说,我们将探讨如何保护微服务之间的通信通道,并验证和控制用户的访问权限。

所以让我们从保护通信通道开始。

使用 TLS 验证 gRPC 通道

传输层安全 (TLS) 旨在为两个通信应用程序提供隐私和数据完整性保护。在这里,它关注的是为 gRPC 客户端和服务器应用程序之间提供安全连接。根据传输层安全协议规范,当客户端和服务器之间的连接是安全的时,它应具有以下一个或多个属性:

连接是私密的

对数据加密使用对称加密技术。这是一种加密类型,其中仅使用一个密钥(秘密密钥)进行加密和解密。这些密钥根据会话开始时协商的共享秘密生成,对每个连接都是唯一的。

连接是可靠的

这是因为每条消息都包含消息完整性检查,以防止在传输过程中发生未检测到的数据丢失或更改。

因此,通过安全连接发送数据非常重要。使用 TLS 保护 gRPC 连接并不困难,因为这种认证机制已内置于 gRPC 库中。它还推广了 TLS 的使用,用于验证和加密交换。

那么,我们如何在 gRPC 连接中启用传输层安全呢?客户端和服务器之间的安全数据传输可以实现为单向或双向(也称为双向 TLS 或 mTLS)。在接下来的部分中,我们将讨论如何以这些方式启用安全性。

启用单向安全连接

在单向连接中,只有客户端验证服务器,以确保它从预期的服务器接收数据。在建立客户端和服务器之间的连接时,服务器会向客户端共享其公共证书,客户端然后验证接收到的证书。对于由证书颁发机构(CA)签名的证书,这是通过证书颁发机构(CA)完成的。一旦证书验证通过,客户端就使用密钥加密发送数据。

CA 是一个受信任的实体,负责管理和签发用于公共网络中安全通信的安全证书和公钥。由该受信任实体签署或颁发的证书称为 CA 签名证书。

要启用 TLS,首先我们需要创建以下证书和密钥:

server.key

用于签署和验证公共密钥的私有 RSA 密钥。

server.pem/server.crt

自签名的 X.509 公钥用于分发。

注意

缩写 RSA 代表三位发明者的名字:Rivest、Shamir 和 Adleman。RSA 是最流行的公钥密码系统之一,在安全数据传输中被广泛使用。在 RSA 中,公钥(可被所有人知道)用于加密数据。然后使用私钥解密数据。其思想是只有使用私钥才能在合理的时间内解密用公钥加密的消息。

要生成这些密钥,我们可以使用 OpenSSL 工具,这是一个开源工具包,用于 TLS 和安全套接字层(SSL)协议。它支持生成不同大小和密码短语的私钥、公共证书等。还有其他工具如 mkcertcertstrap,也可以用来轻松生成密钥和证书。

我们不会在此描述如何生成自签名证书的密钥,因为在源代码仓库的 README 文件中已经详细描述了生成这些密钥和证书的逐步详细过程。

假设我们已经创建了私钥和公共证书。让我们使用它们,为我们在第 1 和 2 章讨论的在线产品管理系统中的 gRPC 服务器和客户端之间的通信进行安全保护。

启用 gRPC 服务器中的单向安全连接

这是加密客户端和服务器通信的最简单方法。这里服务器需要用公共/私有密钥对初始化。我们将解释如何在我们的 gRPC Go 服务器中执行这个过程。

要在安全 Go 服务器中启用安全连接,让我们根据 示例 6-1 更新服务器实现的主函数。

示例 6-1. 用于托管 ProductInfo 服务的 gRPC 安全服务器实现
package main

import (
  "crypto/tls"
  "errors"
  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
  "log"
  "net"
)

var (
  port = ":50051"
  crtFile = "server.crt"
  keyFile = "server.key"
)

func main() {
  cert, err := tls.LoadX509KeyPair(crtFile,keyFile) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  if err != nil {
     log.Fatalf("failed to load key pair: %s", err)
  }
  opts := []grpc.ServerOption{
     grpc.Creds(credentials.NewServerTLSFromCert(&cert)) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  }

  s := grpc.NewServer(opts...) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  pb.RegisterProductInfoServer(s, &server{}) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

  lis, err := net.Listen("tcp", port) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
  if err != nil {
     log.Fatalf("failed to listen: %v", err)
  }

  if err := s.Serve(lis); err != nil { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
     log.Fatalf("failed to serve: %v", err)
  }
}

1

读取和解析公共/私有密钥对,并创建证书以启用 TLS。

2

通过添加证书作为 TLS 服务器凭据,为所有传入连接启用 TLS。

3

通过传递 TLS 服务器凭据创建一个新的 gRPC 服务器实例。

4

通过调用生成的 API,将实现的服务注册到新创建的 gRPC 服务器。

5

在端口(50051)上创建一个 TCP 监听器。

6

将 gRPC 服务器绑定到侦听器并开始侦听端口(50051)上的传入消息。

现在我们已经修改了服务器,使其接受可以验证服务器证书的客户端的请求。让我们修改我们的客户端代码,与这个服务器通信。

在 gRPC 客户端启用单向安全连接

为了使客户端连接成功,客户端需要服务器的自签名公钥。我们可以修改我们的 Go 客户端代码,以连接示例 6-2 中的服务器。

示例 6-2. gRPC 安全客户端应用程序
package main

import (
  "log"

  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc/credentials"
  "google.golang.org/grpc"
)

var (
  address = "localhost:50051"
  hostname = "localhost
  crtFile = "server.crt"
)

func main() {
  creds, err := credentials.NewClientTLSFromFile(crtFile, hostname) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png) if err != nil {
     log.Fatalf("failed to load credentials: %v", err)
  }
  opts := []grpc.DialOption{
     grpc.WithTransportCredentials(creds), ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png) }

  conn, err := grpc.Dial(address, opts...) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png) if err != nil {
     log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close() ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
  c := pb.NewProductInfoClient(conn) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

  .... // Skip RPC method invocation. }

1

读取和解析公共证书并创建证书以启用 TLS。

2

将传输凭证添加为 DialOption

3

使用拨号选项与服务器建立安全连接。

4

传递连接并创建存根。该存根实例包含所有远程方法,以调用服务器。

5

当一切都完成时,关闭连接。

这是一个相当简单直接的过程。我们只需从原始代码中添加三行并修改一行。首先,我们从服务器公钥文件创建一个凭证对象,然后将传输凭证传递给 gRPC 拨号器。这将在每次客户端建立与服务器之间的连接时启动 TLS 握手。

在单向 TLS 中,我们只验证服务器身份。让我们在下一节中同时验证双方(客户端和服务器)。

启用 mTLS 安全连接

客户端和服务器之间的 mTLS 连接的主要目的是控制连接到服务器的客户端。与单向 TLS 连接不同,服务器配置为接受来自一组经过验证的客户端的连接。在这种连接中,双方都彼此分享其公共证书并验证对方。连接的基本流程如下:

  1. 客户端发送请求以访问服务器上的受保护信息。

  2. 服务器向客户端发送其 X.509 证书。

  3. 客户端通过 CA 验证接收到的证书以验证 CA 签名的证书。

  4. 如果验证成功,客户端将其证书发送给服务器。

  5. 服务器还通过 CA 验证客户端的证书。

  6. 一旦成功,服务器允许访问受保护的数据。

要在我们的示例中启用 mTLS,我们需要解决如何处理客户端和服务器证书的问题。我们需要创建一个带有自签名证书的 CA,为客户端和服务器分别创建证书签名请求,并使用我们的 CA 签名它们。与之前的单向安全连接一样,我们可以使用 OpenSSL 工具生成密钥和证书。

假设我们拥有所有必需的证书来启用客户端与服务器的 mTLS 通信。如果您正确生成了它们,您的工作空间中将创建以下密钥和证书:

server.key

服务器的私有 RSA 密钥。

server.crt

服务器的公共证书。

client.key

客户端的私有 RSA 密钥。

client.crt

客户端的公共证书。

ca.crt

用于签署所有公共证书的 CA 的公共证书。

让我们首先修改示例中的服务器代码,直接创建 X.509 密钥对,并基于 CA 公钥创建证书池。

在 gRPC 服务器中启用 mTLS

在 Go 服务器中启用 mTLS,请按照示例 6-3 中显示的方式更新服务器实现的主函数。

示例 6-3. 用于在 Go 中托管 ProductInfo 服务的 gRPC 安全服务器实现
package main

import (
  "crypto/tls"
  "crypto/x509"
  "errors"
  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
  "io/ioutil"
  "log"
  "net"
)

var (
  port = ":50051"
  crtFile = "server.crt"
  keyFile = "server.key"
  caFile = "ca.crt"
)

func main() {
  certificate, err := tls.LoadX509KeyPair(crtFile, keyFile) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  if err != nil {
     log.Fatalf("failed to load key pair: %s", err)
  }

  certPool := x509.NewCertPool() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  ca, err := ioutil.ReadFile(caFile)
  if err != nil {
     log.Fatalf("could not read ca certificate: %s", err)
  }

  if ok := certPool.AppendCertsFromPEM(ca); !ok { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
     log.Fatalf("failed to append ca certificate")
  }

  opts := []grpc.ServerOption{
     // Enable TLS for all incoming connections.
     grpc.Creds( ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
        credentials.NewTLS(&tls.Config {
           ClientAuth:   tls.RequireAndVerifyClientCert,
           Certificates: []tls.Certificate{certificate},
           ClientCAs:    certPool,
           },
        )),
  }

  s := grpc.NewServer(opts...) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
  pb.RegisterProductInfoServer(s, &server{}) ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)

  lis, err := net.Listen("tcp", port) ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
  if err != nil {
     log.Fatalf("failed to listen: %v", err)
  }

  if err := s.Serve(lis); err != nil { ![8](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/8.png)
     log.Fatalf("failed to serve: %v", err)
  }
}

1

直接从服务器证书和密钥创建 X.509 密钥对。

2

从 CA 创建证书池。

3

将来自 CA 的客户端证书附加到证书池。

4

通过创建 TLS 凭据为所有传入连接启用 TLS。

5

通过传递 TLS 服务器凭据创建新的 gRPC 服务器实例。

6

通过调用生成的 API 将 gRPC 服务注册到新创建的 gRPC 服务器。

7

在端口(50051)上创建 TCP 监听器。

8

将 gRPC 服务器绑定到侦听器,并开始在端口(50051)上监听传入的消息。

现在我们已经修改了服务器以接受来自验证客户端的请求。让我们修改我们的客户端代码以与此服务器通信。

在 gRPC 客户端中启用 mTLS

为了使客户端连接,客户端需要按照与服务器类似的步骤进行修改。我们可以修改我们的 Go 客户端代码,如示例 6-4 所示。

示例 6-4. 在 Go 中的 gRPC 安全客户端应用程序
package main

import (
  "crypto/tls"
  "crypto/x509"
  "io/ioutil"
  "log"

  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
)

var (
  address = "localhost:50051"
  hostname = "localhost"
  crtFile = "client.crt"
  keyFile = "client.key"
  caFile = "ca.crt"
)

func main() {
  certificate, err := tls.LoadX509KeyPair(crtFile, keyFile) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  if err != nil {
     log.Fatalf("could not load client key pair: %s", err)
  }

  certPool := x509.NewCertPool() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  ca, err := ioutil.ReadFile(caFile)
  if err != nil {
     log.Fatalf("could not read ca certificate: %s", err)
  }

  if ok := certPool.AppendCertsFromPEM(ca); !ok { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
     log.Fatalf("failed to append ca certs")
  }

  opts := []grpc.DialOption{
     grpc.WithTransportCredentials( credentials.NewTLS(&tls.Config{ ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
        ServerName:   hostname, // NOTE: this is required!
        Certificates: []tls.Certificate{certificate},
        RootCAs:      certPool,
     })),
  }

  conn, err := grpc.Dial(address, opts...) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
  if err != nil {
     log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
  c := pb.NewProductInfoClient(conn) ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)

  .... // Skip RPC method invocation. }

1

直接从服务器证书和密钥创建 X.509 密钥对。

2

从 CA 创建证书池。

3

将来自 CA 的客户端证书附加到证书池。

4

将传输凭据作为连接选项添加。这里的 ServerName 必须等于证书上的 Common Name

5

通过传递选项与服务器建立安全连接。

6

将连接传递并创建一个存根。该存根实例包含调用服务器的所有远程方法。

7

在一切完成后关闭连接。

现在,我们已经使用基本的单向 TLS 和 mTLS 保护了 gRPC 应用程序的客户端和服务器之间的通信通道。下一步是在每个调用的基础上启用身份验证,这意味着凭据将附加到调用中。每个客户端调用都有身份验证凭据,服务器端检查调用的凭据,并决定是否允许客户端调用或拒绝。

验证 gRPC 调用

gRPC 设计用于使用严格的身份验证机制。在前一节中,我们介绍了如何使用 TLS 加密客户端和服务器之间交换的数据。现在,我们将讨论如何验证调用者的身份,并使用不同的调用凭据技术(如基于令牌的身份验证等)应用访问控制。

为了方便验证调用者,gRPC 提供了客户端在每个调用中注入其凭据(如用户名和密码)的能力。gRPC 服务器能够拦截来自客户端的请求,并为每个传入的调用检查这些凭据。

首先,我们将回顾一个简单的身份验证场景,以解释每个客户端调用如何工作的身份验证。

使用基本身份验证

基本身份验证 是最简单的身份验证机制。在这种机制中,客户端发送带有 Authorization 头的请求,该头的值以单词 Basic 开头,后跟一个空格和一个 base64 编码的字符串 username:password。例如,如果用户名是 admin,密码是 admin,则头的值如下所示:

Authorization: Basic YWRtaW46YWRtaW4=

通常情况下,gRPC 不鼓励我们使用用户名/密码来验证服务。这是因为与其他令牌(JSON Web Tokens [JWTs]、OAuth2 访问令牌)相比,用户名/密码在时间上没有控制。这意味着当我们生成一个令牌时,我们可以指定其有效期。但是对于用户名/密码,我们无法指定有效期。它的有效性会一直持续,直到我们修改密码。如果您需要在应用程序中启用基本身份验证,建议在客户端和服务器之间的安全连接中共享基本凭据。我们选择基本身份验证是因为这样更容易解释 gRPC 中身份验证的工作原理。

让我们首先讨论如何将用户凭据(在基本身份验证中)注入到调用中。由于 gRPC 中没有基本身份验证的内置支持,我们需要将其作为自定义凭据添加到客户端上下文中。在 Go 中,我们可以通过定义一个凭据结构体并实现 PerRPCCredentials 接口来轻松实现此操作,如 示例 6-5 所示。

示例 6-5. 实现 PerRPCCredentials 接口以传递自定义凭据
type basicAuth struct { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  username string
  password string
}

func (b basicAuth) GetRequestMetadata(ctx context.Context,
  in ...string)  (map[string]string, error) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  auth := b.username + ":" + b.password
  enc := base64.StdEncoding.EncodeToString([]byte(auth))
  return map[string]string{
     "authorization": "Basic " + enc,
  }, nil
}

func (b basicAuth) RequireTransportSecurity() bool { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  return true
}

1

定义一个结构体来保存您想要在 RPC 调用中注入的字段集合(在我们的情况下,这些是像用户名和密码这样的用户凭据)。

2

实现GetRequestMetadata方法并将用户凭据转换为请求元数据。在我们的情况下,“Authorization”是键,其值是“Basic”后跟 base64 编码(<用户名>:<密码>)。

3

指定是否需要通过这些凭据传递通道安全性。如前所述,建议使用通道安全性。

一旦实现了凭据对象,我们需要使用有效的凭据初始化它,并在创建连接时传递,如示例 6-6 所示。

示例 6-6. 带有基本身份验证的 gRPC 安全客户端应用程序
package main

import (
  "log"
  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc/credentials"
  "google.golang.org/grpc"
)

var (
  address = "localhost:50051"
  hostname = "localhost"
  crtFile = "server.crt"
)

func main() {
  creds, err := credentials.NewClientTLSFromFile(crtFile, hostname)
  if err != nil {
     log.Fatalf("failed to load credentials: %v", err)
  }

  auth := basicAuth{ ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
    username: "admin",
    password: "admin",
  }

  opts := []grpc.DialOption{
     grpc.WithPerRPCCredentials(auth), ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
     grpc.WithTransportCredentials(creds),
  }

  conn, err := grpc.Dial(address, opts...)
  if err != nil {
     log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewProductInfoClient(conn)

  .... // Skip RPC method invocation. }

1

使用有效的用户凭据(用户名和密码)初始化auth变量。auth变量保存我们将要使用的值。

2

auth变量传递给grpc.WithPerRPCCredentials函数。grpc.WithPerRPCCredentials()函数接受一个接口作为参数。由于我们定义了符合该接口的认证结构,我们可以传递我们的变量。

现在客户端在其调用期间推送额外的元数据,但服务器不关心。因此,我们需要告诉服务器检查元数据。让我们更新我们的服务器以读取如示例 6-7 所示的元数据。

示例 6-7. 使用基本用户凭据验证的 gRPC 安全服务器
package main

import (
  "context"
  "crypto/tls"
  "encoding/base64"
  "errors"
  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/credentials"
  "google.golang.org/grpc/metadata"
  "google.golang.org/grpc/status"
  "log"
  "net"
  "path/filepath"
  "strings"
)

var (
  port = ":50051"
  crtFile = "server.crt"
  keyFile = "server.key"
  errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
  errInvalidToken    = status.Errorf(codes.Unauthenticated, "invalid credentials")
)

type server struct {
  productMap map[string]*pb.Product
}

func main() {
  cert, err := tls.LoadX509KeyPair(crtFile, keyFile)
  if err != nil {
     log.Fatalf("failed to load key pair: %s", err)
  }
  opts := []grpc.ServerOption{
     // Enable TLS for all incoming connections.
     grpc.Creds(credentials.NewServerTLSFromCert(&cert)),

     grpc.UnaryInterceptor(ensureValidBasicCredentials), ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  }

  s := grpc.NewServer(opts...)
  pb.RegisterProductInfoServer(s, &server{})

  lis, err := net.Listen("tcp", port)
  if err != nil {
     log.Fatalf("failed to listen: %v", err)
  }

  if err := s.Serve(lis); err != nil {
     log.Fatalf("failed to serve: %v", err)
  }
}

func valid(authorization []string) bool {
  if len(authorization) < 1 {
     return false
  }
  token := strings.TrimPrefix(authorization[0], "Basic ")
  return token == base64.StdEncoding.EncodeToString([]byte("admin:admin"))
}

func ensureValidBasicCredentials(ctx context.Context, req interface{}, info
*grpc.UnaryServerInfo,
     handler grpc.UnaryHandler) (interface{}, error) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  md, ok := metadata.FromIncomingContext(ctx) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  if !ok {
     return nil, errMissingMetadata
  }
  if !valid(md["authorization"]) {
     return nil, errInvalidToken
  }
  // Continue execution of handler after ensuring a valid token.
  return handler(ctx, req)
}

1

使用 TLS 服务器证书初始化新的服务器选项(grpc.ServerOption)。grpc.UnaryInterceptor是一个函数,我们在其中添加拦截器以拦截来自客户端的所有请求。我们将引用传递给一个函数(ensureValidBasicCredentials),因此拦截器将所有客户端请求传递给该函数。

2

定义一个名为ensureValidBasicCredentials的函数来验证调用者身份。在这里,context.Context对象包含我们需要的元数据,并且在请求的生命周期内将存在。

3

从上下文中提取元数据,获取authentication的值,并验证凭据。metadata.MD中的键被规范化为小写。因此,我们需要检查小写键的值。

现在服务器在每次调用中验证客户端身份。这是一个非常简单的例子。您可以在服务器拦截器内部具有复杂的认证逻辑来验证客户端身份。

既然你已经对客户端认证工作原理有基本了解,让我们来讨论常用和推荐的基于令牌的认证(OAuth 2.0)。

使用 OAuth 2.0

OAuth 2.0 是一个访问委托框架。它允许用户代表他们授予对服务的有限访问权限,而不像使用用户名和密码那样给予他们总访问权限。在这里我们不打算详细讨论 OAuth 2.0。如果您对 OAuth 2.0 的工作原理有一些基本的了解,则有助于在您的应用程序中启用它。

注意

在 OAuth 2.0 流程中,有四个主要角色:客户端、授权服务器、资源服务器和资源所有者。客户端希望访问资源服务器中的资源。为了访问资源,客户端需要从授权服务器获取一个令牌(这是一个任意字符串)。这个令牌必须是适当长度的,并且不可预测。一旦客户端收到令牌,客户端可以使用这个令牌向资源服务器发送请求。资源服务器随后与相应的授权服务器交流并验证令牌。如果这个资源所有者验证通过,客户端就可以访问资源。

gRPC 具有内置支持,可以在 gRPC 应用程序中启用 OAuth 2.0。让我们首先讨论如何将令牌注入到调用中。由于我们的示例中没有授权服务器,我们将为令牌值硬编码一个任意的字符串。示例 6-8 展示了如何向客户端请求添加 OAuth 令牌。

示例 6-8. 在 Go 中使用 OAuth 令牌的 gRPC 安全客户端应用程序
package main

import (
  "google.golang.org/grpc/credentials"
  "google.golang.org/grpc/credentials/oauth"
  "log"

  pb "productinfo/server/ecommerce"
  "golang.org/x/oauth2"
  "google.golang.org/grpc"
)

var (
  address = "localhost:50051"
  hostname = "localhost"
  crtFile = "server.crt"
)

func main() {
  auth := oauth.NewOauthAccess(fetchToken()) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

  creds, err := credentials.NewClientTLSFromFile(crtFile, hostname)
  if err != nil {
     log.Fatalf("failed to load credentials: %v", err)
  }

  opts := []grpc.DialOption{
     grpc.WithPerRPCCredentials(auth), ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
     grpc.WithTransportCredentials(creds),
  }

  conn, err := grpc.Dial(address, opts...)
  if err != nil {
     log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewProductInfoClient(conn)

  .... // Skip RPC method invocation. }

func fetchToken() *oauth2.Token {
  return &oauth2.Token{
     AccessToken: "some-secret-token",
  }
}

1

设置连接的凭据。我们需要提供一个 OAuth2 令牌值来创建凭据。这里我们使用一个硬编码的字符串值作为令牌。

2

配置 gRPC 拨号选项,以在同一连接上的所有 RPC 调用中应用单个 OAuth 令牌。如果要每次调用应用一个 OAuth 令牌,则需要使用 CallOption 配置 gRPC 调用。

注意,我们还启用了通道安全,因为 OAuth 要求底层传输是安全的。在 gRPC 内部,提供的令牌被前缀为令牌类型,并附加到具有键authorization的元数据中。

在服务器端,我们添加了类似的拦截器来检查和验证客户端请求中带有的令牌,如示例 6-9 所示。

示例 6-9. 使用 OAuth 用户令牌验证的 gRPC 安全服务器
package main

import (
  "context"
  "crypto/tls"
  "errors"
  "log"
  "net"
  "strings"

  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/credentials"
  "google.golang.org/grpc/metadata"
  "google.golang.org/grpc/status"
)

// server is used to implement ecommerce/product_info. type server struct {
  productMap map[string]*pb.Product
}

var (
  port = ":50051"
  crtFile = "server.crt"
  keyFile = "server.key"
  errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
  errInvalidToken    = status.Errorf(codes.Unauthenticated, "invalid token")
)

func main() {
  cert, err := tls.LoadX509KeyPair(crtFile, keyFile)
  if err != nil {
     log.Fatalf("failed to load key pair: %s", err)
  }
  opts := []grpc.ServerOption{
     grpc.Creds(credentials.NewServerTLSFromCert(&cert)),
     grpc.UnaryInterceptor(ensureValidToken), ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  }

  s := grpc.NewServer(opts...)
  pb.RegisterProductInfoServer(s, &server{})

  lis, err := net.Listen("tcp", port)
  if err != nil {
     log.Fatalf("failed to listen: %v", err)
  }

  if err := s.Serve(lis); err != nil {
     log.Fatalf("failed to serve: %v", err)
  }
}

func valid(authorization []string) bool {
  if len(authorization) < 1 {
     return false
  }
  token := strings.TrimPrefix(authorization[0], "Bearer ")
  return token == "some-secret-token"
}

func ensureValidToken(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
     handler grpc.UnaryHandler) (interface{}, error) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  md, ok := metadata.FromIncomingContext(ctx)
  if !ok {
     return nil, errMissingMetadata
  }
  if !valid(md["authorization"]) {
     return nil, errInvalidToken
  }
  return handler(ctx, req)
}

1

添加新的服务器选项(grpc.ServerOption)以及 TLS 服务器证书。通过 grpc.UnaryInterceptor 函数,我们添加一个拦截器来拦截来自客户端的所有请求。

2

定义一个名为ensureValidToken的函数来验证令牌。如果令牌丢失或无效,拦截器将阻止执行并返回错误。否则,拦截器调用下一个处理程序,传递上下文和接口。

可以使用拦截器为所有 RPC 配置令牌验证。服务器可以根据服务类型配置 grpc.UnaryInterceptorgrpc.StreamInterceptor

类似于 OAuth 2.0 认证,gRPC 也支持基于 JSON Web Token(JWT)的认证。在接下来的章节中,我们将讨论如何进行配置以启用基于 JWT 的认证。

使用 JWT

JWT 定义了一个容器,用于在客户端和服务器之间传输身份信息。签名的 JWT 可以用作自包含的访问令牌,这意味着资源服务器无需与认证服务器交互来验证客户端令牌。它可以通过验证签名来验证令牌。客户端从认证服务器请求访问权限,认证服务器验证客户端凭据,创建一个 JWT 并将其发送给客户端。带有 JWT 的客户端应用允许访问资源。

gRPC 对 JWT 有内置支持。如果您从认证服务器获取了 JWT 文件,则需要传递该令牌文件并创建 JWT 凭据。示例 6-10 中的代码片段演示了如何从 JWT 令牌文件(token.json)创建 JWT 凭据,并将其作为 DialOptions 传递给 Go 客户端应用程序。

示例 6-10. 在 Go 客户端应用程序中使用 JWT 设置连接
jwtCreds, err := oauth.NewJWTAccessFromFile(“token.json”) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
if err != nil {
  log.Fatalf("Failed to create JWT credentials: %v", err)
}

creds, err := credentials.NewClientTLSFromFile("server.crt",
     "localhost")
if err != nil {
    log.Fatalf("failed to load credentials: %v", err)
}
opts := []grpc.DialOption{
  grpc.WithPerRPCCredentials(jwtCreds),
  // transport credentials.
  grpc.WithTransportCredentials(creds), ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
}

// Set up a connection to the server. conn, err := grpc.Dial(address, opts...)
if err != nil {
  log.Fatalf("did not connect: %v", err)
}
  .... // Skip Stub generation and RPC method invocation.

1

调用 oauth.NewJWTAccessFromFile 来初始化 credentials.PerRPCCredentials。我们需要提供一个有效的令牌文件来创建凭据。

2

使用 DialOption WithPerRPCCredentials 配置 gRPC 拨号,以在同一连接上为所有 RPC 调用应用 JWT 令牌。

除了这些认证技术外,我们还可以通过在客户端扩展 RPC 凭据并在服务器端添加新的拦截器来添加任何认证机制。gRPC 还为调用部署在 Google Cloud 中的 gRPC 服务提供了特殊的内置支持。在接下来的章节中,我们将讨论如何调用这些服务。

使用 Google 基于令牌的认证

用户身份验证以及决定是否允许他们使用部署在 Google 云平台上的服务是由可扩展服务代理(ESP)控制的。ESP 支持多种认证方法,包括 Firebase、Auth0 和 Google ID 令牌。在每种情况下,客户端需要在其请求中提供有效的 JWT。为了生成认证 JWT,我们必须为每个部署的服务创建一个服务账号。

一旦我们获取了服务的 JWT 令牌,我们可以在请求中发送令牌来调用服务方法。我们可以创建通道并传递凭据,如 示例 6-11 所示。

示例 6-11. 在 Go 客户端应用程序中设置与 Google 终端点的连接
perRPC, err := oauth.NewServiceAccountFromFile("service-account.json", scope) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
if err != nil {
  log.Fatalf("Failed to create JWT credentials: %v", err)
}

pool, _ := x509.SystemCertPool()
creds := credentials.NewClientTLSFromCert(pool, "")

opts := []grpc.DialOption{
  grpc.WithPerRPCCredentials(perRPC),
  grpc.WithTransportCredentials(creds), ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
}

conn, err := grpc.Dial(address, opts...)
if err != nil {
  log.Fatalf("did not connect: %v", err)
}
.... // Skip Stub generation and RPC method invocation.

1

调用 oauth.NewServiceAccountFromFile 来初始化 credentials.PerRPCCredentials。我们需要提供一个有效的令牌文件来创建凭证。

2

与之前讨论的认证机制类似,我们配置了一个 gRPC 连接的 DialOption WithPerRPCCredentials,以便将认证令牌作为元数据应用于同一连接上的所有 RPC 调用。

摘要

在构建一个生产就绪的 gRPC 应用程序时,至少需要满足 gRPC 应用程序的最低安全要求,以确保客户端和服务器之间的安全通信。gRPC 库设计用于与不同类型的认证机制一起工作,并能够通过添加自定义认证机制来扩展支持。这使得安全地使用 gRPC 与其他系统进行通信变得简单。

gRPC 支持两种类型的凭证:通道凭证和调用凭证。通道凭证附加到通道上,如 TLS 等。调用凭证附加到调用上,如 OAuth 2.0 令牌、基本认证等。我们甚至可以将这两种凭证类型应用到 gRPC 应用程序中。例如,我们可以启用 TLS 连接客户端和服务器之间的连接,并且还可以将凭证附加到在该连接上进行的每个 RPC 调用。

在本章中,您学习了如何为您的 gRPC 应用程序启用这两种凭证类型。在下一章中,我们将扩展您学到的概念和技术,以便在生产环境中构建和运行真实的 gRPC 应用程序。我们还将讨论如何为服务和客户端应用程序编写测试用例,如何在 Docker 和 Kubernetes 上部署应用程序,以及在生产环境中运行时如何观察系统。

第七章:运行 gRPC 在生产中

在之前的章节中,我们专注于设计和开发基于 gRPC 的应用程序的各个方面。现在,是时候深入探讨如何在生产环境中运行 gRPC 应用程序的细节了。在本章中,我们将讨论如何为您的 gRPC 服务和客户端开发单元测试或集成测试,以及如何将它们与持续集成工具集成。然后,我们将进入 gRPC 应用程序的持续部署,探讨虚拟机(VM)、Docker 和 Kubernetes 上的一些部署模式。最后,在生产环境中操作您的 gRPC 应用程序时,您需要一个稳固的可观察性平台。这就是我们将讨论不同的 gRPC 应用程序可观察性工具,并探索 gRPC 应用程序的故障排除和调试技术的地方。让我们从测试这些应用程序开始讨论。

测试 gRPC 应用程序

开发的任何软件应用程序(包括 gRPC 应用程序)都需要与应用程序一起进行关联的单元测试。由于 gRPC 应用程序总是与网络交互,因此测试还应涵盖服务器和客户端 gRPC 应用程序的网络 RPC 方面。我们将从测试 gRPC 服务器开始。

测试 gRPC 服务器

gRPC 服务测试通常使用 gRPC 客户端应用程序作为测试用例的一部分进行。服务器端测试包括启动具有所需 gRPC 服务的 gRPC 服务器,然后使用客户端应用程序连接到服务器,在其中实现您的测试用例。让我们看一下为我们的 ProductInfo 服务的 Go 实现编写的示例测试用例。在 Go 中,gRPC 测试用例的实现应作为使用 testing 包的通用测试用例来实现(参见 Example 7-1)。

Example 7-1. 使用 Go 进行 gRPC 服务器端测试
func TestServer_AddProduct(t *testing.T) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
	grpcServer := initGRPCServerHTTP2() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
	conn, err := grpc.Dial(address, grpc.WithInsecure()) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
	if err != nil {

           grpcServer.Stop()
           t.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewProductInfoClient(conn)

	name := "Sumsung S10"
	description := "Samsung Galaxy S10 is the latest smart phone, launched in
	February 2019"
	price := float32(700.0)
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	r, err := c.AddProduct(ctx, &pb.Product{Name: name,
	                                Description: description, Price: price}) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
	if err != nil { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
		t.Fatalf("Could not add product: %v", err)
	}

	if r.Value == "" {
		t.Errorf("Invalid Product ID %s", r.Value)
	}
	log.Printf("Res %s", r.Value)
      grpcServer.Stop()
}

1

传统测试启动 gRPC 服务器和客户端,以测试通过 RPC 提供服务。

2

启动运行在 HTTP/2 上的传统 gRPC 服务器。

3

连接到服务器应用程序。

4

发送用于 AddProduct 方法的 RPC。

5

验证响应消息。

由于 gRPC 测试用例基于标准语言测试用例,你执行它们的方式与标准测试用例没有区别。关于服务器端的 gRPC 测试的一个特别之处是,它们要求服务器应用程序打开一个客户端应用程序连接到的端口。如果你不希望这样做,或者你的测试环境不允许这样做,你可以使用一个库来帮助避免使用真实端口号启动服务。在 Go 中,你可以使用bufconn,它提供了一个由缓冲区实现的net.Conn及相关的拨号和监听功能。你可以在本章的源代码存储库中找到完整的代码示例。如果你使用 Java,你可以使用一个测试框架,如JUnit,并按照完全相同的过程编写服务器端的 gRPC 测试用例。但是,如果你希望在不启动 gRPC 服务器实例的情况下编写测试用例,那么你可以使用 Java 实现的 gRPC 内部服务器。你可以在本书的代码存储库中找到这种用 Java 编写的完整代码示例。

你可以在不经过 RPC 网络层的情况下,直接测试你开发的远程函数的业务逻辑。你可以直接调用这些函数来进行测试,而不使用 gRPC 客户端。

通过这样,我们已经学会了如何为 gRPC 服务编写测试。现在让我们谈谈如何测试你的 gRPC 客户端应用程序。

测试 gRPC 客户端

当我们为 gRPC 客户端开发测试时,其中一种可能的测试方法是启动一个 gRPC 服务器并实现一个模拟服务。然而,这并不是一项非常直接的任务,因为它会产生打开端口和连接服务器的开销。因此,为了在不连接到真实服务器的情况下测试客户端逻辑,你可以使用一个模拟框架。通过对 gRPC 服务器端进行模拟,开发人员可以编写轻量级单元测试,以检查客户端侧的功能,而无需调用服务器的 RPC 调用。

如果你正在使用 Go 开发 gRPC 客户端应用程序,你可以使用Gomock来模拟客户端接口(使用生成的代码),并编程设置其方法来期望并返回预定值。使用 Gomock,你可以为 gRPC 客户端应用程序生成模拟接口:

mockgen github.com/grpc-up-and-running/samples/ch07/grpc-docker/go/proto-gen \
ProductInfoClient > mock_prodinfo/prodinfo_mock.go

在这里,我们将ProductInfoClient指定为要模拟的接口。然后,你编写的测试代码可以导入mockgen生成的包以及gomock包,围绕客户端逻辑编写单元测试。如示例 7-2 所示,你可以创建一个模拟对象来期待其方法调用并返回响应。

示例 7-2. 使用 Gomock 进行 gRPC 客户端测试
func TestAddProduct(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()
	mocklProdInfoClient := NewMockProductInfoClient(ctrl) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
     ...
	req := &pb.Product{Name: name, Description: description, Price: price}

	mocklProdInfoClient. ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
	 EXPECT().AddProduct(gomock.Any(), &rpcMsg{msg: req},). ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
	 Return(&wrapper.StringValue{Value: "ABC123" + name}, nil) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

	testAddProduct(t, mocklProdInfoClient) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
}

func testAddProduct(t *testing.T, client pb.ProductInfoClient) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	...

	r, err := client.AddProduct(ctx, &pb.Product{Name: name,
    Description: description, Price: price})

	// test and verify response. }

1

创建一个模拟对象来期待对远程方法的调用。

2

编写模拟对象。

3

预期调用AddProduct方法。

4

返回产品 ID 的模拟值。

5

调用实际的测试方法,调用客户端存根的远程方法。

如果您使用 Java,可以使用Mockito测试客户端应用程序,并使用 gRPC Java 实现的进程内服务器实现。您可以参考源代码库以获取这些示例的更多详情。一旦您在服务器端和客户端完成所需的测试,就可以将它们与您使用的持续集成工具集成起来。

需要注意的是,模拟 gRPC 服务器不会像真实的 gRPC 服务器那样提供完全相同的行为。因此,除非重新实现 gRPC 服务器中存在的所有错误逻辑,否则某些功能可能无法通过测试进行验证。实际上,您可以通过模拟验证一组选定的功能,其余功能需要与实际的 gRPC 服务器实现进行验证。现在让我们看看如何对您的 gRPC 应用程序进行负载测试和基准测试。

负载测试

对于 gRPC 应用程序,使用传统工具进行负载测试和基准测试比较困难,因为这些应用程序更多或多或少地受限于特定的协议,例如 HTTP。因此,对于 gRPC,我们需要定制的负载测试工具,可以通过向服务器生成 RPC 的虚拟负载来对 gRPC 服务器进行负载测试。

ghz是这样一款负载测试工具;它是一个使用 Go 语言实现的命令行实用程序。它可用于本地测试和调试服务,并且在自动化持续集成环境中用于性能回归测试。例如,使用 ghz 可以通过以下命令运行负载测试:

ghz --insecure \
  --proto ./greeter.proto \
  --call helloworld.Greeter.SayHello \
  -d '{"name":"Joe"}'\
  -n 2000 \
  -c 20 \

  0.0.0.0:50051

在此,我们不安全地调用Greeter服务的SayHello远程方法。我们可以指定请求的总数(-n 2000)和并发数(20 个线程)。结果也可以生成各种输出格式。

一旦您在服务器端和客户端完成所需的测试,就可以将它们与您使用的持续集成工具集成起来。

持续集成

如果您对持续集成(CI)还不熟悉,它是一种开发实践,要求开发人员频繁将代码集成到共享存储库中。在每次提交代码时,自动化构建会对代码进行验证,使团队能够早期发现问题。在涉及 gRPC 应用程序时,通常服务器端和客户端应用程序是独立的,可能使用不同的技术构建。因此,在 CI 过程中,您将需要使用我们在前一节学习的单元测试和集成测试技术来验证 gRPC 客户端或服务器端代码。然后,根据您用来构建 gRPC 应用程序的语言,您可以将这些应用程序的测试(例如,Go 测试或 Java JUnit)集成到您选择的 CI 工具中。例如,如果您使用 Go 编写了测试,则可以轻松地将您的 Go 测试与JenkinsTravisCISpinnaker等工具集成。

一旦您为您的 gRPC 应用程序建立了测试和持续集成(CI)流程,接下来需要关注的是部署您的 gRPC 应用程序。

部署

现在,让我们看看我们开发的 gRPC 应用程序的不同部署方法。如果您打算在本地或虚拟机上运行 gRPC 服务器或客户端应用程序,则部署主要取决于您为 gRPC 应用程序的相应编程语言生成的二进制文件。对于本地或基于虚拟机的部署,通常使用标准的部署实践来实现 gRPC 服务器应用程序的扩展性和高可用性,例如使用支持 gRPC 协议的负载均衡器。

大多数现代应用程序现在都以容器方式部署。因此,看看如何在容器上部署您的 gRPC 应用程序是非常有用的。Docker 是基于容器的应用程序部署的标准平台。

在 Docker 上部署

Docker 是一个用于开发、发布和运行应用程序的开放平台。使用 Docker,您可以将应用程序与基础设施分离开来。它提供了在被称为容器的隔离环境中打包和运行应用程序的能力,以便您可以在同一主机上运行多个容器。容器比传统的虚拟机更轻量级,并直接在主机机器的内核中运行。

让我们看一些将 gRPC 应用程序部署为 Docker 容器的示例。

注意

Docker 的基础知识超出了本书的范围。因此,如果您对 Docker 不熟悉,我们建议您参考Docker 文档和其他资源。

开发完 gRPC 服务器应用程序后,您可以为其创建一个 Docker 容器。示例 7-3 展示了一个基于 Go 的 gRPC 服务器的 Dockerfile。在 Dockerfile 中有许多特定于 gRPC 的结构。在这个例子中,我们使用了多阶段的 Docker 构建,在第一阶段构建应用程序,然后在第二阶段作为更轻量级的运行时运行应用程序。生成的服务器端代码也被添加到构建应用程序之前的容器中。

示例 7-3. Go gRPC 服务器的 Dockerfile
# Multistage build 
# Build stage I: ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
FROM golang AS build
ENV location /go/src/github.com/grpc-up-and-running/samples/ch07/grpc-docker/go
WORKDIR ${location}/server

ADD ./server ${location}/server
ADD ./proto-gen ${location}/proto-gen

RUN go get -d ./... ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
RUN go install ./... ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

RUN CGO_ENABLED=0 go build -o /bin/grpc-productinfo-server ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

# Build stage II: ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
FROM scratch
COPY --from=build /bin/grpc-productinfo-server /bin/grpc-productinfo-server ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)

ENTRYPOINT ["/bin/grpc-productinfo-server"]
EXPOSE 50051

1

只需使用 Go 语言和 Alpine Linux 来构建程序。

2

下载所有的依赖项。

3

安装所有的包。

4

构建服务器应用程序。

5

Go 二进制文件是自包含的可执行文件。

6

将在上一个阶段构建的二进制文件复制到新位置。

创建 Dockerfile 后,您可以使用以下命令构建 Docker 镜像:

docker image build -t grpc-productinfo-server -f server/Dockerfile

gRPC 客户端应用程序可以使用相同的方法创建。这里唯一的例外是,由于我们在 Docker 上运行服务器应用程序,客户端应用程序用于连接 gRPC 的主机名和端口现在不同了。

当我们在 Docker 上同时运行服务器和客户端的 gRPC 应用程序时,它们需要通过主机机器与彼此和外部世界进行通信。因此,必须涉及网络层。Docker 支持不同类型的网络,每种适用于特定的用例。因此,当我们运行服务器和客户端 Docker 容器时,我们可以指定一个共同的网络,以便客户端应用程序可以根据主机名发现服务器应用程序的位置。这意味着客户端应用程序的代码必须更改,以便连接到服务器的主机名。例如,我们的 Go gRPC 应用程序必须修改为调用服务的主机名,而不是 localhost:

conn, err := grpc.Dial("productinfo:50051", grpc.WithInsecure())

您可以从环境中读取主机名,而不是在客户端应用程序中硬编码它。完成对客户端应用程序的更改后,您需要重新构建 Docker 镜像,然后像这样运行服务器和客户端镜像:

docker run -it --network=my-net --name=productinfo \
    --hostname=productinfo
    -p 50051:50051  grpc-productinfo-server ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

docker run -it --network=my-net \
    --hostname=client grpc-productinfo-client ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

1

在 Docker 网络 my-net 上运行带有主机名为 productinfo,端口为 50051 的 gRPC 服务器。

2

在 Docker 网络 my-net 上运行 gRPC 客户端。

在启动 Docker 容器时,可以指定容器所在的 Docker 网络。如果服务共享同一个网络,那么客户端应用程序可以使用与 docker run 命令一起提供的主机名发现主机服务的实际地址。

当您运行的容器数量较少且它们的交互相对简单时,您可能完全可以在 Docker 上构建解决方案。然而,大多数现实场景需要管理多个容器及其交互。仅基于 Docker 构建这样的解决方案非常繁琐。这就是容器编排平台发挥作用的地方。

部署在 Kubernetes 上

Kubernetes 是一个开源平台,用于自动化部署、扩展和管理容器化应用程序。当您使用 Docker 运行容器化的 gRPC 应用程序时,默认情况下不提供可扩展性或高可用性保证。您需要在 Docker 容器之外构建这些功能。Kubernetes 提供了广泛的功能,使您能够将大多数容器管理和编排任务卸载到底层 Kubernetes 平台上。

注意

Kubernetes 提供了一个可靠且可扩展的平台,用于运行容器化的工作负载。Kubernetes 负责处理扩展需求、故障转移、服务发现、配置管理、安全性、部署模式等等。

Kubernetes 的基础知识超出了本书的范围。因此,我们建议您参考 Kubernetes 文档 和其他类似资源以获取更多信息。

让我们看看如何将您的 gRPC 服务器应用程序部署到 Kubernetes 中。

用于 gRPC 服务器的 Kubernetes 部署资源

要在 Kubernetes 中部署,您需要做的第一件事是为您的 gRPC 服务器应用程序创建一个 Docker 容器。我们在前一节中已经做到了这一点,您可以在这里使用相同的容器。您可以将容器镜像推送到像 Docker Hub 这样的容器注册表中。

对于本示例,我们已将 gRPC 服务器 Docker 镜像推送到 Docker Hub,并使用标签 kasunindrasiri/grpc-productinfo-server。Kubernetes 平台不直接管理容器,而是使用称为 pod 的抽象。Pod 是一个逻辑单元,可以包含一个或多个容器;它是 Kubernetes 中的复制单位。例如,如果您需要多个 gRPC 服务器应用程序的实例,那么 Kubernetes 将创建更多的 pod。在给定 pod 上运行的容器共享相同的资源和本地网络。然而,在我们的情况下,我们只需要在 pod 中运行一个 gRPC 服务器容器。因此,这是一个只有单个容器的 pod。Kubernetes 不直接管理 pod。而是使用另一个称为 deployment 的抽象。部署指定了应该同时运行的 pod 数量。创建新部署时,Kubernetes 会启动部署中指定数量的 pod。

要在 Kubernetes 中部署我们的 gRPC 服务器应用程序,我们需要使用 示例 7-4 中显示的 YAML 描述符创建一个 Kubernetes 部署。

示例 7-4. Go gRPC 服务器应用程序的 Kubernetes 部署描述符
apiVersion: apps/v1
kind: Deployment ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
metadata:
  name: grpc-productinfo-server ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
spec:
  replicas: 1 ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  selector:
    matchLabels:
      app: grpc-productinfo-server
  template:
    metadata:
      labels:
        app: grpc-productinfo-server
    spec:
      containers:
      - name: grpc-productinfo-server ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
        image: kasunindrasiri/grpc-productinfo-server ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
        resources:
          limits:
            memory: "128Mi"
            cpu: "500m"
        ports:
        - containerPort: 50051
          name: grpc

1

声明一个 Kubernetes Deployment对象。

2

部署的名称。

3

同时运行的 gRPC 服务器 pod 的数量。

4

关联的 gRPC 服务器容器的名称。

5

gRPC 服务器容器的镜像名称和标签。

当您在 Kubernetes 中使用kubectl apply -f server/grpc-prodinfo-server.yaml应用此描述符时,您将在 Kubernetes 集群中获得一个运行中的 gRPC 服务器 pod 的部署。然而,如果 gRPC 客户端应用程序必须访问运行在同一 Kubernetes 集群中的 gRPC 服务器 pod,则必须找到 pod 的确切 IP 地址和端口并发送 RPC。但是,当 pod 重新启动时,IP 地址可能会更改,并且如果您运行多个副本,则必须处理每个副本的多个 IP 地址。为了克服这个限制,Kubernetes 提供了一个称为service的抽象。

用于 gRPC 服务器的 Kubernetes 服务资源

您可以创建一个 Kubernetes 服务,并将其与匹配的 pod(在本例中为 gRPC 服务器 pod)关联起来,您将获得一个 DNS 名称,该名称将自动将流量路由到任何匹配的 pod。因此,您可以将服务视为一个 Web 代理或负载均衡器,用于将请求转发到底层的 pod。示例 7-5 显示了 gRPC 服务器应用程序的 Kubernetes 服务描述符。

示例 7-5. Go gRPC 服务器应用程序的 Kubernetes 服务描述符
apiVersion: v1
kind: Service ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
metadata:
  name: productinfo ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
spec:
  selector:
    app: grpc-productinfo-server ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  ports:
  - port: 50051 ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    targetPort: 50051
    name: grpc
  type: NodePort

1

指定一个Service描述符。

2

服务的名称。客户端应用程序连接到服务时将使用此名称。

3

这告诉服务将请求路由到具有匹配标签grpc-productinfo-server的 pod。

4

服务运行在端口 50051,并将请求转发到目标端口 50051。

所以,一旦您创建了DeploymentService描述符,您可以使用kubectl apply -f server/grpc-prodinfo-server.yaml将此应用部署到 Kubernetes 中(您可以将两个描述符放在同一个 YAML 文件中)。成功部署这些对象应该会给您一个运行中的 gRPC 服务器 pod,一个用于 gRPC 服务器的 Kubernetes 服务以及一个部署。

下一步是将 gRPC 客户端部署到 Kubernetes 集群。

用于运行 gRPC 客户端的 Kubernetes Job

当您在 Kubernetes 集群上成功运行 gRPC 服务器后,还可以在同一集群中运行 gRPC 客户端应用程序。客户端可以通过我们在前一步创建的名为 productinfo 的 gRPC 服务访问 gRPC 服务器。因此,在客户端的代码中,您应将 Kubernetes 服务名称用作主机名,并使用 gRPC 服务器的服务端口作为端口名称。因此,客户端在连接 Go 实现的服务器时将使用 grpc.Dial("productinfo:50051", grpc.WithInsecure())。如果假设我们的客户端应用程序需要运行指定次数(即仅调用 gRPC 服务、记录响应并退出),则我们可能不使用 Kubernetes 部署,而是使用 Kubernetes job。Kubernetes job 设计用于指定次数运行 Pod。

您可以以与 gRPC 服务器相同的方式创建客户端应用程序容器。一旦容器推送到 Docker 注册表中,然后您可以按照 示例 7-6 中显示的 Kubernetes Job 描述符指定它。

示例 7-6. gRPC 客户端应用程序作为 Kubernetes job 运行
apiVersion: batch/v1
kind: Job ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
metadata:
  name: grpc-productinfo-client ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
spec:
  completions: 1 ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  parallelism: 1 ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
  template:
    spec:
      containers:
      - name: grpc-productinfo-client ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
        image: kasunindrasiri/grpc-productinfo-client ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
      restartPolicy: Never
  backoffLimit: 4

1

指定一个 Kubernetes Job

2

作业的名称.

3

Pod 成功运行的次数,作业才被视为完成。

4

应并行运行的 Pod 数量。

5

关联的 gRPC 客户端容器的名称。

6

与此作业关联的容器镜像。

接下来,您可以使用 kubectl apply -f client/grpc-prodinfo-client-job.yaml 部署 gRPC 客户端应用程序的作业,并检查 Pod 的状态。

该作业执行成功后,会向我们的 ProductInfo gRPC 服务发送添加产品的 RPC。因此,您可以观察服务器和客户端 Pod 的日志,查看是否获得了预期的信息。

然后我们可以使用入口资源将您的 gRPC 服务暴露到 Kubernetes 集群之外。

用于外部暴露 gRPC 服务的 Kubernetes Ingress

到目前为止,我们已经在 Kubernetes 上部署了一个 gRPC 服务器,并使其对同一集群中作为作业运行的另一个 Pod 可访问。如果我们想将 gRPC 服务暴露给 Kubernetes 集群之外的外部应用程序,会怎样?正如您所了解的那样,Kubernetes 服务构造仅用于将给定的 Kubernetes Pod 暴露给集群中运行的其他 Pod。因此,Kubernetes 服务无法被位于 Kubernetes 集群之外的外部应用程序访问。Kubernetes 提供了另一个称为 ingress 的抽象来实现此目的。

我们可以将 Ingress 想象成位于 Kubernetes 服务与外部应用程序之间的负载均衡器。Ingress 将外部流量路由到服务;服务然后在匹配的 Pod 之间路由内部流量。Ingress 控制器管理给定 Kubernetes 集群中的 Ingress 资源。Ingress 控制器的类型和行为可能会根据您使用的集群而变化。此外,当您向外部应用程序公开 gRPC 服务时,支持在 Ingress 层面进行 gRPC 路由是必需的条件之一。因此,我们需要选择一个支持 gRPC 的 Ingress 控制器。

在这个示例中,我们将使用基于 Nginx 负载均衡器的 Nginx Ingress 控制器。根据您使用的 Kubernetes 集群,您可以选择支持 gRPC 的最合适的 Ingress 控制器。Nginx Ingress 支持将外部流量路由到内部服务的 gRPC。

因此,为了将我们的 ProductInfo gRPC 服务器应用程序暴露给外部世界(即 Kubernetes 集群之外),我们可以创建如 示例 7-7 所示的 Ingress 资源。

示例 7-7. Go gRPC 服务器应用程序的 Kubernetes Ingress 资源
apiVersion: extensions/v1beta1
kind: Ingress ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
metadata:
  annotations: ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
    nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
  name: grpc-prodinfo-ingress ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
spec:
  rules:
  - host: productinfo ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
    http:
      paths:
      - backend:
          serviceName: productinfo ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
          servicePort: grpc ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)

1

指定 Ingress 资源。

2

与 Nginx Ingress 控制器相关的注解,并指定 gRPC 作为后端协议。

3

Ingress 资源的名称。

4

这是向外界公开的主机名。

5

关联的 Kubernetes 服务的名称。

6

在 Kubernetes 服务中指定的服务端口的名称。

在部署上述的入口资源之前,您需要安装 Nginx Ingress 控制器。您可以在 Kubernetes 的 Ingress-Nginx 仓库中找到有关安装和使用 Nginx Ingress 与 gRPC 的更多详细信息。一旦您部署了这个 Ingress 资源,任何外部应用程序都可以通过主机名 (productinfo) 和默认端口 (80) 调用 gRPC 服务器。

通过以上内容,您已经学习了关于在 Kubernetes 上部署生产就绪的 gRPC 应用程序的所有基础知识。正如您所见,由于 Kubernetes 和 Docker 提供的能力,我们不必过多担心大多数非功能性需求,如可伸缩性、高可用性、负载均衡、故障转移等,因为 Kubernetes 已作为底层平台的一部分提供了这些功能。因此,一些我们在 第 6 章 中学习的概念,如负载均衡、gRPC 代码层面的名称解析等,在运行 gRPC 应用程序时在 Kubernetes 上是不必要的。

一旦您的基于 gRPC 的应用程序运行起来,您需要确保生产环境中应用程序的平稳运行。为了实现这一目标,您需要持续观察您的 gRPC 应用程序,并在需要时采取必要的行动。让我们深入探讨 gRPC 应用程序的可观测性方面的细节。

可观测性

正如我们在上一节中讨论的那样,gRPC 应用程序通常部署和运行在容器化环境中,其中有多个此类容器通过网络进行通信。然后出现了如何跟踪每个容器并确保它们实际工作的问题。这就是可观测性的作用所在。

正如维基百科的定义所述,“可观测性是指从系统外部输出的知识中推断出系统内部状态的程度。”基本上,有关系统可观测性的目的是回答问题:“系统当前有问题吗?”如果答案是肯定的,我们还应该能够回答一系列其他问题,如“出了什么问题?”和“为什么会发生这种情况?”如果我们能够在任何给定时间和系统的任何部分回答这些问题,我们可以说我们的系统是可观测的。

还需要注意的是,可观测性是系统的一个属性,与效率、可用性和可靠性一样重要。因此,在构建 gRPC 应用程序时,必须从一开始考虑它。

谈论可观测性时,我们通常谈论三个主要支柱:指标、日志记录和跟踪。这些是用于获得系统可观测性的主要技术。让我们在接下来的部分分别讨论每一个。

指标

指标是对数据在时间间隔内的数值表示。谈到指标时,我们可以收集两种类型的数据。一种是系统级指标,如 CPU 使用率、内存使用率等。另一种是应用级指标,如入站请求速率、请求错误率等。

当应用程序运行时通常会捕获系统级指标。如今,有许多工具用于捕获这些指标,通常由 DevOps 团队捕获。但是应用程序级指标在不同的应用程序之间有所不同。因此,在设计新应用程序时,应用程序开发人员需要决定需要捕获哪些类型的应用程序级指标,以了解系统行为。在本节中,我们将重点介绍如何在我们的应用程序中启用应用程序级指标。

使用 gRPC 的 OpenCensus

对于 gRPC 应用程序,OpenCensus 库提供了标准指标。我们可以通过在客户端和服务器应用程序中添加处理程序来轻松启用它们。我们还可以添加自己的指标收集器(示例 7-8)。

注意

OpenCensus 是一个用于收集应用程序指标和分布式跟踪的开源库集合;它支持多种语言。它从目标应用程序收集指标并实时传输数据到您选择的后端。目前支持的后端包括 Azure Monitor、Datadog、Instana、Jaeger、SignalFX、Stackdriver 和 Zipkin。我们也可以为其他后端编写自定义导出器。

示例 7-8. 为 gRPC Go 服务器启用 OpenCensus 监控
package main

import (
  "errors"
  "log"
  "net"
  "net/http"

  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "go.opencensus.io/plugin/ocgrpc" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "go.opencensus.io/stats/view"
  "go.opencensus.io/zpages"
  "go.opencensus.io/examples/exporter"
)

const (
  port = ":50051"
)

// server is used to implement ecommerce/product_info. type server struct {
  productMap map[string]*pb.Product
}

func main() {

  go func() { ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
     mux := http.NewServeMux()
     zpages.Handle(mux, "/debug")
     log.Fatal(http.ListenAndServe("127.0.0.1:8081", mux))
  }()

   view.RegisterExporter(&exporter.PrintExporter{}) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

  if err := view.Register(ocgrpc.DefaultServerViews...); err != nil { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
     log.Fatal(err)
  }

  grpcServer := grpc.NewServer(grpc.StatsHandler(&ocgrpc.ServerHandler{})) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
  pb.RegisterProductInfoServer(grpcServer, &server{}) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)

  lis, err := net.Listen("tcp", port)
  if err != nil {
     log.Fatalf("Failed to listen: %v", err)
  }

  if err := grpcServer.Serve(lis); err != nil { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
     log.Fatalf("failed to serve: %v", err)
  }
}

1

指定我们需要添加的外部库以启用监控。gRPC OpenCensus 提供了一组预定义的处理程序,以支持 OpenCensus 监控。在这里,我们将使用这些处理程序。

2

注册统计导出器以导出收集的数据。这里我们添加 PrintExporter,它将导出的数据记录到控制台。这仅用于演示目的;通常不建议记录所有生产负载。

3

注册视图以收集服务器请求计数。这些是预定义的默认服务视图,用于收集每个 RPC 的接收字节、发送字节、RPC 的延迟和已完成的 RPC。我们可以编写自己的视图来收集数据。

4

创建一个带有统计处理程序的 gRPC 服务器。

5

将我们的 ProductInfo 服务注册到 gRPC 服务器。

6

开始监听端口(50051)上传入的消息。

7

启动 z-Pages 服务器。一个 HTTP 端点从端口 8081 开始,用于可视化指标的上下文为 /debug

类似于 gRPC 服务器,我们可以使用客户端端处理程序在 gRPC 客户端中启用 OpenCensus 监控。示例 7-9 提供了向 Go 编写的 gRPC 客户端添加度量处理程序的代码片段。

示例 7-9. 为 gRPC Go 服务器启用 OpenCensus 监控
package main

import (
  "context"
  "log"
  "time"

  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "go.opencensus.io/plugin/ocgrpc" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "go.opencensus.io/stats/view"
  "go.opencensus.io/examples/exporter"
)

const (
  address = "localhost:50051"
)

func main() {
  view.RegisterExporter(&exporter.PrintExporter{}) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

   if err := view.Register(ocgrpc.DefaultClientViews...); err != nil { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
       log.Fatal(err)
   }

  conn, err := grpc.Dial(address, ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
        grpc.WithStatsHandler(&ocgrpc.ClientHandler{}),
          grpc.WithInsecure(),
          )
  if err != nil {
     log.Fatalf("Can't connect: %v", err)
  }
  defer conn.Close() ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)

  c := pb.NewProductInfoClient(conn) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)

  .... // Skip RPC method invocation. }

1

指定我们需要添加的外部库以启用监控。

2

注册统计和跟踪导出器以导出收集的数据。这里我们将添加 PrintExporter,它将导出的数据记录到控制台。这仅用于演示目的。通常不建议记录所有生产负载。

3

注册视图以收集服务器请求计数。这些是预定义的默认服务视图,用于收集每个 RPC 的接收字节、发送字节、RPC 的延迟和已完成的 RPC。我们可以编写自己的视图来收集数据。

4

设置与服务器的连接,并注册客户端状态处理程序。

5

使用服务器连接创建客户端存根。

6

在一切都完成后关闭连接。

一旦运行服务器和客户端,我们就可以通过创建的 HTTP 端点访问服务器和客户端度量(例如,RPC 度量位于http://localhost:8081/debug/rpcz,跟踪位于http://localhost:8081/debug/tracez))。

如前所述,我们可以使用预定义的导出器将数据发布到支持的后端,也可以编写自己的导出器将跟踪和度量发送到能够消费它们的任何后端。

在下一节中,我们将讨论另一个流行的技术,Prometheus,通常用于为 gRPC 应用程序启用度量。

Prometheus 与 gRPC

Prometheus 是一个用于系统监控和警报的开源工具包。您可以使用grpc Prometheus 库为您的 gRPC 应用程序启用度量。通过为客户端和服务器应用程序添加拦截器,我们可以轻松实现此功能,还可以添加自定义度量收集器。

注意

Prometheus 通过调用以上下文/metrics开头的 HTTP 端点从目标应用程序收集度量。它存储所有收集的数据,并对这些数据运行规则,以聚合并记录新的时间序列或生成警报。我们可以使用诸如Grafana之类的工具可视化这些聚合结果。

示例 7-10 演示了如何在我们使用 Go 编写的产品管理服务器中添加度量拦截器和自定义度量收集器。

示例 7-10. 为 gRPC Go 服务器启用 Prometheus 监控
package main

import (
  ...
  "github.com/grpc-ecosystem/go-grpc-prometheus" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "github.com/prometheus/client_golang/prometheus"
  "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
  reg = prometheus.NewRegistry() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

  grpcMetrics = grpc_prometheus.NewServerMetrics() ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

   customMetricCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
       Name: "product_mgt_server_handle_count",
       Help: "Total number of RPCs handled on the server.",
   }, []string{"name"}) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
)

func init() {
   reg.MustRegister(grpcMetrics, customMetricCounter) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
}

func main() {
  lis, err := net.Listen("tcp", port)
  if err != nil {
     log.Fatalf("failed to listen: %v", err)
  }

  httpServer := &http.Server{
      Handler: promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
        Addr:  fmt.Sprintf("0.0.0.0:%d", 9092)} ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)

  grpcServer := grpc.NewServer(
     grpc.UnaryInterceptor(grpcMetrics.UnaryServerInterceptor()), ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/7.png)
  )

  pb.RegisterProductInfoServer(grpcServer, &server{})
  grpcMetrics.InitializeMetrics(grpcServer) ![8](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/8.png)

  // Start your http server for prometheus.
  go func() {
     if err := httpServer.ListenAndServe(); err != nil {
        log.Fatal("Unable to start a http server.")
     }
  }()

  if err := grpcServer.Serve(lis); err != nil {
     log.Fatalf("failed to serve: %v", err)
  }
}

1

指定我们需要添加的外部库以启用监控。gRPC 生态系统提供了一组预定义的拦截器来支持 Prometheus 监控。在这里,我们将使用这些拦截器。

2

创建度量注册表。该注册表保存系统中注册的所有数据收集器。如果需要添加新的收集器,我们需要在此注册表中注册它。

3

创建标准客户端度量。这些是库中定义的预定义度量。

4

创建名为product_mgt_server_handle_count的自定义度量计数器。

5

在第 2 步创建的注册表中注册标准服务器度量和自定义度量收集器。

6

为 Prometheus 创建一个 HTTP 服务器。用于度量收集的 HTTP 端点在端口 9092 上以上下文/metrics开头。

7

创建一个带有度量拦截器的 gRPC 服务器。这里我们使用 grpcMetrics.UnaryServerInterceptor,因为我们有一个一元服务。还有一个叫做 grpcMetrics.StreamServerInterceptor() 的拦截器用于流式服务。

8

初始化所有标准度量。

使用第 4 步创建的自定义度量计数器,我们可以添加更多用于监控的度量指标。比如说,我们想要收集添加到产品管理系统中具有相同名称的产品的数量。如示例 7-11 所示,在 addProduct 方法中我们可以向 customMetricCounter 添加新的度量指标。

示例 7-11. 向自定义度量计数器添加新的度量指标
// AddProduct implements ecommerce.AddProduct
func (s *server) AddProduct(ctx context.Context,
     in *pb.Product) (*wrapper.StringValue, error) {
     customMetricCounter.WithLabelValues(in.Name).Inc()
  ...
}

类似于 gRPC 服务器,我们可以使用客户端拦截器在 gRPC 客户端中启用 Prometheus 监控。示例 7-12 提供了在 Go 中编写的 gRPC 客户端添加度量拦截器的代码片段。

示例 7-12. 为 gRPC Go 客户端启用 Prometheus 监控
package main

import (
  ...
  "github.com/grpc-ecosystem/go-grpc-prometheus" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "github.com/prometheus/client_golang/prometheus"
  "github.com/prometheus/client_golang/prometheus/promhttp"
)

const (
  address = "localhost:50051"
)

func main() {
  reg := prometheus.NewRegistry() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  grpcMetrics := grpc_prometheus.NewClientMetrics() ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  reg.MustRegister(grpcMetrics) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

  conn, err := grpc.Dial(address,
        grpc.WithUnaryInterceptor(grpcMetrics.UnaryClientInterceptor()), ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
          grpc.WithInsecure(),
          )
  if err != nil {
     log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()

   // Create a HTTP server for prometheus.
   httpServer := &http.Server{
        Handler: promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
          Addr: fmt.Sprintf("0.0.0.0:%d", 9094)} ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)

   // Start your http server for prometheus.
   go func() {
       if err := httpServer.ListenAndServe(); err != nil {
           log.Fatal("Unable to start a http server.")
       }
   }()

  c := pb.NewProductInfoClient(conn)
  ...
}

1

指定我们需要添加的外部库以启用监控。

2

创建度量注册表。与服务器代码类似,这个注册表包含系统中注册的所有数据收集器。如果我们需要添加一个新的收集器,我们需要将其注册到这个注册表中。

3

创建标准服务器度量。这些是库中定义的预定义度量指标。

4

将标准客户端度量注册到第 2 步创建的注册表中。

5

使用度量拦截器建立与服务器的连接。这里我们使用 grpcMetrics.UnaryClientInterceptor,因为我们有一个一元客户端。另一个拦截器叫做 grpcMetrics.StreamClientInterceptor(),用于流式客户端。

6

为 Prometheus 创建一个 HTTP 服务器。一个 HTTP 端点从 /metrics 开始,监听 9094 端口用于度量收集。

一旦服务器和客户端运行,我们可以通过创建的 HTTP 端点访问服务器和客户端的度量(例如,服务器度量在 http://localhost:9092/metrics,客户端度量在 http://localhost:9094/metrics))。

正如之前提到的,Prometheus 可以通过访问上述 URL 收集度量。Prometheus 将所有度量数据存储在本地,并应用一组规则来聚合和创建新的记录。通过将 Prometheus 作为数据源,我们可以使用像 Grafana 这样的工具在仪表板中可视化度量数据。

注意

Grafana 是一个开源的度量仪表板和图形编辑器,用于 Graphite、Elasticsearch 和 Prometheus。它允许您查询、可视化和理解您的度量数据。

系统中基于度量的监控的一个优势是处理度量数据的成本不会随系统活动的增加而增加。例如,应用程序流量的增加不会增加处理成本,如磁盘利用率、处理复杂性、可视化速度、运营成本等。它具有恒定的开销。此外,一旦收集到度量数据,我们可以进行多种数学和统计转换,并得出有关系统的有价值结论。

观察性的另一个支柱是日志,在下一节中我们将讨论它。

日志

日志是不可变的、带有时间戳的离散事件记录,记录了系统在时间上发生的具体事件。作为应用程序开发者,我们通常将数据转储到日志中,以便在特定时间点告知系统的内部状态在何处以及是什么。日志的好处在于它们最容易生成,并且比度量更加精细化。我们可以附加特定操作或一堆上下文信息,例如唯一标识符、我们将要执行的操作、堆栈跟踪等。不利之处在于它们非常昂贵,因为我们需要以易于搜索和使用的方式存储和索引它们。

在 gRPC 应用程序中,我们可以使用拦截器启用日志记录。正如我们在 第五章 中讨论的那样,我们可以在客户端和服务器端都附加新的日志拦截器,并记录每次远程调用的请求和响应消息。

注意

gRPC 生态系统为 Go 应用程序提供了一组预定义的日志拦截器。其中包括 grpc_ctxtags,一个库,它将一个标签映射添加到上下文中,并从请求体中填充数据;grpc_zap,将 zap 日志库集成到 gRPC 处理程序中;以及 grpc_logrus,将 logrus 日志库集成到 gRPC 处理程序中。有关这些拦截器的更多信息,请查看 gRPC Go Middleware 仓库

一旦在您的 gRPC 应用程序中添加了日志,它们将根据您的日志配置打印到控制台或日志文件中。如何配置日志取决于您使用的日志框架。

我们现在已经讨论了观察性的两个支柱:度量和日志。这些足以理解单个系统的性能和行为,但不足以理解跨多个系统遍历的请求的生命周期。分布式追踪是一种技术,它提供了对请求生命周期在多个系统中的可见性。

追踪

跟踪是一系列相关事件的表示,构建了通过分布式系统的端到端请求流。正如我们在章节 “使用 gRPC 进行微服务通信” 中讨论的,在现实场景中,我们有多个微服务提供不同和特定的业务能力。因此,从客户端发起的请求通常会经过多个服务和不同的系统,然后响应返回给客户端。所有这些中间事件都是请求流的一部分。通过跟踪,我们可以看到请求所经过的路径以及请求的结构。

在跟踪中,一个跟踪是 跨度 的树形结构,跨度是分布式跟踪的主要构建块。跨度包含任务的元数据、延迟(完成任务所花费的时间)和任务的其他相关属性。一个跟踪有自己的 ID,称为 TraceID,它是一个唯一的字节序列。这个 TraceID 将不同的跨度分组和区分开来。让我们尝试在我们的 gRPC 应用程序中启用跟踪。

类似于指标,OpenCensus 库支持在 gRPC 应用程序中启用跟踪。我们将使用 OpenCensus 在我们的产品管理应用程序中启用跟踪。正如前面所述,我们可以将任何支持的导出器插入到不同的后端以导出跟踪数据。我们将使用 Jaeger 作为分布式跟踪样例的后端。

gRPC Go 默认启用了跟踪功能。因此,只需注册一个导出器即可开始在 gRPC Go 集成中收集跟踪数据。让我们在客户端和服务器应用程序中启动一个 Jaeger 导出器。示例 7-13 演示了如何使用库初始化 OpenCensus Jaeger 导出器。

示例 7-13. 初始化 OpenCensus Jaeger 导出器
package tracer

import (
  "log"

  "go.opencensus.io/trace" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "contrib.go.opencensus.io/exporter/jaeger"

)

func initTracing() {

   trace.ApplyConfig(trace.Config{DefaultSampler: trace.AlwaysSample()})
   agentEndpointURI := "localhost:6831"
   collectorEndpointURI := "http://localhost:14268/api/traces" ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
    exporter, err := jaeger.NewExporter(jaeger.Options{
            CollectorEndpoint: collectorEndpointURI,
            AgentEndpoint: agentEndpointURI,
            ServiceName:    "product_info",

    })
    if err != nil {
       log.Fatal(err)
    }
    trace.RegisterExporter(exporter) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

}

1

导入 OpenTracing 和 Jaeger 库。

2

创建 Jaeger 导出器,并指定收集器端点、服务名称和代理端点。

3

将导出器注册到 OpenCensus 追踪器中。

一旦我们在服务器上注册了导出器,我们就可以通过跟踪来为服务器添加仪表。示例 7-14 展示了如何在服务方法中添加仪表。

示例 7-14. 为 gRPC 服务方法添加仪表
// GetProduct implements ecommerce.GetProduct func (s *server) GetProduct(ctx context.Context, in *wrapper.StringValue) (
         *pb.Product, error) {
  ctx, span := trace.StartSpan(ctx, "ecommerce.GetProduct") ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  defer span.End() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  value, exists := s.productMap[in.Value]
  if exists {
     return value, status.New(codes.OK, "").Err()
  }
  return nil, status.Errorf(codes.NotFound, "Product does not exist.", in.Value)
}

1

使用跨度名称和上下文启动新跨度。

2

当所有操作完成时停止跨度。

类似于 gRPC 服务器,我们可以像示例 7-15 中显示的那样通过跟踪来为客户端添加仪表。

示例 7-15. 为 gRPC 客户端添加仪表
package main

import (
  "context"
  "log"
  "time"

  pb "productinfo/client/ecommerce"
  "productinfo/client/tracer"
  "google.golang.org/grpc"
  "go.opencensus.io/plugin/ocgrpc" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  "go.opencensus.io/trace"
  "contrib.go.opencensus.io/exporter/jaeger"

)

const (
  address = "localhost:50051"
)

func main() {
  tracer.initTracing() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

  conn, err := grpc.Dial(address, grpc.WithInsecure())
  if err != nil {
     log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewProductInfoClient(conn)

  ctx, span := trace.StartSpan(context.Background(),
          "ecommerce.ProductInfoClient") ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

  name := "Apple iphone 11"
  description := "Apple iphone 11 is the latest smartphone,
            launched in September 2019"
  price := float32(700.0)
  r, err := c.AddProduct(ctx, &pb.Product{Name: name,
      Description: description, Price: price}) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/5.png)
  if err != nil {
     log.Fatalf("Could not add product: %v", err)
  }
  log.Printf("Product ID: %s added successfully", r.Value)

  product, err := c.GetProduct(ctx, &pb.ProductID{Value: r.Value}) ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/6.png)
  if err != nil {
    log.Fatalf("Could not get product: %v", err)
  }
  log.Printf("Product: ", product.String())
  span.End() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)

}

1

导入 OpenTracing 和 Jaeger 库。

2

调用initTracing函数并初始化 Jaeger 导出器实例,并注册到追踪器。

3

使用 span 名称和上下文启动新的跨度。

4

当一切都完成时停止跨度。

5

通过传递新产品详细信息调用 addProduct 远程方法。

6

通过传递 productID 调用 getProduct 远程方法。

一旦我们运行了服务器和客户端,追踪跨度将被发布到 Jaeger 代理,其中一个守护进程用作缓冲,将批处理处理和路由从客户端抽象出来。一旦 Jaeger 代理接收到来自客户端的追踪日志,它将其转发到收集器。收集器处理日志并存储它们。通过 Jaeger 服务器,我们可以可视化追踪。

从这里我们将结束可观察性的讨论。日志、度量和跟踪各自具有其独特的目的,最好在系统中启用这三个支柱,以获取对内部状态的最大可见性。

一旦您在生产环境中运行了基于 gRPC 的可观察应用程序,您可以随时观察其状态,并在出现问题或系统中断时轻松找到问题所在。在诊断系统问题时,找到解决方案、测试并尽快部署到生产环境非常重要。为了实现这个目标,您需要有良好的调试和故障排除机制。让我们深入了解 gRPC 应用程序的这些机制的细节。

调试和故障排除

调试和故障排除是查找问题根本原因并解决应用程序中出现问题的过程。为了调试和排除问题,我们首先需要在较低的环境(称为开发或测试环境)中重现相同的问题。因此,我们需要一套工具来生成类似的请求负载,就像在生产环境中一样。

这个过程在 gRPC 服务中相对较难,因为工具需要支持根据服务定义编码和解码消息,并且能够支持 HTTP/2。常见的工具如 curl 或 Postman,用于测试 HTTP 服务,不能用于测试 gRPC 服务。

但是有很多有趣的工具可用于调试和测试 gRPC 服务。您可以在awesome gRPC repository中找到这些工具的列表。它包含了大量可用于 gRPC 的资源集合。调试 gRPC 应用程序的最常见方式之一是使用额外的日志记录。

启用额外的日志记录

我们可以启用额外的日志和跟踪来诊断您的 gRPC 应用程序问题。在 gRPC Go 应用程序中,我们可以通过设置以下环境变量来启用额外的日志:

GRPC_GO_LOG_VERBOSITY_LEVEL=99 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
GRPC_GO_LOG_SEVERITY_LEVEL=info ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

1

详细程度意味着每五分钟任何单个信息消息应该打印多少次。默认情况下,详细程度设置为 0。

2

将日志严重级别设置为 info。所有信息性消息将被打印出来。

在 gRPC Java 应用程序中,没有环境变量来控制日志级别。我们可以通过提供一个 logging.properties 文件来打开额外的日志,其中包含日志级别更改。假设我们想要在我们的应用程序中调试传输级别帧,我们可以在应用程序中创建一个新的 logging.properties 文件,并将较低的日志级别设置为特定的 Java 包(netty transport package)如下:

handlers=java.util.logging.ConsoleHandler
io.grpc.netty.level=FINE
java.util.logging.ConsoleHandler.level=FINE
java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter

然后使用 JVM 标志启动 Java 二进制文件:

-Djava.util.logging.config.file=logging.properties

一旦我们在应用程序中设置了较低的日志级别,所有日志级别等于或高于配置的日志级别的日志将在控制台/日志文件中打印出来。通过阅读日志,我们可以深入了解系统的状态。

通过以上内容,我们已经覆盖了在生产环境中运行 gRPC 应用程序时应知道的大部分内容。

概要

制作生产就绪的 gRPC 应用程序需要我们关注与应用程序开发相关的多个方面。我们首先通过设计服务契约并为服务或客户端生成代码来开始,然后实现我们服务的业务逻辑。一旦我们实现了服务,我们需要专注于以下几点,以使 gRPC 应用程序达到生产就绪状态。测试 gRPC 服务器和客户端应用程序是必不可少的。

gRPC 应用程序的部署遵循标准的应用程序开发方法。对于本地和虚拟机部署,只需使用服务器或客户端程序的生成二进制文件。您可以将 gRPC 应用程序作为 Docker 容器运行,并找到用于在 Docker 上部署 Go 和 Java 应用程序的标准 Dockerfile 示例。在 Kubernetes 上运行 gRPC 类似于标准的 Kubernetes 部署。当您在 Kubernetes 上运行 gRPC 应用程序时,您会使用底层特性,如负载均衡、高可用性、入口控制器等。使 gRPC 应用程序可观察对于在生产中使用它们至关重要,当 gRPC 应用程序在生产中运行时,通常使用 gRPC 应用程序级指标。

在 gRPC 中最受欢迎的指标支持实现之一,即 gRPC-Prometheus 库中,我们在服务器和客户端端使用拦截器来收集指标,同时还使用拦截器启用 gRPC 日志记录。对于生产中的 gRPC 应用程序,您可能需要通过启用额外的日志记录来进行故障排除或调试。在接下来的章节中,我们将探讨一些对于构建 gRPC 应用程序很有用的 gRPC 生态系统组件。

第八章:gRPC 生态系统

在本章中,我们将探讨一些不属于核心 gRPC 实现但在构建和运行真实用例的 gRPC 应用程序中可能非常有用的项目。这些项目是 gRPC 生态系统父项目的一部分,这里提到的技术都不是运行 gRPC 应用程序的强制要求。如果您有类似的需求,可以探索和评估这些技术。

让我们从 gRPC 网关开始讨论。

gRPC 网关

gRPC 网关插件使协议缓冲编译器能够读取 gRPC 服务定义并生成反向代理服务器,将 RESTful JSON API 转换为 gRPC。这是专门为 Go 编写的,支持从 gRPC 和 HTTP 客户端应用程序调用 gRPC 服务。图 8-1 说明了它如何提供在 gRPC 和 RESTful 方式中调用 gRPC 服务的能力。

如图所示,我们有一个 ProductInfo 服务合同,并使用该合同构建了一个名为 ProductInfoService 的 gRPC 服务。之前我们构建了一个 gRPC 客户端来与此 gRPC 服务通信。但是在这里,我们将构建一个反向代理服务,为 gRPC 服务中的每个远程方法提供 RESTful API,并接受来自 REST 客户端的 HTTP 请求。一旦收到 HTTP 请求,它将请求转换为 gRPC 消息,并调用后端服务中的远程方法。来自后端服务器的响应消息再次转换为 HTTP 响应并回复给客户端。

gRPC 网关

图 8-1. gRPC 网关

要为服务定义生成反向代理服务,首先需要通过更新服务定义将 gRPC 方法映射到 HTTP 资源。让我们使用之前创建的 ProductInfo 服务定义,添加映射条目。示例 8-1 显示了更新后的协议缓冲区定义。

示例 8-1. 更新了 ProductInfo 服务的协议缓冲区定义
syntax = "proto3";

import "google/protobuf/wrappers.proto";
import "google/api/annotations.proto"; ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

package ecommerce;

service ProductInfo {
   rpc addProduct(Product) returns (google.protobuf.StringValue) {
       option (google.api.http) = { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
           post: "/v1/product"
           body: "*"
       };
   }
   rpc getProduct(google.protobuf.StringValue) returns (Product) {
        option (google.api.http) = { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
            get:"/v1/product/{value}"
        };
   }
}

message Product {
   string id = 1;
   string name = 2;
   string description = 3;
   float price = 4;
}

1

导入 google/api/annotations.proto proto 文件以向协议定义添加注解支持。

2

将 gRPC/HTTP 映射添加到 addProduct 方法。指定 URL 路径模板 (/v1/product)、HTTP 方法 (post) 和消息体的结构。在消息体映射中使用 * 来定义未被路径模板绑定的每个字段应映射到请求体中。

3

将 gRPC/HTTP 映射添加到 getProduct 方法。这是一个 GET 方法,URL 路径模板为 /v1/product/{value},并将 ProductID 作为路径参数传递。

当我们映射 gRPC 方法到 HTTP 资源时,我们需要了解更多的附加规则。下面列出了一些重要的规则。您可以参考Google API 文档了解有关 HTTP 到 gRPC 映射的更多详细信息。

  • 每个映射都需要指定一个 URL 路径模板和一个 HTTP 方法。

  • 路径模板可以包含 gRPC 请求消息中的一个或多个字段。但这些字段应该是具有原始类型的非重复字段。

  • 如果没有 HTTP 请求体,请求消息中的任何字段都将自动成为 HTTP 查询参数,如果没有路径模板。

  • 映射到 URL 查询参数的字段应该是原始类型、重复的原始类型或非重复的消息类型之一。

  • 对于查询参数中的重复字段类型,参数可以在 URL 中重复显示为...?param=A&param=B

  • 对于查询参数中的消息类型,消息的每个字段都映射到一个单独的参数,如...?foo.a=A&foo.b=B&foo.c=C

一旦编写了服务定义,我们需要使用协议缓冲编译器对其进行编译,并生成反向代理服务的源代码。让我们来讨论如何在 Go 语言中生成代码并实现服务器。

在我们能够编译服务定义之前,我们需要获取一些依赖包。使用以下命令下载这些包:

go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway
go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger
go get -u github.com/golang/protobuf/protoc-gen-go

下载完依赖包后,执行以下命令编译服务定义(product_info.proto)并生成存根:

protoc -I/usr/local/include -I. \
-I$GOPATH/src \
-I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
--go_out=plugins=grpc:. \
product_info.proto

一旦执行该命令,它将在相同位置生成一个存根(product_info.pb.go)。除了生成的存根外,我们还需要创建一个反向代理服务来支持 RESTful 客户端调用。这个反向代理服务可以通过 Go 编译器支持的网关插件来生成。

注意

gRPC 网关仅在 Go 中支持,这意味着我们无法在其他语言中编译并生成 gRPC 网关的反向代理服务。

让我们通过执行以下命令从服务定义生成一个反向代理服务:

protoc -I/usr/local/include -I. \
-I$GOPATH/src \
-I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
--grpc-gateway_out=logtostderr=true:. \
product_info.proto

一旦执行该命令,它将在相同位置生成一个反向代理服务(product_info.pb.gw.go)。

让我们为 HTTP 服务器创建监听端点,并运行我们刚刚创建的反向代理服务。示例 8-2 展示了如何创建一个新的服务器实例并注册服务以监听传入的 HTTP 请求。

示例 8-2. Go 语言中的 HTTP 反向代理
package main

import (
  "context"
  "log"
  "net/http"

  "github.com/grpc-ecosystem/grpc-gateway/runtime"
  "google.golang.org/grpc"

  gw "github.com/grpc-up-and-running/samples/ch08/grpc-gateway/go/gw" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
)

var (
  grpcServerEndpoint = "localhost:50051" ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
)

func main() {
  ctx := context.Background()
  ctx, cancel := context.WithCancel(ctx)
  defer cancel()

  mux := runtime.NewServeMux()
  opts := []grpc.DialOption{grpc.WithInsecure()}
  err := gw.RegisterProductInfoHandlerFromEndpoint(ctx, mux,
      grpcServerEndpoint, opts) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)
  if err != nil {
     log.Fatalf("Fail to register gRPC gateway service endpoint: %v", err)
  }

  if err := http.ListenAndServe(":8081", mux); err != nil { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
     log.Fatalf("Could not setup HTTP endpoint: %v", err)
  }
}

1

导入到生成的反向代理代码所在的包。

2

指定 gRPC 服务器端点 URL。确保后端 gRPC 服务器在所述端点上正常运行。这里我们使用了同一章中创建的 gRPC 服务 Chapter 2。

3

将 gRPC 服务器端点注册到代理处理程序。在运行时,请求多路复用器将 HTTP 请求与模式进行匹配,并调用相应的处理程序。

4

监听端口(8081)上的 HTTP 请求。

一旦我们建立了 HTTP 反向代理服务器,我们可以通过同时运行 gRPC 服务器和 HTTP 服务器来测试它。在这种情况下,gRPC 服务器监听端口 50051,HTTP 服务器监听端口 8081。

让我们使用 curl 发出几个 HTTP 请求并观察其行为:

  1. ProductInfo 服务添加新产品。

    $ curl -X POST http://localhost:8081/v1/product
            -d '{"name": "Apple", "description": "iphone7", "price": 699}'
    
    "38e13578-d91e-11e9"
    
  2. 使用 ProductID 获取现有产品:

    $ curl http://localhost:8081/v1/product/38e13578-d91e-11e9
    
    {"id":"38e13578-d91e-11e9","name":"Apple","description":"iphone7",
    "price":699}
    
  3. 添加到反向代理服务中,gRPC 网关还支持通过执行以下命令生成反向代理服务的 swagger 定义:

    protoc -I/usr/local/include -I. \
      -I$GOPATH/src \
      -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/\
      third_party/googleapis \
      --swagger_out=logtostderr=true:. \
      product_info.proto
    
  4. 一旦我们执行该命令,它将在相同位置为反向代理服务生成一个 swagger 定义文件(product_info.swagger.json)。对于我们的 ProductInfo 服务,生成的 swagger 定义看起来像这样:

    {
     "swagger": "2.0",
     "info": {
       "title": "product_info.proto",
       "version": "version not set"
     },
     "schemes": [
       "http",
       "https"
     ],
     "consumes": [
       "application/json"
     ],
     "produces": [
       "application/json"
     ],
     "paths": {
       "/v1/product": {
         "post": {
           "operationId": "addProduct",
           "responses": {
             "200": {
               "description": "A successful response.",
               "schema": {
                 "type": "string"
               }
             }
           },
           "parameters": [
             {
               "name": "body",
               "in": "body",
               "required": true,
               "schema": {
                 "$ref": "#/definitions/ecommerceProduct"
               }
             }
           ],
           "tags": [
             "ProductInfo"
           ]
         }
       },
       "/v1/product/{value}": {
         "get": {
           "operationId": "getProduct",
           "responses": {
             "200": {
               "description": "A successful response.",
               "schema": {
                 "$ref": "#/definitions/ecommerceProduct"
               }
             }
           },
           "parameters": [
             {
               "name": "value",
               "description": "The string value.",
               "in": "path",
               "required": true,
               "type": "string"
             }
           ],
           "tags": [
             "ProductInfo"
           ]
         }
       }
     },
     "definitions": {
       "ecommerceProduct": {
         "type": "object",
         "properties": {
           "id": {
             "type": "string"
           },
           "name": {
             "type": "string"
           },
           "description": {
             "type": "string"
           },
           "price": {
             "type": "number",
             "format": "float"
           }
         }
       }
     }
    }
    

现在我们已经使用 gRPC 网关实现了 gRPC 服务的 HTTP 反向代理服务。通过这种方式,我们可以将我们的 gRPC 服务器暴露给 HTTP 客户端应用程序使用。您可以从 gRPC 网关存储库 获取有关网关实现的更多信息。

正如我们之前提到的,gRPC 网关仅在 Go 中受支持。相同的概念也称为 HTTP/JSON 转码。让我们在下一节中更详细地讨论 HTTP/JSON 转码。

gRPC 的 HTTP/JSON 转码

转码 是将 HTTP JSON 调用转换为 RPC 调用并传递给 gRPC 服务的过程。当客户端应用程序不支持 gRPC 并需要通过 JSON over HTTP 提供访问 gRPC 服务时,这非常有用。有一个用 C++ 语言编写的库来支持 HTTP/JSON 转码,称为 grpc-httpjson-transcoding,目前在 IstioGoogle Cloud Endpoint 中使用。

Envoy 代理 也通过为 gRPC 服务提供 HTTP/JSON 接口来支持转码。类似于 gRPC 网关,我们需要为 gRPC 服务提供 HTTP 映射的服务定义。它使用在 Google API 文档 中指定的相同映射规则。因此,我们在 Example 8-1 中修改的服务定义也可以应用于 HTTP/JSON 转码。

例如,Product Info 服务的 getProduct 方法在 .proto 文件中定义,其请求和响应类型如下:

   rpc getProduct(google.protobuf.StringValue) returns (Product) {
        option (google.api.http) = {
            get:"/v1/product/{value}"
        };
   }

如果客户端通过向 URL http://localhost:8081/v1/product/2 发送 GET 请求调用此方法,则代理服务器将创建一个值为 2 的google.protobuf.StringValue,然后调用带有此值的 gRPC 方法getProduct()。 gRPC 后端然后返回 ID 为 2 的请求的Product,代理服务器将其转换为 JSON 格式并返回给客户端。

现在我们已经介绍了 HTTP/JSON 转码,下一节我们将讨论另一个重要的概念,即 gRPC 服务器反射。

gRPC 服务器反射协议

服务器反射是在 gRPC 服务器上定义的一项服务,它提供有关该服务器上公开访问的 gRPC 服务的信息。简单来说,服务器反射为客户端应用程序提供了在服务器上注册的服务的服务定义。因此,客户端不需要预编译的服务定义来与服务通信。

正如我们在第二章中讨论的那样,要使客户端应用程序能够连接并与 gRPC 服务通信,它必须具有该服务的服务定义。我们首先需要编译服务定义并生成相应的客户端存根。然后,我们需要创建调用存根方法的客户端应用程序。使用 gRPC 服务器反射,我们不需要预编译服务定义来与服务通信。

当我们构建用于调试 gRPC 服务器的命令行(CLI)工具时,服务反射非常有用。我们不需要为工具提供服务定义,而是提供方法和文本负载。它将二进制负载发送到服务器,并以人类可读的格式将响应返回给用户。为了使用服务反射,我们首先需要在服务器端启用它。示例 8-3 演示了如何启用服务器反射。

示例 8-3. 在 gRPC Go 服务器中启用服务器反射
package main

import (
  ...

  pb "productinfo/server/ecommerce"
  "google.golang.org/grpc"
  "google.golang.org/grpc/reflection" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
)

func main() {
  lis, err := net.Listen("tcp", port)
  if err != nil {
     log.Fatalf("failed to listen: %v", err)
  }
  s := grpc.NewServer()
  pb.RegisterProductInfoServer(s, &server{})
  reflection.Register(s) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  if err := s.Serve(lis); err != nil {
     log.Fatalf("failed to serve: %v", err)
  }
}

1

导入反射包以访问反射 API。

2

在您的 gRPC 服务器上注册反射服务。

在您的服务器应用程序中启用服务器反射后,您可以使用 gRPC CLI 工具检查您的服务器。

注意

gRPC CLI 工具随 gRPC 仓库提供。它支持许多功能,例如列出服务器服务和方法,并带有元数据发送和接收 RPC 调用。截至撰写本文时,您需要从源代码构建此工具。有关如何构建和使用该工具的详细信息,请参阅gRPC CLI 工具仓库

一旦您从源代码构建了 gRPC CLI 工具,您可以使用它来检查服务。让我们尝试通过我们在第二章中构建的产品管理服务来理解这一点。一旦启动产品管理服务的 gRPC 服务器,您就可以运行 CLI 工具来检索服务信息。

以下是您可以从 CLI 工具执行的操作:

列出服务

运行以下命令以列出端点localhost:50051中的所有公共服务:

$ ./grpc_cli ls localhost:50051

Output:
ecommerce.ProductInfo
grpc.reflection.v1alpha.ServerReflection

列出服务详细信息

通过提供服务的全名(格式为.)运行以下命令以检查服务:

$ ./grpc_cli ls localhost:50051 ecommerce.ProductInfo -l

Output:
package: ecommerce;
service ProductInfo {
rpc addProduct(ecommerce.Product) returns
(google.protobuf.StringValue) {}
rpc getProduct(google.protobuf.StringValue) returns
(ecommerce.Product) {}
}

列出方法详细信息

通过提供方法的全名(格式为..)来获取方法详细信息:

$ ./grpc_cli ls localhost:50051 ecommerce.ProductInfo.addProduct -l

Output:
rpc addProduct(ecommerce.Product) returns
(google.protobuf.StringValue) {}

检查消息类型

运行以下命令,通过提供消息类型的全名(格式为.)来检查消息类型:

$ ./grpc_cli type localhost:50051 ecommerce.Product

Output:
message Product {
string id = 1[json_name = "id"];
string name = 2[json_name = "name"];
string description = 3[json_name = "description"];
float price = 4[json_name = "price"];
}

调用远程方法

运行以下命令以向服务器发送远程调用并获取响应:

  1. 调用ProductInfo服务中的addProduct方法:

    $ ./grpc_cli call localhost:50051 addProduct "name:
     'Apple', description: 'iphone 11', price: 699"
    
    Output:
    connecting to localhost:50051
    value: "d962db94-d907-11e9-b49b-6c96cfe0687d"
    
    Rpc succeeded with OK status
    
  2. 调用ProductInfo服务中的getProduct方法:

    $ ./grpc_cli call localhost:50051 getProduct "value:
     'd962db94-d907-11e9-b49b-6c96cfe0687d'"
    
    Output:
    connecting to localhost:50051
    id: "d962db94-d907-11e9-b49b-6c96cfe0687d"
    name: "Apple"
    description: "iphone 11"
    price: 699
    
    Rpc succeeded with OK status
    

现在我们可以在 gRPC Go 服务器中启用服务器反射,并使用 CLI 工具进行测试。我们还可以在我们的 gRPC Java 服务器中启用服务器反射。如果您更熟悉 Java,可以参考源代码存储库中的 Java 示例。

让我们讨论另一个有趣的概念,称为 gRPC 中间件。

gRPC 中间件

在基本术语中,中间件是分布式系统中的软件组件,用于连接不同的组件,将客户端生成的请求路由到后端服务器。在gRPC 中间件中,我们还讨论了在 gRPC 服务器或客户端应用程序之前和之后运行代码的情况。

实际上,gRPC 中间件基于我们在第五章学到的拦截器概念。它是一个基于 Go 的拦截器、辅助函数和实用工具的集合,在构建基于 gRPC 的应用程序时需要这些工具。它允许您在客户端或服务器端应用多个拦截器作为拦截器链。此外,由于拦截器通常用于实现常见模式,如认证、日志记录、消息验证、重试或监控,因此 gRPC 中间件项目作为 Go 的这些可重用功能的首选点。在示例 8-4 中,我们展示了 gRPC 中间件包的常见用法。在这里,我们用它来为一元和流式消息应用多个拦截器。

示例 8-4。在 Go gRPC 中间件中服务器端的拦截器链
import "github.com/grpc-ecosystem/go-grpc-middleware"

orderMgtServer := grpc.NewServer(
    grpc.Unaryinterceptor(grpc_middleware.ChainUnaryServer( ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
        grpc_ctxtags.UnaryServerinterceptor(),
        grpc_opentracing.UnaryServerinterceptor(),
        grpc_prometheus.UnaryServerinterceptor,
        grpc_zap.UnaryServerinterceptor(zapLogger),
        grpc_auth.UnaryServerinterceptor(myAuthFunction),
        grpc_recovery.UnaryServerinterceptor(),
    )),
    grpc.Streaminterceptor(grpc_middleware.ChainStreamServer( ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
        grpc_ctxtags.StreamServerinterceptor(),
        grpc_opentracing.StreamServerinterceptor(),
        grpc_prometheus.StreamServerinterceptor,
        grpc_zap.StreamServerinterceptor(zapLogger),
        grpc_auth.StreamServerinterceptor(myAuthFunction),
        grpc_recovery.StreamServerinterceptor(),
    )),
    )

1

为服务器添加一元拦截器链。

2

为服务器添加流式拦截器链。

拦截器按照它们在 Go gRPC 中间件中注册的顺序被调用。该项目还为常见模式提供了一些可重用的拦截器。以下是一些常见模式和拦截器实现:

认证

grpc_auth

可定制的(通过AuthFunc)认证中间件。

日志记录

grpc_ctxtags

一个将标签映射添加到上下文中的库,数据来自请求体。

grpc_zap

将 zap 日志库集成到 gRPC 处理程序中。

grpc_logrus

将 logrus 日志库集成到 gRPC 处理程序中。

监控

grpc_prometheus

Prometheus 客户端和服务器端监控中间件。

grpc_opentracing

客户端和服务器端的 OpenTracing 拦截器支持流式处理和处理程序返回的标签。

客户端

grpc_retry

通用的 gRPC 响应代码重试机制,客户端中间件。

服务器

grpc_validator

.proto选项中生成入站消息验证的代码。

grpc_recovery

将恐慌转换为 gRPC 错误。

ratelimit

通过自己的限制器对 gRPC 进行速率限制。

在客户端使用 Go gRPC 中间件的方式完全相同。示例 8-5 展示了使用 Go gRPC 中间件进行客户端拦截器链的代码片段。

示例 8-5. 使用 Go gRPC 中间件进行客户端端拦截器链
import "github.com/grpc-ecosystem/go-grpc-middleware"

clientConn, err = grpc.Dial(
   address,
     grpc.WithUnaryinterceptor(grpc_middleware.ChainUnaryClient(
          monitoringClientUnary, retryUnary)), ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
     grpc.WithStreaminterceptor(grpc_middleware.ChainStreamClient(
          monitoringClientStream, retryStream)), ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
)

1

客户端一元拦截器链。

2

客户端流式拦截器链。

类似于服务器端,拦截器按照它们在客户端注册的顺序执行。

接下来,我们将讨论如何公开 gRPC 服务器的健康状态。在高可用系统中,有一种方法可以检查服务器的健康状态,以便我们可以定期检查并采取措施以减少损害。

健康检查协议

gRPC 定义了一个健康检查协议(健康检查 API),允许 gRPC 服务公开服务器状态,以便消费者可以探测服务器的健康信息。服务器的健康状态是通过响应一个不健康状态来确定,当服务器没有准备好处理 RPC 或者在健康探测请求中没有响应时。如果响应表明不健康状态或者在某个时间窗口内未收到响应,则客户端可以相应地采取行动。

gRPC 健康检查协议定义了一个基于 gRPC 的 API。然后,gRPC 服务用作健康检查机制,既可以用于简单的客户端到服务器的场景,也可以用于负载均衡等其他控制系统。示例 8-6 展示了 gRPC 健康检查接口的标准服务定义。

示例 8-6. gRPC 健康检查 API 的服务定义
syntax = "proto3";

package grpc.health.v1;

message HealthCheckRequest { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
  string service = 1;
}

message HealthCheckResponse { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
  }
  ServingStatus status = 1;
}

service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse); ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/3.png)

  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse); ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/4.png)
}

1

健康检查请求消息结构。

2

带服务状态的健康检查响应。

3

客户端可以通过调用Check方法查询服务器的健康状态。

4

客户端可以调用Watch方法执行流式健康检查。

健康检查服务的实现与任何传统的 gRPC 服务非常相似。通常情况下,您将在同一个 gRPC 服务器实例中使用多路复用同时运行健康检查服务和相关的 gRPC 业务服务(我们在第五章中讨论过)。由于它是一个 gRPC 服务,进行健康检查与进行普通 RPC 相同。它还提供了细粒度的服务健康语义,包括每个服务的健康状态等详细信息。此外,它能够重用服务器上的所有现有信息并对其进行全面控制。

基于示例 8-6 中显示的服务器接口,客户端可以调用Check方法(可选参数为服务名)来检查特定服务或服务器的健康状态。

此外,客户端还可以调用Watch方法执行流式健康检查。这使用服务器流式传输消息模式,这意味着一旦客户端调用此方法,服务器将开始发送指示当前状态的消息,并在状态更改时发送后续新消息。

这些是了解 gRPC 健康检查协议的关键要点:

  • 为了为服务器中注册的每个服务提供状态,我们应手动注册所有服务,并在服务器中设置空服务名的总体状态。

  • 客户端的每个健康检查请求应该设置截止日期,以便客户端可以确定如果 RPC 未在截止期内完成,则服务器状态为不健康。

  • 对于每个健康检查请求,客户端可以设置一个服务名或为空。如果请求中有服务名并且在服务器注册表中找到,必须返回带有 HTTP OK 状态的响应,并且HealthCheckResponse消息的状态字段应设置为特定服务的状态(可以是SERVINGNOT_SERVING)。如果在服务器注册表中找不到该服务,则服务器应以NOT_FOUND状态响应。

  • 如果客户端需要查询服务器的总体状态而不是特定服务的状态,则客户端可以发送带有空字符串值的请求,以便服务器响应服务器的总体健康状态。

  • 如果服务器没有健康检查 API,则客户端应自行处理。

健康检查服务由其他 gRPC 消费者或中间子系统(如负载均衡器或代理)消耗。与从头开始实现客户端不同,您可以使用诸如grpc_health_probe之类的现有健康检查客户端的实现。

gRPC 健康探针

grpc_health_probe 是社区提供的一个工具,用于检查将其状态作为服务公开的服务器的健康状态,通过 gRPC 健康检查协议。它是一个通用客户端,可以与 gRPC 标准健康检查服务进行通信。您可以像下面显示的那样使用 grpc_health_probe_ 二进制文件作为 CLI 实用程序:

$ grpc_health_probe -addr=localhost:50051 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)

healthy: SERVING
$ grpc_health_probe -addr=localhost:50052 -connect-timeout 600ms \
 -rpc-timeout 300ms ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)

failed to connect service at "localhost:50052": context deadline exceeded
exit status 2

1

对运行在本地主机端口 50051 上的 gRPC 服务器进行健康检查请求。

2

健康检查请求还包括与连接相关的其他几个附加参数。

如前述 CL 输出所示,grpc_health_probe 发起 RPC 请求至 /grpc.health.v1.Health/Check。如果它响应 SERVING 状态,则 grpc_health_probe 将成功退出;否则,它将以非零退出代码退出。

如果您在 Kubernetes 上运行 gRPC 应用程序,则可以运行 grpc_health_probe 来检查您的 gRPC 服务器 pod 的 Kubernetes 的 活性和就绪 检查。

为此,您可以将 gRPC 健康探测与您的 Docker 镜像捆绑在一起,如下所示的 Dockerfile 片段:

RUN GRPC_HEALTH_PROBE_VERSION=v0.3.0 && \
    wget -qO/bin/grpc_health_probe \
    https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/
            ${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
    chmod +x /bin/grpc_health_probe

然后在 Kubernetes 部署的 pod 规范中,您可以像这样定义 livenessProbe 和/或 readinessProbe

spec:
  containers:
  - name: server
    image: "kasunindrasiri/grpc-productinfo-server"
    ports:
    - containerPort: 50051
    readinessProbe:
      exec:
        command: ["/bin/grpc_health_probe", "-addr=:50051"] ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/1.png)
      initialDelaySeconds: 5
    livenessProbe:
      exec:
        command: ["/bin/grpc_health_probe", "-addr=:50051"] ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/grpc-uprn/img/2.png)
      initialDelaySeconds: 10

1

grpc_health_probe 指定为就绪探测。

2

grpc_health_probe 指定为活性探测。

当您使用 gRPC 健康探测设置活性和就绪探测时,Kubernetes 可以基于您的 gRPC 服务器的健康状态做出决策。

其他生态系统项目

在构建基于 gRPC 的应用程序时,还有一些其他生态系统项目可以提供帮助。客户端 protoc 插件是一个类似的生态系统需求,例如 protoc-gen-star (PG)* 开始引起关注。此外,诸如 protoc-gen-validate (PGV) 的库提供了一个 protoc 插件,用于生成多语言消息验证器。生态系统随着新项目的增加而不断发展,以满足 gRPC 应用程序开发中的各种需求。

至此,我们结束了对 gRPC 生态系统组件的讨论。重要的是要记住,这些生态系统项目并不是 gRPC 项目的一部分。在生产环境中使用它们之前,您需要适当评估它们。此外,它们可能会发生变化:一些项目可能会变得过时,其他项目可能变得主流,而另一些全新的项目可能会出现在 gRPC 生态系统中。

总结

正如您所看到的,尽管 gRPC 生态系统项目不是核心 gRPC 实现的一部分,但它们在构建和运行面向实际用例的 gRPC 应用程序中非常有用。这些项目围绕 gRPC 构建,以解决在使用 gRPC 构建生产系统时遇到的问题或限制。例如,当我们将我们的 RESTful 服务迁移到 gRPC 服务时,我们需要考虑那些习惯于以 RESTful 方式调用我们服务的现有客户端。为了解决这个问题,引入了 HTTP/JSON 转码和 gRPC 网关概念,以便现有的 RESTful 客户端和新的 gRPC 客户端都可以调用同一个服务。类似地,引入了服务反射来克服使用命令行工具测试 gRPC 服务的限制。

由于 gRPC 在云原生世界中非常流行,开发人员现在逐渐从 REST 服务向 gRPC 迁移,我们将在未来看到更多围绕 gRPC 构建的类似项目。

恭喜!您刚刚完成了阅读《gRPC: Up and Running》,并基本涵盖了 gRPC 应用程序的整个开发生命周期,包括基于 Go 和 Java 的大量代码示例。我们希望本书为您在将 gRPC 作为应用程序和微服务之间的进程间通信技术使用的旅程奠定了基础。您在本书中学到的内容将帮助您快速构建 gRPC 应用程序,了解它们如何与其他技术共存,并在生产环境中运行。

因此,现在是进一步探索 gRPC 的时候了。尝试通过应用本书中学到的技术来构建实际应用程序。gRPC 有很多功能取决于您用于开发 gRPC 应用程序的编程语言,因此您需要学习特定于您使用的语言的某些技术。此外,gRPC 生态系统正呈指数级增长,了解支持 gRPC 的最新技术和框架将非常有帮助。前进,探索吧!

posted @ 2024-06-18 18:05  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报