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
变量,你会发现这个路由是由 ServiceName
和 MethodName
属性拼接而成的。这将使得服务器知道如何将请求路由到 _AccountService_Logout_Handler
处理器。
就这样。我们可以看到,gRPC 处理了所有调用端点的样板代码。我们只需要通过调用 NewAccountServiceClient
来创建一个符合 AccountServiceClient
接口的对象,然后通过该对象调用 Logout
方法。