拦截器:日志、鉴权、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。

笔记加载中…