ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Protobuf与JSON互转的正确姿势:官方库选型与精度陷阱全解析

Protobuf与JSON互转的正确姿势:官方库选型与精度陷阱全解析 做了几年后端我几乎每天都要在 Protobuf 和 JSON 之间来回切换。内部服务用 gRPC消息体是二进制 Protobuf排障、联调、日志、前端接口又离不开 JSON。时间久了你会发现真正难的不是“能不能转”而是“转得对不对”。一个 int64 变 float 丢精度、一个字段名从 snake_case 变 camelCase 导致前端解析失败、一个默认值没输出让下游判空逻辑崩掉……这种问题排查起来比写代码还耗时间。这篇文章把我平时做 Protobuf 与 JSON 转换的思路、工具、代码和踩过的坑整理了一遍覆盖 Go、Java、Python 三种主流语言。适合刚接触 protobuf 的后端开发也适合做接口联调、数据同步时被各种序列化问题折磨的各位。看完你至少能知道哪些转换结果是对的、哪些是库帮你兜底的、哪些坑是必须自己规避的。1. 为什么大家都在做 Protobuf 与 JSON 的转换1.1 两种格式的本质差异打个比方。Protobuf 像物流仓库里的标准化托盘每个货位都有预定编号装卸靠机器速度快、码放密JSON 像手写的快递单任何人拿到都能看懂但同样的信息量纸张更厚、填写更慢、还容易写错别字。从技术上讲Protobuf 是二进制协议消息在传递前按照.proto文件定义的 schema 进行编码没有冗余的字段名只有字段编号和长度前缀。JSON 则把键名、类型符号、缩进全带上人类可读性极高但代价是体积膨胀。同一个消息Protobuf 序列化后的体积通常只有 JSON 的三分之一到十分之一解析速度也快上数倍。高并发、大流量场景下这两个差异会被放大得非常明显。但 JSON 有一种 Protobuf 永远比不上的能力不需要任何 schema随便一个{}谁都能看明白。浏览器里的 DevTools、curl命令、日志系统、Kafka 里的 avro schema 注册之外的那堆原始数据全是 JSON 的天下。所以实际工程里几乎不会“二选一”而是各取所长内部高性能链路走 Protobuf对外暴露、调试、存储、日志走 JSON。1.2 转换的典型业务场景我整理了一下工作中最常见的转换场景基本绕不开这几个接口调试与排障。gRPC 返回的是二进制直接看抓包内容就是一堆乱码。grpcurl这类工具能把响应转成 JSON 展示没有这个转换联调效率会低到一个令人发指的程度。微服务 API 网关对外暴露。内部服务之间用 gRPC但对浏览器、小程序、外部合作方暴露时通常要转成 RESTful JSON。典型的方案是 grpc-gateway它会在网关层完成 Protobuf message 到 JSON 的自动转换。数据落库与检索。把 Protobuf message 存进 Redis、Elasticsearch、ClickHouse 时直接塞二进制不利于排障和查询。我现在的习惯是DB 里保留业务可读的 JSON 字段必要时再冗余一份二进制用于高性能对账。日志与链路追踪。分布式日志采集器普遍把结构化数据输出成 JSON linesJava 里的logstash-logback-encoder、Go 里的zap都能把业务字段序列化成 JSON。如果上游消息是 Protobuf就需要先转成 JSON 再写日志。前端数据消费。绝大多数前端数据交换仍然以 JSON 为主后端把 gRPC 响应转成 JSON 后返回给 React/Vue 页面这已经是标准姿势。1.3 从“能转”到“转得对”很多人第一次做转换就是拿json.Marshal(protoMessage)直接序列化某些语言下也能得到一串看似正常的 JSON。但这套方案并不可靠——它不遵循 protobuf 官方的 JSON 映射规范字段名、枚举、时间类型、int64的处理全都随心所欲换一个语言、换一个库结果就对不上了。正确做法是使用官方 JSON 映射库例如 Go 的protojson、Java 的protobuf-java-util、Python 的json_format。它们严格实现了 protobuf JSON 映射规范 保证字段名策略、特殊类型、枚举表达方式在各语言间完全一致。2. 环境准备与工具链选型2.1 protoc 编译器与代码生成插件做转换的前提是先有 Protobuf message 的公共代码。最基础的工具是protoc编译器它能根据.proto文件生成各语言的类。安装方式很简单# macOS brew install protobuf # Ubuntu / Debian sudo apt-get install protobuf-compiler # 验证版本 protoc --version日常开发中我建议用 v3.20 以上的 protoc对 proto3 的可选字段、Any、扩展等特性支持得更完善。Go 语言还需要额外安装两个插件go install google.golang.org/protobuf/cmd/protoc-gen-golatest go install google.golang.org/grpc/cmd/protoc-gen-go-grpclatest注意到这里我用的是google.golang.org/protobufv2 API对应的 JSON 库是protojson。老项目里可能还在用github.com/golang/protobufv1 API它的 JSON 能力非常有限建议迁移到 v2。Java 项目只需在pom.xml中加入protobuf-java-util依赖Python 项目则直接安装protobuf包5.x 版本会自动携带google.protobuf.json_format模块pip install protobuf安装 Python 的 protobuf 时如果你本机已有旧版本pip 会打印一段形如Attempting uninstall: protobuf found existing installation: protobuf 5.29.6的日志。这只是一个替换旧版本的常规提示不影响功能但要注意 5.x 版本对 Python 版本有要求建议在 Python 3.9 环境下使用。2.2 各语言官方 JSON 库与核心 API我接触过的转换库基本就这几家全是对应语言的“官方正统”优先用它们语言库 / 模块核心 APIGogoogle.golang.org/protobuf/encoding/protojsonprotojson.Marshal/protojson.UnmarshalJavacom.google.protobuf.util.JsonFormatprotobuf-java-utilJsonFormat.printer().print()/JsonFormat.parser().merge()Pythongoogle.protobuf.json_formatjson_format.MessageToJson()/json_format.Parse()Cgoogle/protobuf/util/json_util.hMessageToJsonString()/JsonStringToMessage()选择原则就一条能用官方库就不要自己写转换函数。官方库处理了字段名转换、特殊类型映射、未知字段策略这些易错细节自己写等于重新发明轮子还大概率是椭圆形的。2.3 辅助调试工具grpcurl 与 jq实际联调时我不想为了“看看返回结果”就写一坨临时代码。grpcurl能直接调用 gRPC 接口并把二进制响应转换成 JSON 打印到终端# 格式grpcurl -d 请求JSON addr service/method grpcurl -d {user_id: 1001} localhost:50051 user.UserService/GetUser输出是一段 JSON配合jq做格式化、字段筛选排障体验直线上升。比如只取某个字段grpcurl -d {user_id: 1001} localhost:50051 user.UserService/GetUser | jq .user_name这套组合是我日常排查 gRPC 问题的第一反应强烈建议所有做微服务的同事都装上。3. 从零开始三种主流语言的互转实操3.1 Goprotojson 的使用与关键选项先看一个简单的 user proto// user.proto syntax proto3; package user; option go_package demo/userpb; message User { int32 user_id 1; string user_name 2; int64 score 3; repeated string tags 4; }生成代码protoc --go_out. --go_optpathssource_relative user.proto然后写一个转换函数package main import ( fmt google.golang.org/protobuf/encoding/protojson demo/userpb ) func main() { u : userpb.User{ UserId: 1001, UserName: zhangsan, Score: 9007199254740993, // 注意这个数 Tags: []string{vip, admin}, } // 默认配置camelCase 输出、不带缩进、忽略默认值 jsonBytes, err : protojson.Marshal(u) if err ! nil { panic(err) } fmt.Println(string(jsonBytes)) }输出{userId:1001,userName:zhangsan,score:9007199254740993,tags:[vip,admin]}注意两点字段名user_id变成了userIdscore是int64输出变成了字符串。这正是官方 JSON 映射规范要求的原因后文详述。反序列化同样简单jsonStr : {userId:1001,userName:lisi,score:100,tags:[normal]} var u2 userpb.User err : protojson.Unmarshal([]byte(jsonStr), u2) if err ! nil { panic(err) } fmt.Println(u2.GetUserName(), u2.GetScore())protojson.Unmarshal对字段名很宽容userId、user_id、甚至USERID都能正确解析因为它实现了大小写不敏感和下划线不敏感的匹配策略。这个特性在对接外部系统时非常实用对方给的字段名风格不一致也不会解析失败。再往下看三个高频选项。MarshalOptions.UseProtoNames默认false输出 camelCase 字段名置为true后输出原始 proto 字段名snake_case。m : protojson.MarshalOptions{UseProtoNames: true} jsonBytes, _ : m.Marshal(u) // {user_id:1001,user_name:zhangsan,score:9007199254740993,tags:[vip,admin]}MarshalOptions.EmitUnpopulated默认falseproto3 中值为零值例如0、、false、空数组的字段不会出现在 JSON 里。如果你希望输出完整字段方便下游判空、对账、快照对比把它置为true。m : protojson.MarshalOptions{EmitUnpopulated: true} jsonBytes, _ : m.Marshal(userpb.User{}) // {userId:0,userName:,score:0,tags:[]}MarshalOptions.Indent指定输出缩进字符串置为 或\t即可得到格式化 JSON适合日志或调试输出。UnmarshalOptions.DiscardUnknown默认false当 JSON 里有 proto 定义之外的字段时Unmarshal会返回错误如果你的上游会加字段但你暂时不想升级 proto把它置为true就能忽略未知字段。u : protojson.UnmarshalOptions{DiscardUnknown: true} err : u.Unmarshal([]byte({unknown_field:1,userId:2}), u3) // err nil未知字段被丢弃3.2 Pythonjson_format 的两行代码Python 侧的核心 API 是MessageToJson和Parse同样是官方实现行为和 Go 的protojson对齐。from google.protobuf import json_format import user_pb2 u user_pb2.User() u.user_id 1001 u.user_name zhangsan u.score 9007199254740993 u.tags.extend([vip, admin]) json_str json_format.MessageToJson(u) # {userId:1001,userName:zhangsan,score:9007199254740993,tags:[vip,admin]}注意到score同样输出为字符串这就是规范一致性带来的好处。两个常用参数preserving_proto_field_name默认False输出 camelCase置为True时输出 snake_case。indent输出缩进调试时传2能得到格式化 JSON。反序列化用Parsefrom google.protobuf import json_format import user_pb2 u2 user_pb2.User() json_format.Parse({userId:1001,userName:lisi,score:100}, u2) print(u2.user_name, u2.score)如果 JSON 里有位置字段Parse默认会报错需要忽略时传参数ignore_unknown_fieldsTrue。3.3 JavaJsonFormat 的 Printer 与 ParserJava 侧使用protobuf-java-util里的JsonFormat先加依赖dependency groupIdcom.google.protobuf/groupId artifactIdprotobuf-java-util/artifactId version4.29.3/version /dependency序列化import com.google.protobuf.util.JsonFormat; UserProto.User u UserProto.User.newBuilder() .setUserId(1001) .setUserName(zhangsan) .setScore(9007199254740993L) .addTags(vip) .addTags(admin) .build(); String json JsonFormat.printer().print(u); System.out.println(json);输出同样是{userId:1001,userName:zhangsan,score:9007199254740993,tags:[vip,admin]}。反序列化UserProto.User.Builder builder UserProto.User.newBuilder(); JsonFormat.parser().merge({\userId\:1001,\userName\:\lisi\,\score\:\100\}, builder); UserProto.User u2 builder.build();Java 版同样支持链式配置例如保留 proto 字段名和输出默认值JsonFormat.printer() .preservingProtoFieldNames() .includingDefaultValueFields() .print(u);忽略未知字段则用parser().ignoringUnknownFields()JsonFormat.parser() .ignoringUnknownFields() .merge(jsonStr, builder);三套语言跑下来你会发现一个规律官方 JSON 映射库在不同语言里的行为是高度一致的。只要规范定了crash 点只剩业务逻辑不会再出现“Go 转出来 int64 是字符串Java 转出来是数字”这种跨语言撕裂。4. 真正值钱的坑与处理技巧工具学会了但转换过程中的暗坑才是决定线上质量的关键。这一节是我最想让你仔细看的部分。4.1 字段命名snake_case 与 camelCase 的拉锯战proto 文件里的字段名约定是 snake_case但 JSON 输出默认是 camelCase。比如user_name默认输出成userName。前端同学看到 camelCase 通常很舒服因为 JS 风格约定如此但你要是对接一个 Java 老系统对方接口文档里全是 snake_case那你必须在序列化时开启UseProtoNames/preservingProtoFieldNames/preserving_proto_field_nameTrue。这套字段名策略是整个团队需要提前对齐的而不是上线后发现前端拿不到user_name才去查。我的习惯是dto 文档以 proto 字段名为准JSON 输出策略由接口网关统一配置所有服务的转换选项保持一致避免上下游各转各的。4.2 三个特殊类型int64、bytes、floatint64/uint64/fixed64序列化成字符串。这个设计初看反直觉其实是防 JS 精度丢失。JavaScript 的 Number 类型安全整数范围是-(2^53 - 1)到2^53 - 1而int64的上限约9.2 * 10^18远超出安全范围。如果 JSON 原样输出数字浏览器端拿到后会被静默截断成不精确的值。官方 JSON 映射规范干脆规定64 位整数一律编码成字符串由接收方按需解析。所以我在第 3.1 节刻意把score设成9007199254740993它在 JSON 里是9007199254740993如果被当成 number 解析就变成9007199254740992差 1这就是经典的精度踩坑现场。bytes字段编码成 base64 字符串。proto 里的bytes类型在 JSON 中没有原生对应规范统一转成 base64方便在文本协议中传输。转换时不需要你自己 base64 处理官方库已内置。float的NaN/Infinity。proto 里的浮点数允许出现NaN、Infinity、-Infinity但 JSON 标准不允许这些字面量。规范的处理方式是输出成字符串NaN、Infinity、-Infinity。如果你自己写转换器或者用了不规范的第三方库这里很容易产出非法 JSON。还有一个容易误判的是double类型。它和int64不同double序列化正常输出为 JSON number不需要转字符串——只有整数位超过 2^53 的int64才需要。别一看到大数就套string的逻辑。4.3 默认值与未设置值proto3 的 field presence 陷阱这是排查次数最多的一个问题。proto3 里所有标量字段默认不跟踪“是否被显式赋值”0、、false 这类零值在序列化时被直接省略。对上一个接口联调时对方说“我这个字段明明是 0你那边怎么没有”多半就是踩了这个坑。大背景是proto3 引入了field presence概念。默认情况下标量字段是 implicit presence零值等于“未设置”只有optional关键字声明的字段和message类型字段才有 explicit presence。如果你需要区分“传了 0”和“没传”必须在上游 proto 中给字段加optionalmessage Order { optional int32 status_code 1; }这样status_code即使为 0 也会被序列化输出。但要注意optional改变了字段在内存中的表示生成代码会包一层指针 / wrapper逻辑判断时也要跟着改。如果不改 proto只想在 JSON 输出时把零值也带上就用第 3 节提到的EmitUnpopulatedGo /includingDefaultValueFields()Java。这个选项适合对账、快照、日志场景代价是 JSON 体积明显变大空字段也会输出一大堆性能敏感路径要慎重。还有一个容易被忽略的点空的message字段输出成{}。比如某个optional或message类型字段被设置为一个空消息序列化后是{someField:{}}而不是整段消失。下游拿到{}再做非空判断时看起来像是“有个空对象”容易混淆。4.4 enum、oneof、Any、时间类型的边界行为enum 默认输出为枚举名而不是数字。这个设计是为了可读性。但问题来了你的上游改了枚举名或者传过来一个 proto 里没定义的枚举数字解析时可能报错。Go 的protojson在遇到未知枚举数字时不会自动恢复为数字而是报错Java 的 parser 默认也会抛异常。解决方案是要么保证 proto 枚举定义和线上数据严格一致要么在解析 JSON 前对enum相关的字符串做一次容错替换要么用UseEnumNumbers: true仅序列化让输出变成数字。从我的经验来看对外暴露的接口建议输出UseEnumNumbers因为数字的语义边界比字符串更清晰而且不怕枚举名重构对内调试再用默认的枚举名输出。oneof 字段在 JSON 中表现为普通的一个键。比如oneof contact { string email 1; string phone 2; }序列化时只输出实际存在的那个字段{phone:13800000000}。反序列化时 JSON 里只能有一个对应字段如果同时传 email 和 phone官方库会报错这是设计使然别想着让库帮你“后者覆盖前者”。Any 类型序列化成带type的对象。例如{ type: type.googleapis.com/user.User, userId: 1001 }type的值是一个完整的 type URL接收方用它反查出具体类型再解析。跨团队对接时如果你的服务端注册了自定义类型解析器Any 还能正常展开没有注册的话对方拿到的是个不透明对象很难继续处理。所以 Any 字段尽量只在内部强约定场景使用。google.protobuf.Timestamp输出为 RFC 3339 字符串例如2025-01-01T08:00:00Zgoogle.protobuf.Duration输出为带单位的字符串例如1.5s。官方库会自动处理不需要手写格式化但解析时要确保字符串合法。还有一个冷门但真实存在的坑google.protobuf.Struct和Value类型会直接映射成 JSON 的 object / value此时 proto 的字段名策略对它内部字段不生效它内部怎么存就怎么输出。这个特性用好了很爽可以用Struct承载自由格式的配置数据用不好就是灾难——字段名风格和数据一致性全得自己维护。5. 高频问题速查与落地建议5.1 高频问题速查表把平时答疑最多的问题整理成了一张表遇到问题先对照看现象可能原因解决办法JSON 里找不到值为 0 的字段proto3 implicit presence零值默认省略字段加optional或开启EmitUnpopulated/includingDefaultValueFieldsint64字段输出成了字符串官方 JSON 映射规范要求防 JS 精度丢失接收方按字符串解析或统一转double前先确认精度解析 JSON 时报 “unknown field”DiscardUnknown/ignore_unknown_fields未开启研发阶段可以严查生产环境建议忽略未知字段字段名对不上前端取不到值camelCase / snake_case 策略未对齐统一使用UseProtoNames/preservingProtoFieldNames并确保网关层一致enum 解析报错JSON 里的枚举名或数字与 proto 定义不匹配确认 proto 版本与数据来源序列化用UseEnumNumbers输出数字Any反序列化后无法还原具体类型缺少自定义 type resolver 注册在解析端注册类型解析器或改用普通 message 字段Python 安装 protobuf 时提示替换旧版本pip 替换已有安装包的常规日志确认新版本兼容必要时在虚拟环境安装gRPC 调用返回乱码没法直接看二进制消息直接打印导致用grpcurl或代码中先做 protojson 转换5.2 生产环境的 JSON 转换策略结合我的项目经验给几个比较清醒的建议。第一内部服务间尽量走 Protobuf对外接口统一转 JSON。内部链路追求的是体积和速度二进制序列化的收益极高对外接口追求的是兼容性和可读性JSON 是当前生态的事实标准。不要在内部链路也坚持 JSON那是给自己找麻烦。第二中间件选型要考虑格式成本。如果消息队列、缓存里存的是 JSON要注意字段冗余带来的存储增长。一个高频调用的 message 如果每天写几亿条JSON 体积带来的存储开销会非常可观。可以选择“核心字段冗余 JSON 用于排障其余保持二进制”的双写策略。第三转换操作的性能开销不能忽略。虽然 protojson 的实现已经很快但数据量大时序列化和反序列化依然占用明显的 CPU。能用[]byte传输二进制时不要偷懒转 JSON 字符串只有当目的地确实需要人类可读的 JSON 时才做转换。第四用 stripe 风格的元素做字段名映射时明确好边界。团队大、服务多最怕的就是 A 服务输出 camelCaseB 服务输出 snake_caseC 服务默认值不输出D 服务又开启 EmitUnpopulated。转换选项应该在团队规范里固定最好收敛成一个公共工具类 / 中间件统一处理而不是每个开发者各写各的。5.3 protobuf 版本更迭的提醒protobuf 近期的版本迭代明显加快。Go API 从 v1 切到 v2 后API 表面有了很大变化老代码里github.com/golang/protobuf/proto的多数函数要迁移到google.golang.org/protobuf/proto。Java 的 protobuf-java 和 protobuf-java-util 要保持在相近版本否则可能出现JsonFormat与主库版本不匹配的兼容问题。Python 上 protobuf 5.x 安装时会替换旧版本上面提到过 pip 日志里那句 “Attempting uninstall” 是正常现象但要注意grpcio等依赖库对 protobuf 版本的上下限要求装完记得跑一遍集成测试不要默认“升级一切顺利”。另外一个容易被坑的点是proto3 的optional关键字与旧版本 protoc 的兼容性。optional在 proto3 里重新引入后要求 protoc 3.15 才能生成带 presence 的代码。如果你公司内部还在用老的 protoc 版本用optional会导致生成代码行为异常建议先升级工具链别在老旧环境里硬试新特性。最后再分享一个小技巧我在实际维护接口时最头疼的就是排查“到底是谁在转换过程中丢了字段”。后来我给所有对外接口加了一条约定每个接口必须提供一份“全字段 JSON 样例”作为契约测试基线用EmitUnpopulated生成一条完整 JSON放在接口文档里联调时双方先对齐这条基线再谈业务逻辑。这个习惯帮我省下了大量扯皮时间也顺便让下游清楚自己可以依赖哪些字段、哪些字段可能缺失。另外如果你在写日志采集器不妨把protojson.MarshalOptions{Indent: , EmitUnpopulated: true}封装成一个LogJSON()函数所有 message 统一走这条路。日志里多出的几个字节换来的是排查问题时的极度舒适这笔账怎么算都不亏。
返回列表