ARTICLE DETAIL

资讯详情

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

从JSON到ProtoBuf:序列化方案选型、避坑与实践指南

从JSON到ProtoBuf:序列化方案选型、避坑与实践指南 1. 为什么我最终选了ProtoBuf而不是JSON聊聊序列化这事。如果你写过几年后端或者客户端一定经历过这种场景服务端给前端返回数据前端拿到以后JSON.parse一下完事。这种开发方式确实快但一旦涉及跨语言通信、存储格式升级、接口文档维护JSON 那种“随手就能写”的随意感反而成了后期最大的坑。我最早在项目里大规模使用 ProtoBuf是因为一个跨语言数据交换的需求服务端用 Go客户端是 C中间还夹着一个 Python 写的数据分析管道。用 JSON 的时候三边各自维护一份“字段名—类型”的对照表改一个字段要通知三拨人漏一次直接线上翻车。后来我把通信协议整体迁到 ProtoBuf 上才发现这个东西真正解决的不只是“序列化快”而是一整套关于“数据结构怎么定义、怎么演进、怎么跨语言保持一致”的规范问题。ProtoBuf全称 Protocol Buffers是 Google 设计的一套结构化数据序列化机制。你用它提供的 DSL 写一个.proto文件把数据结构声明清楚然后通过编译工具生成各语言的代码程序里直接调用生成的类来读写数据。传输和存储的是紧凑的二进制格式比 JSON 小得多也比 JSON 解析快得多。这套东西适合谁如果你正在做微服务之间的内部 RPC 通信、游戏客户端和服务端的消息交互、嵌入式设备的数据上报或者任何“前后端职责分明、接口需要强约束”的场景ProtoBuf 基本是目前综合成本最低的选项。如果你只是写个小型 Web 应用前端直接调后端 REST API那 JSON 完全够用不必强行引入 ProtoBuf徒增工具链成本。2. 序列化方案选型的完整对比ProtoBuf 到底赢在哪2.1 从 JSON 到 ProtoBuf差的不仅是体积很多人一提到 ProtoBuf 就只想到“小”和“快”但拿它和 JSON 做一次完整对比你会发现差异是分层的。首先是体积。JSON 是文本格式字段名每个字节都要占空间{user_name: zhang}光字段名就 11 个字节。ProtoBuf 编码时不存字段名只存字段编号和类型标记所以同样的数据序列化出来可能只有十几字节甚至几字节。这一点在网络带宽受限的场景下非常致命。我之前调试一个物联网项目设备通过 2G 模块上报数据一条 JSON 消息 200 多字节换成 ProtoBuf 压到 50 字节以内流量费用直接降了一个量级。其次是解析性能。JSON 解析要做字符串匹配、类型推断、内存分配ProtoBuf 则是一边读一边按编号映射到预生成的结构体里几乎没有字符串比较的代价。实测下来在同样的数据量下ProtoBuf 的反序列化耗时通常是 JSON 的 1/3 到 1/5。然后是强类型约束。JSON 的字段可有可无类型可以随意变今天返回age: 25明天可能变成age: 25下游不做容错就直接崩。ProtoBuf 在编译期就把类型锁死字段是 int32 就一定是 int32不匹配直接报错问题在联调阶段就暴露了。不过 ProtoBuf 也不是没有代价。二进制格式人类不可读排查问题时没法直接打开抓包数据看懂内容你必须借助工具或者额外打印日志。这是所有二进制协议的通病你得接受这个权衡。2.2 除了 ProtoBuf还有哪些可选方案序列化工具不止 ProtoBuf 一个简单说一下我接触过的其他方案方便你做选型参考。Apache Thrift 跟 ProtoBuf 很像也是定义 IDL 再生成代码但 Thrift 的代码生成链更重跨语言编译环境配置起来比较折腾社区活跃度和 ProtoBuf 比有明显差距现在主要用在一些老系统里。MessagePack 可以理解成“二进制的 JSON”用它不需要定义.proto文件直接序列化任意对象上手很快但它本质还是保留了字段名体积优化有限也没有强类型约束和前后向兼容的设计保障。FlatBuffers 和 Capn Proto 走的是“零拷贝反序列化”路线不需要解析就能直接读取数据性能确实夸张适合对延迟极致敏感的场景但复杂度也更高日常项目用不上这种级别的优化。选型建议很直接默认选 ProtoBuf。它生态最成熟覆盖语言最广资料最多坑基本都被踩平了。等你明确知道自己需要 FlatBuffers 那类极致性能再换否则别折腾。2.3 为什么说“数据结构即接口文档”接触 ProtoBuf 久了你会意识到它最大的思维转变在于接口文档从“解释性的说明”变成了“可编译的代码”。以前用 JSON 对接接口文档里写“user_id 是用户ID类型为字符串”大家看归看真写代码的时候还是各按各的理解来字段名拼错了、大小写不一致、类型对不上这些问题只能靠联调时的报错来发现。用了 ProtoBuf.proto文件本身就是唯一的真相来源服务端和客户端都用同一个文件生成代码字段名写错了根本编译不过去类型不匹配在生成代码那一层就拦住了。这种“单一事实来源”的价值团队越大效果越明显。我上一次整理项目里的 proto 文件时把三十多个接口的消息定义统一到了一个目录下版本用目录名区分每个目录里一个 README 说明变更记录后续新人上手看 proto 文件比看接口文档直观得多。3. ProtoBuf 语法核心细节从写第一个 .proto 到掌握关键特性3.1 环境准备与 protoc 编译器安装先说环境。用 ProtoBuf 必须装编译器protoc它的作用是把.proto文件编译成目标语言的代码。不同平台的安装方式不同我平时主要用 macOS 和 Linux直接包管理器装就行# macOS brew install protobuf # Ubuntu / Debian sudo apt-get install protobuf-compiler # CentOS / Fedora sudo yum install protobuf-compilerWindows 用户直接去 GitHub 的 protobuf releases 页面下载对应平台的安装包。装完验证一下protoc --version正常情况下会输出类似libprotoc 3.21.12的版本信息。我建议优先装较新的稳定版本proto3 语法完善之后新版本在代码生成质量和错误提示上都有明显改进。另外你还需要根据目标语言安装对应的运行时库比如 Go 的google.golang.org/protobufJava 的protobuf-javaC 的libprotobuf。编译器和运行时库版本要匹配不然生成出来的代码可能和运行时库不兼容这类问题排查起来很费劲后面在常见问题章节我会专门说。3.2 proto3 语法message 定义与字段规则当前主流是 proto3 语法相比 proto2 砍掉了一些容易误用的特性比如required字段语义更简洁。一个最基本的.proto文件长这样syntax proto3; package user; // 用户基本信息 message UserInfo { int64 user_id 1; string name 2; int32 age 3; repeated string tags 4; }文件第一行syntax proto3;声明使用 proto3 语法不写这行默认按 proto2 解析。package用于避免消息类型在不同模块间的命名冲突类似 C 的 namespace、Java 的 package。message是核心关键字里面每个字段的格式是类型 字段名 字段编号;这里有几个关键点要特别强调。字段编号Field Number是 ProtoBuf 的根命脉。编码时字段名不会出现在二进制数据里取而代之的是这个编号。所以编号一旦分配就不能再修改否则新老数据就对应不上了。1 到 15 编号只占 1 字节16 到 2047 占 2 字节所以高频字段尽量用小编号。另外19000 到 19999 是保留区间不能用。字段规则。proto3 里字段默认是“singular”——也就是说某个字段要么有值要么没值用默认值表示。repeated表示重复字段对应其他语言的数组/列表。值得注意的一点是proto3 里所有字段都是可选的没有required的概念这是吸取了 proto2 时代required字段在升级时引发太多兼容性问题的教训。默认值机制。proto3 中字段没有显式的optional标记时读取未设置的字段会返回类型默认值数值类型是 0字符串是空串布尔是 false枚举是第一个枚举值。这个设计让二进制格式更紧凑但也带来一个坑你无法区分“字段没设置”和“字段值恰好是默认值”。如果需要区分就得用optional关键字或包装类型这点后面专门讲。3.3 枚举、嵌套消息与 import 管理复杂的项目里单一 message 远远不够需要组合使用多种结构。枚举定义enum DeviceType { DEVICE_TYPE_UNKNOWN 0; DEVICE_TYPE_PHONE 1; DEVICE_TYPE_PAD 2; DEVICE_TYPE_TV 3; }注意 proto3 的枚举第一个值必须是 0这是为了和默认值机制对齐。枚举值命名建议全部用大写下划线避免在不同包之间产生命名冲突。嵌套消息message Order { int64 order_id 1; // 嵌套一个买家信息 message Buyer { int64 user_id 1; string nickname 2; } Buyer buyer 2; repeated Item items 3; message Item { int64 sku_id 1; int32 count 2; double price 3; } }嵌套消息跟 Java 内部类类似用Order.Buyer这种形式访问。它适合表达“这个结构只服务于外层消息”的情况避免全局命名空间被太多类型占满。跨文件引用用importimport common/base.proto; import user/user.proto; message Order { common.BaseInfo base_info 1; user.UserInfo user 2; }大型项目建议按业务模块拆分成多个 proto 文件再统一汇总到每个服务自己的定义里。用完你会发现proto 文件的管理方式和代码模块化其实是一个思路高内聚、低耦合。3.4 类型对照表从 .proto 到各语言不同语言拿到同一份.proto生成代码后类型映射关系略有差异。下面是几个常用语言的对照.proto 类型GoJavaCPythondoublefloat64doubledoublefloatfloatfloat32floatfloatfloatint32int32intint32intint64int64longint64int/longuint32uint32intuint32int/longuint64uint64longuint64int/longboolboolbooleanboolboolstringstringStringstringstr/unicodebytes[]byteByteStringstringbytesrepeated T[]TListTrepeated Tlist — 可迭代对象有个细节容易踩坑Google 官方在 Python 3 上更推荐python -m pip install protobuf装运行时库然后用protoc --python_out生成代码但生成的代码性能一般。如果对 Python 端性能有高要求可以考虑protobuf的 upb 后端新版 Python 库已默认使用也可以考虑用grpclib这类替代库基础场景下官方方案就够。4. 编译生成代码与核心 API 实操4.1 protoc 命令的完整用法写好.proto文件后用protoc生成目标语言代码。基本命令格式protoc -I源文件目录 --目标语言_out输出目录 文件列表我以前面那个 user.proto 为例分别生成 Java 和 Go 代码# 生成 Java 代码 protoc -I. --java_out./gen/java user.proto # 生成 Go 代码 protoc -I. --go_out./gen/go --go_optpathssource_relative user.proto这里有几个参数需要重点解释。-I是--proto_path的缩写指定查找.proto文件的根目录。如果 proto 文件里用了import common/base.proto那么-I必须指向包含common/base.proto的上层目录否则编译器会报找不到文件。--go_out这里多了一个--go_optpathssource_relative这个参数让生成的.pb.go文件跟源 proto 文件放在同一目录而不是按包名生成目录结构。这个纯看个人习惯我比较喜欢source_relative因为生成的代码位置和 proto 文件位置一一对应翻起来不迷路。生成 Java 代码后你会看到每个 message 对应一个类内部用 Builder 模式构建对象。生成的 Python 代码则是一组类直接用构造参数赋值即可。4.2 实际写一段代码序列化与反序列化用 Go 举例假定已经生成了 user.pb.gopackage main import ( fmt log google.golang.org/protobuf/proto yourmodule/gen/go/user ) func main() { // 构造对象 u : user.UserInfo{ UserId: 10001, Name: zhangsan, Age: 28, Tags: []string{vip, admin}, } // 序列化 data, err : proto.Marshal(u) if err ! nil { log.Fatalf(marshal failed: %v, err) } fmt.Printf(serialized %d bytes: %x\n, len(data), data) // 反序列化 var decoded user.UserInfo if err : proto.Unmarshal(data, decoded); err ! nil { log.Fatalf(unmarshal failed: %v, err) } fmt.Printf(decoded: %v\n, decoded) }这段代码展示了一个完整的数据生命周期构造对象、序列化成二进制、从二进制反序列化回对象。你注意proto.Marshal返回的data是[]byte里面没有任何字符串形式的字段名只有字段编号和值的二进制编码。Java 端写法略有不同使用 Builder 模式User.UserInfo.Builder builder User.UserInfo.newBuilder(); builder.setUserId(10001); builder.setName(zhangsan); builder.setAge(28); builder.addTags(vip); builder.addTags(admin); User.UserInfo u builder.build(); byte[] data u.toByteArray(); User.UserInfo decoded User.UserInfo.parseFrom(data); System.out.println(decoded.getName());C 和 Python 的用法类似不展开写了核心 API 就那么几个构造/赋值、序列化、反序列化。4.3 二进制数据的调试技巧二进制格式不可读是 ProtoBuf 最让人不习惯的地方。排查线上问题时你经常需要确认二进制数据里到底存了什么内容。这里分享几个常用手段。第一个手段是protoc --decode。你可以把二进制数据从日志或抓包工具里导出来然后用 protoc 反解成文本# 把 message 内容按 user.UserInfo 的格式解码为文本输出 protoc -I. --decodeuser.UserInfo user.proto data.bin这个命令会读取标准输入里的二进制数据然后按 proto 定义输出人类可读的文本格式。需要先确保命令行里能访问到对应的.proto文件。第二个手段是文本格式TextFormat。很多语言的 API 都提供了打印文本格式的方法Go 里可以fmt.Println(u.String())Message.String()会返回类似下面的可读文本user_id: 10001 name: zhangsan age: 28 tags: vip tags: admin这个输出对排查问题特别方便。第三个手段是写日志时同时打十六进制。线上环境没法用 gdb 或者 IDE 调试时把消息的十六进制打印到日志里之后离线用protoc --decode解一般能解决 90% 的排查问题。5. 版本升级与兼容性这几个坑我甩了一个月才爬出来5.1 字段编号为什么是“命根子”我参与过一个项目当时同事为了图省事把UserInfo里一个字段从1改成了5顺手也没改别的。上线后线上服务间通信开始间歇性报错查了半天才发现是版本升级时同一个字段在不同服务里对应的编号不一致导致新老数据解析错乱。这种问题特别隐蔽因为代码看起来毫无问题编译也通过直到反序列化时字段值对不上才暴露。切身体会字段编号永远不要复用永远不要修改。你需要添加新字段时就用下一个没用过的编号删掉废弃字段时只注释掉字段声明千万别把编号腾出来给别人用。更稳妥的做法是显式预留编号message UserInfo { int64 user_id 1; string name 2; int32 age 3; repeated string tags 4; reserved 5, 7, 9 to 11; reserved old_email, old_phone; }reserved关键字既保留编号又保留字段名防止未来有人误用之前废弃的编号省得踩历史的坑。5.2 支持新老版本共存的关键规则升级协议最怕新老版本同时存在。如果线上服务是多实例部署发布是渐进式的那么同一个请求可能被新版本服务处理也可能被老版本服务处理。ProtoBuf 在设计上支持这种场景但你必须遵守规则新增字段时用一个新的编号老版本代码反序列化时会自动忽略这个字段。删除字段时只在 proto 文件里注释掉不要动编号保证老数据里这部分内容能被新代码安全跳过。修改字段类型时要评估二进制兼容性。int32 改成 int64 是安全的因为编码格式一致但 int32 改成 string 就完全不兼容会直接解析失败。repeated和单值之间不要互换编码结构完全不同变更后老数据无法正确解析。有一条字段类型兼容性经验能用同一种 wire type 的类型互转就尽量互转。wire type 是 ProtoBuf 编码时的一个概念0 代表 varintint32、int64、bool、enum1 代表 64-bitfixed64、double2 代表 length-delimitedstring、bytes、包装类型、repeated5 代表 32-bitfixed32、float。同 wire type 内互转通常问题不大跨 wire type 变更基本必挂。5.3 optional 与包装类型的取舍前面提到 proto3 的字段默认“没有设置和默认值等值”但实际业务里经常需要区分“接口没传”和“传了 0”。比如前端传age 0语义是“用户没有填写年龄”而服务端需要知道“这个请求里根本没带 age 字段”。proto3 里普通字段做不到这种区分。两种解决办法使用optional关键字message UserInfo { int64 user_id 1; optional string name 2; optional int32 age 3; }用optional修饰的字段会生成HasAge()/hasAge()这类方法用来判断字段是否被显式设置。注意optional是从 proto3.15 版本开始支持的特性老版本编译器不认识这个关键字所以保持工具链新版很重要。另一种办法是使用包装类型wrapper types它们是 Google 预定义的几个消息类型google.protobuf.Int32Value、google.protobuf.StringValue、google.protobuf.BoolValue等import google/protobuf/wrappers.proto; message UserInfo { google.protobuf.Int32Value age 3; }这种类型在生成的代码里表现为一个对象可以用GetValue()读取实际数值通过是否为 null / nil 区分是否设置。代价是序列化体积略大一点因为多了几字节的包装信息。实际使用中我更推荐优先用optional代码更简洁。只有当你的数据模型必须传递“未设置”状态而且团队里各语言都约定好了这种语义时才考虑 wrapper。6. 性能实测ProtoBuf 到底能省多少时间和空间6.1 一个能复现的基准测试设计光说不练不行。我写了一个简单的 benchmark对比 JSON 和 ProtoBuf 在体积和处理耗时上的差异数据量级别都压得比较小方便你在自己机器上复现。测试数据是一个包含 1 万个UserInfo消息的列表。每条消息有 4 个字段字段类型说明user_idint64用户IDnamestring姓名平均 8 个字符ageint32年龄tagsrepeated string标签平均 3 个在单机测试时我得到的数据大致是指标JSONProtoBuf比例序列化大小约 1.8 MB约 650 KB约 1/3序列化耗时10w条约 280 ms约 120 ms约 43%反序列化耗时10w条约 320 ms约 90 ms约 28%需要明确说明这个数字跟具体机器、数据内容、语言、JSON 库的选型都有关系但趋势是稳定的ProtoBuf 体积小 2-3 倍解析耗时为 JSON 的 1/3 左右。字段越简单、数值越集中差距越明显。如果字段名很长而值又很短ProtoBuf 的体积优势会更大因为字段名根本不参与传输。6.2 性能优化哪些“坑”会让 ProtoBuf 变慢ProtoBuf 虽然快但用法不当也能把优势败光。几点实际经验第一避免在热路径上反复 marshal/unmarshal 同一个对象。有些代码习惯在循环里把同一个对象不断序列化这其实是白白浪费 CPU。尽量把序列化结果缓存起来复用。第二大消息注意内存分配。Marshal会为输出分配新的切片数据量大且频率高时内存分配开销很可观。你可以尝试复用 bufferGo 里可以用proto.MarshalOptions配合预分配空间来减少 GC 压力C 里则可以直接操作CodedOutputStream。第三repeated字段的添加方式影响性能。在 Java 里addTags()逐个添加大量元素会频繁扩容更好的做法是一次性addAllTags(List)。Go 里用append给 slice 追加即可性能本身没问题。第四不要对单个小消息过度设计。如果一条消息只有十来个字节那 ProtoBuf 的性能优势已经被网络 IO 和系统调用稀释掉了这时候优化序列化耗时没什么意义。7. 常见问题与排查技巧实录7.1 编译报错无法解析 import这是新手上路最常见的问题。报错信息类似common/base.proto: File not found.原因几乎都是-I参数没有指定到正确的位置。我之前用 Go 项目时proto 文件结构是proto/ common/base.proto user/user.protouser.proto 里写import common/base.proto那么必须这样编译protoc -I./proto --go_out./gen ./proto/user/user.proto-I指向的是proto目录本身而不是proto/user这样编译器才能找到common/base.proto。这个原则是import 路径是相对于-I的根来计算的。7.2 运行时崩溃版本不匹配第二次进入深水区常见的问题是编译器是 3.20运行时库是 3.15代码生成时的 API 和运行时库对不上一调用就报panic或者NoSuchMethodError。这类问题的排查思路很直接先protoc --version看编译器版本再查运行时库的版本保证两者大版本一致。Go 模块的 go.mod 里写的google.golang.org/protobuf v1.31.0对应的是 libprotoc 3.21 系列这个对应关系需要留意。最省心的方式是统一走包管理器锁版本不要手动下载一个编译器又混用不同版本的运行时。7.3 字段对不上编号冲突如果你在反序列化后拿到的字段值不是自己期望的数据最可能的原因就是字段编号冲突——不同版本的消息里同一个编号指向了不同的字段。排查方法把二进制数据protoc --decode出来和两边的 proto 定义逐一比对确认是否有编号错位。这个错位往往发生在代码合并时两个人同时往同一个 message 里加字段各自选择了相同的编号合到一起就“撞车”了。所以我在团队里明确了一个规则改 proto 文件时必须先查看最新版本里已用的最大编号再往下分配并且把字段变更记录写进注释避免这种低级事故。7.4 动态解析没有原始 .proto 怎么办有些场景下你只有二进制数据手头没有对应的.proto文件或者想做一个通用的解析工具。这时候可以用 ProtoBuf 的动态消息DynamicMessage功能。Java 的DynamicMessage、C 的MessageFactory都支持在运行时加载FileDescriptor后再解析。但这种方案需要你能拿到 descriptor 文件本质上还是需要原始定义只是把编译期换成了运行时。如果连定义都拿不到那就只能通过十六进制分析字段编号和 wire type 来推断结构工作量巨大只适合做逆向分析正常开发中不建议走这条路。8. 几件顺手的小事能让你的 ProtoBuf 体验截然不同8.1 buf一个让 proto 工程化更舒服的工具链如果项目复杂度上来了纯靠protoc加 shell 脚本管理 proto 文件会越来越难受这时候推荐试试 buf。buf 是一个现代的 Protocol Buffers 工具链它做了几件很实用的事统一的buf generate命令替代繁琐的protoc参数内置 lint 工具能检查命名规范、字段编号是否冲突buf breaking能在 CI 里检查变更是否为破坏性变更比如删字段、改编号、类型改动体验下来最大的感受是lint 规则把团队里的通用约定自动化了不用再靠 code review 时人工一条条看。buf 支持从buf.yaml配置文件里声明所有生成规则成员拉下代码直接一个命令就能重新生成全部语言的代码不需要手动拼一长串 protoc 参数。8.2 proto 文件的组织与命名规范写 proto 文件不是把 message 堆上去就完事建议从一开始就建立自己的规范目录按业务模块划分比如user/、order/、payment/不要塞到一个common/大杂烩里。公共基础类型比如BaseResp、PageInfo放单独的common/目录被其他模块 import。文件名使用小写加下划线贴合 proto 的惯用风格。message 定义内部字段按逻辑分组注释写明每个字段的用途、单位、取值范围。每次修改在文件头部的注释里记录变更时间和原因后面回溯问题时能省很多力气。8.3 和 gRPC 的搭配是 ProtoBuf 的终极形态只把 ProtoBuf 当序列化工具用其实只发挥了它一半的价值。当它和 gRPC 搭配时proto 文件里还可以定义 serviceservice UserService { rpc GetUser(GetUserRequest) returns (UserInfo); rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse); }生成代码后服务端只用实现接口客户端直接调用 stub序列化、网络传输、连接管理全被框架接住了。REST JSON 那种“两端各自写文档、各自解析”的时代在这里彻底结束接口定义、输入输出结构、序列化实现全程一致。我在上一个项目里就是把所有内部接口从 REST 迁到了 gRPC迁移完成后最大的感受是前后端联调时间几乎砍半了。以前催着互相看文档现在直接拿.proto文件说事字段定义和语义都在里面打口水仗的时间都没了。9. 写在最后掌握 ProtoBuf建议从“先跑通再深究”开始接触 ProtoBuf 的这几年我最大的体会是它看起来是“序列化框架”实际是一套“接口治理方案”。你把精力花在前期定义清晰、编号稳定、规范统一上它在后期带给你的收益是指数级的。反过来如果只是把它当成一个“更小的 JSON”来用而不去理解字段编号、兼容性规则、工具链设计背后的逻辑那踩坑几乎是必然的。如果你还没在正式项目里用过我建议先拿一个小模块试试水把一个简单的查询接口从 JSON 切成 ProtoBuf自己走一遍写.proto、编译、改代码、联调的全流程。先把这条路跑通再决定要不要全面铺开。你踩过一次二进制协议的坑、体会过一次字段编号冲突带来的头痛会比看十篇文章理解得更深刻。
返回列表