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

372 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [gRPC, Interceptor, Middleware, Go]
create time: 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)拦截器的核心类型如下:
```go
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 之前或之后插入逻辑**。
```go
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 的拦截器有所不同,因为数据是通过流传递的:
```go
type StreamServerInterceptor func(
srv interface{},
ss ServerStream,
info *StreamServerInfo,
handler StreamHandler,
) error
```
注意几点差异:
- 第一个参数是 `srv`(服务实例),而非 `ctx`——stream 的 context 通过 `ss.Context()` 获取
- 返回的是整条 stream 的错误,不是单个 message 的错误
- 你无法直接修改发送/接收的消息内容
```go
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 对象中获取:
```go
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 的「后置逻辑」一样自然。
### 客户端拦截器
服务端拦截器处理入站请求,而客户端拦截器包裹出站调用。它们的签名略有不同:
```go
// 客户端 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`——真正的错误在后续收发消息时抛出
```go
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 来理解其原理:
```go
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 函数:
```go
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:
```go
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()` 兜底:
```go
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:
```go
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 提取身份信息
```go
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
```go
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 模式总结
```mermaid
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-日志与链路追踪]]