跳到主要内容

JSON 转 Protobuf:从 message 定义到字段编号的完整写法

把一段真实 JSON 写成 proto3 的 message 定义,讲清字段编号怎么分、JSON 类型怎么映射到 int64 和 double、嵌套对象如何抽成命名 message,以及微服务里为什么换 protobuf。

发布于 作者 李雷
#protobuf #grpc #json #微服务 #proto3

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 又小又无符号,粘进来之后手动收窄成 int32uint32 也行,但默认给 int64 是为了让任何范围内的 JSON 数字都能往返。

嵌套 message 和 repeated 数组

嵌套对象不会被压平,它会抽成自己的命名 message,名字用 PascalCase 拼成 ${父名}${key}。比如 User 里的 address 对象,会变成一个独立的 message UserAddress,父级用一行 UserAddress address = 2; 引用它。

数组分两种处理。标量数组直接变 repeated stringrepeated int64 这样的字段。对象数组更聪明:它把所有元素的 key 取并集,折叠成一个 message,字段标 repeated。所以 [{"a":1},{"a":1,"b":2}] 出来的是一个同时带 ab 的 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