用 Java 学会 Protocol Buffers从 0 到 1 的完整实战
1. 什么是 Protocol Buffers(protobuf)
Protocol Buffers 是一种高效、可演进的结构化数据序列化机制。你先用 .proto
描述数据结构,编译器会为你生成对应语言(这里是 Java)的类,自动完成高效二进制的编码与解析,并提供字段的 getter/setter、整体读写等能力。
2. 为什么不用 Java 序列化 / 手写编码 / XML?
-
Java 序列化
内置方便,但与跨语言(C++/Python 等)互通较差,且存在众所周知的问题(参见 Effective Java)。 -
手写“简易编码”
例如把int
拼成"12:3:-23:67"
,虽灵活但需要专门的编码/解析代码,易错且维护成本高。 -
XML
可读性尚可且语言绑定多,但空间占用大、编解码开销高,DOM 访问也更复杂。
protobuf 的优势:二进制高效 + 自动生成读写代码 + 良好的前后兼容演进。
3. 示例工程与学习目标
-
示例是一个“地址簿 AddressBook”应用:读写文件中的联系人(姓名、ID、邮箱、电话)。
-
学会:
- 在
.proto
中定义消息; - 使用
protoc
生成 Java 类; - 用 Java API 读写消息对象。
- 在
4. 定义协议:从 addressbook.proto
开始
4.1 完整示例
syntax = "proto2";package tutorial;option java_multiple_files = true;
option java_package = "com.example.tutorial.protos";
option java_outer_classname = "AddressBookProtos";message Person {optional string name = 1;optional int32 id = 2;optional string email = 3;enum PhoneType {PHONE_TYPE_UNSPECIFIED = 0;PHONE_TYPE_MOBILE = 1;PHONE_TYPE_HOME = 2;PHONE_TYPE_WORK = 3;}message PhoneNumber {optional string number = 1;optional PhoneType type = 2 [default = PHONE_TYPE_HOME];}repeated PhoneNumber phones = 4;
}message AddressBook {repeated Person people = 1;
}
4.2 package
与 Java 专用选项
package tutorial;
:避免跨项目命名冲突。option java_package
:指定生成 Java 类的包名(建议使用域名式前缀)。option java_outer_classname
:指定外层包装类名(未指定时由文件名驼峰化而来)。option java_multiple_files = true
:为每个生成类型单独产出.java
文件(推荐)。
4.3 消息、字段与类型
message
就是一组带类型的字段;简单类型有bool/int32/float/double/string
等;- 字段可嵌套其他消息或枚举(如
Person.PhoneNumber
、Person.PhoneType
); - 字段标签号(tag):
= 1, = 2 ...
在二进制编码中唯一标识字段。1–15 更省空间,优先分配给高频或 repeated 字段。
4.4 字段修饰符与默认值
optional
:可不设置;未设置时使用默认值(可自定义或系统默认:数值 0、字符串空、布尔 false;消息为默认实例)。repeated
:可出现 0~N 次,顺序保留,类似动态数组。required
:必须设置,否则构建或解析会抛异常。
⚠️ 强烈建议避免
required
(proto3 已取消)
一旦使用required
,将来把它改回optional
会破坏兼容性:旧读者会把缺字段的消息视为“不完整”。
5. 用 protoc
生成 Java 代码
- 安装编译器
protoc
(按 README 指南)。 - 运行命令(例):
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
生成的 Java 文件位于 $DST_DIR/com/example/tutorial/protos/
。
6. 认识生成代码与 Java API
6.1 访问器(getter/setter/has/clear)
- 消息类:只包含 getter;
- Builder:包含 getter + setter +
clearXxx()
; - 每个标量字段都有
hasXxx()
判断是否已设置; repeated
字段额外提供:getCount()
(实际是列表大小)、按索引 get/set、add()
、addAll()
等。
命名风格:
.proto
字段名使用下划线小写,生成的 Java 方法会自动转为驼峰,保证符合 Java 习惯(其它语言亦然)。
6.2 枚举与嵌套类
Person.PhoneType
生成为 Javaenum
(嵌套在Person
内);Person.PhoneNumber
生成为Person
的嵌套类。
6.3 Builder vs. Message(可变 vs. 不可变)
- 消息类不可变;需要通过 Builder 设置字段后
build()
得到最终对象; - Builder 的 setter 返回自身,支持链式调用。
6.4 常用通用方法
isInitialized()
:检查所有required
是否就绪;toString()
:友好字符串(便于调试);mergeFrom(Message other)
(Builder):合并另一个消息(覆盖标量、合并复合、拼接 repeated);clear()
(Builder):清空到初始状态。
6.5 解析与序列化
toByteArray()
:序列化为byte[]
;parseFrom(byte[])
/parseFrom(InputStream)
:从字节数组/输入流解析;writeTo(OutputStream)
:写入输出流。
(更多变体详见Message
API。)
7. 写入示例:交互式添加联系人并保存
import com.example.tutorial.protos.AddressBook;
import com.example.tutorial.protos.Person;
import java.io.*;class AddPerson {static Person PromptForAddress(BufferedReader stdin, PrintStream stdout) throws IOException {Person.Builder person = Person.newBuilder();stdout.print("Enter person ID: ");person.setId(Integer.valueOf(stdin.readLine()));stdout.print("Enter name: ");person.setName(stdin.readLine());stdout.print("Enter email address (blank for none): ");String email = stdin.readLine();if (email.length() > 0) {person.setEmail(email);}while (true) {stdout.print("Enter a phone number (or leave blank to finish): ");String number = stdin.readLine();if (number.length() == 0) break;Person.PhoneNumber.Builder phoneNumber =Person.PhoneNumber.newBuilder().setNumber(number);stdout.print("Is this a mobile, home, or work phone? ");String type = stdin.readLine();if (type.equals("mobile")) {phoneNumber.setType(Person.PhoneType.PHONE_TYPE_MOBILE);} else if (type.equals("home")) {phoneNumber.setType(Person.PhoneType.PHONE_TYPE_HOME);} else if (type.equals("work")) {phoneNumber.setType(Person.PhoneType.PHONE_TYPE_WORK);} else {stdout.println("Unknown phone type. Using default.");}person.addPhones(phoneNumber);}return person.build();}public static void main(String[] args) throws Exception {if (args.length != 1) {System.err.println("Usage: AddPerson ADDRESS_BOOK_FILE");System.exit(-1);}AddressBook.Builder addressBook = AddressBook.newBuilder();try {addressBook.mergeFrom(new FileInputStream(args[0]));} catch (FileNotFoundException e) {System.out.println(args[0] + ": File not found. Creating a new file.");}addressBook.addPerson(PromptForAddress(new BufferedReader(new InputStreamReader(System.in)), System.out));FileOutputStream output = new FileOutputStream(args[0]);addressBook.build().writeTo(output);output.close();}
}
8. 读取示例:打印地址簿中所有联系人
import com.example.tutorial.protos.AddressBook;
import com.example.tutorial.protos.Person;
import java.io.FileInputStream;class ListPeople {static void Print(AddressBook addressBook) {for (Person person: addressBook.getPeopleList()) {System.out.println("Person ID: " + person.getId());System.out.println(" Name: " + person.getName());if (person.hasEmail()) {System.out.println(" E-mail address: " + person.getEmail());}for (Person.PhoneNumber phoneNumber : person.getPhonesList()) {switch (phoneNumber.getType()) {case PHONE_TYPE_MOBILE: System.out.print(" Mobile phone #: "); break;case PHONE_TYPE_HOME: System.out.print(" Home phone #: "); break;case PHONE_TYPE_WORK: System.out.print(" Work phone #: "); break;}System.out.println(phoneNumber.getNumber());}}}public static void main(String[] args) throws Exception {if (args.length != 1) {System.err.println("Usage: ListPeople ADDRESS_BOOK_FILE");System.exit(-1);}AddressBook addressBook = AddressBook.parseFrom(new FileInputStream(args[0]));Print(addressBook);}
}
9. 架构与设计建议:面向对象如何与 protobuf 协作
- 生成的 protobuf 类本质上是数据载体(像 C 的
struct
),不适合作为有复杂行为的“领域对象”。 - 若需要更丰富的行为,请用应用层类进行封装(wrapper),在其中持有/代理 protobuf 对象,暴露更贴合业务的接口。
- 不要继承生成类添加行为,这会破坏内部机制,也违背良好的 OOP 实践。
10. 协议演进(前后兼容)黄金法则
当你要“升级”消息定义以适配新需求时:
- 不要修改任何既有字段的 tag;
- 不要添加或删除任何
required
字段; - 可以删除
optional
/repeated
字段; - 可以新增
optional
/repeated
字段,但必须使用从未用过的 tag(包括删掉的旧字段占用过的 tag 也不能再用)。
遵循这些规则:
- 旧代码读取新消息时,会忽略不认识的新字段;
- 新代码也能透明读取旧消息;
- 新增
optional
在旧消息中不存在:要么hasXxx()
检查,要么在.proto
里用[default = ...]
指定默认值; - 新增
repeated
在新代码中无法区分“被留空”还是“从未设置”,因为没有has_
标志。
11. 高级用法:反射与跨格式转换
Message
/Message.Builder
接口提供反射 API,可不绑定具体类型地遍历与操作字段;- 常见用途:与 JSON/XML 之间的互转、差异对比、按“模式”匹配消息等;
- 通过反射,protobuf 可被应用到比“序列化”更广泛的场景中。
12. 实战小结与最佳实践清单
- 风格:
.proto
字段统一用下划线小写,生成代码自动遵循各语言风格(Java 驼峰)。 - 标签号分配:1–15 留给高频/重复字段,节省空间。
- 少用/慎用
required
:优先用optional
+ 应用层校验。 - 面向对象:封装而非继承生成类型。
- 演进:遵守兼容性规则,提前预留 tag 空间。
- 构建流程:
.proto
→protoc --java_out
→ Java Builder 构造对象 →writeTo/parseFrom
持久化与传输。
13. FAQ(基于文中要点)
-
Q:为什么我的字段 getter 是驼峰?
A:编译器会把.proto
的下划线命名转换为 Java 的驼峰命名,符合语言风格。 -
Q:如何把消息写入文件并再读出来?
A:message.writeTo(OutputStream)
写入;Message.parseFrom(InputStream)
读取。 -
Q:如何保证老版本还能读新消息?
A:别改已有 tag,不要动required
,新增字段用未使用过的 tag 即可。