gRPC 用服务定义描述 RPC 方法和消息类型,tonic 提供 Rust 的异步实现,常见组合是 Tokio、Tower 与 Prost。生成代码减少手写序列化和接口样板,但网络两端不会因为一方修改 .proto 就自动重新编译。
协议的编译期类型检查、线上编码兼容和业务语义是三个层次。HTTP API 同样可以使用 OpenAPI 等描述与代码生成;选择 gRPC 的理由应来自调用模型、流式能力及生态要求,而不是把另一类接口一概说成没有合约。
先理解四种 RPC
| 形式 | 请求消息数 | 响应消息数 | 典型问题 |
| Unary | 1 | 1 | 一次查询或命令 |
| Server streaming | 1 | 多个 | 逐步发送查询结果 |
| Client streaming | 多个 | 1 | 分批上传后汇总 |
| Bidirectional streaming | 多个 | 多个 | 两个方向独立推进 |
双向流不要求每收到一条请求才发一条响应,也不保证两个方向存在一一对应关系。每个方向内的消息有顺序,应用仍需定义消息 ID、完成条件与失败后的恢复方式。
最小完整工程:明确生成工具版本
以下使用 tonic 0.14 系列。该系列的 Prost 集成位于 tonic-prost 与 tonic-prost-build;不要直接混用旧教程的 tonic_build::compile_protos。需要本机安装 protoc,并保存 Cargo.lock 以记录实际解析版本。tonic、tonic-prost-build
Cargo.toml:
[package]
name = "grpc-demo"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"
[build-dependencies]
tonic-prost-build = "0.14"
proto/greeter.proto:
syntax = "proto3";
package greeter;
service Greeter {
rpc SayHello(HelloRequest) returns (HelloReply);
}
message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }
build.rs:
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=proto/greeter.proto");
tonic_prost_build::configure()
.compile_protos(&["proto/greeter.proto"], &["proto"])?;
Ok(())
}
src/lib.rs:
pub mod greeter {
tonic::include_proto!("greeter");
}
src/bin/server.rs:
use grpc_demo::greeter::{HelloReply, HelloRequest};
use grpc_demo::greeter::greeter_server::{Greeter, GreeterServer};
use tonic::{Request, Response, Status};
#[derive(Default)]
struct HelloService;
#[tonic::async_trait]
impl Greeter for HelloService {
async fn say_hello(&self, request: Request<HelloRequest>)
-> Result<Response<HelloReply>, Status>
{
let name = request.into_inner().name;
if name.trim().is_empty() {
return Err(Status::invalid_argument("name is required"));
}
Ok(Response::new(HelloReply { message: format!("Hello, {name}!") }))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic::transport::Server::builder()
.add_service(GreeterServer::new(HelloService))
.serve("127.0.0.1:50051".parse()?)
.await?;
Ok(())
}
src/bin/client.rs:
use grpc_demo::greeter::{HelloRequest, greeter_client::GreeterClient};
use std::time::Duration;
use tonic::Request;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = GreeterClient::connect("http://127.0.0.1:50051").await?;
let mut request = Request::new(HelloRequest { name: "Rust".into() });
request.set_timeout(Duration::from_secs(2));
let reply = client.say_hello(request).await?.into_inner();
println!("{}", reply.message);
Ok(())
}
分别在两个终端运行 cargo run --bin server 和 cargo run --bin client,客户端应打印 Hello, Rust!。此处本地明文监听用于观察请求链路;真实部署根据环境配置 TLS、身份验证及资源限制。
#[tonic::async_trait] 用于匹配该系列生成的 trait 形式。Rust 从 1.75 已支持 trait 中的原生 async fn,保留宏不能解释为语言“尚不支持”;原生 async trait、动态分派与生成库采用的约定需要分别看待。
修改 .proto 为什么不一定破坏旧客户端
Protobuf 二进制编码依赖字段号。增加兼容字段时,旧客户端仍可能正常读取自己认识的字段;本地 Rust 代码是否需要修改,取决于是否重新生成及如何构造类型。线上兼容不能由单个仓库编译通过证明。
删除字段后应保留其编号,通常也保留名字以防将来误用;不要复用旧编号表达不同含义。字段类型变化要按 wire 兼容规则逐项判断。即使编码仍可解码,单位从秒改成毫秒也可能直接破坏业务语义。Proto3 更新规则
需要区分“没有提供”与“提供了默认值”时,应明确使用字段存在性设计,例如适用的 optional 字段。空字符串并不会因为类型是 string 就自动变成验证错误,所以上面 handler 显式检查 name。
用两份消息类型验证字段存在性与旧中转节点
下面是独立的 src/bin/compat.rs,使用工程已有的 Prost 依赖,直接声明消息以便在同一个程序中同时观察新旧结构。字段号 1 保留 name,版本二增加 optional 的 locale 字段号 2。
use prost::Message;
#[derive(Clone, PartialEq, Message)]
struct V1 {
#[prost(string, tag = "1")]
name: String,
}
#[derive(Clone, PartialEq, Message)]
struct V2 {
#[prost(string, tag = "1")]
name: String,
#[prost(string, optional, tag = "2")]
locale: Option<String>,
}
fn main() {
let absent = V2 { name: String::new(), locale: None };
let empty = V2 { name: String::new(), locale: Some(String::new()) };
assert!(absent.encode_to_vec().is_empty());
assert_eq!(empty.encode_to_vec(), [0x12, 0]);
let new = V2 { name: "Rust".into(), locale: Some("zh-CN".into()) };
let bytes = new.encode_to_vec();
let old = V1::decode(bytes.as_slice()).unwrap();
assert_eq!(old.name, "Rust");
let relayed = old.encode_to_vec();
let decoded = V2::decode(relayed.as_slice()).unwrap();
assert_eq!(decoded.name, "Rust");
assert_eq!(decoded.locale, None);
}
optional 将缺失和显式空值映射到 None 与 Some("")。本例中 0x12 是字段号 2、wire type 2 的键,后面的 0 表示零字节长度;因此两种状态即使业务字符串同样为空,线上表示也不同。Prost 字段表示
旧 V1 可以读取新数据中认识的 name,但这个 Prost 消息结构不会在重新编码时保留未知 locale。于是“旧客户端能读取新增字段的报文”与“经过旧节点解码再编码后,新字段仍能到达终点”是两种兼容要求。如果旧节点只是透传原始消息字节,则没有发生这里的有损重建;具体链路要按实际转换过程验证。
发布前按读写方向检查
| 场景 | 要检查的结果 |
| 新写入 → 旧读取 | 未知字段能否跳过,旧业务是否仍正确 |
| 旧写入 → 新读取 | 缺失值是否有合理含义,不能默认都合法 |
| 新写入 → 旧中转 → 新读取 | 重编码、JSON 转换是否丢失新增信息 |
| 新枚举值 → 旧逻辑 | 是否保存原始整数,是否错误套用默认含义 |
| 数据兼容但单位变化 | 业务计算、范围和展示是否改变 |
逐步发布通常先让读取方接受新形式,再启用新写入;回滚还需检查已经写下的新数据是否能被旧版本合理处理。保留字段号避免编码误读,却不会自动解决单位、权限或状态机语义改变。
旧节点读得懂,也能保留新字段吗?
比较两个只有细微差别的报文:None 根本不发送字段 2,Some("") 则发送字段键和零长度。原始转发能保留这一差别;本文的旧 Prost V1 只认识 name,重建消息后就丢掉 locale。兼容性因此需要检查整条读写路径,而不只是旧端能否解码。
正在呈现知识画面
旧节点读得懂,也能保留新字段吗?Some("") 编码为字段 2 的 12 00;旧 Prost 消息解码再编码会丢掉未声明字段,原始透传则保留。
{"id":"rust-protobuf-presence","title":"旧节点读得懂,也能保留新字段吗?","summary":"Some(\"\") 编码为字段 2 的 12 00;旧 Prost 消息解码再编码会丢掉未声明字段,原始透传则保留。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><label class=\"check\"><input type=\"checkbox\" id=\"present\" checked><span data-i18n=\"present\"></span></label><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"raw\" data-i18n=\"raw\"></button><button data-mode=\"decode\" data-i18n=\"decode\"></button></div><div class=\"track-row\"><span data-i18n=\"before\"></span><div id=\"before\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"after\"></span><div id=\"after\" class=\"track\"></div></div><section class=\"panel\"><h3 data-i18n=\"receiver\"></h3><div id=\"receiver\"></div></section><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"explain\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:550;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:550 20px ui-monospace,monospace}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px ui-monospace,monospace;background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px ui-monospace,monospace}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 ui-monospace,monospace;white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:ui-monospace,monospace}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px ui-monospace,monospace;padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 ui-monospace,monospace;overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n.chart-wrap{margin:18px 0}.chart-label{font-size:12px;color:var(--muted);min-height:3.2em}.curve{display:block;width:100%;height:auto;max-height:320px;overflow:visible}.curve text{font:11px ui-monospace,monospace;fill:var(--muted)}.gridline{stroke:var(--rule);stroke-width:1}.metrics strong{font-size:20px;min-height:3.2em}.formula{font:12px/1.8 ui-monospace,monospace;min-height:9em}.readout{min-height:8em}.presets button{flex-basis:30%}.scope p{overflow-wrap:anywhere} .unit-note{font-size:12px;color:var(--muted);margin:10px 0}\n\n.metrics span{word-break:break-all;line-break:strict}.metrics>div{padding-right:6px}\n\n.cell{min-height:72px}.metrics strong{min-height:3.6em}\n\n#receiver{min-height:3em;font:16px ui-monospace,monospace}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';row.style.gridTemplateColumns='repeat('+r.length+',minmax(0,1fr))';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function plot(id,series,xr,yr,opts={}){const e=$('#'+id),X=x=>42+(x-xr[0])/(xr[1]-xr[0])*298,Y=y=>175-(y-yr[0])/(yr[1]-yr[0])*148;let out='<defs><clipPath id=\"clip-'+id+'\"><rect x=\"42\" y=\"27\" width=\"298\" height=\"148\"/></clipPath></defs>';for(let i=0;i<3;i++){let x=xr[0]+i*(xr[1]-xr[0])/2,y=yr[0]+i*(yr[1]-yr[0])/2;out+='<path class=\"gridline\" d=\"M '+X(x)+' 27V175M42 '+Y(y)+'H340\"/><text x=\"'+X(x)+'\" y=\"194\" text-anchor=\"middle\">'+esc(opts.xfmt?opts.xfmt(x):Number(x.toFixed(2)))+'</text><text x=\"36\" y=\"'+(Y(y)+4)+'\" text-anchor=\"end\">'+esc(opts.yfmt?opts.yfmt(y):Number(y.toFixed(2)))+'</text>';}out+='<g clip-path=\"url(#clip-'+id+')\">';for(const s of series){out+='<path fill=\"none\" stroke=\"'+(s.color||'var(--accent)')+'\" stroke-width=\"2.5\" '+(s.dash?'stroke-dasharray=\"5 4\"':'')+' d=\"'+s.pts.map((p,i)=>(i?'L':'M')+X(p[0]).toFixed(2)+','+Y(p[1]).toFixed(2)).join(' ')+'\"/>';}for(const p of opts.points||[])out+='<circle cx=\"'+X(p[0])+'\" cy=\"'+Y(p[1])+'\" r=\"4\" fill=\"var(--second)\" stroke=\"var(--paper)\" stroke-width=\"1.5\"/>';if(opts.cursor!==undefined)out+='<path d=\"M'+X(opts.cursor)+' 27V175\" stroke=\"var(--muted)\" stroke-dasharray=\"3 3\"/>';e.innerHTML=out+'</g>';e.dataset.curves=JSON.stringify(series.map(s=>s.pts));}const curve=(fn,lo,hi,n=120)=>Array.from({length:n+1},(_,i)=>{const x=lo+(hi-lo)*i/n;return[x,fn(x)]});function inputs(draw){document.querySelectorAll('input,select').forEach(e=>{e.addEventListener('input',draw);e.addEventListener('change',draw)});draw()}const n=id=>+$('#'+id).value;const show=(id,v,d=3)=>set(id,Number(v.toFixed(d)));let mode='raw';function draw(){pressed(mode);let p=$('#present').checked,a=['0A','01','41',...(p?['12','00']:[])],b=mode==='decode'?a.slice(0,3):a;for(let [id,vals]of [['before',a],['after',b]])cells(id,Array.from({length:5},(_,i)=>({text:vals[i]||'—',cls:i<vals.length?(i>=3?'gold':'on'):'empty'})));set('receiver',p&&mode==='raw'?'Some(\"\")':'None');set('explanation',t('explain'));}document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{mode=b.dataset.mode;draw()});inputs(draw);","audio":false,"strings":{"after":"中转后字节","before":"V2 发出的字节","decode":"V1 解码再编码","explain":"12 = (2 << 3) OR 2,00 是零长度。字段存在性来自编码,不等于字符串内容非空。","heading":"旧节点读得懂,也能保留新字段吗?","intro":"选择 optional locale 的缺失或显式空值,再选择原始透传或旧 Prost 重编码。","mode":"旧中转行为","present":"显式发送 Some(\"\")","raw":"原始字节透传","receiver":"V2 接收端 locale","reset":"重置","scope":"固定 name=\"A\"、字段 1;optional locale 字段 2。仅模拟本文 Prost V1/V2 消息,不推广到所有 Protobuf 实现。","scopeLabel":"模型范围与约定"}}
Status、deadline 与重试
Status 表示 RPC 结果,不能只看 HTTP 响应状态。常用分类如下,完整代码定义见 gRPC Status Codes:
| 代码 | 应用上的判断 |
| InvalidArgument | 请求本身不符合要求,原样重试通常无益 |
| Unauthenticated / PermissionDenied | 缺少有效身份 / 权限不允许 |
| NotFound / AlreadyExists | 与业务资源状态有关 |
| ResourceExhausted | 配额或容量限制,需要节流或调整预算 |
| Unavailable | 可能是临时不可用,但仍须判断操作能否安全重试 |
| DeadlineExceeded / Cancelled | 调用方停止等待,不证明远端没有产生效果 |
客户端应有截止时间,总预算要覆盖排队、连接、处理与可能的重试。Request::set_timeout 写入 grpc-timeout metadata,和仅在本地设定传输超时不是同一个操作;上面的设置发生在 connect 之后,因此没有给建连本身设置两秒上限。
服务取消后,应用还需停止自己创建的后台工作。向下游继续发 RPC 时应传播剩余预算,而不是每层重新给完整超时;不同语言和库对传播的支持不同,需明确验证。gRPC Deadlines
流式处理的生命周期更长
服务端返回 Response 只表示建立了响应流,不表示所有业务消息已经完成。随后流的元素通常是 Result<Message, Status>;消费端须区分正常结束与错误,不能用 while let Some(Ok(...)) 静默吞掉错误。
向有界 mpsc 通道 send().await 可把消费者变慢反馈给生产者。发送失败表示接收方已消失,应结束相应工作而不是 unwrap;无界队列则可能把网络背压转成内存增长。后台任务还需持有并响应适当的取消信号。
消息大小限制不等于整个流的总大小限制。长流还需要时长、总字节、并发数或业务窗口控制;客户端只拉取一部分后丢弃,服务端也应释放关联资源。
在进程内验证生产者和消费者的终止协议
为前面的 tokio 依赖 features 加上 "sync",再保存下面的 src/bin/stream_state.rs。它使用与流式服务常见生产端相同的有界通道,直接观察队列中的 Result;这一步不需要监听端口或调用远端服务。
use tokio::sync::mpsc;
use tonic::{Code, Status};
use grpc_demo::greeter::HelloReply;
#[tokio::main]
async fn main() {
let (sender, mut receiver) = mpsc::channel::<Result<HelloReply, Status>>(1);
let producer = tokio::spawn(async move {
if sender.send(Ok(HelloReply { message: "part-1".into() })).await.is_err() {
return;
}
let _ = sender.send(Err(Status::unavailable("source interrupted"))).await;
});
let mut received = Vec::new();
let mut failure = None;
while let Some(item) = receiver.recv().await {
match item {
Ok(reply) => received.push(reply.message),
Err(status) => {
failure = Some(status.code());
break;
}
}
}
drop(receiver);
producer.await.unwrap();
assert_eq!(received, ["part-1"]);
assert_eq!(failure, Some(Code::Unavailable));
let (sender, receiver) = mpsc::channel::<Result<HelloReply, Status>>(1);
drop(receiver);
assert!(sender.send(Ok(HelloReply { message: "unused".into() })).await.is_err());
}
有界容量 1 只约束排队的消息项数,不限制一条 message 里的字符串字节数,也不限制生产者在 send 前已经构造的对象。生产者遇到接收方离开会停止发送;如果还持有数据库游标或启动了其他任务,应同时释放或取消那些资源。
实际 tonic 客户端可按 stream.message().await 的 Result<Option, Status> 分别处理消息、正常结束和错误,不能把 Err 当成 None。若业务要求一次完整快照,上例中的 part-1 只能算暂存数据,最终失败时不应当成完整结果提交;若业务允许增量消费,则需要消息序号、恢复游标和幂等处理来承接重连。
本地验证与传输验证覆盖不同问题
可以直接调用生成的 Greeter trait 方法验证空 name 与正常响应,再用 Prost 测试字段编码;这些检查快速定位业务和协议模型问题。传输层还需单独检查 metadata、TLS、消息限制、deadline 和流结束状态,因为直接调用 handler 不会经过 tonic 的 HTTP/2 编解码及中间件。
同样,客户端 set_timeout 产生的 metadata 需要服务端和中转链路正确理解。它不为创建 Channel 之前的 DNS/连接等待补上预算,也不等于后台任务会自动停止。围绕一次用户操作建立总期限,再分别验证建连、RPC 等待与应用资源清理。
Interceptor 适合什么
Interceptor 的接口处理 Request<()> 并同步返回请求或 Status,适合读取 metadata、注入轻量上下文和即时拒绝。它不能直接 await 远程鉴权,也不提供任意响应体变换能力。Interceptor
需要异步外部查询、请求与响应完整观测或更细的流控制时,使用适合插入层次的 Tower 服务,或在业务服务中显式完成。无论放在哪里,都应明确错误如何变成 Status,以及流结束时怎样清理资源,见 Tower 与中间件。