Init
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
---
|
||||
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-日志与链路追踪]]
|
||||
@@ -0,0 +1,291 @@
|
||||
---
|
||||
tags: [gRPC, Metadata, Auth, TLS, Security, JWT, Interceptor]
|
||||
create time: 2026-05-11 16:30
|
||||
---
|
||||
|
||||
# 元数据与鉴权
|
||||
|
||||
## 概述
|
||||
|
||||
Metadata 是 gRPC 的请求头(HTTP/2 headers)。你可以在 metadata 里放任何键值对,最经典的用途就是传递认证 token、租户 ID、追踪 ID。本篇讲清楚 metadata 的读写机制和几种常见的鉴权策略。
|
||||
|
||||
## 正文
|
||||
|
||||
### Metadata 基础
|
||||
|
||||
服务端读取 metadata:
|
||||
|
||||
```go
|
||||
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unauthenticated, "no metadata")
|
||||
}
|
||||
|
||||
tokens := md.Get("authorization")
|
||||
token := ""
|
||||
if len(tokens) > 0 {
|
||||
token = tokens[0]
|
||||
}
|
||||
// validate token...
|
||||
_ = token
|
||||
return &pb.User{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
客户端发送 metadata:
|
||||
|
||||
```go
|
||||
ctx := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-correlation-id", "abc123")
|
||||
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
|
||||
```
|
||||
|
||||
> [!example] Metadata 典型使用场景
|
||||
> | 场景 | Key(推荐前缀) | 用途 |
|
||||
> |------|----------------|------|
|
||||
> | 认证令牌 | `authorization` | Bearer token、API Key |
|
||||
> | 链路追踪 | `x-correlation-id` / `x-trace-id` | 跨服务请求关联 |
|
||||
> | 租户隔离 | `x-tenant-id` | SaaS 多租户识别 |
|
||||
> | 国际化 | `accept-language` | 响应语言偏好 |
|
||||
> | 重试标识 | `x-retry-count` | 服务端限流或降级决策 |
|
||||
> | 自定义超时 | `grpc-timeout` | gRPC 原生支持的超时格式(如 `1S`, `500MS`) |
|
||||
> | 二进制数据 | `x-session-data-bin` | 需要 `-bin` 后缀 + base64 编码 |
|
||||
|
||||
#### Response Metadata
|
||||
|
||||
除了请求 metadata,gRPC 还支持在响应中附加 metadata——比如返回会话信息、速率限制等:
|
||||
|
||||
```go
|
||||
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
|
||||
// ... 业务逻辑 ...
|
||||
|
||||
// 设置响应头(客户端可通过 resp.Header() 读取)
|
||||
err := grpc.SetHeader(ctx, metadata.Pairs(
|
||||
"x-rate-limit", "100",
|
||||
"x-response-time", "42ms",
|
||||
))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pb.User{Name: "Alice"}, nil
|
||||
}
|
||||
|
||||
// trailer 仅在流结束时发送,适合放汇总信息:
|
||||
grpc.SetTrailer(ctx, metadata.Pairs("x-total-cost", "0.003"))
|
||||
```
|
||||
|
||||
客户端读取 Response Header / Trailer:
|
||||
|
||||
```go
|
||||
hdr, _ := resp.Header() // 响应头 map[string][]string
|
||||
trl := resp.Trailer() // 仅包含 SetTrailer 设置的 key
|
||||
```
|
||||
|
||||
### 鉴权请求全链路
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["客户端发送请求\n(metadata + credentials)"] --> B["gRPC 框架层\n合并 Per-RPC Credentials"]
|
||||
B --> C["Server Interceptor Chain\n按注册逆序执行"]
|
||||
C --> D{Auth Interceptor}
|
||||
D -- "无 token" --> E["返回 Unauthenticated\n401"]
|
||||
D -- "token 无效" --> E
|
||||
D -- "token 有效" --> F["解析 claims\n注入 Context"]
|
||||
F --> G{"白名单路由检查"}
|
||||
G -- "公共接口" --> H["直接放行 → Handler"]
|
||||
G -- "受保护接口" --> I["权限校验\nPermissionDenied?"]
|
||||
I -- "无权限" --> J["返回 PermissionDenied\n403"]
|
||||
I -- "有权限" --> H
|
||||
H --> K["Handler 业务逻辑\n返回响应"]
|
||||
|
||||
style D fill:#f9d,stroke:#936
|
||||
style E fill:#fcc,stroke:#c66
|
||||
style J fill:#fcc,stroke:#c66
|
||||
style F fill:#ddf,stroke:#669
|
||||
```
|
||||
|
||||
> [!question 思考一下]
|
||||
> 为什么图中拦截器是按**注册逆序**执行的?这跟 HTTP 中间件的洋葱模型是一个道理——后注册的包裹在最外层,先执行的拦截器拿到的是完整请求上下文,适合做日志记录;先注册的在内层,靠近业务逻辑,适合做精细校验。理解这个顺序有助于调试多拦截器叠加时的行为。
|
||||
|
||||
### Metadata 规范速查表
|
||||
|
||||
| 维度 | 规则 |
|
||||
|------|------|
|
||||
| 编码 | 必须 ASCII(非 ASCII 需要 base64 编码后加 -bin 后缀) |
|
||||
| 大小写 | gRPC Go 统一转小写处理 |
|
||||
| 大小限制 | Default: 8KB max received metadata size |
|
||||
| Binary fields | Key 必须以 `-bin` 结尾,值是 base64 bytes |
|
||||
|
||||
### 自定义鉴权 Interceptor
|
||||
|
||||
将鉴权逻辑抽成 interceptor 的好处是全局生效、无需每个 handler 重复:
|
||||
|
||||
```go
|
||||
func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
|
||||
auths := md.Get("authorization")
|
||||
if len(auths) == 0 {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing auth")
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(auths[0], "Bearer ")
|
||||
claims, err := jwt.Validate(token)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, claimsKey{}, claims)
|
||||
return handler(ctx, req)
|
||||
}
|
||||
```
|
||||
|
||||
这里把解析后的 claims 注入到 context,下游 handler 可以直接使用,避免了在每个接口中重复解析 JWT。
|
||||
|
||||
#### Context Key 防冲突
|
||||
|
||||
用自定义类型作为 context key 是 Go 的最佳实践,避免与其他库产生键名冲突:
|
||||
|
||||
```go
|
||||
// claimsKey 是 unexported type —— context.WithValue 的 key 应当不可导出
|
||||
type claimsKey struct{}
|
||||
|
||||
// 读取时通过包级辅助函数统一取值
|
||||
func GetClaims(ctx context.Context) *jwt.Claims {
|
||||
v := ctx.Value(claimsKey{})
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.(*jwt.Claims)
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] Token 过期 vs 无权访问
|
||||
> Token 无效或过期时返回 `codes.Unauthenticated`(401);Token 有效但权限不足时返回 `codes.PermissionDenied`(403)。语义不同,前端处理方式也不同——前者应跳转到登录页,后者可能显示"无权限"弹窗。
|
||||
|
||||
### Server-Side Streaming 拦截器
|
||||
|
||||
Server Stream(`serverStreaming`)的拦截器签名为 `grpc.StreamServerInterceptor`,鉴权逻辑几乎一致,但需要同时处理请求和响应流:
|
||||
|
||||
```go
|
||||
func StreamAuthInterceptor(srv interface{}, ss grpc.ServerStream,
|
||||
*grpc.StreamServerInfo, grpc.StreamHandler,
|
||||
) error {
|
||||
md, ok := metadata.FromIncomingContext(ss.Context())
|
||||
if !ok {
|
||||
return status.Error(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
|
||||
auths := md.Get("authorization")
|
||||
if len(auths) == 0 || !strings.HasPrefix(auths[0], "Bearer ") {
|
||||
return status.Error(codes.Unauthenticated, "missing auth token")
|
||||
}
|
||||
|
||||
// Token 校验通过后放行到业务 handler
|
||||
return handler(srv, ss)
|
||||
}
|
||||
|
||||
// 注册方式:
|
||||
server := grpc.NewServer(
|
||||
grpc.ChainStreamInterceptor(StreamAuthInterceptor, LoggingStreamInterceptor),
|
||||
)
|
||||
```
|
||||
|
||||
> [!tip] Unary vs Stream 的选择
|
||||
> - Unary Interceptor 处理单请求单响应(绝大多数 RPC 方法)
|
||||
> - Stream Interceptor 处理 Server/Client/Bidirectional Stream
|
||||
> - `grpc.ChainUnaryInterceptor(...)` 和 `grpc.ChainStreamInterceptor(...)` 分别串联多个 interceptor
|
||||
> - **注意**:Chain 对 unary 和 stream 是**分开配置**的,不能混用
|
||||
|
||||
> [!question 思考一下]
|
||||
> Bidirectional Stream(双向流)场景下,token 只需要在连接建立时校验一次即可——因为 metadata 是在 `Dial` / `NewStream` 阶段发送的,一旦握手成功整个流的生命周期内不再重新认证。这和 WebSocket 的鉴权模型是一致的。
|
||||
|
||||
### Per-RPC Credentials
|
||||
|
||||
当客户端需要在每次调用前动态获取 token(例如自动刷新 accessToken),可以用 Per-RPC Credentials:
|
||||
|
||||
```go
|
||||
type bearerCreds struct {
|
||||
token string
|
||||
}
|
||||
|
||||
func (c bearerCreds) RequireTransportSecurity() bool { return true }
|
||||
func (c bearerCreds) GetPerRPCCredentials() (metadata.MD, error) {
|
||||
return metadata.Pairs("authorization", "Bearer "+c.token), nil
|
||||
}
|
||||
|
||||
// 使用:每个 call 可以传入不同的 credential
|
||||
resp, _ := client.GetUser(
|
||||
ctx,
|
||||
&pb.GetUserRequest{},
|
||||
grpc.Creds(bearerCreds{token: getFreshToken()}),
|
||||
)
|
||||
```
|
||||
|
||||
优势是每个 call 可以用不同的 credential,非常适合 token 刷新场景。缺点是需要为每个 call 单独传 option,不如 interceptor 全局生效方便。两者结合使用时,interceptor 放在外层做校验,Per-RPC credentials 负责提供最新 token。
|
||||
|
||||
### TLS / mTLS
|
||||
|
||||
生产环境必须启用 TLS,gRPC Go 支持完整的证书体系:
|
||||
|
||||
```go
|
||||
cert, _ := tls.LoadX509KeyPair("server.crt", "server.key")
|
||||
ca, _ := os.ReadFile("ca.crt")
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(ca)
|
||||
|
||||
creds := credentials.NewTLS(&tls.Config{
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, // mTLS 模式
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientCAs: pool,
|
||||
})
|
||||
|
||||
server := grpc.NewServer(grpc.Creds(creds))
|
||||
```
|
||||
|
||||
Certificate 级别的选择:
|
||||
- `tls.NoClientCert`:仅服务端验证(常规 TLS)
|
||||
- `tls.VerifyClientCertIfGiven`:客户端有证书就验证,没有也行
|
||||
- `tls.RequireAnyClientCert`:必须有证书但不校验内容
|
||||
- `tls.RequireAndVerifyClientCert`:mTLS,严格双向校验
|
||||
|
||||
### 白名单路由
|
||||
|
||||
不是所有接口都需要鉴权——health check、ping 之类的公共接口应该放行:
|
||||
|
||||
```go
|
||||
func ServiceAuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
allowedPublic := map[string]bool{
|
||||
"/health.v1.Health/Check": true,
|
||||
"/status.v1.Status/Ping": true,
|
||||
}
|
||||
|
||||
if allowedPublic[info.FullMethod] {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
// 走正常鉴权逻辑...
|
||||
return handler(ctx, req)
|
||||
}
|
||||
```
|
||||
|
||||
### 最佳实践 Checklist
|
||||
|
||||
- [ ] **公共接口排除鉴权** — health check、登录等接口不应走 auth interceptor
|
||||
- [ ] **区分 Unauthenticated (401) 和 PermissionDenied (403)** — token 问题用前者,权限问题用后者
|
||||
- [ ] **Context Key 使用 unexported type** — 避免与其他库产生键名冲突
|
||||
- [ ] **metadata key 统一小写** — gRPC Go 转小写存储,硬编码时保持一致
|
||||
- [ ] **禁止 `WithInsecure`** — production 必须 TLS,开发环境可用 `credentials.NewTLS(nil)`
|
||||
- [ ] **敏感数据不放 metadata** — metadata 最终会落入 HTTP/2 headers,可能被代理或日志记录
|
||||
- [ ] **binary field 加 `-bin` 后缀** — 非 ASCII 值必须 base64 编码 + `-bin` 命名约定
|
||||
- [ ] **控制 metadata 大小** — 默认限制 8KB,超限会被拒绝
|
||||
- [ ] **Interceptor Chain 分层设计** — 外层做认证授权(auth),内层做日志追踪(logging)
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]]
|
||||
- [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪]]
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
tags: [gRPC, Logging, Tracing, OpenTelemetry, Observability]
|
||||
create time: 2026-05-11 17:00
|
||||
---
|
||||
|
||||
# 日志与链路追踪
|
||||
|
||||
## 概述
|
||||
|
||||
微服务的 observability 三支柱:Metrics、Logs、Traces。gRPC 生态已经为这三者提供了完善的工具链。不需要手写 logging interceptor——用成熟的库就行。但你需要理解这些库背后是怎么工作的,才能正确配置和调试。
|
||||
|
||||
## 正文
|
||||
|
||||
### 自动埋点(OpenTelemetry)
|
||||
|
||||
最简单且最可靠的方式是用官方 OTel SDK,一行代码搞定 span 创建、trace context propagation、RPC metrics 采集:
|
||||
|
||||
```go
|
||||
import (
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
)
|
||||
|
||||
server := grpc.NewServer(
|
||||
grpc.StatsHandler(otelgrpc.NewServerHandler()),
|
||||
)
|
||||
|
||||
conn, _ := grpc.Dial(addr, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
|
||||
```
|
||||
|
||||
这是生产环境的首选方案。你只需引入包即可自动获得完整的分布式追踪能力。
|
||||
|
||||
> [!tip] StatsHandler vs Interceptor
|
||||
> OTel 内部使用 StatsHandler API,不是 Interceptor。这意味着它对业务逻辑零侵入、性能开销更低。这也是为什么推荐优先选 StatsHandler 做可观测性。
|
||||
|
||||
### 日志 Interceptor(手动实现)
|
||||
|
||||
虽然 OTel 足够好,但有时候你需要更细粒度的控制,比如把某些字段打到自己的结构化日志系统里:
|
||||
|
||||
```go
|
||||
func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
start := time.Now()
|
||||
md, _ := metadata.FromIncomingContext(ctx)
|
||||
traceID := getTraceID(md)
|
||||
|
||||
resp, err := handler(ctx, req) // 调用真正的业务 handler
|
||||
|
||||
log.Info("rpc_complete",
|
||||
"method", info.FullMethod,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"error", err,
|
||||
"trace_id", traceID,
|
||||
"status_code", status.Code(err),
|
||||
)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// getTraceID 从 metadata 中提取 trace ID
|
||||
func getTraceID(md metadata.MD) string {
|
||||
tpp := md.Get("traceparent")
|
||||
if len(tpp) > 0 {
|
||||
return extractTraceID(tpp[0])
|
||||
}
|
||||
xid := md.Get("x-trace-id")
|
||||
if len(xid) > 0 {
|
||||
return xid[0]
|
||||
}
|
||||
return ulid.Make().String() // 无上游 trace,生成根 span
|
||||
}
|
||||
|
||||
// extractTraceID 解析 W3C Trace Context 格式
|
||||
func extractTraceID(traceParent string) string {
|
||||
parts := strings.Split(traceParent, "-")
|
||||
if len(parts) >= 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
这段代码展示了完整的 logging interceptor 模式——先提取 trace ID,调用 handler 后记录耗时和错误。`getTraceID` 的辅助逻辑做了三件事:优先解析 W3C `traceparent`;回退到 `x-trace-id`;不存在时生成新 root span。这样既能融入分布式链路,也能独立产生新链路。
|
||||
|
||||
> [!question] 为什么在 handler 之后才打日志?
|
||||
> 因为我们需要知道请求的处理结果(耗时、错误)才能记录完整信息。如果在 handler 之前打日志,你只能拿到请求参数而拿不到响应。这也是 interceptor "洋葱模型"的核心优势——你可以包裹住 handler 的整个执行生命周期。这也正是 [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 中强调的 chain 原理。
|
||||
|
||||
### Trace ID 透传机制
|
||||
|
||||
gRPC 通过 metadata 传递 W3C Trace Context 标准格式——这是目前业界最通用的分布式追踪协议。Trace Context 由四个部分组成:
|
||||
|
||||
```
|
||||
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
|
||||
│ ───────────────────── ────────────────────── ──
|
||||
│ trace-id span-id flags
|
||||
│ (16 bytes) (8 bytes) (1 byte)
|
||||
└── version (00)
|
||||
```
|
||||
|
||||
| 字段 | 长度 | 说明 |
|
||||
|------|------|------|
|
||||
| version | 2 hex chars | 当前固定为 `00` |
|
||||
| trace-id | 32 hex chars | 整个链路的唯一标识(所有 span 共享) |
|
||||
| span-id | 16 hex chars | 当前操作的唯一标识 |
|
||||
| flags | 2 hex chars | `01` 表示已采样,`00` 表示未采样 |
|
||||
|
||||
#### 服务端读取上游 Trace ID
|
||||
|
||||
```go
|
||||
md, _ := metadata.FromIncomingContext(ctx)
|
||||
traceParent := md.Get("traceparent")
|
||||
var traceID string
|
||||
if len(traceParent) > 0 {
|
||||
traceID = extractTraceID(traceParent[0]) // 见上节 helper
|
||||
}
|
||||
// traceID 为空 → 当前服务是链路起点,需要生成新 root span
|
||||
```
|
||||
|
||||
#### 客户端向下游注入 Trace ID
|
||||
|
||||
在使用 OpenTelemetry SDK 时,SDK 会自动完成 context propagation(详见本节开头的 `otelgrpc`),但如果你手动构造 metadata,需要这样写:
|
||||
|
||||
```go
|
||||
span := otel.Tracer("my-service").SpanContext()
|
||||
ctx = metadata.AppendToOutgoingContext(
|
||||
ctx,
|
||||
"traceparent", fmt.Sprintf("00-%s-%s-01",
|
||||
span.TraceID().String(),
|
||||
span.SpanID().String(),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
> [!question] 为什么 OTel 不需要手写 traceparent?
|
||||
> OTel 的 `StatsHandler` 底层使用了 Go 的 `stats.Handler` API,它在 RPC 各个生命周期节点(如 `InboundPayload`, `OutboundPayload`)自动读写 metadata、创建 span。**你只需要引入一个包**,框架会帮你处理所有细节。这也是为什么官方强烈推荐使用自动埋点而非手动实现。
|
||||
|
||||
### RPC Metrics 指标清单(OTel 自动产出)
|
||||
|
||||
| Metric | Type | Labels | 用途 |
|
||||
|--------|------|--------|------|
|
||||
| `rpc.server.duration` | Histogram | method, service, status | 服务端延迟分布,用于画 P95/P99 |
|
||||
| `rpc.server.requests.count` | Counter | method, service | 服务端请求总量,监控流量趋势 |
|
||||
| `rpc.server.responses.count` | Counter | method, service, status | 按状态码统计响应数,发现错误突增 |
|
||||
| `rpc.client.duration` | Histogram | method, service, status | 客户端视角延迟,含网络 + 服务端总耗时 |
|
||||
| `rpc.client.attempts.count` | Counter | method, service | 含重试的调用次数,>1 说明有重试发生 |
|
||||
|
||||
这些指标可以直接对接 Prometheus/Grafana,用于构建实时监控面板和告警规则。常见告警示例:
|
||||
- **SLO violation**:`rpc.server_duration{le="0.5"} / rpc_server_requests_count > 0.01` → P99 < 500ms 的 SLA 被击穿超过 1%
|
||||
- **错误突增**:`increase(rpc_server_responses_count{status="Internal"}[5m]) > 10` → 5 分钟内 Internal 错误超 10 次
|
||||
|
||||
> [!question] client duration vs server duration 为什么不同?
|
||||
> server duration 是服务端处理耗时,client duration 包含网络 RTT + 排队 + 服务端处理。如果 client duration >> server duration,说明问题出在网络或客户端侧(比如连接池太小导致排队),而不是服务端逻辑慢。这是排查性能问题的关键区分点。[[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 中的 Interceptor 也可以手动记录 server duration 来做对比验证。
|
||||
|
||||
### 端到端 Observability 架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["客户端服务"]
|
||||
C1["App"] -->|"grpc.Dial WithStatsHandler"| C2["gRPC ClientConn"]
|
||||
end
|
||||
|
||||
subgraph Network["网络层 Service Mesh / LB"]
|
||||
S1["Proxy Collect spans + traceparent"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
G1["gRPC Server"] -->|"otelgrpc StatsHandler"| O1["OTel SDK Create span"]
|
||||
O1 -->|"exporter"| E[("Tracing Backend")]
|
||||
O1 -->|"metrics"| P[("Metrics Backend")]
|
||||
D1["Interceptor Chain logging + trace_id"] -->|"structured log"| L[("Log Store")]
|
||||
end
|
||||
|
||||
C2 -->|"HTTP/2 + traceparent metadata"| S1
|
||||
S1 -->|"forwarded + preserved traces"| G1
|
||||
G1 --> H["handler"]
|
||||
H --> D1
|
||||
|
||||
style C2 fill:#00B6BC,color:#fff
|
||||
style G1 fill:#FFD43B
|
||||
style O1 fill:#EE5A24,color:#fff
|
||||
style S1 fill:#9B59B6,color:#fff
|
||||
```
|
||||
|
||||
数据流说明:
|
||||
1. **客户端**通过 `otelgrpc.NewClientHandler()` 自动创建 client span,并将 `traceparent` 写入 metadata
|
||||
2. **网络层**(如 Envoy、Istio)可以采集 spans 并透传 trace context
|
||||
3. **服务端**的 `otelgrpc.NewServerHandler()` 提取 upstream trace ID,继续串联当前服务的 span
|
||||
4. **自定义 Interceptor** 从 metadata 中提取 traceID,将结构化日志发送到独立日志系统
|
||||
|
||||
> [!tip] 为什么 Metrics 和 Traces 分开?
|
||||
> Metrics 回答"系统怎么样"(P99 延迟多少?错误率多少?),Traces 回答"哪里出问题"(哪个具体的请求链路慢了?)。二者互补——Grafana dashboard 用 metrics 发现异常,JAEGER/Tempo 用 traces 定位根因。
|
||||
|
||||
### 性能考量
|
||||
|
||||
- StatsHandler 比 Interceptor 性能更好——它通过 Go runtime channel 异步上报数据,不阻塞业务 goroutine
|
||||
- 生产环境优先 StatsHandler + OTel,Interceptor 做附加层(如自定义日志格式)
|
||||
- 不要每次都打印 full request/response(太吵),只在 debug level 或采样率下打印
|
||||
- OTel SDK 默认使用 `BatchSpanProcessor`,会将 spans 批量导出。调整 `ScheduleDelayMillis` 和 `ExportTimeoutMillis` 可以权衡延迟与吞吐量
|
||||
|
||||
> [!warning] 日志采样
|
||||
> 全量打印每条 RPC 的详细信息会迅速压垮日志系统。建议对 INFO 级别做采样(如每秒 1%),DEBUG 级别仅在开发环境开启。对于 Trace ID,即使是采样的日志也必须带上——否则无法在日志系统中将分散的采样日志聚合到同一条链路。
|
||||
|
||||
### 最佳实践 Checklist
|
||||
|
||||
- [ ] **StatsHandler 优先** — 可观测性用 OTel StatsHandler,不需要手写 interceptor
|
||||
- [ ] **Trace Context 遵循 W3C 标准** — 统一使用 `traceparent` key,避免各团队自定 header
|
||||
- [ ] **结构化日志带 trace_id** — 即使做了采样,也要保证每条日志可追溯到对应链路
|
||||
- [ ] **Root span 生成新 trace ID** — 服务作为链路起点时,自动生成新 trace ID 而非留空
|
||||
- [ ] **Context Key 防冲突** — 见 [[hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权]] 的 context key 规范
|
||||
- [ ] **Interceptor Chain 分层** — 外层做 auth,内层做 logging(见 [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 的 chain 原理)
|
||||
- [ ] **Metrics 对接 Grafana** — 利用 OTel 产出的标准 metrics,快速搭建 dashboard
|
||||
- [ ] **gRPCurl 注入 traceparent 验证** — 排查 tracing 问题时,手动注入已知 trace ID 验证透传链路
|
||||
- [ ] **区分 client/server duration** — client 视角包含网络 RTT,server 视角只含处理时间
|
||||
- [ ] **控制 metadata 大小** — 每个 traceparent 约 70 bytes,大量 metadata 累计可观,注意默认 8KB 限制
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]]
|
||||
- [[hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权]]
|
||||
Reference in New Issue
Block a user