先弄懂 metadata 与 context 的关系
写 gRPC 服务的时候,很多人会先碰到一个困惑:以前在 HTTP 接口里往 header 塞一个 token,服务端从 request header 里拿出来就行;换成 gRPC 之后,客户端和服务端之间的额外信息到底放在哪里?答案就是 metadata。这篇文章会从 gRPC Go 的 metadata 传递机制讲起,重点解决两个实际问题:怎么在请求头中携带认证信息,怎么把 trace ID 稳定地透传到下游服务。

metadata 在 gRPC 中扮演的角色,其实和 HTTP header 非常像,只是它和 context 深度绑定,所以刚上手时会觉得有点绕。真正把概念理顺之后,你会发现它比 HTTP header 更简单,也更不容易用错。
gRPC Go 中 metadata 的类型是 metadata.MD,本质是 map[string][]string。它与 context 绑定的方式非常特殊:客户端把要发送给服务端的 metadata 放进 outgoing context,服务端则从 incoming context 中读取。代码中常用的是 metadata.NewOutgoingContext 和 metadata.FromIncomingContext。
很多新人的误区是,把 metadata 理解为 gRPC 请求对象上的一个字段。实际上,在 Unary RPC 的调用链里,metadata 从来不会出现在服务方法签名的参数里,它永远藏在 context.Context 中。只有当你把 context 传入 invoker 或 handler 时,metadata 才会跟着走。
这里要特别强调 AppendToOutgoingContext 的使用方式:它返回一个新的 context,而不是在原地追加。
// 正确用法
ctx = metadata.AppendToOutgoingContext(ctx, "k", "v")
// 错误用法:把返回值丢掉
metadata.AppendToOutgoingContext(ctx, "k", "v")
很多线上问题就是这一行丢掉的。
metadata 的 key 和 value 有哪些约束
你可以在 metadata 里放任意 key-value,但不是完全没有规矩。gRPC 官方规定 metadata key 必须由小写字母、数字、下划线、点和连字符组成,并且不能以 grpc- 开头。value 是字符串;如果 value 是二进制数据,key 必须以 -bin 结尾,并且 value 需要自行 base64 编码。
如果你的 key 用了大写,或者把二进制数据直接放进普通 key,gRPC 底层可能不会报错,但在传输或接收时会出现不可预期的问题。最稳妥的做法是全局统一小写,比如 authorization、x-trace-id。
| 类型 | key 示例 | value 形式 | 典型用途 |
|---|---|---|---|
| 普通 metadata | authorization、x-trace-id | 直接字符串 | token、trace ID、地域标识 |
| binary metadata | payload-bin、token-bin | base64 编码后的字符串 | protobuf 序列化、加密扩展 |
大部分场景用普通 metadata 就足够了。如果你发现自己要把一个反序列化之后的对象放进去,最好先停下来想想:这个信息是不是应该放进请求体?
在客户端拦截器中注入认证信息
假设服务要求每次请求都携带 Bearer Token。如果到处手写 metadata.Pairs,代码会非常冗余。更合理的做法是在 client 启动的时候挂一个拦截器,在 invoker 执行前把 token 和 trace ID 追加到 outgoing context。
func clientMetadataInterceptor(token string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any,
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx,
"authorization", "Bearer "+token,
"x-trace-id", extractTraceID(ctx),
)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
注意这里用的是 AppendToOutgoingContext,它会保留 context 里已有的 outgoing metadata,如果改用 NewOutgoingContext,之前由其他拦截器写入的 metadata 都会被清空。
认证信息不一定要从函数参数传入。你可以在 context 中放入一个 tokenProvider,拦截器在每次请求发出前再从 provider 里取一次 token。这样可以支持 token 刷新,也能避免客户端拦截器长时间持有过期 token。
服务端怎么读取并校验 metadata
服务端在拦截器或 handler 中通过 metadata.FromIncomingContext(ctx) 拿到 MD。拿到之后,先取 authorization 字段,再判断是否满足认证策略。
func serverAuthInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
authValues := md.Get("authorization")
if len(authValues) == 0 || !isValidToken(authValues[0]) {
return nil, status.Error(codes.Unauthenticated, "missing or invalid token")
}
return handler(ctx, req)
}
}
isValidToken 只是一个占位实现,在实际服务里要对接 JWT 解析、签名校验或远程鉴权。需要注意 md.Get 返回切片,不要假设一定只有一个值。
不要在 handler 里做认证。认证属于横切关注点,应该收敛在拦截器里,否则每个业务方法都要重复写,而且容易漏掉入口。
追踪 ID 总是丢:metadata 传递里的几个真实陷阱
trace ID 的传递逻辑看起来和 token 一样,但实际上更容易出问题,因为链路追踪要求的是保持不丢,而不是每次注入。
最常见的故障是把 trace ID 丢了。比如某个服务的客户端拦截器这样写:
md := metadata.Pairs("x-trace-id", "123")
ctx = metadata.NewOutgoingContext(ctx, md)
这段代码看起来没问题,实际上下游收到的 metadata 里可能只有 x-trace-id。如果之前拦截器已经把认证信息放进了 outgoing context,NewOutgoingContext 会把它们全部替换掉。
另一个常见错误是忘记接收 AppendToOutgoingContext 的返回值,前面已经提过。还有服务端把 incoming metadata 拿过来之后,不经过任何过滤就直接写入下游的 outgoing context,虽然能解决“丢”的问题,但下游会莫名多出一些内部 header,比如 :authority、content-type。这种做法不够干净。
正确的透传方式应该是先复制一份 MD,再添加或更新 key:
func propagateTraceID(ctx context.Context, traceID string) context.Context {
md, _ := metadata.FromIncomingContext(ctx)
md = md.Copy()
md.Set("x-trace-id", traceID)
return metadata.NewOutgoingContext(ctx, md)
}
这段代码适合在服务端处理完请求后继续调用下游时使用,或者在 client 拦截器里保留上游传入的 trace ID。核心思想不是从零构造 metadata,而是基于原有信息做增量修改。
gRPC Go 提供了 metadata.Join,它可以把多个 MD 合并成一个新 MD。在多个拦截器需要协作时,Join 比手动遍历 map 更容易读。但要注意 Join 返回的是新 MD,不会修改参与合并的原始对象。
流式 RPC 的 metadata 要控制时序
unary 调用里 metadata 相对简单,流式 RPC 则需要额外注意 header 和 trailer 的发送时机。在 gRPC Go 中,服务端可以通过 grpc.SetHeader 设置响应 metadata,通过 grpc.SetTrailer 设置 trailer。
对于 server streaming,SetHeader 必须在第一个消息通过 stream.Send 发送之前调用。如果已经 Send 了,再想设置的新 header 其实已经太晚,客户端读取不到。所以建议在 handler 一开头就设置好所有 header,再开始发送消息。
trailer 则更宽松,它会在 RPC 结束时随最后的 HEADERS 帧发出,通常用于传递最终的错误状态或服务端内部标识。一个典型的例子是,业务同学在 stream handler 里先循环发送几条消息,最后才 SetHeader,结果客户端一直等不到服务端标识,排查了很久。
设计 metadata 时应该注意什么
最后整理几条实践建议,算是我在项目里看到最容易踩的地方。
| 容易踩的坏味道 | 推荐做法 |
|---|---|
| key 大小写混用 | 统一定义为小写常量,放公共包 |
| 每个调用都手动构造 metadata | 用拦截器统一处理 |
| 把大对象塞进 metadata | 改用请求体或二进制 metadata |
| 不保留旧 metadata 直接覆盖 | 先 Copy 再 Set,或用 Append |
| 流式 handler 里后置 SetHeader | 在发送第一个消息前完成 header 设置 |
- 先定协议:确认哪些 key 需要跨服务透传,哪些只在当前服务使用。
- 建立公共库:在共享的 Go 包中维护 metadata key 的常量,以及认证、trace 的拦截器实现。
- 补全测试:写几个集成测试,验证 metadata 在 unary、server streaming、client streaming 下都能被正确传递。
- 留好 Debug 手段:在服务端打印 incoming metadata 的 key 名(不要打 value),方便排查链路问题。
说到底,metadata 传递机制本质上就是 context 的一种约定用法,真正让你上手的不是记忆函数名,而是理解数据流向。认证信息、trace ID 这类基础设施信息,用拦截器统一管理,比每个业务方法里手工操作可靠得多。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/1014/