Protobuf与JSON互转实战:原理、库选型与生产踩坑指南 如果你做过网关或者中间层开发大概率会碰到这样一个场景上游微服务内部已经换成了 Protobuf但下游的 App、浏览器、运营后台还在等 JSON。于是“Protobuf 与 JSON 转换”就成了每天绕不开的活儿。我最近两年在两个项目里完整踩过这条链路——从最开始的暴力反序列化到后来把字段命名、枚举、时间戳这些细节收拾干净中间踩了不少坑。这篇文章把我实际用过的转换方案、库的选型和边界情况都整理出来适合正在做接口改造、协议兼容或者单纯想搞清楚两者差异的后端/网关开发同学参考。内容不涉及复杂框架原理我尽量用项目里的真实消息做例子。1. 为什么我最终把项目里的 JSON 换成了 Protobuf又保留了 JSON1.1 一次接口改造的真实场景有一段时间我负责的订单查询服务 QPS 很高调用链是 App → 网关 → 订单服务 → 库存服务每一层之间都是 JSON。一个订单详情响应体大概 900 字节看着不大但一天下来流量和解析时间都很可观。压测时发现订单服务 CPU 有 30% 花在 JSON 的序列化和解析上而且手动拼 JSON 的代码特别容易出问题——字段漏写、类型传错、嵌套层级对不上排查起来非常头疼。后来我们把订单服务和库存服务之间的 RPC 改成了 Protobuf体积和耗时确实降下来了。但网关到前端仍然是 JSON原因很简单前端不能直接读二进制浏览器里调试工具看到的是一堆乱码POSTMAN 也没法友好展示。而且运营后台、日志系统、数据同步任务大多还是消费 JSON 的不可能为了内部优化把整个链路都推倒重来。所以最终架构变成了内部服务之间走 Protobuf网关层做一次“Protobuf 转 JSON”再返回给外部。这个模式在现在很多微服务项目里都很常见。你可以说它是“协议隔离”也可以说它是“内部快、外部稳”的折中方案。但不管叫什么转换这一层只要稍微粗糙一点线上就会冒出一堆隐蔽问题。这也是我写这篇文章的原因。1.2 JSON 做对了什么做错了什么先说结论JSON 和 Protobuf 不是替代关系它们解决的是不同问题。JSON 做对的事情很明显自描述。每个字段名都写在数据里不需要额外 schema 就能看懂。生态广。浏览器、Python、Go、Java、手机端全都原生支持。调试友好。随手一个JSON.parse就能看结构接口文档也好写。但 JSON 的短板同样明显字段名重复传输同样一条数据体积比二进制大很多。没有强类型。整数、浮点、字符串全靠解析器猜数字超过 2^53 就丢精度。解析性能比二进制格式差高 QPS 下 CPU 和内存都吃紧。Protobuf 的优势正好补上这些强类型、二进制紧凑、有.proto文件当契约。但它的缺点也很现实二进制不可读没有.proto文件时你根本不知道里面是什么跨语言还要先编译生成代码。所以“内部 Protobuf对外 JSON”不是偷懒而是把各自的优势用在合适的位置。转换不是额外开销而是这套架构里必须认真设计的一环。2. Protobuf 与 JSON 的本质差异不只是“省流量”这么简单2.1 Wire Format 看不懂用一条消息把两种格式都“拆开”很多人觉得 Protobuf 就是把 JSON 里的字段名换成短名字省几个字节。其实不是。Protobuf 的二进制格式根本没有字段名它只靠字段编号和 wire type 来识别字段。举个例子有这样一条 protosyntax proto3; message Order { string order_id 1; int64 user_id 2; double total 3; }如果order_id 12345user_id 100total 100.0那么序列化出来的字节大概是0A 05 31 32 33 34 35 10 64 19 00 00 00 00 00 00 59 40逐个拆开看0A低 3 位是010表示 wire type 为 2length-delimited高 5 位是 1表示 field 1。05后面字符串长度是 5 字节。31 32 33 34 35就是 ASCII 里的12345。10field 2wire type 为 0varint。64varint 编码的 100。19field 3wire type 为 164-bit fixed。后面 8 字节是 double 100.0 的 IEEE 754 表示。同样的内容JSON 是{ orderId: 12345, userId: 100, total: 100.0 }JSON 把字段名写了两遍而 Protobuf 只写字段编号和值。这就是为什么 Protobuf 通常比 JSON 小 60% 左右。但这里有个重要推论既然二进制里只有字段编号那字段编号就是协议的一部分。你把order_id从 1 改成 2旧消费者解析出来的就是另一个字段你把字段类型从int64改成stringwire type 可能一样但语义完全变了。这类问题在 JSON 里不会出现因为 JSON 至少还会带字段名。2.2 字段编号、类型标签和默认值转换时最容易踩的几个坑第一个坑字段编号随意改动。我见过一个团队为了“让 proto 看着整齐”把两个字段编号互换结果线上消息解析错乱排查了很久才发现是编号对不上。这个问题在做 Protobuf 与 JSON 转换时尤其隐蔽——因为 JSON 侧看起来字段名是对的但二进制侧实际取到的值已经串了。第二个坑wire type 与字段类型不匹配。正常情况下你用官方库解析字段类型对不上会直接报错。但如果你在转换过程中自己拼字节或者用一些“零拷贝”的 hack 方案就很容易出现解析出垃圾值的情况。我的建议是永远不要绕过官方生成代码去手动解析 wire format除非你真的在写一个调试工具。第三个坑也是最常见的proto3 默认值不参与序列化。user_id 0、total 0.0、空字符串这些字段在二进制里根本不会出现。如果你把二进制字节直接交给一个不知道默认值规则的转换工具这些字段在 JSON 里就会消失。正确的做法一定是先把二进制反序列化成 Message 对象再对 Message 做 JSON 序列化。因为反序列化时 Protobuf 库会把没出现的字段填成默认值这时候 JSON 化才不会丢字段。这个坑我在项目里真实遇到过。同事从消息队列里拿了一段 proto 字节图省事直接用现成工具转 JSON结果所有为 0 的金额字段全没了线上对账对不上。后来改成标准流程字节 → Message → JsonFormat问题立刻消失。3. 转换实操一套配置吃透三种互转路径3.1 官方 JsonFormatproto3 下最稳的 JSON 序列化方案如果你用的是 Java首选就是protobuf-java-util里的JsonFormat。它严格实现了 Protobuf 官方的 JSON 映射规范而且能处理很多你自己手写容易漏的情况。举个例子Order order Order.newBuilder() .setOrderId(12345) .setUserId(100) .setTotal(100.0) .build(); // Message 转 JSON String json JsonFormat.printer() .includingDefaultValueFields() .print(order); // JSON 转 Message Order.Builder builder Order.newBuilder(); JsonFormat.parser() .ignoringUnknownFields() .merge(json, builder); Order parsed builder.build();几个选项是实际项目里必须关心的includingDefaultValueFields()输出 0 值字段。否则订单金额为 0 时JSON 里根本没有这个字段排查问题会很难受。preservingProtoFieldNames()保留 proto 里的原始 snake_case 字段名。默认情况下order_id会转成orderId。ignoringUnknownFields()JSON 里出现 proto 中没有的字段时不报错。对兼容旧客户端很有用。Go 那边官方推荐google.golang.org/protobuf/encoding/protojsonimport google.golang.org/protobuf/encoding/protojson m : orderpb.Order{ OrderId: 12345, UserId: 100, Total: 100.0, } jsonBytes, err : protojson.MarshalOptions{ EmitUnpopulated: true, UseProtoNames: true, }.Marshal(m) err protojson.UnmarshalOptions{ DiscardUnknown: true, }.Unmarshal(jsonBytes, m)Python 也有对应的google.protobuf.json_formatfrom google.protobuf.json_format import MessageToDict, ParseDict data MessageToDict( order, preserving_proto_field_nameTrue, always_print_fields_with_no_presenceTrue, ) parsed ParseDict(data, Order(), ignore_unknown_fieldsTrue)这些官方库的共同点是都遵循同一个 JSON Mapping 规范int64输出字符串、枚举输出名称、Timestamp输出 RFC3339。如果你自己写转换逻辑很容易在这些细节上偏离规范导致网关与客户端各说各话。3.2 第三方库怎么选protobufjs / google-protobuf / pbjson 的取舍后端语言基本都有官方实现但前端和网关用起来又是另一套情况。我整理过一张选型表列一下常见的几种库/工具语言/平台特点注意点protobuf-java-util的 JsonFormatJava官方实现功能全依赖 protobuf-javaprotojsonGo官方现代 API输出 int64 为字符串protobufjsJavaScript/TypeScript可动态加载 proto 或静态生成前端性能有限注意字段命名google-protobufJavaScript官方 JS 库API 较老toObject 返回字段名是下划线pbjsonRust按 proto3 JSON Mapping 实现适合已经用 serde 的 Rust 服务前端场景我推荐protobufjs因为它能直接加载.proto文件也可以把 proto 编译成 JSON descriptor前端代码不需要维护一堆生成类。但要注意protobufjs的toObject默认输出的是原始 proto 字段名下划线风格你需要自己决定是否转 camelCase否则跟前端代码风格会很拧巴。如果你是在做一个对外 REST 网关又不想手动处理转换可以看看grpc-gateway。它会在生成 REST 接口的同时自动把 JSON 请求体映射成 proto message响应体再映射回 JSON本质上就是封装了protojson。适合 gRPC 和 HTTP 接口同时暴露的场景。3.3 动态消息与 Unknown Fields遇到没有 .proto 文件时怎么转经常有人问我手里只有一段 proto 二进制没有.proto文件怎么转成 JSON理论上你确实可以从 wire format 反推字段编号和类型。但实际这么做风险很高因为同一个字段编号在不同 proto 版本里可能对应不同类型而且oneof、map、Any这些结构光靠猜是猜不出来的。更稳的办法是用protoc生成 descriptorprotoc -I. --descriptor_set_outorder.desc order.proto然后在代码里加载这个.desc文件用DynamicMessage动态构建消息再走JsonFormat。Java 里大概是Descriptors.DescriptorDynamicMessage的组合。这种方式适合做通用调试工具或数据迁移任务但很依赖描述符文件与线上版本严格一致否则解析出来照样是乱的。更现实的问题其实是 unknown fields。一条 proto 消息从旧版本升级到新版本后旧消费者可能遇到新加的字段。官方 JSON 转换默认会丢弃这些未知字段但某些场景下你希望把它们保留下来透传给下游。我的做法是在 proto 里预留一个mapstring, string extra 100;或者google.protobuf.Struct extra 100;把未知的扩展数据塞进去转 JSON 时再原样展开。这样既不会破坏 proto 的强类型又能保住动态扩展能力。4. 转换时绕不过的边界场景枚举、Timestamp、Oneof 和 JSON 别名4.1 枚举值应该输出字符串还是数字这个坑几乎每个项目都会遇到。proto3 的枚举在 JSON Mapping 规范里建议输出名称字符串比如enum OrderStatus { ORDER_STATUS_UNSPECIFIED 0; ORDER_STATUS_CREATED 1; ORDER_STATUS_PAYED 2; }默认情况下JsonFormat.printer().print()输出的是status: ORDER_STATUS_PAYED而不是status: 2。如果前端当初是按数字写的判断逻辑看到字符串直接懵。反过来有些实现也支持数字。Java 里是useIntegersForEnums(true)Go 的protojson是UseEnumNumbers: true。两种方案各有适用场景方案输出示例适合场景默认字符串status:ORDER_STATUS_PAYED对外 API、Swagger 文档、日志排查数字status:2旧客户端、需要压缩体积、内部接口我的经验是对外 API 优先用字符串因为枚举名通常比数字更稳定而且自解释性强。如果你因为历史原因只能输出数字一定要在接口文档里把枚举映射表写死不要靠前后端各自维护一份。4.2 Timestamp 的 RFC3339 字符串与 int64 微秒的选择这个坑比枚举更隐蔽。google.protobuf.Timestamp在 JSON 里的标准表示是 RFC3339 字符串例如{ eventTime: 2024-06-01T08:00:00Z }很多人第一次转出来看到的是字符串下意识以为应该输出毫秒数直接拿int64去解析前端new Date(int64)得到 Invalid Date。如果你在 proto 里用的不是google.protobuf.Timestamp而是普通的int64 create_time_ms 1;那么 JSON 输出又会变成字符串或数字取决于实现。比如官方 protojson 会输出字符串1717228800000而某些手写转换会输出数字1717228800000。前端处理方式完全不同。我的建议是在项目里明确规定时间字段的两种表达方式。对外展示型接口用google.protobuf.Timestamp输出 RFC3339 字符串前端直接new Date(value)。内部计算型接口用int64毫秒统一约定输出字符串前端用Number(value)或BigInt(value)转一下避免数字精度丢失。最怕的是同一套接口里一会儿字符串一会儿数字前端被迫写兼容逻辑。4.3 Oneof 字段在 JSON 中如何表态oneof表示“同一时刻只有一个字段被设置”。这种字段转 JSON 时标准规则是“哪个被设置就输出哪个字段名”。比如这样message Notify { string email 1; string phone 2; oneof channel { string sms 3; string push 4; } }如果sms被设置了转换出来的 JSON 只有sms:xxx不会出现push。如果 JSON 里同时传了sms和push解析时有的实现会报错有的实现只保留最后一个值行为不统一。判断 oneof 到底命中了哪个字段一定要用生成的HasChannelCase()/WhichOneof(channel)这类 API不能通过“字段值是否为空”来判断。因为 oneof 字段理论上可以设置空字符串这时候字段存在但值为空跟未设置是两回事。另外Any和Struct也经常在 JSON 转换中出问题。google.protobuf.Any在 JSON 里会变成{ type: type.googleapis.com/orderpb.Order, value: { orderId: 12345 } }如果类型注册表里没有对应类型很多转换库会直接报错。Struct则是一棵任意 JSON 树转换时基本不会报错但你也失去了强类型保护。我的建议是对外 API 尽量少用Any如果必须用要在网关里维护好类型注册表。5. 性能与数据验证我在网关层做的 3 轮实测5.1 序列化体积和耗时对比先放一组我在本地环境做的非严谨压测数据环境是 Java 17消息是一个包含 10 个字段的订单对象序列化 1 万次取平均值指标JSONProtobuf消息体大小856 B312 B序列化耗时1.2 ms/次0.4 ms/次反序列化耗时1.8 ms/次0.5 ms/次体积差距主要来自字段名消失耗时差距来自 JSON 的字符串处理和对象分配。看起来 Protobuf 全面占优但这里有个很容易被忽略的点网关层的实际链路不是“JSON 对比 Protobuf”而是“JSON → parse → Proto → 内部调用 → Proto → serialize → JSON”。也就是说你为了让外部拿到 JSON内部还是要做两次转换。我实际测下来如果只是网关透传直接把外部 JSON 原样传给内部消费方比“JSON 转 Proto 再转 JSON”快很多。所以不要盲目地在每一层都做转换。转换只应该在协议边界发生一次别层层转换。5.2 转换吞吐量与 GC 压力这条经验是用线上事故换来的。之前我把所有对外的 REST 请求都在网关里做了一次“JSON → proto → JSON”的完整转换结果高 QPS 下 GC 变得非常频繁。原因是JsonFormat和所有 JSON 库一样都需要构建中间对象字符串、Map、List、byte[]每条请求都会产生大量临时对象。应对措施我总结成三点JsonFormat.Printer和Parser实例尽量复用它们设计上通常是线程安全的不用每次 new。热路径上避免反复toString()和字符串拼接尤其别把 JSON 字符串拿来再做一次String.format。如果只是为了打日志而转 JSON记得采样不要全量打印。流量大的接口全量打日志GC 和磁盘都扛不住。如果你们用的是响应式框架还要注意避免在一次请求链路里创建太多中间缓冲区。能流式输出就流式输出不要先把整个 JSON 字符串拼好在内存里再返回。5.3 校验策略JSON Schema 还是 proto 约束外部 JSON 进网关时很多人习惯先做 JSON Schema 校验再做 proto 解析觉得双重校验更安全。我实际测过这种方式在低 QPS 下没什么问题但高 QPS 下 CPU 消耗直接翻倍而收益非常有限。更合理的策略是类型和必填校验交给 proto 解析。字段类型不对、数字格式错、枚举值非法parse 阶段就会抛异常。业务约束比如金额必须大于 0、时间范围不能倒挂交给专门的校验器而不是在 JSON Schema 里重复写。如果必须支持未知扩展字段用mapstring, string或google.protobuf.Struct兜底。一个常见问题是前端把超长整数传成数字proto 解析成int64时会直接精度丢失。这时候网关要配置成“数字或字符串都接受”但入库和回包时统一输出字符串才能避免前端二次丢失。6. 从“能转”到“转得对”我在生产环境总结的检查清单6.1 代码、proto、JSON 三方字段命名统一默认情况下proto 里的order_id转成 JSON 会变成orderId。如果你们的接口文档写的是order_id前端拿到orderId就会对不上。两种解法需求做法JSON 保持 snake_case转换时配置preservingProtoFieldNames/UseProtoNames: true固定某个 proto 字段的 JSON 名称在 proto 字段上加[json_name order_id]json_name是显式声明改动会影响所有调用方所以要格外谨慎。我的习惯是新建项目直接用 camelCase老项目如果前端已经写了 snake_case就统一加json_name不要靠转换库的默认行为隐式控制。6.2 int64 精度问题为什么有时候 JSON 里会变成字符串JavaScript 的 Number 最大安全整数是 2^53 - 1而 int64 的范围是 9 后面跟 18 个 0直接输出成数字必丢精度。所以官方 JSON Mapping 规范里int64 和 uint64 都建议输出为字符串。这意味着你在浏览器里看到{ userId: 123456789012345678 }不要觉得奇怪。真正危险的是某些手写转换把 Long 直接塞进 JSONObject输出成数字前端拿到一个被截断的值又没有任何报错。等数据写回后端时id 已经变了。解决方式proto 里用 int64 的地方转换统一交给官方库。如果必须手写 JSON 序列化器把 Long 转成 String 再输出。前端解析时明确知道这个字段是字符串不要盲目JSON.parse后再做运算。6.3 版本兼容性把 .proto 当作 API 契约管理二进制转换最怕的就是新旧版本字段错位。我的建议是把.proto文件当作对外 API 一样管理字段编号只能追加不能复用。废弃字段用reserved关键字占住。枚举值一旦发布数值就不能改名称可以加但不要删。每次改动前跑一下buf breaking或者至少人工 review 字段编号变更。千万不要为了“让 proto 文件看起来整齐”去重排字段编号。如果你在转换层遇到解析结果跟预期不一致优先怀疑是不是线上某条消息是旧版本序列化的而当前 proto 已经改了编号。6.4 日志、监控、告警里的 JSON 化策略内部链路都走 Protobuf 后日志里直接打二进制没法看。我以前遇到过一个线上事故订单金额为 0 的数据日志里根本没有金额字段排查了半天才发现是因为 JsonFormat 默认不输出 0 值字段。后来我们规定所有关键日志必须用includingDefaultValueFields()/EmitUnpopulated: true输出确保字段结构完整。但这会带来日志体积变大所以高频接口要做采样不能每条请求都打全量 JSON。监控指标如果依赖 proto 字段也建议在网关层统一输出成一棵 JSON 树再喂给指标系统避免每个业务方各写一套拼接逻辑。如果只让我总结一句话我会说Protobuf 与 JSON 转换本身不难难的是把字段语义、类型精度、版本兼容这些“看不见的协议”管起来。建议开工前先把这个检查清单过一遍能少踩一大半坑。