我第一次接触 Protocol Buffers,并不是因为学习序列化,而是刚参加一个项目,有个同事丢给我一句话:"这里有一份 PB 数据,你解析一下。"
拿到这个任务,我想PB是什么?
是不是类似http协议那样开源协议?
而解析这个数据就是去开源的解析库或者自己理解PB协议后手手搓一个解析方法。
后来才明白,并不是这样的—
PB 并不是一种固定的数据格式,而是一套数据定义规范。
每一份 PB 数据,都对应着一份自定义的数据结构(.proto),只有拿到这份定义,才能正确解析。
知道这些后,我就
- 去确认这份数据来自哪个服务;
- 去找这个服务负责人要到对应的
.proto定义; - 获取已经生成好的 Java 类(或者自己用
protoc生成); - 调用
parseFrom()即可完成解析。
做完这个任务,我继续思考Protocol Buffers到底是什么?
谷歌官方定义:
Protocol Buffers 是 Google 提供的语言无关、平台无关且可扩展的数据序列化机制。
从这句话来看:Protocol Buffers 本质就是一种序列化方案,然后这种序列化有语言无关、平台无关、还可以扩展的的特点。
回顾下序列化定义:序列化 不是将对象转换转化成字节序列的过程,而是将对象状态(所有属性值)转换成方便传输和保存的数据格式(一般指字符串、字符序列、二进制文件等)的过程。
为了使"不同语言和平台可以正确解析同一份数据",PB机制做了几个事情:
-
发明「中立的、跨语言的协议格式.proto来定义数据:使用
.proto描述双方共同遵守的数据结构; - 做好多平台生编译:通过工具protoc编译后可自动生成 Java、Go、C++、Python 等不同语言的数据模型(数据bean类);
- 序列化逻辑自动生成:序列化 / 反序列化的代码 由 protoc 自动生成,不需要手写,且跨语言行为完全一致。
简单来说就是
PB 可以让让所有兼容语言都能按照同一份协议(.proto文件),把对象可靠地编码成 byte[],并从 byte[] 恢复出等价的对象。
PB使用示例
第一步:写一个 .proto 文件
// 指定 Protobuf 语法版本(PB 关键字)
// proto2:支持 required、optional、default
// proto3:目前使用更多,不支持 required
syntax = "proto2";
// package:指定生成代码后的包名(PB 关键字)
package demo;
// message:定义一个消息(PB 最核心关键字)
// 可以理解成 Java 中的 class
message Person {
// required:必填字段(PB 关键字)
// 表示序列化时该字段必须赋值,否则会报错
required int64 id = 1;
// optional:可选字段(PB 关键字)
// 可以不赋值,如果没有值则使用默认值
optional string name = 2;
// default:默认值(PB 关键字,仅 proto2 支持)
optional int32 age = 3 [default = 18];
// repeated:数组/集合(PB 关键字)
// 对应 Java 中的 List<String>
repeated string hobbies = 4;
// 嵌套 message(PB 关键字)
// 一个 message 可以作为另一个 message 的字段
optional Address address = 5;
}
// 再定义一个 message
message Address {
// string:字符串类型(PB 内置类型)
optional string city = 1;
optional string street = 2;
}
就这一份文件就是数据协议。
第二步:用 protoc 编译成 Java 类
命令行直接调:
protoc --java_out=./src/main/java person.proto
或者你项目里那种 gradle 插件方式:
apply plugin: 'com.google.protobuf'
sourceSets { main.proto.srcDirs 'src/main/proto' }
dependencies {
implementation 'com.google.protobuf:protobuf-javalite:3.15.8'
}
build 时插件会自动跑 protoc。产物大概长这样
src/main/java/com/example/Person.java // 一个文件里塞了 Person 和 Person.PhoneNumber 两类
生成的 Person 类大致有这些方法:
package demo;
// 自动生成的 Protobuf 消息基类
public final class Person extends com.google.protobuf.GeneratedMessageLite<...> {
// 1. 所有字段的编号常量(对应 proto 里的 =1 =2 =3...)
public static final int ID_FIELD_NUMBER = 1;
public static final int NAME_FIELD_NUMBER = 2;
public static final int AGE_FIELD_NUMBER = 3;
public static final int HOBBIES_FIELD_NUMBER = 4;
public static final int ADDRESS_FIELD_NUMBER = 5;
// 2. 内部存储字段(私有,不直接暴露)
private long id_;
private java.lang.String name_;
private int age_;
private com.google.protobuf.LazyStringArrayList hobbies_;
private demo.Address address_;
// 3. 构造器私有,只能通过 Builder 构建
private Person() {
// 内部会自动设置 default 值
age_ = 18; // 对应 [default = 18]
}
// 4. Getter 方法
public long getId() { return id_; }
public java.lang.String getName() { return name_; }
public int getAge() { return age_; }
public java.util.List<java.lang.String> getHobbiesList() { return hobbies_; }
public demo.Address getAddress() { return address_; }
// 5. 核心:序列化方法
@Override
public void writeTo(com.google.protobuf.CodedOutputStream output)
throws java.io.IOException {
// 按 tag(字段编号+类型) + 值 编码
if (id_ != 0) {
output.writeInt64(1, id_);
}
if (!name_.isEmpty()) {
output.writeString(2, name_);
}
if (age_ != 18) {
output.writeInt32(3, age_);
}
for (String hobby : hobbies_) {
output.writeString(4, hobby);
}
if (address_ != null) {
output.writeMessage(5, address_);
}
}
// 6. 核心:反序列化方法
public static demo.Person parseFrom(byte[] data)
throws com.google.protobuf.InvalidProtocolBufferException {
return parser().parseFrom(data);
}
// 7. Builder 模式(proto2 标准写法)
public static final class Builder extends
com.google.protobuf.GeneratedMessageLite.Builder<demo.Person, Builder> {
public Builder setId(long value) { ... }
public Builder setName(java.lang.String value) { ... }
public Builder setAge(int value) { ... }
public Builder addHobbies(java.lang.String value) { ... }
public Builder setAddress(demo.Address.Builder value) { ... }
public demo.Person build() { ... }
}
}
// Address 类结构基本一样,也会生成在同一个文件里
public final class Address extends ... { ... }
第三步:在代码里构造对象并序列化成字节
import com.example.Person;
Person p = new Person()
.setName("张三")
.setAge(28)
.setEmail("zs@example.com");
p.getPhonesList().add(
new Person.PhoneNumber().setNumber("13800000000").setType(1)
);
byte[] bytes = p.toByteArray(); // ← 这一行就是"序列化"
System.out.println("size=" + bytes.length);
// bytes 可以塞进 HTTP body、写进 Kafka、存到磁盘、通过蓝牙传给车机...
sendOverNetwork(bytes);
toByteArray() 内部其实就是把每个已设置的字段按 tag 编码后拼起来。
第四步:反序列化——字节变回对象
接收端拿到那段 byte 数组后:
byte[] received = receiveFromNetwork();
Person restored = Person.parseFrom(received); // ← 这一调用完成反解析
Log.d("TAG", "name=" + restored.getName()); // 张三
for (Person.PhoneNumber ph : restored.getPhonesList()) {
Log.d("TAG", "phone=" + ph.getNumber() + ", type=" + ph.getType());
}
常见问题
问题一、如果pb不是自己工程定义的, 只有编译后的pb得到的文件 那么序列化和反序列化是否受影响
答:运行时序列化反序列化完全不依赖原始的 .proto 文件,只要有生成出来的类就够了。
它所有的信息在编译期就被烘焙进了生成的字节码里。具体落在三个地方:
- 字段编号变成常量
.proto 里写的 na = 1 这种声明被固化成静态常量:
// 你之前 javap 看到的:
public static final int NA_FIELD_NUMBER; // 来自 na=1
public static final int TALOS_FIELD_NUMBER; // 来自 talos=2
public static final int RENDER_MODE_FIELD_NUMBER; // 来自 render_mode=3
public static final int FULL_NA_FIELD_NUMBER;
public static final int PAGE_NUM_FIELD_NUMBER;
这些数字一旦编译进 class 就再也改不动了——所以不能自己补本地 .proto 替换线上版本":tag 号必须和服务器一致才能正确解析。
- 字段类型与顺序写进
writeTo()的方法体
public void writeTo(CodedOutputStreamMicro output) {
// 这里调用的具体方法名(writeMessage/writeString/writeInt32)
// 就决定了每个字段的 wire_type 是什么 —— 这些是源码层面硬编码的,
// 不需要任何外部描述符告诉它。
if (hasNa()) {
output.writeMessage(NA_FIELD_NUMBER, getNa()); // 嵌套消息→wire type 2
}
if (hasTalos()) {
output.writeString(TALOS_FIELD_NUMBER, getTalos()); // string →wire type 2
}
if (hasRenderMode()) {
output.writeInt32(RENDER_MODE_FIELD_NUMBER, ...); // varint →wire type 0
}
...
}
也就是说:"这个字段是什么类型、用什么方式编码、按什么顺序输出"全部锁死在了方法体里。运行时 CodedOutputStreamMicro 本身是个无脑的二进制写入工具,根本不知道你在写什么结构的数据。
- 反向解析逻辑写进
mergeFrom()的 switch-case
public CarMapResponse mergeFrom(CodedInputStreamMicro input) {
while (true) {
int tag = input.readTag();
switch (tag >>> 3 /* 取出字段号 */) {
case 1 /* NA */ : setNa(new CarMapNAData().mergeFrom(input.readMessage(...))); break;
case 2 /* TALOS */: setTalos(input.readString()); break;
case 3 /* RENDER_MODE */: setRenderMode(input.readInt32()); break;
default: input.skipField(tag); break; // ← 兼容性也藏在这里
}
}
}
哪个 tag 对应调用什么 reader 方法(readString/readInt32/readMessage),完全是代码写死的分支判断;同样不需要查任何外部的 schema 表。
一句话总结分工
| 角色 | 出现时机 | 干嘛 |
|---|---|---|
| .proto 文件 | 仅构建期 | 描述数据形状给 protoc 看 |
| protoc 工具 | 仅构建期一次执行 | 把上述描述翻译成各语言的类 |
| 生成的 .class 文件 | 运行期常驻 | 携带所有必要信息:field 常量+编码方法体+解析 switch 分支 |
protocol buffer优势
JSON:
{
"id":1001,
"name":"Tom"
}
每次都会发送:
"id"
"name"
这些字段名称。
PB 认为:
客户端和服务器不是早就知道数据结构了吗?为什么还要反复发送字段名字?
于是,PB 要求双方提前约定协议,例如:
message User{
int32 id = 1;
string name = 2;
}
真正发送的时候,不再发送:
"id"
"name"
而是发送:
字段1 = 1001
字段2 = Tom
因此:
- 数据更小;
- 解析更快;
- CPU 开销更低。
这也是 PB 被 Google、gRPC、微服务、地图、车机、物联网等系统广泛采用的重要原因。
序列化方法该怎么选择,什么时候该选择 PB?什么时候该选择 JSON?
很多人学习 PB 后都会问:
PB 和 JSON 到底该选哪个?
其实没有绝对的答案。
我通常会按照下面四个维度来选择一种序列化方案:
| 维度 | 关注点 |
|---|---|
| 是否需要人阅读 | 数据是否需要直接查看、调试、编辑 |
| 是否需要跨语言共享协议 | 是否有多个系统、多种语言共同维护同一份数据结构 |
| 性能要求 | 是否对带宽、CPU、延迟有较高要求 |
| 是否需要长期演进 | 是否需要兼容新老版本、长期维护协议 |
基于这些标准,我们可以比较几种常见的序列化方案:
| 对比维度 | JSON | Protocol Buffers | Java Serializable | XML |
|---|---|---|---|---|
| 可读性 | ⭐⭐⭐⭐⭐ | ⭐ | ⭐ | ⭐⭐⭐⭐ |
| 数据体积 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
| 序列化性能 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
| 跨语言 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
| 是否有统一协议格式 | ❌ | ✅ .proto
|
❌ | 可选 |
| 版本兼容 | 一般 | ⭐⭐⭐⭐⭐ | 较差 | 一般 |
| 调试方便 | ⭐⭐⭐⭐⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
| 典型场景 | Web API | RPC、微服务、客户端通信 | Java 对象持久化 | 配置、文档 |
| 使用场景 | 推荐方案 |
|---|---|
| 开放接口、Web API | JSON |
| Android、iOS、车机、IoT、游戏等客户端通信 | Protocol Buffers |
| 微服务、RPC | Protocol Buffers(如 gRPC) |
| Java 程序内部对象持久化 | Serializable |
| 配置文件 | XML / YAML |
一句话总结就是:
JSON 更关注人,PB 更关注机器。
JSON 让开发者更容易理解数据,PB 让程序更高效地交换数据。
它们不是竞争关系,而是针对不同场景做出的不同选择。