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

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/