gRPC Go 拦截器链的执行顺序:从 UnaryInterceptor 到 StreamInterceptor 的完整链路

本文从 gRPC Go 拦截器链的执行顺序出发,解析 UnaryInterceptor 与 StreamInterceptor 在服务端调用链路中的嵌套机制,说明 chain 拦截器的前置和后置逻辑执行次序,并结合 go-grpc-middleware 给出注册顺序、测试方法和常见误区,帮助微服务开发者避免链路顺序导致的问题。

先从一次日志顺序异常说起

假设一个 Go 写的 gRPC 服务,为了治理加了三个拦截器:一个用于 panic recover,一个输出请求日志,一个做简单的限流。你预期它们按注册顺序执行,可是日志一打出来,panic 恢复的日志出现在限流之后,或者某个拦截器在 context 里塞的字段,下一个拦截器竟然读不到。这类问题大多不是逻辑写错,而是没有理解 gRPC Go 拦截器链的嵌套调用顺序。

gRPC Go 拦截器链的执行顺序:从 UnaryInterceptor 到 StreamInterceptor 的完整链路

gRPC Go 拦截器链的执行顺序,决定了一个请求进入业务 handler 之前会先经过哪些处理,也决定了 handler 返回后哪些清理逻辑先跑。如果顺序判断错误,日志会乱、context 会丢、甚至限流会被绕过。这篇内容把 UnaryInterceptor 和 StreamInterceptor 的链路放一起看,理清完整链路到底是怎么串起来的。

所谓拦截器链,就是把多个拦截器像套管一样套在一起。每个拦截器都持有下一个环节的调用权,选择放行就调用 next,不放行就返回。在服务端,grpc.ChainUnaryInterceptor 用于把一组一元拦截器串成链。虽然名字里写着 Chain,但真实执行路径并不是从前到后的数组遍历。

先看一元调用:UnaryInterceptor 的链式结构

服务端普通方法对应的拦截器链,可以用一个简化代码还原出来。下面这段代码等价于 go-grpc-middleware 里 chain 的核心思想:

func chainUnaryServer(interceptors ...grpc.UnaryServerInterceptor) grpc.UnaryServerInterceptor {
    n := len(interceptors)
    if n == 0 {
        return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
            return handler(ctx, req)
        }
    }
    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        var i int
        var next grpc.UnaryHandler
        next = func(ctx context.Context, req interface{}) (interface{}, error) {
            if i == len(interceptors)-1 {
                return handler(ctx, req)
            }
            i++
            return interceptors[i](ctx, req, info, next)
        }
        return interceptors[0](ctx, req, info, next)
    }
}

这段代码的核心是 next 闭包。i 的初始值是 0,第一次进入时 interceptors[0] 会拿到 next。interceptors[0] 如果调用 next,i 变成 1,进入 interceptors[1]。依次向下,直到最内层的真实 handler。

所以,真正 handler 始终处于最内层。如果把链画成洋葱,注册时靠前的拦截器在外层,靠后的在内层。调用 next 之前的逻辑,按照注册顺序从外到内执行;调用 next 之后的逻辑,按照注册顺序从内到外执行。这个结构决定了日志先后差一点就很正常。

Unary 与 Stream:两条独立链路

gRPC 服务可以同时提供普通方法和流式方法。服务端注册拦截器时,专门区分了 Unary 和 Stream。grpc.ChainUnaryInterceptor 只会作用于非流式方法,grpc.ChainStreamInterceptor 只会作用于流式方法。两者不是一条链展开的两个阶段,而是各自维护的一组包装。

如果一个服务同时有 Unary 方法和 Stream 方法,Unary 配的日志拦截器不会自动作用到 Stream 方法上。写认证、指标采集时,要考虑是否两边都要接。很多人只配置了 ChainUnaryInterceptor,流式接口成了裸奔状态。

StreamInterceptor 的签名稍微不同。它收到的是 grpc.ServerStream,而不是 req/reply:

func streamLog(ctx context.Context, srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
    // 流开始前
    err := handler(ctx, srv, ss)
    // 流结束后
    return err
}

它内部的链式嵌套与 Unary 相同。但如果你想对流向中的每一条消息做修改,需要包装 grpc.ServerStream 的 RecvMsg 与 SendMsg,而不像 Unary 那样直接拿 req 和 reply。

客户端拦截器的顺序也别忘

同样的机制也存在于客户端。grpc.Dial 阶段可以设置 grpc.WithChainUnaryInterceptor 或 grpc.WithChainStreamInterceptor。多个客户端拦截器嵌套后,最内层是底层的 invoker。第一个注册的客户端拦截器会先看到请求,最后一个注册的拦截器会先收到底层返回。后处理阶段同样是逆序。

客户端链和服务端链叠加在一起,一次 RPC 实际上会经过客户端拦截器链、网络传输、服务端拦截器链、业务 handler。调试超时问题时,画出这条完整链路比直接看日志更有效。

执行顺序里最容易忽略的几个问题

  • 后置逻辑顺序相反。很多人把收尾日志放在 next 之后,却发现最外层拦截器的收尾最后才执行。因为后置逻辑是从内向外走的。如果你希望最外层的清理先执行,就得把它放在更外层注册。
  • 拦截器链中断不透明。一个拦截器不调用 next,后面的拦截器和 handler 都不会执行。这个特性用于限流很合适,但会让日志少一段调用记录。
  • context 派生链。每个拦截器可以基于 ctx 派生新 context 给 next,但不要把从 handler 传出的 context 随便丢给异步 goroutine。handler 结束后这个 context 会被 cancel。
  • Stream 与 Unary 混用。Stream 方法不会被 UnaryInterceptor 包裹,认证、恢复这类基础能力要同时覆盖两条链。

自研链与第三方库如何取舍

刚才的简化代码已经可以动手实现一条链。生产项目里通常选择 grpc-ecosystem/go-grpc-middleware,节省对 chain 索引细节的维护成本。两者重点差异在流式支持、组件丰富度和可控性。

实现方式 注册方式 流式支持 定位难度 适合场景
grpc.ChainUnaryInterceptor 原生注册 不支持 Stream 需要自己写索引时较高 只有一元方法的小服务
go-grpc-middleware Chain 组件复用 支持完整 Stream 链 中大型服务、需要成熟拦截器组件
自研闭包链 代码自行拼装 需要自己包装 对顺序有特殊定制要求的团队

这里说 go-grpc-middleware 定位难度低,并不是因为它会自动解决业务逻辑,而是把 chain 的顺序封装好,你只需要把精力放在拦截器内部。如果服务只有一个普通方法,直接 grpc.UnaryInterceptor 就够了,不必引第三方库。

一个实践示例:注册顺序与测试断言

服务端注册时,常见顺序是 recovery、auth、日志、参数校验。建议把不打算被内层跳过的拦截器放外层,把内层故障可绕过的逻辑放内层。注册顺序一变,行为就会变。

经验提醒:限流拦截器如果放在日志外层,被限流的请求也会记日志;放在内层则不会。这不是对错问题,而是可观测策略问题,建议先想清楚再决定。

验证执行顺序,可以写一个最小 handler,用 context 或在切片里记录访问顺序。下面这段测试思路能有效防止别人误改注册顺序:

func TestChainOrder(t *testing.T) {
    var order []string
    first := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        order = append(order, `first-pre`)
        resp, err := handler(ctx, req)
        order = append(order, `first-post`)
        return resp, err
    }
    second := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        order = append(order, `second-pre`)
        resp, err := handler(ctx, req)
        order = append(order, `second-post`)
        return resp, err
    }
    chain := chainUnaryServer(first, second)
    _, _ = chain(context.Background(), nil, &grpc.UnaryServerInfo{}, func(ctx context.Context, req interface{}) (interface{}, error) {
        return nil, nil
    })
}

运行后 order 应该是 first-pre、second-pre、second-post、first-post。如果测试断言不是这个顺序,说明你仍然把链理解成了数组遍历。

脑子里要有这张洋葱图

gRPC Go 拦截器链的执行顺序并不复杂,但很容易被直觉带偏。它的核心是嵌套:前置逻辑从外到内,后置逻辑从内到外,handler 永远在最内层。UnaryInterceptor 与 StreamInterceptor 各自成链,不要混用。做系统设计时,先画一遍洋葱图,再决定注册顺序,很多所谓的神秘问题都能避免。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/966/

(0)
上一篇 4小时前
下一篇 4小时前

相关推荐