返回文章列表

用 Rust 手搓 gRPC:tonic 替你藏起来的那些坑

786·5 分钟阅读
RustgRPCtonicHTTP/2protobuf

用 tonic 写 gRPC 的人很多,搞懂 gRPC 到底是个啥的人不多。你问一个 Rust 开发者"gRPC 是什么",他大概率会说"就是 protobuf 加 HTTP/2"。这话对,但等于没说——就像别人问你红烧肉是什么,你说"就是猪肉加酱油"。

把它从零手搓一遍,想搞清楚那个黑盒里到底装了什么,结论有点意外:

gRPC 的"协议本体"薄得吓人,核心规范就 5 个字节。

真正复杂的活儿,protobuf 和 HTTP/2 早替它干完了。

这篇我们就把它一点点掰开,顺手分享几个手搓时踩过的坑。

先把锅甩清楚:protobuf 和 HTTP/2,这俩不是 gRPC 的活

很多教程一上来就教你装 protobuf 编译器、写 .proto、生成 Rust 代码,搞得你以为这些是 gRPC 的一部分。说实话,它们跟 gRPC 一毛钱关系都没有。

  • protobuf 只管一件事:把一个结构体变成字节流,再变回来。它就是个序列化库。
  • HTTP/2 只管一件事:在一条连接上可靠地跑一堆双向字节流。它是个传输协议。

gRPC 做的事情,是在这俩之间塞了一层薄薄的"约定":告诉你在 HTTP/2 的某条流上,用 protobuf 序列化出来的消息应该怎么排布、怎么知道一条消息结束了、出错了怎么报。

所以这篇我们手搓的就是这一层。HTTP/2 我用现成的 h2 crate,protobuf 我用现成的 prost。不是偷懒,是因为那俩每一个都够写三篇,而且——它们不是 gRPC 的本体。把范围圈定好,才看得清 gRPC 自己到底干了啥。

protobuf 先把消息压成字节

先定义我们要传的消息。一个最朴素的 Hello 服务:

#[derive(prost::Message)]
struct HelloRequest {
    #[prost(string, tag = "1")]
    name: String,
}
 
#[derive(prost::Message)]
struct HelloReply {
    #[prost(string, tag = "1")]
    message: String,
}

注意,这段代码里没有任何"gRPC"的影子。你拿这两个结构体去存盘、塞 Redis、走 HTTP/1,都一样能用。prost::Message 只给你两个核心方法:

let bytes: Vec<u8> = req.encode_to_vec();              // 结构体 -> 字节
let req: HelloRequest = HelloRequest::decode(bytes)?;  // 字节 -> 结构体

到此为止,序列化搞定。下面才是 gRPC 登场的地方。

那 5 个字节,就是 gRPC 的真身

如果你只读这一段,就读这段。

gRPC 的全部协议规范,浓缩成一句话:每条消息前面,加一个 5 字节的头。 拆开是这样:

字节 含义
第 1 字节 压缩标志(0 = 不压缩,1 = gzip)
第 2~5 字节 消息长度(大端无符号整数)
后面 protobuf 序列化后的字节

没了。就这。整个 gRPC wire protocol,就是个"长度前缀帧"(Length-Prefixed-Message)。编码函数写出来不到 10 行:

use bytes::{Bytes, BytesMut, BufMut};
 
fn encode_frame(msg: &[u8]) -> Bytes {
    let mut buf = BytesMut::with_capacity(5 + msg.len());
    buf.put_u8(0);                  // 不压缩
    buf.put_u32(msg.len() as u32);  // 4 字节大端长度
    buf.put_slice(msg);             // protobuf payload
    buf.freeze()
}

解码反过来:先死读 5 字节头拿到长度,再按长度读 body:

async fn read_frame(stream: &mut h2::RecvStream) -> Result<Bytes> {
    let header = read_exact(stream, 5).await?;
    let _compressed = header[0]; // 先不支持压缩
    let len = u32::from_be_bytes([
        header[1], header[2], header[3], header[4],
    ]) as usize;
    read_exact(stream, len).await
}

我第一次看到这个规范时,反应是"就这?"。是的,就这。你以为神秘兮兮的 gRPC 帧协议,核心就是"长度前缀"这个 1970 年代的古董技巧——TCP 之上的几乎所有应用协议都是这么干的:HTTP/1 的 chunked encoding、Redis 的 RESP、websocket,全是这个套路。

但这层薄薄的约定,恰恰是 gRPC 最聪明的地方:它不重新发明轮子,就靠这 5 个字节把"消息边界"这件事解决了。

一个必踩的坑:帧边界和 HTTP/2 的 DATA 帧根本不对齐

这里有个新手几乎人人会踩的坑。你会很自然地以为"HTTP/2 发我一个 DATA 帧,我就收一个 DATA 帧"。错。

HTTP/2 给你的字节流是按它自己的节奏切的,跟你 gRPC 的消息边界八竿子打不着。一个 gRPC 消息可能被切成好几块 DATA 帧发过来,也可能好几个消息挤在同一个 DATA 帧里。所以 read_exact 你得自己写,还得自己缓冲多读出来的字节:

async fn read_exact(stream: &mut h2::RecvStream, n: usize) -> Result<Bytes> {
    let mut buf = BytesMut::with_capacity(n);
    while buf.len() < n {
        let chunk = stream.data().await.ok_or(Error::Eof)??;
        let len = chunk.len();
        buf.put_slice(&chunk);
        // 别忘了还配额,否则流控会把对端活活卡死
        stream.flow_control().release_capacity(len)?;
    }
    Ok(buf.split_to(n).freeze()) // 多读的字节留给下一帧
}

split_to 这步是命门——一次 DATA 帧很可能读超了,多出来的那几个字节属于下一个 gRPC 帧,必须留着。漏掉这一步,你的服务就会偶尔"莫名其妙少几个字节然后挂掉",这种 bug 能让你 debug 到怀疑人生,因为它只在消息跨帧的时候才犯病。

把帧扔到 HTTP/2 上跑

协议有了,传输有了,现在把它俩接起来。gRPC 规定:每个 RPC,就是 HTTP/2 上的一次 POST 请求。

请求长这样:

  • 方法:POST
  • 路径:/{包名}.{服务名}/{方法名},比如 /hello.Greeter/SayHello
  • 头:Content-Type: application/grpcTE: trailers
  • body:就是我们上面那套 5 字节帧

服务端的核心逻辑,就是接住这个 POST,按帧读消息,处理后按帧写回去:

let (req, mut respond) = h2.accept().await?.?; // respond: SendResponse
let path = req.uri().path().to_string();
let mut body = req.into_body(); // RecvStream
 
// 先把响应头发出去(含 content-type),拿到写 body 的流
let resp = http::Response::builder()
    .header("content-type", "application/grpc")
    .body(())?;
let mut send = respond.send_response(resp, false)?; // send: SendStream
 
match path.as_str() {
    "/hello.Greeter/SayHello" => {
        let payload = read_frame(&mut body).await?;
        let req_msg: HelloRequest = prost::Message::decode(payload)?;
 
        let reply = HelloReply {
            message: format!("hello {}", req_msg.name),
        };
        send.send_data(encode_frame(&reply.encode_to_vec()), true)?; // END_STREAM
    }
    _ => send_grpc_status(&mut send, 12)?, // 12 = UNIMPLEMENTED
}

send_data 第二个参数 true 是 HTTP/2 的 END_STREAM 标志,告诉对方"我说完了"。Unary 调用就这一进一出,极其朴素。

四种调用模式,全是 HTTP/2 的 free ride

gRPC 引以为傲的四种调用模式:

gRPC 调用模式 请求数 响应数 靠 HTTP/2 的什么实现
Unary 1 1 最朴素的请求-响应
Server Streaming 1 N 一条流上发多个 DATA 帧
Client Streaming N 1 一条流上收多个 DATA 帧
Bidi Streaming N N HTTP/2 的全双工流

看出来了吗?这四种模式在协议层没有任何区别。都是同一种 5 字节帧、同一个 POST、同一条 HTTP/2 stream。区别只在于你在上面读几帧、写几帧。

服务端流式,无非是把 send_data 塞进循环里:

// 客户端发来一个请求,我连着回三条
for name in ["alice", "bob", "carol"] {
    let reply = HelloReply { message: format!("hi {name}") };
    // false = 还有后续,别关流
    send.send_data(encode_frame(&reply.encode_to_vec()), false)?;
}
// 最后发个空帧收尾,标记 END_STREAM
send.send_data(Bytes::new(), true)?;

这就是为什么 gRPC 必须是 HTTP/2 而不是 HTTP/1——HTTP/1 的响应是"一锤子买卖",body 流出去就不能再写了,更别提全双工。 HTTP/2 的 stream 是双向、可分段、可并发的,刚好把 gRPC 的四种模式全接住了。

我个人觉得,这才是 gRPC 真正"赌对"的地方:不是它的协议多精妙(5 字节而已),而是它押注 HTTP/2 这个决定。等 HTTP/2 普及,gRPC 的流式能力几乎是白捡的。

出错了塞进 trailer,而不是 HTTP 状态码

这里有个反直觉的设计,值得单独说。

gRPC 出错时,HTTP 状态码还是 200。真正的错误信息藏在 HTTP/2 的 trailer(尾部头)里:

use http::HeaderMap;
 
fn send_grpc_status(send: &mut h2::SendStream<Bytes>, code: u8) -> Result<()> {
    let mut trailers = HeaderMap::new();
    trailers.insert("grpc-status", code.to_string().parse()?);
    trailers.insert("grpc-message", "internal error".parse()?);
    send.send_trailers(trailers)?;
    Ok(())
}

第一次看到这个设计我也觉得别扭:报错就走 HTTP 500 啊,为啥还 200?后来想通了——这是为了流式响应。

服务端流式调用,可能已经成功吐了 5 条消息给客户端了,到第 6 条挂了。这时候整个响应已经是"半成功"状态,你怎么用 HTTP 状态码表达?HTTP 状态码在响应头里,body 都开始发了,头早送出去了,改不了了。trailer 是 HTTP/2 独有的、在 body 之后还能再补一坨头的机制,正好用来塞"最终的、全局的"状态。这是 HTTP/1 死都做不到的。

记住这条就行:gRPC 里 HTTP 状态码只表示"传输层有没有问题"(连接断了、TLS 挂了、对方根本不是 gRPC 服务),业务错误一律看 grpc-status

客户端:镜像的另一面

客户端就是把上面这套反过来。建一条 HTTP/2 连接,POST 到 /hello.Greeter/SayHello,把请求帧塞进 body,然后从响应流里按帧读结果。代码跟服务端几乎对称,核心就两步:

// 1. 发请求帧
let req = HelloRequest { name: "ferris".into() };
let request = http::Request::builder()
    .method("POST")
    .uri("/hello.Greeter/SayHello")
    .header("content-type", "application/grpc")
    .header("te", "trailers")
    .body(encode_frame(&req.encode_to_vec()))?;
let (resp, mut body) = client.send_request(request, false)?;
 
// 2. 读响应帧,再看 trailer 里的 grpc-status
let payload = read_frame(&mut body).await?;
let reply: HelloReply = prost::Message::decode(payload)?;

完整跑通的那一刻你会发现自己用了不到 300 行 Rust,实现了一个能跟标准 gRPC 客户端互通的服务。

tonic 到底替你藏了多少坑

到目前为止我们讲的 5 字节帧、HTTP/2、trailer,只是 gRPC 真实复杂度的冰山一角。

用 tonic 的时候,gRPC 的表面美好得不像话:类型安全的 trait、async/await 天然契合、性能还快,你甚至会觉得它就是"定义 proto → 生成代码 → 调用方法",三步走完。但把整条链路从底到顶拆开看,tonic 在每一层都帮你兜了至少一个能让你 debug 到天亮的坑:

这层在干啥 手搓会咬你的地方
Protobuf 编解码 varint / zigzag / wire type / 嵌套 一个字节错,整个消息直接解崩
HTTP/2 分帧 / 多路复用 / 流控 / 流状态机 流控卡死、状态机死锁
HPACK 头部压缩(动态表 + Huffman) 表状态错一步,整个连接 header 全解错
gRPC 语义 status / trailer / deadline / cancel HTTP 200 假成功、取消信号不传播
连接管理 连接池 / 重连退避 / DNS 刷新 资源泄漏,或雪崩重连
TLS 证书链 / ALPN / SNI 连不上,或者中间人

这还只是挑了大头,每一层单拎出来都够写一篇。Protobuf 编解码那层(varint、zigzag、wire type,一个字节错就解崩)我直接用 prost 躲过了,下面是我真刀真枪自己撞的几层。

传输层:HTTP/2 是坑最密的一层

忘了 release_capacity,连接就"假死"

h2 给每条流一个流量控制窗口,默认就 64KB 左右。你从 RecvStream 读出来的字节,对应的窗口配额是被占着的——必须主动 release_capacity 还回去,不然读够 64KB 之后对端就再也发不进来,连接看起来像挂了但没有任何报错。我在这卡了一晚上,最后靠加日志才发现窗口被吃满了。前面那段 read_exact 里的 release_capacity 不是装饰,是保命的。

这其实就是背压问题——流控不管好,要么内存暴涨,要么延迟飙升,要么干脆卡死。

还有帧边界、状态机、HPACK

帧边界和 DATA 帧不对齐前面讲过了。再往下还有 HTTP/2 那套状态机:RST_STREAMGOAWAY、流的生命周期,每一个都得正确响应,不懂状态机分分钟死锁,或者被对端踢掉还不知道为啥。还有 HPACK 头部压缩(动态表 + Huffman),表状态错一步,整个连接的 header 全解错。这层我用 h2 现成的躲过了,真要全自己写,光是 HPACK 就够你喝一壶。

gRPC 语义层:最反直觉的一层

HTTP 状态码 200,但调用其实是失败的

第一次拿标准客户端联调,抓包看到 200 OK 就以为成功了,结果客户端一直报错,服务端日志也一切正常。懵了半小时才想起来——gRPC 把业务错误塞在 trailer 的 grpc-status 里,HTTP 层永远是 200。调试时第一眼先看 trailer,别被 200 骗了,这个弯路我替你走过。

Content-Type 和 TE 头,错一个就不通

这俩头是 gRPC 的"身份证",错一个服务端直接当不认识:

  • Content-Type 必须是 application/grpc(或 application/grpc+proto),手滑写成 application/protobuf 是最常见的低级错误。
  • 请求必须带 TE: trailers。更阴的是,线上有些反向代理(老版本 nginx 之类)默认会剥掉 trailer,结果状态码莫名其妙丢失,排查起来非常折磨。

这层还藏了一堆语义要自己实现:deadline(超时)要从请求头读出来一路往下传,客户端 cancel 要靠 HTTP/2 的 stream reset 传播,元数据还有大小限制。任何一个跟标准不一致,跟 tonic 客户端互通就出诡异问题。

消息大小:4MB 这个隐形天花板

gRPC 默认单条消息上限 4MB。手搓时你自己没设上限,但跟 tonic 客户端互通时,tonic 默认也是 4MB,超了直接 INVALID_ARGUMENT。传大文件别硬塞 unary,要么调上限,要么老老实实走流式分块。

这其实是"边界与健壮性"的一个特例——头部大小限制、恶意输入防御、不设上限时的内存保护,都是手搓时最容易漏的。生产里你不设防,一个大包打过来内存就炸。

连接管理 & TLS:手搓到这步就该收手了

再往下是连接池、空闲连接回收、DNS 刷新与断线重连退避,还有 TLS(证书链、SNI、ALPN 协商 h2)。这两层写好了是基础设施,写不好要么资源泄漏,要么雪崩重连,要么干脆连不上还排查不出原因。tonic + tower 这套把它们全封进了 Channel / Endpoint,你日常几乎感觉不到——但它们就在那,替你扛着。

回头看 tonic,突然就豁然开朗

搓完这一遍,你再打开 tonic 的代码,会突然看懂很多之前觉得"为什么要这么绕"的设计:

  • tonic 生成的 trait 里,每个方法返回值都是 Stream<Item = Result<T>>——因为底层就是一条 HTTP/2 流,不管是 Unary 还是 Bidi,本质都是往流上读写帧。
  • tonic::Status 为什么是"响应结束时才知道"?因为它在 trailer 里,而 trailer 是 body 之后才到的。
  • tonic 那一堆 interceptor、timeout、retry 的中间件——全是建立在"一个 RPC 就是一个 HTTP/2 stream"这个模型上的。
  • ChannelEndpoint、reconnect、keepalive 那一坨——就是在帮你兜上面那张表里连接管理层和 TLS 层的所有坑。

说白了,tonic 干的所有"魔法",都是在帮你把那 5 字节帧、HTTP/2 stream 和上面六层坑,包装成好用的 Rust trait 和类型。魔法底下,还是我们今天搓的这点东西——只是这点东西展开后,比你想的多得多。

结论

手搓一遍 gRPC 之后,我最大的收获不是"会写 gRPC 了"——真正的收获是祛魅 + 知敬畏

  • gRPC 的协议本体确实薄(就那 5 字节帧),这是它被"神化"的部分;
  • 但它脚下踩的 HTTP/2 + HPACK + 连接管理 + TLS 这几座大山,每一座都够你爬半年,这才是 tonic 真正替你扛下的东西;
  • 所谓"高级框架",本质是把一整条链路上的坑挨个填平,再给你一个干净的 API。

下次再有同事觉得 gRPC 神秘,你既能告诉他协议本体就 5 字节,也能告诉他——别在生产环境手搓。

手搓可以学原理,但生产环境请珍惜生命,善用轮子。 日常还是交给 tonic 吧,它替你藏起来的那些坑,藏得有道理。