序列化之—Protocol Buffers

我第一次接触 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 文件,只要有生成出来的类就够了。

它所有的信息在编译期就被烘焙进了生成的字节码里。具体落在三个地方:

  1. 字段编号变成常量

.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 号必须和服务器一致才能正确解析。

  1. 字段类型与顺序写进 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 本身是个无脑的二进制写入工具,根本不知道你在写什么结构的数据。

  1. 反向解析逻辑写进 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 让程序更高效地交换数据。

它们不是竞争关系,而是针对不同场景做出的不同选择。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容