用 Kotlin 玩转 Protocol Buffers(proto3)
1. 为什么选择 Protocol Buffers
为“结构化数据序列化/反序列化”这一老问题,常见方案有:
- kotlinx.serialization
与 C++/Python 等跨语言互通不理想。它有 protobuf 模式,但不具备 protobuf 官方的全部特性。 - 临时自定义编码
例如把 4 个int
编成"12:3:-23:67"
。实现简单但需要“手写”解析,易错、难维护,只适合极简数据。 - XML
人类可读、生态广,但体积膨胀、编解码开销大,DOM 操作也复杂。
Protocol Buffers(protobuf) 提供了一个高效、紧凑、可演进的二进制格式:
你只需定义 .proto
,编译器就会生成类(这里是 Java 与 Kotlin),自动完成编码/解析,并为字段生成访问器。更重要的是,格式天然支持演进,新旧版本能平滑兼容。
2. 示例与目标
我们实现一组命令行工具来管理 地址簿 文件(protobuf 编码):
add_person_kotlin
:向文件添加一个联系人;list_people_kotlin
:读取并打印所有联系人。
完整示例在官方仓库
examples
目录可找到(本文给出可运行版本)。
3. 定义协议:addressbook.proto
(proto3)
3.1 文件内容
syntax = "proto3";
package tutorial;import "google/protobuf/timestamp.proto";message Person {string name = 1;int32 id = 2; // Unique ID number for this person.string email = 3;enum PhoneType {PHONE_TYPE_UNSPECIFIED = 0;PHONE_TYPE_MOBILE = 1;PHONE_TYPE_HOME = 2;PHONE_TYPE_WORK = 3;}message PhoneNumber {string number = 1;PhoneType type = 2;}repeated PhoneNumber phones = 4;google.protobuf.Timestamp last_updated = 5;
}message AddressBook {repeated Person people = 1;
}
3.2 关键点速记
- 包名:
package tutorial;
用于避免跨项目命名冲突。 - 消息与字段:
message
是字段的聚合;可以嵌套message
与enum
。 - 标签号(tag):
= 1, = 2 ...
是字段的二进制标识。1–15 更省空间,优先给常用或repeated
字段。 - 默认值:未设置时,数值=0,字符串=“”,布尔=false;嵌套消息默认是“空原型实例”。
repeated
语义:可出现任意次(含 0),顺序保留,可视作动态数组。- 继承:protobuf 不支持类继承。
4. 生成代码:必须同时 --java_out
与 --kotlin_out
4.1 安装 protoc
按官方 README 安装对应平台的 Protocol Buffer Compiler。
4.2 一条命令生成 Java + Kotlin
protoc -I=$SRC_DIR \--java_out=$DST_DIR \--kotlin_out=$DST_DIR \$SRC_DIR/addressbook.proto
-
这会在你指定的目录下生成:
com/example/tutorial/protos/
(若按java_package
配置)里的 .java 与 .kt 文件;
-
Kotlin 代码是在 Java 生成功能之上提供额外 Kotlin API(两者并存,方便 Java/Kotlin 混用)。
当前不支持将 protobuf 直接生成 Kotlin/JS 或 Kotlin/Native 目标。
5. Kotlin 侧 API 你会拿到什么?
编译 addressbook.proto
后,你会获得:
-
Java API(Kotlin 也可直接调用)
AddressBook
(Kotlin 视角:peopleList: List<Person>
)Person
(Kotlin 视角:name, id, email, phonesList
)- 嵌套类
Person.PhoneNumber
(number, type
) - 嵌套枚举
Person.PhoneType
-
Kotlin 扩展 API(DSL 工厂方法)
addressBook { ... }
、person { ... }
PersonKt.phoneNumber { ... }
这意味着你既可以像写 Java 一样操作对象,也能用 Kotlin DSL 写出更简洁的构造代码。
6. 写入示例:add_person_kotlin.kt
(可运行)
说明:proto3 对标量字段默认不生成
hasXxx()
存在性判断(除非声明为optional
)。所以下文打印逻辑以是否为空字符串等方式判断是否展示。
@file:JvmName("add_person_kotlin")package cliimport com.example.tutorial.protos.AddressBook
import com.example.tutorial.protos.Person
import com.example.tutorial.protos.addressBook
import com.example.tutorial.protos.person
import com.example.tutorial.protos.PersonKt.phoneNumber
import com.google.protobuf.Timestamp
import java.io.File
import java.time.Instant// 交互式读取并构造一个 Person
private fun promptPerson(): Person = person {print("Enter person ID: ")id = readln().trim().toInt()print("Enter name: ")name = readln().trim()print("Enter email address (blank for none): ")val emailInput = readln().trim()if (emailInput.isNotEmpty()) email = emailInputwhile (true) {print("Enter a phone number (or leave blank to finish): ")val number = readln().trim()if (number.isEmpty()) breakprint("Is this a mobile, home, or work phone? ")val type = when (readln().trim().lowercase()) {"mobile" -> Person.PhoneType.PHONE_TYPE_MOBILE"home" -> Person.PhoneType.PHONE_TYPE_HOME"work" -> Person.PhoneType.PHONE_TYPE_WORKelse -> {println("Unknown phone type. Using HOME.")Person.PhoneType.PHONE_TYPE_HOME}}phones += phoneNumber {this.number = numberthis.type = type}}// 设置最后更新时间(避免额外依赖 util 包,直接用 Timestamp.Builder)val now = Instant.now()lastUpdated = Timestamp.newBuilder().setSeconds(now.epochSecond).setNanos(now.nano).build()
}// 读取文件 → 添加一个 Person → 写回文件
fun main(args: Array<String>) {if (args.size != 1) {println("Usage: add_person_kotlin ADDRESS_BOOK_FILE")return}val file = File(args[0])val initial = if (file.exists()) {file.inputStream().use { AddressBook.newBuilder().mergeFrom(it).build() }} else {println("${file.path}: not found, creating a new file.")addressBook { }}val updated = initial.toBuilder().apply {addPeople(promptPerson())}.build()file.outputStream().use { updated.writeTo(it) }println("Saved to ${file.path}")
}
7. 读取示例:list_people_kotlin.kt
(可运行)
@file:JvmName("list_people_kotlin")package cliimport com.example.tutorial.protos.AddressBook
import com.example.tutorial.protos.Person
import java.io.Fileprivate fun printBook(book: AddressBook) {for (p in book.peopleList) {println("Person ID: ${p.id}")println(" Name: ${p.name}")if (p.email.isNotEmpty()) {println(" Email: ${p.email}")}for (ph in p.phonesList) {val label = when (ph.type) {Person.PhoneType.PHONE_TYPE_MOBILE -> "Mobile"Person.PhoneType.PHONE_TYPE_HOME -> "Home"Person.PhoneType.PHONE_TYPE_WORK -> "Work"else -> "Unknown"}println(" $label phone #: ${ph.number}")}if (p.hasLastUpdated()) {println(" Updated: ${p.lastUpdated.seconds}.${p.lastUpdated.nanos}")}}
}fun main(args: Array<String>) {if (args.size != 1) {println("Usage: list_people_kotlin ADDRESS_BOOK_FILE")return}val file = File(args[0])if (!file.exists()) {println("File not found: ${file.path}")return}val book = file.inputStream().use {AddressBook.newBuilder().mergeFrom(it).build()}printBook(book)
}
说明:示例中
hasLastUpdated()
可用,因为它是消息类型,proto3 对消息字段保留 presence。
8. 协议演进(兼容性)硬规则
当你需要升级 .proto
时,为了做到新版本向后兼容/旧版本向前兼容,请遵守:
- 不要修改任何既有字段的 tag;
- 可以删除字段;
- 可以新增字段,但必须使用从未使用过的 tag(包括被删字段曾用过的编号也不可重用)。
遵循以上:
- 旧代码能读新消息并忽略不认识的新字段;
- 新代码能透明读旧消息;
- 新增字段在旧消息中不存在,读取时会得到类型默认值(字符串空串、布尔
false
、数值0
),需要在业务层作合理处理。
9. 常见坑与排查
- 忘记
--java_out
:生成 Kotlin 代码时必须同时加--java_out
与--kotlin_out
,因为 Kotlin API 是在 Java 生成物之上补充的。 - proto3 的 presence:标量字段默认没有
hasXxx()
,除非写成optional
。打印时用“是否空值”判断更稳妥。 - 跨语言互通:Kotlin 与 Java 可直接共享同一份消息对象(无需额外转换),这也是官方 Kotlin 生成器的设计初衷。
- Tag 规划:1–15 更省空间,尽量留给高频或
repeated
。新增字段永远不要复用历史 tag。 - Kotlin/JS & Native:当前不支持直接生成这些目标
10. 小结:落地清单(Checklist)
- 写好
addressbook.proto
(proto3 + 合理 tag); -
protoc
同时生成 Java + Kotlin; - 熟悉 Kotlin 侧 DSL 工厂:
addressBook {}
、person {}
、phoneNumber {}
; - 完成读写 CLI:
add_person_kotlin
/list_people_kotlin
; - 严守演进规则:不改 tag、可删、可加新字段但用全新 tag。
至此,你已经掌握了从 .proto → 生成代码 → Kotlin 读写 → 版本演进 的完整闭环。把本文示例直接丢进你的工程,就能“跑起来、用起来”。祝编码愉快!