本节目标:把请求 ID、租户、traceparent 这些横切信息收进拦截器,业务方法只读
Context,不再手工拼Metadata;同时知道拦截器写错时调用会以什么状态失败。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)。
承接的问题
4.1 的 LibraryClient 已经能复用一条 channel,但每次调用仍然要自己决定「带哪些头」。如果让每个业务方法都写一遍 Metadata.put,会出现三类问题:漏带某个头、同一个键在不同方法里拼写不一致、以及服务端拿不到请求上下文只能再往下传参数。重试和灰度上线之后,这些问题会放大:重试必须沿用同一个请求 ID,灰度必须让整条链路看到同一个标记。
gRPC 的拦截器就是为这类横切逻辑准备的。客户端拦截器在 newCall 之后、请求发出之前拿到 Metadata;服务端拦截器在业务方法执行之前拿到请求头,并能包装 ServerCall 改写响应头。Context 则负责把解析后的值从拦截器传到业务代码,避免在每个方法签名里多加一个参数。
这一节先定义 linkd-v3 的元数据键,再写客户端注入、服务端读取与回显,最后讨论拦截器顺序、Context 的清理和几类常见故障。TLS、token 校验和租户授权分别在 10.2、10.3 展开,本节只处理「怎么传、怎么读、怎么回显」。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0。文中的顺序结论和输出都来自本机探针。
核心机制一:Metadata 的键、值与大小上限
Metadata 是一组字符串键值对,键名在 HTTP/2 里就是 header 名,必须是小写 ASCII;大写键会在构造 Metadata.Key 时被规范化或拒绝。gRPC 保留了一些以 grpc- 开头的键,业务自定义键最好带自己的前缀,linkd-v3 统一用 x-。
值的编码分两类:
- ASCII 键:用
Metadata.ASCII_STRING_MARSHALLER,值按 US-ASCII 编码,适合请求 ID、租户 ID、版本号这类短字符串。 - 二进制键:键名以
-bin结尾,用Metadata.BINARY_BYTE_MARSHALLER,值会被 base64 编码后传输,适合证书、序列化后的结构。
不要把中文租户名直接塞进 ASCII 键。ASCII marshaller 不能可靠往返非 ASCII 字符;需要传自由文本时,先在应用层做 URL 编码或 protobuf 序列化,再放进 -bin 键。元数据不是业务数据的通道,大对象应该放在消息体里。
package com.plume.linkd.v3.core.metadata;
import io.grpc.Metadata;
public final class MetadataKeys {
public static final Metadata.Key<String> REQUEST_ID =
Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> TRACEPARENT =
Metadata.Key.of("traceparent", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> TENANT_ID =
Metadata.Key.of("x-tenant-id", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> API_VERSION =
Metadata.Key.of("x-api-version", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> CANARY =
Metadata.Key.of("x-canary", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> AUTHORIZATION =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> IDEMPOTENCY_KEY =
Metadata.Key.of("x-idempotency-key", Metadata.ASCII_STRING_MARSHALLER);
private MetadataKeys() {
}
}
元数据有大小上限。gRPC-Java 1.76.0 的 Netty 服务端默认 maxHeaderListSize 是 8192 字节,客户端也可以用 maxInboundMetadataSize 调整。超过上限的请求会在进入业务代码之前被拒绝;把大 token、完整证书链或调试信息塞进 metadata,是线上 4xx/5xx 的常见来源。
同名键可能有多个值。读取单个值时用 get,需要全部值时用 getAll;不要假设某个键只出现一次。写入时 put 追加,merge 合并另一个 Metadata,removeAll 清空某个键。
核心机制二:客户端拦截器注入 Context
客户端拦截器实现 ClientInterceptor.interceptCall,拿到方法描述符、CallOptions 和下一条 Channel。它返回一个 ClientCall,通常用 ForwardingClientCall.SimpleForwardingClientCall 包住原始调用,在 start 里补 metadata。
package com.plume.linkd.v3.core.metadata;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.Context;
import io.grpc.Deadline;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import java.util.concurrent.TimeUnit;
public final class ContextClientInterceptor implements ClientInterceptor {
public static final Context.Key<RequestContext> REQUEST_CONTEXT =
Context.key("plume.request-context");
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel next) {
RequestContext rc = REQUEST_CONTEXT.get();
if (rc == null) {
throw new IllegalStateException("RequestContext 未设置,拒绝发起调用: "
+ method.getFullMethodName());
}
CallOptions effective = options.getDeadline() == null
? options.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
: options;
Metadata extra = new Metadata();
extra.put(MetadataKeys.REQUEST_ID, rc.requestId());
extra.put(MetadataKeys.TENANT_ID, rc.tenantId());
extra.put(MetadataKeys.TRACEPARENT, rc.trace().toTraceparent());
if (rc.trace().sampled()) {
extra.put(MetadataKeys.CANARY, "v2");
}
return new ForwardingClientCall.SimpleForwardingClientCall<>(
next.newCall(method, effective)) {
@Override
public void start(Listener<RespT> listener, Metadata headers) {
headers.merge(extra);
super.start(listener, headers);
}
};
}
}
几个点:
- 默认总 deadline 在这里兜底。 业务 stub 如果已经
withDeadlineAfter,options.getDeadline()不为空,拦截器不覆盖;没有设置时补 800 ms。这样「忘记设 deadline」不会变成无限等待。 headers.merge(extra)而不是替换。 调用方可能已经放了自定义头,合并能保留它们;同名键会追加,服务端读取时要考虑多值。Context必须在调用前设置。 网关收到 HTTP 请求后构造RequestContext,用Context.current().withValue(...).attach()附着,再发起 gRPC 调用;附着和清理要成对,否则线程池复用时会串上下文。
Context 还负责 deadline 传播。服务端拿到的 Context.current().getDeadline() 是客户端设置的剩余时间;如果要在服务端再调下游,应该用 withDeadline 派生一个更短的预算,而不是重新起一个 800 ms:
package com.plume.linkd.v3.core.metadata;
import io.grpc.Context;
import io.grpc.Deadline;
import java.util.concurrent.Callable;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class HopDeadline {
public static <T> T call(Callable<T> task, ScheduledExecutorService scheduler)
throws Exception {
Deadline hop = Deadline.after(300, TimeUnit.MILLISECONDS);
Deadline parent = Context.current().getDeadline();
if (parent != null && parent.isBefore(hop)) {
hop = parent;
}
return Context.current().withDeadline(hop, scheduler).call(task);
}
}
withDeadline 返回一个 CancellableContext,它会在 deadline 到期时取消自己;传给它的 ScheduledExecutorService 由应用提供,不要用 gRPC 的 EventLoop。call 结束后上下文自动恢复。把阻塞调用提交到线程池时,用 Context.current().wrap(Runnable) 保留上下文:
executor.execute(Context.current().wrap(() -> libraryClient.getBook(isbn)));
核心机制三:服务端拦截器读取、校验与回显
服务端拦截器在业务方法之前执行,职责是「校验 + 放进 Context + 包装响应头」。校验失败要直接 call.close(Status, Metadata) 并返回一个空 listener,不能继续调用 next.startCall,否则业务方法会照常执行。
package com.plume.linkd.v3.core.metadata;
import io.grpc.Context;
import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
public final class ContextServerInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call, Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String requestId = headers.get(MetadataKeys.REQUEST_ID);
String tenantId = headers.get(MetadataKeys.TENANT_ID);
String version = headers.get(MetadataKeys.API_VERSION);
String traceparent = headers.get(MetadataKeys.TRACEPARENT);
if (requestId == null || requestId.isBlank()) {
return reject(call, Status.INVALID_ARGUMENT, "x-request-id is required");
}
if (tenantId == null || tenantId.isBlank()) {
return reject(call, Status.PERMISSION_DENIED, "x-tenant-id is required");
}
if (version != null && !version.equals("v1") && !version.equals("v2")) {
return reject(call, Status.INVALID_ARGUMENT, "unsupported x-api-version: " + version);
}
TraceContext trace = TraceContext.parse(traceparent);
if (trace == null) {
return reject(call, Status.INVALID_ARGUMENT, "invalid traceparent");
}
RequestContext rc = new RequestContext(
requestId, tenantId, trace, Context.current().getDeadline());
Context ctx = Context.current().withValue(ContextClientInterceptor.REQUEST_CONTEXT, rc);
ServerCall<ReqT, RespT> wrapped =
new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void sendHeaders(Metadata responseHeaders) {
responseHeaders.put(MetadataKeys.REQUEST_ID, requestId);
super.sendHeaders(responseHeaders);
}
};
Context previous = ctx.attach();
ServerCall.Listener<ReqT> delegate;
try {
delegate = next.startCall(wrapped, headers);
} finally {
ctx.detach(previous);
}
return new ServerCall.Listener<>() {
@Override
public void onMessage(ReqT message) {
ctx.run(() -> delegate.onMessage(message));
}
@Override
public void onHalfClose() {
ctx.run(delegate::onHalfClose);
}
@Override
public void onCancel() {
ctx.run(delegate::onCancel);
}
@Override
public void onComplete() {
ctx.run(delegate::onComplete);
}
@Override
public void onReady() {
ctx.run(delegate::onReady);
}
};
}
private static <ReqT, RespT> ServerCall.Listener<ReqT> reject(
ServerCall<ReqT, RespT> call, Status status, String message) {
call.close(status.withDescription(message), new Metadata());
return new ServerCall.Listener<>() {
};
}
}
这段代码的关键是 listener 包装。只在 interceptCall 里 attach() 然后 detach() 是不够的:业务方法是在后续的 onMessage/onHalfClose 回调里执行的,那时上下文已经恢复。用 ctx.run(...) 包住每个回调,才能保证业务代码读到的 Context 是这次请求的值。onCancel 和 onComplete 也要包,否则清理逻辑读不到请求 ID。
响应头回显通过 sendHeaders 完成:在业务代码第一次发送响应头之前,把 x-request-id 放进去。客户端可以用原始 ClientCall.Listener.onHeaders 读到它,日志里就能把上下游的请求串起来。
核心机制四:拦截器顺序、异常与边界
拦截器的执行顺序容易记反。1.76.0 的本机探针给出:
服务端 intercept(A).intercept(B)
enter-B
enter-A
leave-A
leave-B
客户端 intercept(A).intercept(B)
enter-B
enter-A
start-B
start-A
也就是说,后添加的拦截器在最外层:它最先看到请求,最后看到响应。这个顺序对「先认证还是先限流」很关键:如果 B 是认证,A 是限流,B 会在限流之前执行;反过来则先限流再认证。团队需要把顺序写进约定,不能依赖阅读代码时的直觉。
拦截器抛异常时,gRPC 会关闭调用,客户端通常看到 UNKNOWN 或 INTERNAL,具体取决于异常发生的阶段和是否有其他拦截器兜底。不要在拦截器里抛未经包装的 NullPointerException;校验失败用 Status.INVALID_ARGUMENT、Status.PERMISSION_DENIED、Status.RESOURCE_EXHAUSTED 这类明确状态,并带上可检索的 description。
Context 的另一个边界是取消。客户端 deadline 到期或主动 cancel 时,服务端的 Context.current().isCancelled() 会变成 true;长时间任务应该在循环里检查它,而不是等下次写响应时才发现没人接收。Context.addListener 可以注册取消回调,但回调线程不确定,不能在里面做重活。
在 linkd-v3 里落地:拦截器注册与 traceparent
linkd-v3 的客户端在 LibraryClient 构造时注册拦截器,服务端在 NettyServerBuilder 上注册。客户端只有一个拦截器,服务端按「认证 → 上下文 → 限流」的顺序添加,由于后添加的在最外层,代码里的顺序要反过来写:
LibraryGrpc.LibraryBlockingStub stub = LibraryGrpc.newBlockingStub(channel)
.withInterceptors(new ContextClientInterceptor());
Server server = NettyServerBuilder.forPort(9093)
.addService(new LibraryServiceImpl())
.intercept(new ContextServerInterceptor())
.build()
.start();
withInterceptors 返回新 stub,不影响共享的基础 stub;如果每个请求都 withInterceptors,会重复包装,建议在构造 LibraryClient 时包一次。服务端的 .intercept() 可以调用多次,后添加的在外层,这一点和客户端一致。
traceparent 按 W3C 格式解析:00-<32 hex>-<16 hex>-<2 hex>。下面是最小实现,解析失败返回 null,由拦截器转成 INVALID_ARGUMENT:
package com.plume.linkd.v3.core.metadata;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public record TraceContext(String traceId, String spanId, boolean sampled) {
private static final Pattern TRACEPARENT =
Pattern.compile("^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$");
public String toTraceparent() {
return "00-" + traceId + "-" + spanId + "-" + (sampled ? "01" : "00");
}
public static TraceContext parse(String value) {
if (value == null) {
return null;
}
Matcher matcher = TRACEPARENT.matcher(value);
if (!matcher.matches()) {
return null;
}
String traceId = matcher.group(1);
String spanId = matcher.group(2);
if (traceId.chars().allMatch(c -> c == '0') || spanId.chars().allMatch(c -> c == '0')) {
return null;
}
boolean sampled = (Integer.parseInt(matcher.group(3), 16) & 0x01) == 1;
return new TraceContext(traceId, spanId, sampled);
}
}
RequestContext 与 TraceContext 是跨章共享类型,签名保持 RequestContext(String requestId, String tenantId, TraceContext trace, Deadline deadline) 和 TraceContext(String traceId, String spanId, boolean sampled)。11.2 的结构化日志会直接读这两个对象,字段名不要改。
常见错误与边界
错误一:键名大小写不一致。 客户端写 X-Request-Id,服务端读 x-request-id,HTTP/2 把键名规范成小写,但 Metadata.Key 的构造和查找仍可能因实现细节不一致。统一用常量类,不在业务代码里手写字符串。
错误二:把二进制内容塞进 ASCII 键。 证书、压缩后的 token、中文名称放进 ASCII_STRING_MARSHALLER 会损坏。二进制键必须以 -bin 结尾,值用 BINARY_BYTE_MARSHALLER。
错误三:只 attach 不 detach。 在线程池里,Context.attach() 之后没有 detach(previous) 会让下一个任务继承上一个请求的租户和 trace,表现为「A 租户的日志里出现 B 租户的请求 ID」。用 try/finally,或者直接用 ctx.run。
错误四:在拦截器里做阻塞 IO。 客户端拦截器运行在调用线程,服务端拦截器运行在 gRPC 的传输线程;在里面查数据库、调远程认证服务,会把整条连接的流一起拖慢。需要远程校验时,把结果缓存起来,或者把校验放到业务线程池并异步关闭调用。
错误五:响应头回显放在 close 之后。 sendHeaders 只能调用一次,而且在 close 之后调用会抛异常。回显逻辑要放在业务代码发送响应之前,通常在包装的 sendHeaders 里。
边界:metadata 不是日志。 元数据会随每个请求传输,也会进入访问日志和 tracing;不要把完整 token、身份证号、手机号放进去。需要审计的信息放服务端日志,并做好脱敏。
边界:拦截器不替代业务校验。 拦截器能保证 x-tenant-id 存在,但不能证明这个租户有权访问某本书;授权模型在 10.3,拦截器只负责把身份信息传进来。
小结
Metadata的键名是小写 ASCII,值分 ASCII 和-bin两类;默认入站元数据上限约 8 KiB,不要塞大对象。- 客户端拦截器在
start里headers.merge(extra),并用CallOptions兜底 800 ms 总 deadline。 - 服务端拦截器校验请求 ID、租户、版本和 traceparent,把
RequestContext放进Context,并用ctx.run包住 listener 的每个回调。 - 响应头回显用
ForwardingServerCall.SimpleForwardingServerCall.sendHeaders,在业务发送响应头之前写入。 - 1.76.0 的顺序是「后添加的拦截器在最外层」;服务端和客户端都遵循这个规则。
Context.current().withDeadline(...)用于派生下游预算,Context.current().wrap(...)用于跨线程池传递上下文。
下一节处理消息本身:protobuf 消息怎么变成字节、gzip 压缩在哪一层生效、4 MiB 上限在客户端和服务端分别怎么配,以及超限时调用会以什么状态失败。
阅读导航:上一节 4.1 Stub 与 Channel 生命周期 | 下一节 4.3 Codec、压缩与消息上限
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。