cover

gRPC 与『面向扩展编程』

sorcererxw

gRPC 通过 Proto DSL 定义类型和接口,生成对各个语言的代码。所以兼容性对于 gRPC 来说是首位重要的,无论是横向上兼容所有语言的特性,还是纵向上对于不同版本的接口定义做到向后兼容,这就需要 gRPC 的接口定义有非常良好的可扩展性。本文将谈谈 gRPC 在向后兼容与可扩展性上的设计,理解这些设计的用意可以帮助我们在未来设计出更易维护与扩展的接口。

一切字段皆可选

在 proto3 当中,optional 关键词被移除了,并且规定了所有字段都必须是可选字段。一开始接触 proto 的时候,觉得这可能在适配部分语言(比如 Go)没有必选字段的概念。

但是更深入思考之后,能够意识到:

  • RPC 跨进程调用,任何输入都是有可能的,我们无法通过约束另外一个进程的输入来保证本进程的逻辑正确性,能做的只能是防御性编程。
  • 另一方面,如果支持必选字段,当我们为现有类型增加了一个必选字段,必然会导致原来依赖这个类型的代码无法兼容,引入 breaking change,现有代码库越庞大,这个变动越恐怖。

所以抛弃必选字段约束在工程上是一个明智的选择,这也一定程度上能够解释为什么 Go 不支持 struct required field。

UnimplementedServer

grpc-go 在生成 server stub 的时候,会生成如下代码:

type Server interface {
	Do(context.Context, *Request) (*Response, error)
	mustEmbedUnimplementedServer()
}

type UnimplementedServer struct {
}

func (UnimplementedServer) Do(context.Context, *Request) (*Response, error) {
	return nil, status.Errorf(codes.Unimplemented, "method CancelContract not implemented")
}

func (UnimplementedServer) mustEmbedUnimplementedServer() {}

由于 mustEmbedUnimplementedServer 没有导出,我们在外部 implement Server 的时候永远无法满足约束。原因是 grpc-go 希望所有实现 Server 的 struct 都 embed UnimplementedServer ,而 UnimplementedServer 会实现完整的 Server ,这样即便未来 Server 内增加了新的接口,现有的代码也能直接兼容。

type impl struct {
	UnimplementedServer
}

但是另一面,由于 UnimplementedServer 隐式地实现了所有接口,让代码无法在编译期检查函数是否被实现,会带来一定的隐患。这个问题在社区里面也有不少的讨论:

protoc-gen-go-grpc: API for service registration · Issue #3669 · grpc/grpc-go
There were some changes in #3657 that make it harder to develop gRPC services and harder to find new unimplemented methods - I wanted to start a discussion around the new default and figure out why...
https://github.com/grpc/grpc-go/issues/3669

输入输出必为结构体

Proto 在定义的 RPC 的时候,只允许使用 message(即结构体) 作为接口的输入或者输出,而不允许使用 primitive type 或者集合类型。因为只有 message 是可扩展的,可以任意地增加字段。

如果我们一开始一厢情愿地认为一个接口就是返回 list 而将接口返回类型直接定义为 list,如果哪一天需要同时返回 pagination cursor,那个时候我们就不得不换用新的接口或者在原来的接口上引入 breaking change,来将 response type 更换为 message。

每个 RPC 专属的输入输出类型

在 proto 本身的约束之外,在 linter 当中也可以找到一些最佳实践。在 Buf 当中有几条规则:

这两条规则可能对编写接口定义带来了不少额外负担,明明是已经有了的类型或者压根没有入参为啥还要多定义一个类型。其实理由和上面提到的输入输出必须要为结构体相似,即我们永远无法判断一个接口未来会增加什么字段:今天两个接口有相同的入参,明天其中一个接口就可能要多加一个字段;今天没有入参的接口,明天可能就要传入一个参数。如果我们在一开始设计的时候不考虑未来的可扩展性,随便复用输入输出或者定义为空,那么在未来不得不作修改的时候就可能引入 breaking change!

总结

无论是 UnimplementedServer 还是可选字段,这些实践都有一个前提,就是向后兼容性一定程度上比正确性更加重要。虽然有些时候可能因此少实现一个函数或者少传一个字段,但相比之下,导致整个代码库 breaking change 是更无法接受的。从工程的角度上来看,这是正确的选择,所以这也对业务代码有了更多的要求:当涉及到 RPC,入参降级、返回类型降级、函数调用降级都是必不可少的。