Files
cs-note/hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器.md
T
2026-05-24 11:42:38 +08:00

14 KiB
Raw Blame History

tags, create time
tags create time
gRPC
Interceptor
Middleware
Go
2026-05-18 10:00

Unary 与 Stream 拦截器

概述

Interceptor 是 gRPC 的「插件系统」——在每个 RPC 调用执行前后注入逻辑。它和 HTTP middleware 概念类似,但接口更底层、更灵活。本文档完整覆盖服务端和客户端的 Unary / Stream 拦截器签名、链式调用原理、Recovery、错误码映射等核心模式——理解这些是你实现鉴权、日志、重试的前提。

[!tip] Interceptor 是单例 Interceptor 在 server/client 初始化时注册一次,之后对每个请求生效。不要在 interceptor 里持有 per-request 状态。

正文

Unary Interceptor 签名

Unary(普通 RPC)拦截器的核心类型如下:

type UnaryServerInterceptor func(
	ctx context.Context,
	req interface{},
	info *UnaryServerInfo,
	handler UnaryHandler,
) (interface{}, error)

type UnaryServerInfo struct {
	Server     string
	FullMethod string // e.g. "user.v1.UserService/CreateUser"
}

handler 就是真正的业务方法实现。你可以选择在调用 handler 之前做任何事(比如鉴权),也可以在调用之后做后处理(比如记录日志)。关键技巧:你可以在调用 handler 之前或之后插入逻辑。

func MyInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
	// BEFORE: 前置逻辑 — 鉴权、校验、埋点
	result, err := handler(ctx, req)  // 调用真正 handler
	// AFTER: 后置逻辑 — 日志、指标、错误处理
	return result, err
}

Stream Interceptor 签名

Streaming RPC 的拦截器有所不同,因为数据是通过流传递的:

type StreamServerInterceptor func(
	srv interface{},
	ss ServerStream,
	info *StreamServerInfo,
	handler StreamHandler,
) error

注意几点差异:

  • 第一个参数是 srv(服务实例),而非 ctx——stream 的 context 通过 ss.Context() 获取
  • 返回的是整条 stream 的错误,不是单个 message 的错误
  • 你无法直接修改发送/接收的消息内容
func StreamLoggerInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
	start := time.Now()
	err := handler(srv, ss) // 调用实际 stream handler
	log.Printf("stream: %s duration=%v err=%v", info.FullMethod, time.Since(start), err)
	return err
}

Stream 拦截器的核心在于它包裹的是 整个流的生命周期——从客户端建立连接到最后一个消息传递完毕。如果你需要在单条消息级别做拦截(比如过滤消息字段),应该使用 gRPC 的 [Plugin](https://github.com/grpc/grpc-go/tree/master/plugin) 机制或自定义封装。

Stream Interceptor 实战:服务端流鉴权

服务端流的鉴权比 Unary 稍复杂,因为 context 需要从 ServerStream 对象中获取:

func StreamAuthInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
	ctx := ss.Context() // ⚠️ 从 ServerStream 提取 ctx
	token := extractToken(ctx)
	if token == "" {
		return status.Error(codes.Unauthenticated, "missing token")
	}
	return handler(srv, ss) // 校验通过放行
}

关键区别对比:

维度 Unary Interceptor Stream Interceptor
Context 来源 ctx 参数直接传入 ss.Context() 提取
返回值 (interface{}, error) error(整条流)
错误粒度 单个 RPC 调用 整个流生命周期
消息拦截 ❌ 不直接可见 ❌ 不直接可见
适用场景 鉴权、日志、限流 流级审计、批量认证

[!warning] Stream 拦截器的常见陷阱 在 stream handler 返回后(即最后一个 message 已发送),你无法再修改响应。如果需要流结束后做清理工作(如关闭资源),在 handler(...) 之后立即执行即可——它和 Unary 的「后置逻辑」一样自然。

客户端拦截器

服务端拦截器处理入站请求,而客户端拦截器包裹出站调用。它们的签名略有不同:

// 客户端 Unary
type UnaryClientInterceptor func(
	ctx context.Context,
	method string,
	req any,
	reply any,
	cc *grpc.ClientConn,
	invoker grpc.UnaryInvoker,
	opts ...grpc.CallOption,
) error

// 客户端 Stream
type StreamClientInterceptor func(
	ctx context.Context,
	desc *StreamDesc,
	cc *grpc.ClientConn,
	method string,
	streamer grpc.Streamer,
	opts ...grpc.CallOption,
) (grpc.ClientStream, error)

关键差异:

  • method 是路径名如 /user.v1.UserService/CreateUser,非完整 method string
  • req 和 reply 都是 any——你可以反序列化后检查响应内容
  • opts ...grpc.CallOption 允许链式追加 CallOption(比如超时、metadata)
  • Client Stream Interceptor 返回 grpc.ClientStream,而非 error——真正的错误在后续收发消息时抛出
func TimeoutInterceptor(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
	ctx, cancel := context.WithTimeout(ctx, time.Second*5)
	defer cancel()
	return invoker(ctx, method, req, reply, cc, opts...)
}

这段代码自动为每个 RPC 调用添加 5 秒超时,无需手动在每个 call 中设置——这是客户端拦截器最常见的用途之一。

手动构建 Interceptor Chain

gRPC Go 原生支持链式调用,我们先手动实现一个 chain 来理解其原理:

func chainUnaryInterceptors(interceptors ...grpc.UnaryServerInterceptor) grpc.UnaryServerInterceptor {
	n := len(interceptors)
	return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
		ch := handler
		for i := n - 1; i >= 0; i-- {
			finalHandler := ch
			ch = func(c context.Context, r interface{}) (interface{}, error) {
				return interceptors[i](c, r, info, finalHandler)
			}
		}
		return ch(ctx, req)
	}
}

这段代码的关键在于从右到左包裹——最后一个 interceptor 最先被传入,离 handler 最近。请求进来时执行顺序是 A → B → C → handler,返回时反向通过每一层。外层 interceptor 能捕获内层的一切异常(包括 panic 和 error),这正是链式拦截器的核心设计。

[!question] 为什么循环要从 n-1 到 0? 因为最后一个 interceptor 应该最先执行(最靠近 handler),这样才能保证第一个 interceptor 在最外层捕获所有下游异常。

gRPC 官方推荐方式

实际使用中直接使用 gRPC 内置的 chain 函数:

server := grpc.NewServer(
	grpc.ChainUnaryInterceptor(
		logInterceptor,        // 第 1 层(最外层)
		authInterceptor,       // 第 2 层
		recoveryInterceptor,   // 第 3 层(最内层)
	),
	grpc.ChainStreamInterceptor(
		logStreamInterceptor,
		authStreamInterceptor,
	),
)

执行顺序与手动 chain 一致:请求到达时从左到右依次进入每一层(A → B → C → handler),返回时反向退出。最外层最先看到请求、最后看到响应。recovery interceptor 放在最内侧以捕获所有 panic。如果 recovery 放最外侧,它会先捕获其他 interceptor 抛出的异常而非让业务处理——这些不是 bug,而是不满足条件的正常错误流。

完整示例:Request Logger

下面是一个实用的请求日志 interceptor:

func RequestLogger(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
	start := time.Now()
	resp, err := handler(ctx, req)
	dur := time.Since(start)

	log.Printf("rpc: %s %s %.2fs err=%v",
		info.FullMethod,
		reflect.TypeOf(req),
		dur.Seconds(),
		err,
	)
	return resp, err
}

这个 interceptor 做了三件事:记录开始时间、调用 handler、打印耗时和错误信息。它可以作为所有 interceptor 链的基础层。

实战示例:Panic Recovery

服务崩掉一个 handler 不应影响整个进程,用 recover() 兜底:

func RecoveryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
	defer func() {
		if r := recover(); r != nil {
			log.Error("panic recovered", "error", r, "method", info.FullMethod)
		}
	}()
	return handler(ctx, req)
}

[!tip] 核心要点

  • 将 handler 放在 defer 之后调用(而非 defer 中),这样 defer 块内的 recover 才能捕获到 handler 的 panic
  • 如果只 log 不处理,下游业务方法收到的是 nil response + nil error——这通常不理想。生产环境可以返回一个 Internal 错误码或恢复默认值

为什么 recovery 必须放最内层?

如果把 recovery 放在最外侧,它会吞掉其他 interceptor(比如 auth)主动返回的错误——这些不是 bug,不该被 recover。所以 recover 应该离 handler 最近,确保只捕获真正的 panic。

错误处理规范

Interceptor 中返回错误时,不要直接返回 errors.New——要用 status.Errorf 映射为 gRPC status code:

import "google.golang.org/grpc/status"

// ✗ 错误做法
return nil, errors.New("user not found")

// ✓ 正确做法
return nil, status.Error(codes.NotFound, "user not found")

// ✓ 带细节的正确做法
return nil, status.Errorf(codes.InvalidArgument, "invalid email: %v", err)
场景 推荐 Code 含义
参数校验失败 codes.InvalidArgument 客户端传参有问题
资源不存在 codes.NotFound ID 对应的记录不存在
未认证 codes.Unauthenticated Token 缺失或无效
无权限 codes.PermissionDenied 认证通过但无权访问
超时 codes.DeadlineExceeded 处理时间超出限制
内部错误 codes.Internal 服务端意外 panic 或 DB 故障
限流 codes.ResourceExhausted 超出速率上限

[!tip] Client 侧重试判定 客户端 interceptor(如重试)依据 status code 决定是否重试:只有 Unavailable、DeadlineExceeded、ResourceExhausted 等可恢复 code 才触发重试。错误的 code 映射会导致不该重试的请求被反复发送。

Interceptor vs StatsHandler

维度 Interceptor StatsHandler
能力 修改 req/res、控制流程 纯观测(metrics/tracing)
可写 可以改返回值 只读
性能 较高开销 更低(异步)
适用 Auth, Recovery, RateLimit Metrics, Tracing, Profiling

如果你在追求高性能的可观测性,优先选 StatsHandler;如果需要修改请求/响应或控制执行流程,Interceptor 是唯一选择。

Context 传递规则(进阶)

Interceptor 可以向 context 注入信息(如用户身份、trace ID),下游 handler 通过 context.Value 读取。核心原则如下:

原则 说明
Key 类型专用 value key 必须定义为不可比较的 struct(如 ctxKey),避免包间冲突
不传敏感数据 原始密码、完整 token 等不应放入 context value——解析后的 claims 可以
Chain 中唯一 如果上游已注入相同 key 的 value,下游会覆盖它
避免阻塞 不要在 interceptor 中做耗时操作,否则会影响所有下游请求
超时感知 从父 ctx 派生的子 ctx 继承 deadline,chain 中每个步骤应尊重已有超时

服务端:从 Metadata 提取身份信息

const authMetadataKey = "authorization"

func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
	token := extractToken(ctx)
	if token == "" {
		return nil, status.Error(codes.Unauthenticated, "missing token")
	}

	claims, err := jwt.Parse(token)
	if err != nil {
		return nil, status.Error(codes.Unauthenticated, "invalid token")
	}

	ctx = context.WithValue(ctx, ctxKey{}, claims)
	return handler(ctx, req)
}

func extractToken(ctx context.Context) string {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return ""
	}
	values := md.Get(authMetadataKey)
	if len(values) == 0 {
		return ""
	}
	// 常见格式: "Bearer <token>"
	token := values[0]
	if strings.HasPrefix(token, "Bearer ") {
		token = token[7:]
	}
	return token
}

服务端通过 metadata.FromIncomingContext 从 incoming 请求中提取 HTTP header(在 gRPC 协议中会被序列化为 metadata key),再写入 context 供下游 handler 使用。

客户端:向 Metadata 注入 Token

func WithAuthToken(ctx context.Context, token string) context.Context {
	md := metadata.Pairs("authorization", "Bearer "+token)
	return metadata.NewOutgoingContext(ctx, md)
}

// 使用时
ctx = WithAuthToken(ctx, myToken)
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})

[!tip] Metadata 大小限制 gRPC 底层基于 HTTP/2,metadata 总大小默认限制为 8KB。如果超过会报错 grpc: trying to send message exceeds the limit。不要将大段信息放在 metadata 中——考虑用 request body 或专门的配置接口。

常见 Interceptor 模式总结

flowchart LR
    subgraph ClientChain["客户端链"]
        A1["Timeout\n自动超时"] --> A2["Retry\n错误重试"]
    end

    subgraph ServerChain["服务端链"]
        B1["Recovery\nPanic 兜底"] --> B2["Auth\n鉴权校验"] --> B3["Logger\n记录耗时"]
    end

    ClientChain -->|"gRPC call"| ServerChain

    style A1 fill:#00B6BC,color:#fff
    style A2 fill:#FFD43B
    style B1 fill:#EE5A24,color:#fff
    style B2 fill:#FFD43B
    style B3 fill:#00B6BC,color:#fff

一个典型生产环境的 interceptor chain 结构如上:客户端侧做超时控制、重试容错;服务端侧做 panic 恢复、鉴权和日志。每一层职责单一,便于测试和维护。

关联笔记

  • hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权
  • hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪