Go语言学习 - RPC篇:gRPC拦截器剖析

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 我们在前几讲提到过,优秀的RPC框架都提供了`middleware`的能力,可以减少很多重复代码的编写。在gRPC-Gateway的方案里,包括了两块中间件的能力:1. gRPC中的`ServerOption`,是所有gRPC+HTTP都会被处理2. gRPC-Gateway中的`ServeMuxOption`,只有HTTP协议会被处理今天,我们先关注共同部分的`ServerOption`,它提供的能力最为全面,让我们一起了解下。

概览

我们在前几讲提到过,优秀的RPC框架都提供了middleware的能力,可以减少很多重复代码的编写。在gRPC-Gateway的方案里,包括了两块中间件的能力:

  1. gRPC中的ServerOption,是所有gRPC+HTTP都会被处理
  2. gRPC-Gateway中的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(
            // 各个拦截器
        ),
)

分析UnaryServerInterceptor

我们先一起看看这个函数的签名:

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这部分的实现是开发者编写的业务逻辑。

而当存在链式的拦截器时,这部分的实现类似于先入后出的逻辑:

  1. 前处理1 -> 前处理2 -> ... -> 前处理n
  2. 具体代码实现
  3. 后处理n -> 后处理n-1 -> ... -> 后处理1

参数说明

  1. ctx - 上下文
  2. req - 入参
  3. info - Unray调用的信息,主要是方法名
  4. handler - 正常处理的函数
  5. resp - 出参
  6. err - 错误

我们要了解这6个参数,才能真正地理解gRPC,进而合理地使用拦截器。下面,我挑选3个重点进行描述:

  1. 我们无法直接使用ctx提取值,而是要用metadata.FromIncomingContext(ctx)提取出gRPC的metadata、再塞入到ctx中。什么是metadata呢?你可以把它简单地类比到HTTP的Header。
  2. req与resp的类型与protobuf中定义的方法对应。不难猜到,对数据的序列化、反序列化等操作,是在拦截器之前工作的。
  3. resp与err这两个返回参数尽可能规范:当err != nil时,调用方只需关注err;当err == nil时,resp才有意义。

这里,我再额外补充两个容易陷入误区的点:

  1. gRPC-Gateway中也有拦截器的实现,但我们尽可能只做协议的转换:将HTTP Header转换到gRPC-Gateway。这样可以保证gRPC和HTTP的调用,数据处理逻辑用一个拦截器就可以完成,如用户认证。
  2. 尽可能只用err来表示错误,而不要在resp里封装errno等字段(我在下一篇也会给出对应兼容的方案)。这里的error用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个日志:

  1. 进入时的Info日志
  2. 返回时
    1. 正常,则打印Info日志
    2. 有错误,则打印Error日志

日志拦截器的对我们的日常开发意义非常大,核心思路是:通过日志的一入一出,快速定位问题。常见的如:

  1. 先看进入时的日志,看看打印的参数是否如预期,如果有错往往先从协议排查,如字段命名
  2. 再看返回的日志,如果打印的输出和预期的一致,那往往是调用方的协议问题,如字段未解析
  3. 如果进入时的日志正确,但返回的打印异常,那就是handler的实现有问题

recovery拦截器

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个细节:

  1. metadata的USER_TOKEN这个Key,按调用方,来源分2种情况:
    1. 如果调用方是gRPC,那就要求调用方在metadata里填充这个Key
    2. 如果调用方是HTTP,需要人工将HTTP的Header映射到gRPC的metadata,这部分就是在gRPC-Gateway的中间件里实现
  2. 示例中的1与2会对未认证的请求直接拦截 - 不会调用到具体handler的代码,直接返回错误给调用方
  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,它能帮助开发者快速实现对应的参数校验:

  • 简单的如整型要大于1,字符串要非空
  • 复杂的如邮箱、IP等格式检查

但是,它需要开发者手工判断一次。这时,我们就可以利用拦截器+接口,组装出一个参数校验的拦截器,而无需再每个handler中都去判定。

这个实现很简洁,也充分利用了接口的特性,是一个经典的拦截器实现。

小结

今天,我们对gRPC中的拦截器进行了分析,并给出了4个经典的拦截器代码实现。而gin等框架中的middleware实现思路也基本与其一致,差别主要在参数类型不一样。

gRPC拦截器能有效地收敛很多重复代码,保证框架的统一与高效;相反地,如果某个公共能力无法用拦截器实现,就非常值得我们反思了。

接下来,我们将视角转移到gRPC-Gateway方案,看看在针对HTTP方面又有哪些高效的middleware。

相关实践学习
日志服务之数据清洗与入湖
本教程介绍如何使用日志服务接入NGINX模拟数据,通过数据加工对数据进行清洗并归档至OSS中进行存储。
目录
相关文章
|
5天前
|
存储 Go API
一个go语言编码的例子
【7月更文挑战第2天】本文介绍Go语言使用Unicode字符集和UTF-8编码。Go中,`unicode/utf8`包处理编码转换,如`EncodeRune`和`DecodeRune`。`golang.org/x/text`库支持更多编码转换,如GBK到UTF-8。编码规则覆盖7位至21位的不同长度码点。
70 1
一个go语言编码的例子
|
8天前
|
JSON 算法 测试技术
在go语言中调试程序
【6月更文挑战第29天】Go语言内置`testing`包支持单元测试、基准测试和模糊测试。`go test`命令可执行测试,如`-run`选择特定测试,`-bench`运行基准测试,`-fuzz`进行模糊测试。
17 2
在go语言中调试程序
|
6天前
|
安全 Go
Go语言的iota关键字有什么用途?
**Go语言中的`iota`是常量生成器,用于在`const`声明中创建递增的常量。`iota`在每个新的`const`块重置为0,然后逐行递增,简化了枚举类型或常量序列的定义。例如,定义星期枚举:** ```markdown ```go type Weekday int const ( Sunday Weekday = iota // 0 Monday // 1 Tuesday // 2 ... ) ``` 同样,`iota`可用于定义不同组的常量,如状态码和标志位,保持各自组内的递增,提高代码可读性。
|
2天前
|
监控 搜索推荐 Go
万字详解!在 Go 语言中操作 ElasticSearch
本文档通过示例代码详细介绍了如何在Go应用中使用`olivere/elastic`库,涵盖了从连接到Elasticsearch、管理索引到执行复杂查询的整个流程。
8 0
|
6天前
|
IDE Linux Go
记录一个go语言与IDE之间的问题
【7月更文挑战第1天】本文介绍在IDE中调试Go应用可能遇到的问题。当问题与IDE的自动完成有关,可以试着使用其他编辑器如Linux的vim是否无此问题。这可以验证表明IDE可能不完全兼容最新语言版本,建议使用无自动检测工具临时解决。
22 0
|
10天前
|
编译器 Go C++
必知的技术知识:go语言快速入门教程
必知的技术知识:go语言快速入门教程
|
11天前
|
编译器 Go 开发者
|
2月前
|
JSON JavaScript Go
Go 语言学习指南:变量、循环、函数、数据类型、Web 框架等全面解析
掌握 Go 语言的常见概念,如变量、循环、条件语句、函数、数据类型等等。深入了解 Go 基础知识的好起点是查阅 Go 官方文档
713 2
|
9月前
|
自然语言处理 Java Go
Go语言学习之函数
Go语言学习之函数
21 0
|
2月前
|
Java Go 数据安全/隐私保护
Go语言学习7-函数类型
本篇 Huazie 向大家介绍 Go 语言的函数类型
45 1
Go语言学习7-函数类型