《Java 服务端通信工程》2.2 JSON 与 Protobuf 的取舍

把同一份 Book 数据分别写成 JSON 和 Protobuf,比较字节数、解析成本、演进能力和调试体验;说明 proto3 JSON 映射里 int64 变字符串、默认值省略、字段名转小驼峰的规则,并给出网关边界上用 JsonFormat 转换的代码。

承接的问题

2.1 拆开了 Protobuf 的字节,但「为什么内部调用不用 JSON」还没有正面回答。常见的两种极端都不对:一种认为 Protobuf 一定更快,所有接口都应该换;另一种认为 JSON 可读性好,调试方便,没必要引入 IDL。

这一节不站队,而是把两种格式放在同一组维度上比较:字节大小、解析成本、演进能力、调试体验、生态约束。结论会落到一个具体的分工:服务内部用 Protobuf,对外边界用 JSON,中间用 JsonFormat 转换。

同一份数据的两种字节

取 2.1 的 Book{isbn="42", title="A", author="B", stock=7}:

格式字节内容
Protobuf120a 02 34 32 12 01 41 1a 01 42 20 07
JSON48{"isbn":"42","title":"A","author":"B","stock":7}

这个例子里 Protobuf 是 JSON 的四分之一。比例不能直接推广:JSON 的固定开销是字段名、引号、冒号和括号,字段名越长、字段越多,开销越大;值本身很长时(例如一段描述文本),两者的差距会缩小,因为大头的字符串内容是一样的。

反过来说,JSON 的可读性也是真实的:上面那 48 字节不用任何工具就能看懂,Protobuf 的 12 字节必须对照 schema 才能解释。调试时这个差别很值钱。

JSON 的强项与代价

JSON 的强项:

  • 人可读。 日志、抓包、curl 输出都能直接看,不需要 schema。
  • 无 IDL。 新增字段不用改 schema,客户端忽略不认识的键即可。
  • 生态广。 浏览器、移动端、网关、脚本语言都原生支持。
  • 调试成本低。 构造一个请求只需要一段文本,不需要生成代码。

JSON 的代价:

  • 没有强类型。 数字只有一种 number,64 位整数在 JavaScript 里会丢精度;布尔和字符串的边界靠解析器。
  • 没有字段号。 重命名字段等于删除加新增,旧客户端读不到;想兼容只能同时保留两个键。
  • 体积大。 字段名重复出现在每条消息里,嵌套越深,冗余越多。
  • 解析慢且分配多。 解析器要建字符串、Map 和对象树;高频路径上 GC 压力明显。
  • 没有默认值语义。 null、缺失、0、"" 是四种不同的状态,团队必须约定哪一种表示「没有」。

JSON 的这些代价不是缺陷,而是设计取舍。它适合「人参与」的边界:对外 API、配置文件、调试接口、事件日志。

Protobuf 的强项与代价

Protobuf 的强项正好对应 JSON 的代价:

  • 二进制紧凑。 字段名不进线上,只有字段号和值。
  • 强类型 schema。 .proto 定义了类型、字段号和调用模式,生成代码负责序列化。
  • 演进规则明确。 新增字段、reserved 删除、枚举扩展都有章可循。
  • 解析快、分配少。 生成的解析器直接读字节,不需要建通用对象树。

它的代价:

  • 需要 IDL 和构建步骤。 改一次 .proto 要重新生成代码,CI 要装 protoc。
  • 不可读。 抓包看到的是字节,必须有 schema 才能解释。
  • 跨语言实现有差异。 未知字段保留、确定性序列化、JSON 映射的细节,不同运行时不完全一致。
  • 不适合公开 API。 外部调用方不愿意为了调一个接口引入你的 .proto 和代码生成器。

proto3 的 JSON 映射规则

Protobuf 官方定义了 JSON 映射,JsonFormat 实现了它。几条最容易踩的规则:

proto 类型JSON 表示注意
int32 / uint32number超出 32 位范围会报错
int64 / uint64string避免 JavaScript 精度丢失
float / doublenumberNaN、Infinity 写成字符串
booltrue / false不接受 "true"
stringstring必须是合法 UTF-8
bytesbase64 string标准 base64,不是 URL-safe
enumstring(枚举名)也接受数字
消息字段object未设置时省略
repeatedarray空数组可能被省略
mapobject键必须是字符串
字段名lowerCamelCase可用 preservingProtoFieldNames() 保留原样

int64 变成字符串是一条硬规则。StockUpdate.version 是 int64,JSON 里必须写成 "9007199254740993",不能写成数字。用 JsonFormat 解析时,字符串和数字都接受;但打印时一定输出字符串。

默认值在 JSON 里同样可能被省略。stock=0 不会出现在输出里,除非显式要求包含默认值。这和 2.1 的「默认值不上线」是同一个语义在两个格式上的表现。

在 linkd-v3 里落地:边界转换

comm-gateway 对外说 JSON,对内说 Protobuf。转换只发生在网关,不让 JSON 渗进 comm-core 和 comm-server:

package com.plume.linkd.v3.gateway;

import com.google.protobuf.util.JsonFormat;
import com.plume.linkd.v3.api.Book;

import java.nio.charset.StandardCharsets;

public final class BookJson {

    private static final JsonFormat.Printer PRINTER =
            JsonFormat.printer().omittingInsignificantWhitespace();
    private static final JsonFormat.Parser PARSER = JsonFormat.parser();

    public static String toJson(Book book) throws Exception {
        return PRINTER.print(book);
    }

    public static Book fromJson(String json) throws Exception {
        Book.Builder builder = Book.newBuilder();
        PARSER.merge(json, builder);
        return builder.build();
    }

    public static void main(String[] args) throws Exception {
        Book book = Book.newBuilder()
                .setIsbn("42")
                .setTitle("A")
                .setAuthor("B")
                .setStock(7)
                .build();
        String json = toJson(book);
        System.out.println("json=" + json);
        System.out.println("json.bytes=" + json.getBytes(StandardCharsets.UTF_8).length);
        System.out.println("proto.bytes=" + book.toByteArray().length);
        System.out.println("roundtrip=" + fromJson(json).getStock());
    }
}

预期输出:

json={"isbn":"42","title":"A","author":"B","stock":7}
json.bytes=48
proto.bytes=12
roundtrip=7

JsonFormat.printer() 默认输出带缩进的多行 JSON,适合人看;网关返回给浏览器时用 omittingInsignificantWhitespace() 去掉空白,减少字节。不要自己拼 JSON 字符串,字段名转义、int64 精度、bytes 的 base64 都容易写错。

再看 int64 的处理:

package com.plume.linkd.v3.gateway;

import com.google.protobuf.util.JsonFormat;
import com.plume.linkd.v3.api.StockUpdate;

public final class StockJson {

    public static void main(String[] args) throws Exception {
        String payload = "{\"isbn\":\"42\",\"stock\":7,\"version\":\"9007199254740993\"}";
        StockUpdate.Builder builder = StockUpdate.newBuilder();
        JsonFormat.parser().merge(payload, builder);
        System.out.println("version=" + builder.getVersion());
        System.out.println("json=" + JsonFormat.printer()
                .omittingInsignificantWhitespace().print(builder.build()));
    }
}

预期输出:

version=9007199254740993
json={"isbn":"42","stock":7,"version":"9007199254740993"}

如果把 version 写成不带引号的数字,JavaScript 客户端在 9007199254740993 上会丢精度,读回来变成 9007199254740992。这不是 Java 的问题,而是 JSON 数字在 IEEE 754 双精度下的上限。服务端统一输出字符串,客户端就不会踩这个坑。

解析时是否忽略未知字段,要分场景:

  • 对外网关:严格解析,未知字段报 INVALID_ARGUMENT。外部调用方拼错字段名应该立刻知道。
  • 内部事件消费:可以用 parser().ignoringUnknownFields(),让旧消费者能读新生产者的事件。
  • 配置加载:严格解析,配置拼错比服务启动失败更危险。

JsonFormat.parser() 默认严格;需要宽松时显式调用 ignoringUnknownFields(),不要全局打开。

性能怎么比较才算数

「Protobuf 比 JSON 快几倍」这种结论,离开测试条件就没有意义。要比较,至少固定六个变量:

变量要求不固定会怎样
数据结构同一份字段、同一组值字段名长度不同,体积比较失真
值分布长短文本、负数、空值的比例一致全用小整数会高估 Protobuf 优势
预热两边都跑足够轮次JIT 未预热,比的是解释执行
度量编码、解码、端到端分开测混在一起,不知道瓶颈在哪
分配记录 GC 次数和分配字节只看耗时,忽略 GC 压力
压缩明确是否 gzip、压缩级别压缩后差距可能反转

四个常见结论及其适用条件:

  • 体积。 小消息、字段名长、字段多时,Protobuf 优势明显;大文本消息两者接近。
  • 编码 CPU。 Protobuf 生成的代码直接写字节,通常分配更少;JSON 要拼字符串或建树,短消息上差距更明显。
  • 解码 CPU。 Protobuf 按 schema 读,JSON 要解析键名和类型;字段越多,差距越大。
  • 压缩后。 JSON 的重复字段名被 gzip 压掉,体积差距缩小;Protobuf 本身紧凑,压缩收益有限。

这些是方向性判断,不是本机实测数字。真正要做选型时,用第 14.2 节的压测方法,拿自己的消息和流量跑一轮,记录 p50/p99、CPU、分配和压缩后体积。没有机器口径和流量剖面的「快 3 倍」不能作为决策依据。

选择矩阵

场景建议理由
服务间高频调用Protobuf体积小、解析快、契约明确
对外公开 APIJSON调用方不需要 IDL,调试成本低
浏览器 / 移动端JSON原生支持,工具链成熟
事件流 / 日志JSON 或 Protobuf + JSON 旁路可读性与体积按保留期权衡
大文本 / 富文本两者都行字符串占大头,编码差异缩小
需要严格兼容的内部契约Protobuf字段号 + reserved + 生成代码
一次性脚本 / 调试接口JSON不值得引入生成步骤
跨组织合作JSON 或 OpenAPI对方不愿意维护你的 .proto
内部管理后台JSON人少、流量低,可读性优先
移动端弱网Protobuf体积和流量敏感,客户端能集成生成代码

矩阵里的「建议」不是硬规则。同一个系统可以两种都用:内部服务之间 Protobuf,对外和管理后台 JSON。关键是边界清晰,不要让两种格式在同一层混用。

一个常见的折中是「内部 Protobuf,边缘 JSON」:linkd-v3 的 9093 只跑 Protobuf,9094 网关把 JSON 转成 Protobuf,再调 9093。这样内部链路享受紧凑和强类型,外部调用方仍然用熟悉的 JSON。代价是网关要做字段映射、错误映射和精度处理,这些工作不会消失,只是被集中到一个模块。

常见错误与边界

错误一:用 JSON 的思维设计 .proto。 给每个字段加 optional、用字符串表示枚举、把数字都定义成 int64,会让 Protobuf 失去优势。枚举用 enum,已知范围的数字用 int32,不要为了「以后可能变大」全部上 64 位。

错误二:把 int64 直接塞进 JSON 数字。 见上面的精度例子。JsonFormat 输出字符串是有意为之,不要用自定义序列化把它改回数字。

错误三:对外暴露 Protobuf 二进制。 除非对方明确要求,否则浏览器和第三方集成会非常痛苦。对外用 JSON,需要高性能时再提供 gRPC 端点作为可选。

错误四:用 JSON 的「缺字段」表达业务状态。 {"stock": null}、{}、{"stock": 0} 是三种不同输入,网关必须明确拒绝哪一种、默认哪一种。不要靠客户端约定。

错误五:在网关里做深度转换。 网关只做协议转换和字段裁剪,不做业务聚合。把 BFF 逻辑塞进转换层,会让 JSON 和 Protobuf 的边界重新模糊。

边界:JSON 没有字段号。 重命名字段时,旧客户端读不到新字段。对外 API 的兼容要靠「同时保留新旧字段一段时间」,或者用版本化路径(/v1/books、/v2/books)。

边界:JsonFormat 不是通用 JSON 库。 它只处理 Protobuf 消息,不支持任意 POJO;日期、枚举、Any 的处理都有特定规则。需要通用 JSON 时用专门的库,不要指望 JsonFormat 兼做两件事。

边界:压缩会改变比较结果。 上面的 48 vs 12 是未压缩字节。对 JSON 做 gzip 后,重复的字段名被压缩,差距会缩小;对已经很小的 Protobuf 再做 gzip,收益有限。是否压缩要看 payload 特征,4.3 会给出判断方法。

小结

  • 同一份 Book,Protobuf 12 字节,JSON 48 字节;比例随字段名长度和值的大小变化,不能直接外推。
  • JSON 强在人可读、无 IDL、生态广;弱在体积、类型、字段号和解析分配。
  • Protobuf 强在紧凑、强类型、演进规则明确;弱在需要构建步骤、不可读、对外不友好。
  • proto3 的 JSON 映射里,int64 必须写成字符串,默认值可能省略,字段名默认转 lowerCamelCase。
  • linkd-v3 的分工是内部 9093 用 Protobuf,对外 9094 用 JSON,网关用 JsonFormat 做边界转换。
  • 网关严格解析未知字段,内部事件消费可以宽松;不要全局打开 ignoringUnknownFields()。
  • 性能结论必须带机器口径、值分布和是否压缩;没有这些条件,「快几倍」不能作为选型依据。
  • 同一系统可以两种格式并存,但边界要清晰:内部 Protobuf、边缘 JSON,中间只做一次转换。

格式选定后,真正难的是「以后怎么改」。2.3 把字段演进拆成一张兼容矩阵,逐条说明新增、删除、改类型、改枚举值时线上会发生什么。

阅读导航:上一节 2.1 Protobuf 编码与 schema | 下一节 2.3 字段演进与兼容矩阵

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收