JSON 转 Protobuf:从 message 定义到字段编号的完整写法
把一段真实 JSON 写成 proto3 的 message 定义,讲清字段编号怎么分、JSON 类型怎么映射到 int64 和 double、嵌套对象如何抽成命名 message,以及微服务里为什么换 protobuf。
JSON 转 Protobuf:从 message 定义到字段编号的完整写法
我去年把一个 REST 接口迁到 gRPC,卡了整整一下午,不是卡在代码,是卡在手抄 schema。后端返回一坨嵌套 JSON,我对着屏幕一个字段一个字段往 .proto 里敲,编号还得自己数,数到第三层嵌套就乱了。后来我发现这件事根本不该手动做:JSON 的结构里已经藏着 message 该有的样子,把它机械翻译出来就行。这篇讲清楚这套翻译规则,以及为什么微服务值得为它换上 protobuf。
为什么微服务要从 JSON 换到 protobuf
JSON 是文本,人读着舒服,机器读着费劲。每个 key 都要在网线上原样传一遍,"created_at" 这一串字符出现一万次就传一万次。protobuf 不传 key,它传字段编号,一个整数。同样一条记录,编码后的体积常常只有 JSON 的三分之一到一半,解析速度也快得多,因为不用做字符串扫描和类型猜测。
线上身份是编号这件事还带来一个副作用:字段名随便改不影响兼容。你把 userId 改成 user_id,只要编号没动,旧的二进制照样能解出来。gRPC 之所以默认绑 protobuf,就是吃这套强类型契约:服务端和客户端从同一份 .proto 各自生成代码,接口对不上在编译期就报错,而不是等到线上才发现某个字段拼错了。
字段编号:每个字段分到唯一的号
这是 protobuf 最容易被忽视、又最关键的一点。每个字段都要有一个编号,编号在一个 message 内部必须唯一,而且从 1 开始按 key 出现顺序递增。进到嵌套 message,编号又从 1 重新数。
我的转换习惯是:第一个 key 拿 1,第二个拿 2,以此类推。注意一个铁律,编号一旦上了生产就不能复用也不能重排。字段名可以随便改,因为线上不认名字;但你若把编号 2 挪给别的字段,旧客户端发来的 2 号数据就会被解析成错误的类型。要给未来新增的字段留位置,用 reserved 把空号占住,别让后人不小心捡回来。
JSON 类型到 proto 标量的映射
JSON 只有一种数字类型,没有宽度也没有符号,所以映射时要往安全的一边靠:
- 整数样本推断成
int64。JS 的 number 是 64 位浮点,毫秒级时间戳和雪花 ID 经常超过int32的范围,用int64才不会溢出。 - 带小数的样本推断成
double,不用float,避免悄无声息的精度丢失。 - 同一个字段有时整数有时带小数,放宽到
double。 - 字符串映射成
string,布尔映射成bool。 - 只出现过
null的值,或真正混合类型的数组,退化成google.protobuf.Any,并自动补上对应的import。
如果你确定某个 ID 又小又无符号,粘进来之后手动收窄成 int32 或 uint32 也行,但默认给 int64 是为了让任何范围内的 JSON 数字都能往返。
嵌套 message 和 repeated 数组
嵌套对象不会被压平,它会抽成自己的命名 message,名字用 PascalCase 拼成 ${父名}${key}。比如 User 里的 address 对象,会变成一个独立的 message UserAddress,父级用一行 UserAddress address = 2; 引用它。
数组分两种处理。标量数组直接变 repeated string、repeated int64 这样的字段。对象数组更聪明:它把所有元素的 key 取并集,折叠成一个 message,字段标 repeated。所以 [{"a":1},{"a":1,"b":2}] 出来的是一个同时带 a 和 b 的 message,而不是两个几乎重复的类型。
一段真实的输入输出
下面这段 JSON 是一个典型的用户接口响应:
{
"user_id": 100000000123,
"name": "李雷",
"active": true,
"address": { "city": "Beijing", "zip": "100000" },
"roles": ["admin", "editor"]
}
转成 proto3,得到:
syntax = "proto3";
message User {
int64 user_id = 1;
string name = 2;
bool active = 3;
UserAddress address = 4;
repeated string roles = 5;
}
message UserAddress {
string city = 1;
string zip = 2;
}
看几个细节:user_id 那个超过 21 亿的值落到 int64;address 抽成了 UserAddress,编号在它内部从 1 重新开始;roles 是字符串数组,变成 repeated string,编号 5。根 message 里五个字段拿到 1 到 5,各占唯一一个号。
把这套规则放进浏览器里跑,就是 /zh/t/json-to-protobuf/:粘 JSON,选 proto3 还是 proto2,设 package,改根名,复制或下载 .proto,整个过程不碰服务器。如果你要的不是 protobuf 而是别的语言结构,同一份 JSON 也能转成 Go 结构体,见 /zh/t/json-to-go-struct/。粘之前若 JSON 格式有问题,先过一道 /zh/t/json-formatter/ 把末尾逗号和注释清掉,因为严格的 JSON.parse 不吃这些。
一点收尾建议
转出来的 .proto 是起点不是终点。生成的编号按 key 顺序排,生产前一定自己审一遍;数字类型默认偏宽,知道真实取值域再手动收窄。把这份 schema 提交进仓库,它就成了活文档,下一个接手的人读的是有类型的契约,而不是对着 json.loads 猜形状。
Made by Toolora · Updated 2026-06-13