本文翻译自 gRPC deep dive: from service definition to wire format,原载于 Hacker News。
在之前的文章中(第一部分和第二部分),我们揭开了 Protocol Buffers 的神秘面纱,了解了数据如何被编码成紧凑的二进制格式。
但 Protobuf 只负责载荷(payload)的序列化。要在微服务(microservices)之间传输这些数据,我们需要一个传输协议。这就是 gRPC 登场的地方。
虽然很多开发者每天都在使用 gRPC,但很少有人真正深入了解它的工作原理。在这篇文章中,我们将超越基础概念,探索完整的 gRPC 协议栈:从高层的服务架构和流式模型,到底层的 HTTP/2 帧和字节级二进制格式。
契约优先的理念
gRPC 的核心是契约优先(contract-first)方法。与 REST 不同——在 REST 中,API 文档(如 OpenAPI)往往是事后补充——gRPC 使用 Protocol Buffers(.proto 文件)在前期就强制定义好结构。
这个契约不仅定义数据结构(Messages),还定义服务能力(RPCs):
package fruit.v1;
service FruitService {
// 一元调用:简单请求 -> 响应
rpc GetFruit(GetFruitRequest) returns (Fruit);
// 服务端流:一个请求 -> 多个响应
rpc ListFruits(ListFruitsRequest) returns (stream Fruit);
// 客户端流:多个请求 -> 一个响应
rpc Upload(stream Fruit) returns (UploadSummary);
// 双向流:多个请求 <-> 多个响应
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
这个定义是唯一的真实来源(single source of truth)。
从这个单一文件出发,Protobuf 编译器(protoc)可以生成几乎所有语言的客户端存根(stubs)和服务端样板代码(Go、Java、C#、Python 等),确保客户端和服务端始终对 API 的形态达成一致。
译者注:这种”契约优先”的方式在国内大厂的微服务架构中非常流行。相比 REST API 文档经常与实际代码脱节的问题,gRPC 的
.proto文件就是代码的一部分,天然保持同步。
四种流式模型
gRPC 最大的差异化优势之一是原生支持流式传输(streaming)。这不仅仅是”分块传输编码”(chunked transfer encoding),而是真正的 API 语义层面的支持。
- 一元调用(Unary):看起来像标准的函数调用或 REST 请求。客户端发送一条消息,服务端返回一条。
- 服务端流(Server streaming):非常适合订阅场景或大数据集。客户端发送查询,服务端随时间返回多个结果。
- 客户端流(Client streaming):适用于发送数据流(如 IoT 设备的遥测数据),服务端可以边接收边处理。
- 双向流(Bidirectional streaming):真正的实时通信。双方可以独立发送消息。常用于聊天应用或多人游戏。
除了实际数据外,gRPC 还允许发送元数据(metadata)。元数据是键值对列表(类似 HTTP headers),提供关于调用的信息。键是字符串,值通常是字符串,但也可以是二进制数据。键名不区分大小写,且不能以 grpc- 开头(保留给 gRPC 内部使用)。二进制值的键必须以 -bin 结尾。
元数据对于不应该包含在业务逻辑载荷中的横切关注点(cross-cutting concerns)至关重要:
- 认证(Authentication):使用 Bearer token(如
Authorization: Bearer <token>) - 链路追踪(Tracing):传递 trace ID(如
transport-id: 12345)以跨微服务跟踪请求 - 基础设施:为负载均衡器或代理提供提示
元数据可以由客户端(在调用开始时)和服务端(作为 headers 在开始时,或作为 trailers 在结束时)发送。
底层:传输层
那么这个契约如何映射到网络?gRPC 构建在 HTTP/2 之上,利用其高级特性实现流式模型。
最重要的概念是流(streams)。每个 gRPC 调用,无论是简单的一元请求还是长时间的双向流,都映射到单个 HTTP/2 流。这允许多路复用(multiplexing):你可以在单个 TCP 连接上有数千个活动的 gRPC 调用,它们的帧可以交错传输。这避免了 HTTP/1.1 需要开启数千个连接的问题。虽然它解决了 HTTP/1.1 的”队头阻塞”(head-of-line blocking)问题,但如果丢包,TCP 层面的阻塞仍然是一个问题。
构建 URL
在发送任何字节之前,我们需要定位资源。在 gRPC 中,URL 从 .proto 定义自动生成:/{Package}.{Service}/{Method}
对于 GetFruit,路径变为:
/fruit.v1.FruitService/GetFruit
这种标准化意味着客户端和服务端永远不需要争论 URL 路径。
HTTP/2 帧
一个 gRPC 调用通常由三个阶段组成,每个阶段映射到 HTTP/2 帧:
- 请求头和元数据(
HEADERS帧):包含元数据如:path、:method(POST)和content-type(application/grpc) - 数据消息(
DATA帧):实际的应用数据 - 响应 trailers(
HEADERS帧):调用的最终状态
网络上的元数据
由于 gRPC 构建在 HTTP/2 之上,元数据简单地映射到 HTTP/2 headers。字符串值原样发送(如 user-agent: grpc-kreya/1.18.0)。
二进制值进行 base64 编码,键必须以 -bin 结尾。库通常会透明地处理这种编码/解码。
长度前缀消息
在 HTTP/2 DATA 帧内部,gRPC 使用一种称为长度前缀帧(length-prefixed framing)的机制包装你的 protobuf 消息。即使在流式调用中,每条消息都是独立的,并以 5 字节头部为前缀:
| 字节 | 用途 | 描述 |
|---|---|---|
| 0 | 压缩标志 | 0 = 未压缩,1 = 已压缩 |
| 1-4 | 消息长度 | 4 字节大端整数,表示载荷大小 |
可视化字节
让我们重用之前文章中的水果消息,它编码为 10 字节的 protobuf 数据:08 96 01 12 05 41 70 70 6c 65
当通过 gRPC 发送时,我们添加头部:
- 压缩标志:
0(无压缩) - 长度:
10(0x0A)
最终的 15 字节 gRPC 消息如下:
00 00 00 00 0a 08 96 01 12 05 41 70 70 6c 65
│ └─ Protobuf 载荷(10 字节)
│ └───────────── 载荷消息长度(0xA = 10 字节)
└──────────────── 压缩标志(0 = false)
这种简单的帧机制允许接收方准确读取下一条消息所需的字节数,解码,然后重复,实现流畅的流式传输。
译者注:这个 5 字节的头部设计非常精妙。第一个字节用于标识是否压缩,后四个字节用大端序表示消息长度。这种设计让接收方可以先读取 5 字节,知道接下来要读多少数据,然后精确读取,非常适合流式场景。
状态码和 Trailers
在 REST 中,你检查 HTTP 状态码(200、404、500)。在 gRPC 中,HTTP 状态几乎总是 200 OK,即使逻辑失败了!
实际的应用状态在 trailers(最后的 HTTP/2 header 帧)中发送。这种分离至关重要:它允许服务端成功流式传输 100 个项目,然后在第 101 个处理步骤报告错误。
典型的 trailer 块如下:
grpc-status: 0
grpc-message: OK
(状态 0 表示成功。非零值表示错误,如 NOT_FOUND、UNAVAILABLE 等)
富错误信息
有时,简单的状态码和字符串消息是不够的。你可能想返回特定字段的验证错误或其他错误详情。富错误模型(Rich error model)(具体是 google.rpc.Status)解决了这个问题。
服务端不只是返回 grpc-status 和 grpc-message,而是将详细的 protobuf 消息序列化为 base64 放入 grpc-status-details-bin trailer。这个标准消息包含:
- Code:gRPC 状态码
- Message:面向开发者的错误消息
- Details:
google.protobuf.Any消息列表,包含任意错误详情(如BadRequest、PreconditionFailure、DebugInfo)
message Status {
// gRPC 状态码(3=INVALID_ARGUMENT, 5=NOT_FOUND 等)
int32 code = 1;
// 错误消息
string message = 2;
// 额外错误详情列表(任意自定义 protobuf 消息,如验证错误详情)
repeated google.protobuf.Any details = 3;
}
客户端可以解码这个 trailer 获取结构化、可操作的错误信息。
译者注:这个特性在处理复杂的业务错误时特别有用。比如用户注册时,可以一次性返回所有字段的验证错误,而不是只返回第一个错误。Google 的 API 设计指南中有详细的使用说明。
压缩
根据环境不同,带宽可能非常宝贵,尤其是在移动网络上。gRPC 内置支持压缩以减少载荷大小。
工作原理
- 协商:客户端发送
grpc-accept-encodingheader(如br, gzip, identity)告诉服务端它支持哪些算法 - 编码:如果服务端决定压缩响应,它设置
grpc-encodingheader(如br) - 标志:对于每条消息,压缩标志(5 字节头部的第 0 字节)设置为
1 - 载荷:消息载荷使用选定的算法压缩
让我们看看启用压缩时二进制格式如何变化。注意用 brotli 压缩我们微小的”Apple”消息由于开销会变大,但结构保持不变:
01 00 00 00 0e 8f 04 80 08 96 01 12 05 41 70 70 6c 65 03
│ └─ 压缩后的载荷
│ └───────────── 压缩消息长度(0xE = 14 字节)
└──────────────── 压缩标志(1 = true)
这是按消息进行的。甚至可以给请求和响应设置不同的压缩设置(非对称压缩)。
替代传输方式
虽然 gRPC 通常通过 TCP/IP 使用 HTTP/2 运行,但协议足够通用,可以在其他地方运行。
- Unix Domain Sockets:非常适合本地 IPC(进程间通信)。它绕过 TCP 网络栈以获得最大效率
- Named Pipes:Windows 上的等效方案
这种灵活性使 gRPC 成为组件之间的通用粘合剂,无论它们位于不同的大陆还是同一块芯片上。
浏览器缺口(gRPC-Web)
gRPC 在一个地方力不从心:浏览器。Web 浏览器不公开 gRPC 所需的低级 HTTP/2 帧控制(具体来说,读取 trailers 和精细的流控制)。
这个挑战由 gRPC-Web 解决,这是一个协议适配:
- 将 trailers 编码到数据流主体内(这样浏览器不需要读取 HTTP trailers)
- 支持基于文本的应用层编码(base64)以绕过二进制限制
我们将在未来的文章中详细介绍 gRPC-Web 的具体工作原理。
总结
gRPC 不仅仅是一个序列化格式,它是一个完整的生态系统,标准化了我们定义、生成和使用 API 的方式。通过理解各个层次——从 .proto 契约到网络上的 5 字节头部——你可以更有效地调试问题并设计更好的系统。
像 Kreya 这样的工具为日常测试抽象了这种复杂性,但了解底层发生的事情让你在事情变得棘手时能够掌控局面。
核心要点
- 契约优先:
.proto文件是 API 的唯一真实来源,自动生成多语言代码 - 四种流式模型:Unary、Server streaming、Client streaming、Bidirectional streaming
- HTTP/2 基础:利用 HTTP/2 的多路复用和流特性
- 5 字节消息头:1 字节压缩标志 + 4 字节长度,实现高效的流式传输
- Trailers 传递状态:真正的错误状态在 trailers 中,HTTP 状态总是 200
- 富错误信息:通过
grpc-status-details-bin传递结构化的错误详情 - 灵活的传输层:除 TCP 外还支持 Unix Domain Sockets 和 Named Pipes
延伸阅读
- gRPC Best Practices:了解 API 设计、版本控制和性能技巧
- gRPC Core concepts, architecture and lifecycle:官方 gRPC 核心概念文档
- gRPC HTTP/2 specification:官方 gRPC HTTP/2 传输规范
- Protobuf (part 1 and part 2):深入理解 Protocol Buffers 格式