vault backup: 2026-05-18 17:09:17
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
tags: [gRPC, Interceptor, Middleware, Go]
|
||||
create time: 2026-05-11 16:00
|
||||
create time: 2026-05-18 10:00
|
||||
---
|
||||
|
||||
# Unary 与 Stream 拦截器
|
||||
@@ -61,6 +61,45 @@ type StreamServerInterceptor func(
|
||||
- 返回的是整条 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 的「后置逻辑」一样自然。
|
||||
|
||||
### 客户端拦截器
|
||||
|
||||
服务端拦截器处理入站请求,而客户端拦截器包裹出站调用。它们的签名略有不同:
|
||||
@@ -186,8 +225,13 @@ func RecoveryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryS
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] Panic Recovery 必须放最内层
|
||||
> 如果把 recovery 放在最外侧,它会吞掉其他 interceptor(比如 auth)主动返回的错误——这些错误不是 bug,不该被 recover。所以 recover 应该离 handler 最近,确保只捕获真正的 panic。
|
||||
> [!tip] 核心要点
|
||||
> - 将 `handler` 放在 `defer` 之后调用(而非 defer 中),这样 `defer` 块内的 `recover` 才能捕获到 handler 的 panic
|
||||
> - 如果只 log 不处理,下游业务方法收到的是 nil response + nil error——这通常不理想。生产环境可以返回一个 Internal 错误码或恢复默认值
|
||||
|
||||
**为什么 recovery 必须放最内层?**
|
||||
|
||||
如果把 recovery 放在最外侧,它会吞掉其他 interceptor(比如 auth)主动返回的错误——这些不是 bug,不该被 recover。所以 recover 应该离 handler 最近,确保只捕获真正的 panic。
|
||||
|
||||
### 错误处理规范
|
||||
|
||||
@@ -230,29 +274,73 @@ return nil, status.Errorf(codes.InvalidArgument, "invalid email: %v", err)
|
||||
|
||||
如果你在追求高性能的可观测性,优先选 StatsHandler;如果需要修改请求/响应或控制执行流程,Interceptor 是唯一选择。
|
||||
|
||||
### Context 传递规则
|
||||
### Context 传递规则(进阶)
|
||||
|
||||
Interceptor 中可以向 context 注入信息,下游 handler 可以读取。服务端和客户端都有各自的传递方向:
|
||||
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
|
||||
type ctxKey struct{}
|
||||
const authMetadataKey = "authorization"
|
||||
|
||||
func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
token := extractToken(ctx)
|
||||
claims, _ := jwt.Parse(token)
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
**服务端**:从 incoming metadata 中提取身份信息 → 写入 context → 传给 handler
|
||||
**客户端**:从 local context 读取 token → 写入 outgoing metadata → 发送给上游
|
||||
服务端通过 `metadata.FromIncomingContext` 从 incoming 请求中提取 HTTP header(在 gRPC 协议中会被序列化为 metadata key),再写入 context 供下游 handler 使用。
|
||||
|
||||
注意事项:
|
||||
- value key 必须定义为专用不可比较的类型(如上 `ctxKey` struct),避免包间冲突
|
||||
- 不要在 interceptor 里阻塞或做耗时操作,否则会影响所有下游请求
|
||||
- context value 不应传递大对象或敏感明文——token 解析后的 claims 可以传,原始密码不行
|
||||
- 如果上游已经注入了相同 key 的 value,下游会覆盖它——确保 chain 中每个步骤使用唯一 key
|
||||
#### 客户端:向 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 模式总结
|
||||
|
||||
|
||||
Reference in New Issue
Block a user