拦截器:日志、鉴权、traceId 与指标
鉴权、日志、耗时统计、traceId 透传这几件事如果写在每个方法里,一定会漏——漏掉的那个方法就是事故入口。gRPC 把它们统一成拦截器(interceptor):一元与流式各一套、服务端与客户端各一套,注册在服务或连接级别,对所有方法生效。
四个签名:一元与流的差别
// 服务端与客户端各有一套:一元是 Unary*,流式是 Stream*
type UnaryServerInterceptor func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error)
type StreamServerInterceptor func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error
type UnaryClientInterceptor func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error
type StreamClientInterceptor func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error)
三处关键差别:一元拦截器直接拿到 req 与响应,流拦截器拿到的是 grpc.ServerStream(想看消息就得包装它);一元用 handler(ctx, req) 继续调用链,流用 handler(srv, ss);流拦截器在建流与流结束时各执行一次,不是每条消息一次。
一元拦截器:鉴权 + traceId + 日志
type traceKey struct{}
// 鉴权:健康检查与反射服务必须放行,否则 K8s 探针和 grpcurl 全部失败
func AuthUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if strings.HasPrefix(info.FullMethod, "/grpc.health") || strings.HasPrefix(info.FullMethod, "/grpc.reflection") {
return handler(ctx, req)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "缺少 metadata")
}
if token := md.Get("authorization"); len(token) == 0 || !verifyToken(token[0]) {
return nil, status.Error(codes.Unauthenticated, "令牌无效")
}
return handler(ctx, req)
}
// traceId + 日志 + 指标:一次调用只写这一处
func TraceUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
traceID := ""
if v := md.Get("x-trace-id"); len(v) > 0 {
traceID = v[0]
}
if traceID == "" {
traceID = newTraceID() // 上游没带就自己生成,保证日志能串起来
}
start := time.Now()
resp, err := handler(context.WithValue(ctx, traceKey{}, traceID), req)
log.Printf("trace=%s method=%s code=%s cost=%s",
traceID, info.FullMethod, status.Code(err), time.Since(start)) // status.Code(nil) == codes.OK
rpcTotal.WithLabelValues(info.FullMethod, status.Code(err).String()).Inc()
return resp, err
}
流拦截器:必须包装 ServerStream
流拦截器拿不到消息体,要挂 RecvMsg/SendMsg 钩子就得包一层,并把包装后的流传给 handler:
type wrappedStream struct{ grpc.ServerStream } // 内嵌后只覆盖需要钩子的方法
func (w *wrappedStream) RecvMsg(m any) error {
err := w.ServerStream.RecvMsg(m)
if err != nil && !errors.Is(err, io.EOF) {
log.Printf("recv 失败: %v", err)
}
return err
}
func TraceStream(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
start := time.Now()
err := handler(srv, &wrappedStream{ServerStream: ss}) // 传原始 ss 的话钩子永不触发
log.Printf("method=%s serverStream=%v clientStream=%v cost=%s code=%s", info.FullMethod, info.IsServerStream, info.IsClientStream, time.Since(start), status.Code(err))
return err
}
panic 要自己兜:gRPC 不会替服务端 recover,一个越界下标就能让进程退出(连带该机所有长连接)。放在最外层拦截器里,利用一元拦截器的具名返回值:
func RecoverUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
defer func() {
if r := recover(); r != nil {
log.Printf("panic: %v\n%s", r, debug.Stack())
err = status.Error(codes.Internal, "服务内部错误") // 异常细节只进日志
}
}()
return handler(ctx, req)
}
注册与顺序
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(TraceUnary, RecoverUnary, AuthUnary), // 左到右 = 外到内
grpc.ChainStreamInterceptor(TraceStream, AuthStream),
)
ChainUnaryInterceptor 的顺序就是洋葱顺序:越靠前越外层。日志放最外才能记录被鉴权拒绝的请求,鉴权放最内能省掉无谓开销。客户端一侧用 grpc.WithChainUnaryInterceptor(TraceClient) 注册,拦截器负责注入 traceId 并原样透传 opts:
func TraceClient(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
id, _ := ctx.Value(traceKey{}).(string)
ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", id) // 空值也无害,服务端会补一个
return invoker(ctx, method, req, reply, cc, opts...) // 少写 opts 会静默丢掉 CallOption
}
一元与流拦截器对比
| 维度 | 一元拦截器 | 流拦截器 |
|---|---|---|
| 执行次数 | 每次调用一次 | 每条流一次(不是每条消息) |
| 能否看到消息体 | 能,直接是 req 与响应 | 不能,需要包装 ServerStream |
| 继续调用链 | handler(ctx, req) | handler(srv, ss) |
| 错误获取点 | handler 的返回值 | handler 的返回值(流结束后) |
| 典型用途 | 鉴权、日志、指标、参数校验 | 建流鉴权、连接级限流、消息级审计 |
常见坑
- 流拦截器的两个典型误用:把原始
ss传给handler会让包装类白写、钩子永不触发;在里面用defer统计"请求耗时"则会把横跨几小时的常驻流算成一次请求,单条消息延迟要在包装的SendMsg/RecvMsg里统计。 - 指标标签用
err.Error():info.FullMethod是有限集合可以打标签,错误文案不行——它会把 Prometheus 的标签基数打爆。 - 鉴权拦截器挡住 health/reflection:
grpcurl、K8s 探针一起失败,务必留白名单。 - 拦截器里再发 RPC 用
context.Background():deadline 与 traceId 一起丢掉,要用传入的ctx。 - recover 之后返回普通 error:要返回
status.Error(codes.Internal, ...),裸 error 会被归成Unknown(第 06 章)。
小结:横切关注点统一放进拦截器——一元拦截器直接拿 req 与响应,流拦截器要包装 grpc.ServerStream 才能挂 SendMsg/RecvMsg 钩子;ChainUnaryInterceptor 的顺序就是洋葱顺序,日志放最外、鉴权放最内并给 health/reflection 留白名单;客户端拦截器负责注入 traceId 并原样透传 opts,panic 兜底要靠自己 recover 后返回 Internal。