我们在前几讲提到过,优秀的RPC框架都提供了middleware
的能力,可以减少很多重复代码的编写。在gRPC-Gateway的方案里,包括了两块中间件的能力:
ServerOption
,是所有gRPC+HTTP都会被处理ServeMuxOption
,只有HTTP协议会被处理今天,我们先关注共同部分的ServerOption
,它提供的能力最为全面,让我们一起了解下。
在官方文件google.golang.org/grpc/server.go
路径下,给出了很多公开的ServerOption
方法。从本质上来说,这些方法都是为了修改服务端的一个核心数据结构体:
type serverOptions struct {
creds credentials.TransportCredentials
codec baseCodec
cp Compressor
dc Decompressor
unaryInt UnaryServerInterceptor
streamInt StreamServerInterceptor
chainUnaryInts []UnaryServerInterceptor
chainStreamInts []StreamServerInterceptor
binaryLogger binarylog.Logger
inTapHandle tap.ServerInHandle
statsHandlers []stats.Handler
maxConcurrentStreams uint32
maxReceiveMessageSize int
maxSendMessageSize int
unknownStreamDesc *StreamDesc
keepaliveParams keepalive.ServerParameters
keepalivePolicy keepalive.EnforcementPolicy
initialWindowSize int32
initialConnWindowSize int32
writeBufferSize int
readBufferSize int
connectionTimeout time.Duration
maxHeaderListSize *uint32
headerTableSize *uint32
numServerWorkers uint32
}
不难从命名中推断到,上述结构体包含了认证、编解码、压缩、日志等各种配置,其中在初始化时有一些默认值。我们将目光聚焦于核心middleware能力的实现 - 拦截器(Interceptor)。
gRPC协议提供了两种RPC调用的方式:
Unary
普通的单次调用Stream
流式调用我们框架的RPC调用都来自gRPC-Gateway对HTTP
协议的转发,是属于Unary
这块,所以我们聚焦于UnaryServerInterceptor
即可。而chainUnaryInts
的数据结构为[]UnaryServerInterceptor
,即支撑了链式middleware
的调用,是自定义入口的关键。
使用示例代码:
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(
// 各个拦截器
),
)
我们先一起看看这个函数的签名:
type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (resp interface{}, err error)
示例如下:
func ExampleInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
// 1 - 前处理
// 2 - 调用具体实现
resp, err = handler(ctx, req)
// 3 - 后处理
return
}
可以看到,整个代码分三步进行,其中handler这部分的实现是开发者编写的业务逻辑。
而当存在链式的拦截器时,这部分的实现类似于先入后出的逻辑:
Unray
调用的信息,主要是方法名我们要了解这6个参数,才能真正地理解gRPC,进而合理地使用拦截器。下面,我挑选3个重点进行描述:
metadata.FromIncomingContext(ctx)
提取出gRPC的metadata、再塞入到ctx中。什么是metadata呢?你可以把它简单地类比到HTTP的Header。protobuf
中定义的方法对应。不难猜到,对数据的序列化、反序列化等操作,是在拦截器之前工作的。err != nil
时,调用方只需关注err;当err == nil
时,resp才有意义。这里,我再额外补充两个容易陷入误区的点:
google.golang.org/grpc/internal/status
生成,如status.Error(codes.Unauthenticated, "用户校验失败")
,这样错误才能兼容框架,同时具备错误码与错误信息。分析完上述内容后,我们结合一些经典的拦截器,方便大家了解它的价值:
func ServerLoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
// 进入打印日志,确认入参
log.Info()
resp, err = handler(ctx, req)
// 处理完打印日志,包括出参和error
if err != nil {
log.Error()
return
}
log.Info()
return
}
一个RPC调用最终会落成2个日志:
日志拦截器的对我们的日常开发意义非常大,核心思路是:通过日志的一入一出,快速定位问题。常见的如:
handler
的实现有问题func ServerRecoveryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
defer func() {
if e := recover(); e != nil {
const size = 64 << 10
stacktrace := make([]byte, size)
stacktrace = stacktrace[:runtime.Stack(stacktrace, false)]
// error及堆栈进行日志打印
log.Error()
err = status.Error(codes.Unavailable, "系统异常")
}
}()
return handler(ctx, req)
}
随着项目的迭代,handler
里的实现很有可能出现会导致panic的代码,我们必须对这种异常兜底,而不是随便导致程序崩溃。
示例代码就是捕获对应的panic,输出到日志,返回给调用方系统异常。recovery是保证HTTP服务稳定的重要实现,其中的日志对开发者事后排查问题也提供了参考,是一个必备的工具利器。
const (
USER_TOKEN = "USER_TOKEN"
CTX_USERNAME = "CTX_USERNAME"
)
func ServerAuthUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
// 1. 提取出metadata
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "用户校验失败")
}
// 2. parseUserName 从对应的metadata的Key里提取信息,解析出用户名
userName, err := parseUserName(md.Get(USER_TOKEN)[0])
if err != nil {
return nil, status.Error(codes.Unauthenticated, "用户校验失败")
}
// 3. 将用户名塞入到ctx中
ctx = context.WithValue(ctx, CTX_USERNAME, userName)
// 4. 继续逻辑处理
return handler(ctx, req)
}
// 在handler里,调用这个函数可以提取到用户名
func GetUserName(ctx context.Context) string {
return ctx.Value(CTX_USERNAME).(string)
}
相关的步骤已经在代码注释里写得很清楚了,这里再补充3个细节:
info.FullMethod
入手,即调用的方法名,也就是增加一段if-else
的判断逻辑// PGV里的结构,都实现了这个方法
type Validator interface {
ValidateAll() error
}
func ServerValidationUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
// 如果接口实现了PGV的方法,就认为必须要进行校验
if r, ok := req.(Validator); ok {
err = r.ValidateAll()
// 校验失败,则打印错误并返回参数校验失败
if err != nil {
log.Error
return nil, status.Error(codes.InvalidArgument, "参数校验失败")
}
}
return handler(ctx, req)
}
在protobuf里有一个非常有用的插件 - PGV,可参考Github,它能帮助开发者快速实现对应的参数校验:
但是,它需要开发者手工判断一次。这时,我们就可以利用拦截器+接口,组装出一个参数校验的拦截器,而无需再每个handler
中都去判定。
这个实现很简洁,也充分利用了接口的特性,是一个经典的拦截器实现。
今天,我们对gRPC中的拦截器进行了分析,并给出了4个经典的拦截器代码实现。而gin
等框架中的middleware实现思路也基本与其一致,差别主要在参数类型不一样。
gRPC拦截器能有效地收敛很多重复代码,保证框架的统一与高效;相反地,如果某个公共能力无法用拦截器实现,就非常值得我们反思了。
接下来,我们将视角转移到gRPC-Gateway方案,看看在针对HTTP方面又有哪些高效的middleware。
Github: https://github.com/Junedayday/code_reading Blog: http://junes.tech/ Bilibili: https://space.bilibili.com/293775192