gRPC 在做什么?

gRPC 被描述为 “Protobuf over HTTP/2”。这意味着 gRPC 会生成所有的通信代码,封装 gRPC 框架,并依赖 Protobuf 来进行数据的序列化和反序列化。为了知道客户端和服务器上有哪些 API 端点,gRPC 会查看我们在 .proto 文件中定义的服务,并从中获取所需的基本信息,进而生成所需的元数据和函数。

理解 gRPC 的第一件事是,它有多个实现。例如,在 Go 中,你会得到一个纯粹的 gRPC 实现。这意味着整个代码生成过程和通信都是用 Go 编写的。其他语言可能也有类似的实现,但其中许多是围绕 C 实现的包装器。虽然在本书的上下文中我们不需要了解它们,但了解它们的存在很重要,因为它解释了 protoc 编译器插件的存在。

如你所知,世界上有很多语言。一些语言相对较新,而一些则相当古老,因此跟踪每种语言的演变几乎是不可能的。这就是我们需要 protoc 插件的原因。每个开发者或公司都可以编写这样的插件来生成能够通过 HTTP/2 发送 Protobuf 的代码。这就是例如苹果为 Swift 支持添加插件的原因。

既然我们在谈论 Go,我们希望了解生成了什么样的代码,以便了解 gRPC 如何工作,并且知道如何调试以及在哪里查找函数签名。让我们从一个简单的服务开始——在 proto/account.proto 文件中,我们有如下内容:

syntax = "proto3";
option go_package = "github.com/PacktPublishing/gRPC-Go-for-Professionals";

message Account {
  uint64 id = 1;
  string username = 2;
}

message LogoutRequest {
  Account account = 1;
}

message LogoutResponse {}

service AccountService {
  rpc Logout (LogoutRequest) returns (LogoutResponse);
}

在这个服务中,我们有一个名为 Logout 的 API 端点,它以 LogoutRequest(这是一个包装了 Account 的类型)作为参数,并返回一个 LogoutResponse 参数。LogoutResponse 是一个空消息,因为我们只需要发送需要停止会话的账户信息,而不需要任何结果,只需要一个指示调用成功的标志。

然后,为了从中生成 Protobuf 和 gRPC 代码,我们将运行以下命令:

$ protoc --go_out=. \
--go_opt=module=github.com/PacktPublishing/gRPC-Go-for-Professionals \
--go-grpc_out=. \
--go-grpc_opt=module=github.com/PacktPublishing/gRPC-Go-for-Professionals \
proto/account.proto

我们已经看到,Protobuf 会将消息转换为结构体,但现在我们还会得到一个名为 _grpc.pb.go 的文件,其中包含了 gRPC 通信代码。

服务器

让我们先看看服务器端生成的代码。我们将从文件的底部开始,查看服务描述符。但首先,我们需要了解什么是描述符。在 Protobuf 和 gRPC 的上下文中,描述符是一个元对象,表示 Protobuf 代码。这意味着,在我们的例子中,我们有一个 Go 对象,代表一个服务或其他概念。事实上,虽然我们在上一章没有深入探讨,但如果你查看生成的 Account 代码,你会发现也提到了 Desc

对于我们的 AccountService 服务,生成的描述符如下所示:

var AccountService_ServiceDesc = grpc.ServiceDesc{
    ServiceName: "AccountService",
    HandlerType: (*AccountServiceServer)(nil),
    Methods: []grpc.MethodDesc{
        {
            MethodName: "Logout",
            Handler: _AccountService_Logout_Handler,
        },
    },
    Streams: []grpc.StreamDesc{},
    Metadata: "account.proto",
}

这意味着我们有一个名为 AccountService 的服务,它与一个名为 AccountServiceServer 的类型关联,并且这个服务有一个叫 Logout 的方法,该方法应该由一个名为 _AccountService_Logout_Handler 的函数处理。

你应该会在服务描述符上方找到处理程序。它看起来像下面这样(简化版):

func _AccountService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
    in := new(LogoutRequest)
    if err := dec(in); err != nil {
        return nil, err
    }
    if interceptor == nil {
        return srv.(AccountServiceServer).Logout(ctx, in)
    }
    //...
}

这个处理程序负责创建一个新的 LogoutRequest 类型的对象并填充它,然后将其传递给 AccountServiceServer 类型的 Logout 函数。请注意,我们假设拦截器(interceptor)始终为 nil,因为这是一个更高级的功能,但稍后我们会看到如何设置和使用拦截器的示例。

最后,我们看到提到了 AccountServiceServer 类型。它看起来是这样的:

type AccountServiceServer interface {
    Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
    mustEmbedUnimplementedAccountServiceServer()
}

这是一个包含我们 RPC 端点的函数签名和一个 mustEmbedUnimplementedAccountServiceServer 函数的类型。

在我们了解 Logout 函数之前,让我们先理解一下 mustEmbedUnimplementedAccountServiceServer。这是 gRPC 中一个重要的概念,因为它提供了服务的向前兼容实现,这意味着我们 API 的旧版本能够与新版本进行通信而不会崩溃。

如果你查看 AccountServiceServer 的定义下方,你会看到如下内容:

// UnimplementedAccountServiceServer 必须嵌入以支持向前兼容的实现。
type UnimplementedAccountServiceServer struct{}

func (UnimplementedAccountServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
    return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
}

通过这些代码,我们可以理解到,UnimplementedAccountServiceServer 类型必须嵌入到某个地方,而这个地方就是我们稍后在本书中定义的类型,专门用于编写我们的 API 端点。我们会编写如下代码:

type Server struct {
    UnimplementedAccountServiceServer
}

这就是所谓的类型嵌入(type embedding),这是 Go 用来将其他类型的属性和方法添加到当前类型的方式。你可能听说过 “偏好组合而不是继承” 的建议,这正是这个意思。我们将 UnimplementedAccountServiceServer 类型的方法定义添加到 Server 类型中。这将让我们拥有默认的实现,在 Logout 方法没有实现时,返回一个 “未实现的方法” 错误。这样,如果一个没有完整实现的服务器接收到未实现的 API 端点的调用,它会返回一个错误,但不会因为缺少端点而崩溃。

一旦我们理解了这一点,Logout 方法的签名就变得简单了。正如前面提到的,稍后我们会定义自己的服务器类型,这个类型会嵌入 UnimplementedAccountServiceServer 类型,并且我们将覆盖 Logout 函数的实现。然后对 Logout 的任何调用都会被重定向到我们自己的实现,而不是默认的生成代码。

客户端

生成的客户端代码比服务器端代码更简单。我们有一个名为 AccountServiceClient 的接口,它包含所有的 API 端点:

type AccountServiceClient interface {
    Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
}

然后我们有一个实现了这个接口的类型,叫做 accountServiceClient

type accountServiceClient struct {
    cc grpc.ClientConnInterface
}

func NewAccountServiceClient(cc grpc.ClientConnInterface) AccountServiceClient {
    return &accountServiceClient{cc}
}

func (c *accountServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
    out := new(LogoutResponse)
    err := c.cc.Invoke(ctx, "/AccountService/Logout", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

我们可以注意到这段代码中的一个重要点:有一个端点路由叫做 /AccountService/Logout。如果你回顾一下在《服务器部分》描述的 AccountService_ServiceDesc 变量,你会发现这个路由是由 ServiceNameMethodName 属性拼接而成的。这将使得服务器知道如何将请求路由到 _AccountService_Logout_Handler 处理器。

就这样。我们可以看到,gRPC 处理了所有调用端点的样板代码。我们只需要通过调用 NewAccountServiceClient 来创建一个符合 AccountServiceClient 接口的对象,然后通过该对象调用 Logout 方法。