Skip to content

协议与服务方法详解:protobuf、HTTP/2 与拦截器

基于 gRPC 1.71 / protobuf proto3 · 核于 2026-08

速查

  • protobuf 编码:每条消息 = 一串 tag-length-value。tag = (field_number << 3) | wire_type(varint 编码)。字段名不进二进制,只存字段号——这是 protobuf 比 JSON 小的根因。
  • wire type:决定 value 怎么编码。0=Varint(int32/int64/bool/enum)、1=64-bit(fixed64/double)、2=Length-delimited(string/bytes/嵌套 message/repeated)、5=32-bit(fixed32/float)。同 wire type 的字段类型可互转(向后兼容)。
  • proto3 演进规则:①新增字段——旧客户端不传,新代码用默认值,安全;②删除字段——必须 reserved N; 占号,防复用;③字段号不可改语义;④string↔bytesint32↔uint32↔int64(同 wire type)可互转;⑤不可把 optional 单值改成 repeated(破坏 wire type 语义)。
  • HTTP/2 stream = 一个 gRPC 调用:gRPC 把每个 RPC 映射到一个 HTTP/2 stream,路径 /{package}.{Service}/{Method},用 POST。请求/响应的 protobuf 消息切成 DATA frame,前面带 5 字节前缀(1 字节压缩标志 + 4 字节长度)。
  • gRPC over HTTP/2 的 5 个 frame:①HEADERS(请求头,含 :path/te: trailers/content-type: application/grpc);②DATA(请求体,长度前缀的 protobuf);③响应 HEADERS;④响应 DATA;⑤Trailers(带 grpc-status 状态码,附在响应尾部的 trailer header)。
  • 四种方法调用流程:Unary(req→resp 一来一回);Server Streaming(req→多次 DATA frame);Client Streaming(多次 DATA frame→一个 resp);Bidirectional(双向独立 stream,互不等待)。
  • metadata(元数据):HTTP/2 头部,key-value 对。规则:①ASCII 键全小写、字母数字加 -_.、大小写不敏感;②用户键不能 grpc- 开头(保留);③二进制值键必须 -bin 结尾(如 traceparent-bin)。用于传 auth、trace、自定义。
  • 拦截器(Interceptor):客户端/服务端两侧的「中间件」,每个 RPC 都经过。客户端 4 种(unary/client-stream/server-stream/bidi)、服务端 4 种。用途:认证、日志、指标、链路追踪、重试、缓存。注意拦截器只作用于单个 RPC 调用,管不了 TCP/TLS 配置。
  • 状态码grpc-status 在 trailer 里,17 个标准码。OK=0DEADLINE_EXCEEDED=4NOT_FOUND=5ALREADY_EXISTS=6PERMISSION_DENIED=7RESOURCE_EXHAUSTED=8UNAVAILABLE=14UNAUTHENTICATED=16。比 HTTP 状态码语义精细。

一、protobuf 编码:tag-length-value

理解 protobuf 为什么小,关键是看它怎么编码。一条 HelloRequest{name:"grpc", id:42} 在二进制里大致是:

字段 name (field=1, wire_type=2):
  tag = (1 << 3) | 2 = 0x0A   ← varint 编码,1 字节
  length = 4                  ← 字符串长度,varint
  value = "grpc"              ← 4 字节 UTF-8

字段 id (field=42, wire_type=0):
  tag = (42 << 3) | 0 = 0xD0 0x02   ← varint 编码,2 字节(>127 要多字节)
  value = 42                  ← varint 编码,1 字节
  • 字段名 name/id 根本不在二进制里——JSON 会存 "name":"grpc"(5 字节键名 + 值),protobuf 只存字段号 1。这就是体积差距的来源。
  • wire type 决定 value 编码:Varint(可变长整数,小数 1 字节、大数多字节)、64-bit/32-bit(定长)、Length-delimited(先存长度再存内容,用于 string/bytes/嵌套 message)。
  • 字段顺序无关:接收方按字段号查找,发的顺序不影响解析。这样新版代码加字段、旧版代码不认的字段会被跳过(向前兼容)。

proto3 演进:如何安全地改契约

// 原始 v1
message User { string name = 1; int32 age = 2; }

// v2 安全变更:删 age(reserved 占号)、加 email
message User {
  string name = 1;
  reserved 2;              // ← age 删了,号不能复用
  string email = 3;        // ← 新字段用新号
}

// v2 不安全变更(千万别做):
// message User { string name = 1; repeated int32 age = 2; }  // ← 单值改 repeated,wire type 语义冲突
// message User { string name = 1; bool active = 2; }          // ← 复用了已删的 age=2,旧数据被误读

规则口诀:加字段随意、删字段要 reserved、号不可复用、同 wire type 才能换类型。

二、gRPC over HTTP/2:一个 RPC 的完整旅程

gRPC 不是裸 TCP,而是规范地跑在 HTTP/2 上。理解 frame 序列对排查问题(抓包、Envoy 配置、grpc-web 转码)至关重要:

客户端                                    服务端
  │                                         │
  │── HEADERS ────────────────────────────► │ stream 开始
  │   :method = POST                        │
  │   :path = /demo.v1.Greeter/SayHello     │ ← 包.服务/方法
  │   :authority = api.example.com          │
  │   te = trailers                         │ ← gRPC 必需
  │   content-type = application/grpc       │
  │   grpc-encoding = identity              │
  │   authorization = Bearer xxx            │ ← metadata
  │                                         │
  │── DATA ──────────────────────────────►  │
  │   [压缩标志 1B][长度 4B][protobuf 消息]  │ ← Length-Prefixed Message
  │── (END_STREAM) ──────────────────────►  │ 客户端发完
  │                                         │
  │                  ◄──── DATA ──────────── │ 服务端回响应
  │                  [压缩标志][长度][resp]   │
  │                                         │
  │                  ◄──── HEADERS (trailers)│ trailer 头
  │                       grpc-status = 0    │ ← OK
  │                       grpc-message =     │
  │── (END_STREAM) ◄──────────────────────── │
  • 5 字节前缀:每个 DATA frame 里的 protobuf 消息前面有 1 字节压缩标志 + 4 字节消息长度——这样才能在一个 stream 里切多条消息(流式 RPC 的基础)。
  • grpc-status 在 trailer:状态码不在开头,而在响应最后的 trailer header 里。这意味着「读完 body 还得读 trailer 才知道成功失败」——这是 gRPC 抓包和 grpc-web 转码要注意的点。
  • te: trailers:HTTP/2 要求 gRPC 必须声明「我要 trailers」,这是区分 gRPC 与普通 HTTP 的标志之一。

三、四种方法:调用流程与适用场景

Unary(一元):一问一答

客户端                          服务端
  │── Request (1 msg) ────────►│
  │                            │ 执行业务
  │◄── Response (1 msg) ───────│

最常用(90% 业务)。签名:客户端 stub.SayHello(req) 返回 Promise<HelloReply>;服务端 async sayHello(req) 返回 reply。语义和普通函数调用一样。

Server Streaming:服务端流

客户端                          服务端
  │── Request (1 msg) ─────────►│
  │                             │
  │◄── Response msg 1 ──────────│
  │◄── Response msg 2 ──────────│  ← 服务端持续推
  │◄── Response msg N ──────────│
  │◄── (END) ───────────────────│

一请求多响应。客户端 for await (const r of call) 逐条收。场景:行情推送、日志 tail、大结果分页(避免一次性 OOM)、订阅通知。注意:服务端推流期间客户端可中途取消(call.cancel())。

Client Streaming:客户端流

客户端                          服务端
  │── Request msg 1 ───────────►│
  │── Request msg 2 ───────────►│  ← 客户端攒批发
  │── Request msg N ───────────►│
  │── (END) ────────────────────►│
  │                             │ 收完处理
  │◄── Response (1 msg) ────────│

多请求一响应。客户端 call.write(msg) 多次后 call.end(),服务端 for await (const r of call) 收完返回一个响应。场景:批量上传、攒批写库、传感器数据汇聚。

Bidirectional Streaming:双向流

客户端                          服务端
  │── msg ─────────────────────►│
  │◄── msg ─────────────────────│  ← 两边独立读写
  │── msg ─────────────────────►│     互不等待
  │◄── msg ─────────────────────│
  │── (END) ────────────────────►│
  │◄── (END) ───────────────────│

两边都拿 stream 对象,独立 read/write。服务端不必等客户端说完才说话。场景:聊天室、协同编辑、实时游戏、双向心跳/控制信令。这是 gRPC 最强也最复杂的模式

四、metadata:gRPC 的「请求头」

metadata 就是 HTTP/2 的头部,用 key-value 传带外信息(不进 protobuf 消息体的那些):

  • 认证authorization: Bearer <token>
  • 链路追踪traceparent: 00-<trace-id>-<span-id>-01(W3C Trace Context)。
  • 业务上下文x-tenant-id: acmex-request-id: uuid
  • 规则:①键全小写(大小写不敏感);②字母数字加 -_.;③不能以 grpc- 开头(这是 gRPC 框架保留前缀,如 grpc-encoding/grpc-status);④二进制值键必须 -bin 结尾(如 traceparent-bin),ASCII 值键不能加。
js
// 客户端发 metadata
const call = stub.sayHello(req, {
  metadata: {
    authorization: `Bearer ${token}`,
    traceparent: traceContext,   // OTel/W3C trace 头
  },
});

// 服务端收 metadata
function sayHello(call) {
  const token = call.metadata.get('authorization')[0];
}

五、拦截器(Interceptor):gRPC 的中间件

拦截器让你在「每个 RPC 调用」前后插入通用逻辑(认证、日志、指标、追踪),不必在每个方法里重复写。类比 Express/Koa 的中间件、Spring 的 AOP。分客户端和服务端两侧,各 4 种(对应四种方法类型):

拦截器触发位置典型用途
Client Unarystub.xxx() 调用前后注入 auth header、记录调用耗时、重试
Client Streaming流式调用前后同上,针对流
Server Unaryhandler 调用前后验证 token、限流、记录指标、注入 trace
Server Streaming流式 handler 前后同上,针对流
  • 客户端拦截器示例(注入 auth + 记录):
js
const authInterceptor = (options, nextCall) => {
  const requester = nextCall(options);
  return {
    start(metadata, listener, next) {
      metadata.set('authorization', `Bearer ${getToken()}`);  // 注入 token
      next(metadata, listener);
    },
  };
};
  • 服务端拦截器示例(认证 + trace):
js
const authInterceptor = (call, callback, next) => {
  const token = call.metadata.get('authorization')[0];
  if (!verify(token)) return callback({code: 16 /* UNAUTHENTICATED */});
  const span = tracer.startSpan(call.getMethod());   // 开 trace
  next(call, (err, resp) => {
    span.end();                                        // 关 trace
    callback(err, resp);
  });
};
  • 关键限制:拦截器只作用于单个 RPC 调用——管 TCP 连接、TLS、端口配置要用 ChannelOption,不是拦截器的活。

下一步

协议与服务方法讲完后,下一步是与 REST/GraphQL 对比与 Connect——何时该用 gRPC、gRPC 在浏览器的痛点、Connect(Buf)如何用一份 proto 同时生成 gRPC + Connect + HTTP/JSON。