Protocol Buffer 基础知识:Kotlin

针对 Kotlin 程序员的 Protocol Buffers 基本入门介绍。

本教程提供了针对 Kotlin 程序员的 Protocol Buffers 基本入门介绍,使用 Protocol Buffers 语言的 proto3 版本。通过创建一个简单的示例应用,它将向你展示如何

  • .proto 文件中定义消息格式。
  • 使用 protocol buffer 编译器。
  • 使用 Kotlin protocol buffer API 编写和读取消息。

这不是一份关于在 Kotlin 中使用 protocol buffers 的全面指南。有关更详细的参考信息,请参阅Protocol Buffer 语言指南Kotlin API 参考Kotlin 生成代码指南编码参考

问题领域

我们将使用的示例是一个非常简单的“地址簿”应用程序,它可以将人员的联系方式读写到文件中。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。

如何序列化和检索这种结构化数据?有几种方法可以解决这个问题

  • 使用 kotlinx.serialization。如果你需要与用 C++ 或 Python 编写的应用程序共享数据,这种方法效果不太好。kotlinx.serialization 有一个 protobuf 模式,但这不提供 protocol buffers 的完整功能。
  • 你可以临时想出一种方法将数据项编码成一个字符串——例如将 4 个 int 编码为“12:3:-23:67”。这是一种简单灵活的方法,但需要编写一次性的编码和解析代码,并且解析会带来少量运行时开销。这种方法最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法非常有吸引力,因为 XML(在某种程度上)是人类可读的,并且有许多语言的绑定库。如果你想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML 众所周知地占用空间很大,并且编码/解码它可能会给应用程序带来巨大的性能损失。此外,遍历 XML DOM 树比通常遍历类中的简单字段复杂得多。

Protocol buffers 是解决这个问题的灵活、高效、自动化的方案。使用 protocol buffers,你编写一个 .proto 文件来描述你想要存储的数据结构。然后,protocol buffer 编译器会创建一个类,该类实现了使用高效的二进制格式自动编码和解析 protocol buffer 数据。生成的类为构成 protocol buffer 的字段提供了 getter 和 setter,并负责以单元形式读写 protocol buffer 的细节。重要的是,protocol buffer 格式支持随着时间推移扩展格式的想法,以便代码仍然可以读取使用旧格式编码的数据。

哪里可以找到示例代码

我们的示例是一组命令行应用程序,用于管理使用 protocol buffers 编码的地址簿数据文件。命令 add_person_kotlin 向数据文件添加新条目。命令 list_people_kotlin 解析数据文件并将数据打印到控制台。

你可以在 GitHub 仓库的 examples 目录中找到完整的示例。

定义你的协议格式

要创建你的地址簿应用程序,你需要从一个 .proto 文件开始。.proto 文件中的定义很简单:你为每个要序列化的数据结构添加一个 message,然后指定消息中每个字段的名称和类型。在我们的示例中,定义这些消息的 .proto 文件是 addressbook.proto

.proto 文件以包声明开头,这有助于防止不同项目之间的命名冲突。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

接下来是你的消息定义。消息只是一个包含一组类型化字段的聚合体。许多标准的简单数据类型都可以作为字段类型,包括 boolint32floatdoublestring。你还可以通过使用其他消息类型作为字段类型来为你的消息添加进一步的结构。

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;
}

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}

在上面的示例中,Person 消息包含 PhoneNumber 消息,而 AddressBook 消息包含 Person 消息。你甚至可以在其他消息内部定义嵌套的消息类型——如你所见,PhoneNumber 类型定义在 Person 内部。如果你希望某个字段具有预定义列表中的一个值,你还可以定义 enum 类型——在这里你希望指定电话号码可以是 PHONE_TYPE_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK 之一。

每个元素上的“= 1”、“= 2”标记标识了该字段在二进制编码中使用的唯一“标签”。标签号 1-15 比更高的数字编码少一个字节,因此作为优化,你可以决定将这些标签用于常用的或重复的元素,而将标签 16 及更高版本留给不太常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合这种优化。

如果字段值未设置,则使用默认值:数值类型为零,字符串为空字符串,布尔值为 false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,其所有字段均未设置。调用访问器获取未显式设置的字段的值始终返回该字段的默认值。

如果一个字段是 repeated(重复的),该字段可以重复任意次数(包括零次)。重复值的顺序将保留在 protocol buffer 中。可以将重复字段视为动态大小的数组。

你可以在Protocol Buffer 语言指南中找到编写 .proto 文件的完整指南——包括所有可能的字段类型。不过,不要寻找类似类继承的功能——protocol buffers 不提供此类功能。

编译你的 Protocol Buffers

现在你有了 .proto 文件,接下来你需要做的就是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,你需要在你的 .proto 文件上运行 protocol buffer 编译器 protoc

  1. 如果你尚未安装编译器,请下载软件包并按照 README 中的说明进行操作。

  2. 现在运行编译器,指定源目录(你的应用程序源代码所在的目录——如果不提供值,则使用当前目录)、目标目录(你希望生成代码存放的目录;通常与 $SRC_DIR 相同),以及你的 .proto 文件路径。在这种情况下,你可以调用

    protoc -I=$SRC_DIR --java_out=$DST_DIR --kotlin_out=$DST_DIR $SRC_DIR/addressbook.proto
    

    因为你需要 Kotlin 代码,所以使用 --kotlin_out 选项——其他受支持的语言也提供了类似的选项。

请注意,如果你想生成 Kotlin 代码,必须同时使用 --java_out--kotlin_out。这将在你指定的 Java 目标目录中生成一个 com/example/tutorial/protos/ 子目录,其中包含一些生成的 .java 文件;并在你指定的 Kotlin 目标目录中生成一个 com/example/tutorial/protos/ 子目录,其中包含一些生成的 .kt 文件。

Protocol Buffer API

针对 Kotlin 的 protocol buffer 编译器生成的 Kotlin API 是对现有针对 Java 的 protocol buffers 生成的 API 的补充。这确保了使用 Java 和 Kotlin 混合编写的代码库可以与相同的 protocol buffer 消息对象交互,无需任何特殊处理或转换。

目前不支持针对其他 Kotlin 编译目标(例如 JavaScript 和原生)的 protocol buffers。

编译 addressbook.proto 会在 Java 中生成以下 API

  • AddressBook
    • 从 Kotlin 角度看,它具有 peopleList : List<Person> 属性
  • Person
    • 从 Kotlin 角度看,它具有 nameidemailphonesList 属性
    • Person.PhoneNumber 嵌套类,具有 numbertype 属性
    • Person.PhoneType 嵌套枚举

但也会生成以下 Kotlin API

  • addressBook { ... }person { ... } 工厂方法
  • 一个 PersonKt 对象,带有一个 phoneNumber { ... } 工厂方法

你可以在Kotlin 生成代码指南中了解关于生成内容的更多细节。

编写消息

现在我们来尝试使用你的 protocol buffer 类。你的地址簿应用程序首先需要能够将个人详细信息写入地址簿文件。为此,你需要创建并填充你的 protocol buffer 类的实例,然后将它们写入输出流。

这是一个程序,它从文件中读取一个 AddressBook,根据用户输入向其中添加一个新的 Person,然后将新的 AddressBook 再次写回文件。直接调用或引用协议编译器生成的代码的部分已突出显示。

import com.example.tutorial.Person
import com.example.tutorial.AddressBook
import com.example.tutorial.person
import com.example.tutorial.addressBook
import com.example.tutorial.PersonKt.phoneNumber
import java.util.Scanner

// This function fills in a Person message based on user input.
fun promptPerson(): Person = person {
  print("Enter person ID: ")
  id = readLine().toInt()

  print("Enter name: ")
  name = readLine()

  print("Enter email address (blank for none): ")
  val email = readLine()
  if (email.isNotEmpty()) {
    this.email = email
  }

  while (true) {
    print("Enter a phone number (or leave blank to finish): ")
    val number = readLine()
    if (number.isEmpty()) break

    print("Is this a mobile, home, or work phone? ")
    val type = when (readLine()) {
      "mobile" -> Person.PhoneType.PHONE_TYPE_MOBILE
      "home" -> Person.PhoneType.PHONE_TYPE_HOME
      "work" -> Person.PhoneType.PHONE_TYPE_WORK
      else -> {
        println("Unknown phone type.  Using home.")
        Person.PhoneType.PHONE_TYPE_HOME
      }
    }
    phones += phoneNumber {
      this.number = number
      this.type = type
    }
  }
}

// Reads the entire address book from a file, adds one person based
// on user input, then writes it back out to the same file.
fun main(args: List) {
  if (arguments.size != 1) {
    println("Usage: add_person ADDRESS_BOOK_FILE")
    exitProcess(-1)
  }
  val path = Path(arguments.single())
  val initialAddressBook = if (!path.exists()) {
    println("File not found. Creating new file.")
    addressBook {}
  } else {
    path.inputStream().use {
      AddressBook.newBuilder().mergeFrom(it).build()
    }
  }
  path.outputStream().use {
    initialAddressBook.copy { peopleList += promptPerson() }.writeTo(it)
  }
}

读取消息

当然,如果无法从中获取任何信息,地址簿就没有多大用处了!这个示例读取上述示例创建的文件并打印其中的所有信息。

import com.example.tutorial.Person
import com.example.tutorial.AddressBook

// Iterates though all people in the AddressBook and prints info about them.
fun print(addressBook: AddressBook) {
  for (person in addressBook.peopleList) {
    println("Person ID: ${person.id}")
    println("  Name: ${person.name}")
    if (person.hasEmail()) {
      println("  Email address: ${person.email}")
    }
    for (phoneNumber in person.phonesList) {
      val modifier = when (phoneNumber.type) {
        Person.PhoneType.PHONE_TYPE_MOBILE -> "Mobile"
        Person.PhoneType.PHONE_TYPE_HOME -> "Home"
        Person.PhoneType.PHONE_TYPE_WORK -> "Work"
        else -> "Unknown"
      }
      println("  $modifier phone #: ${phoneNumber.number}")
    }
  }
}

fun main(args: List) {
  if (arguments.size != 1) {
    println("Usage: list_person ADDRESS_BOOK_FILE")
    exitProcess(-1)
  }
  Path(arguments.single()).inputStream().use {
    print(AddressBook.newBuilder().mergeFrom(it).build())
  }
}

扩展 Protocol Buffer

在你发布使用 protocol buffer 的代码后,迟早会想要“改进” protocol buffer 的定义。如果你希望新的 buffer 向后兼容,旧的 buffer 向前兼容——你几乎肯定希望这样——那么你需要遵循一些规则。在 protocol buffer 的新版本中

  • 不得更改任何现有字段的标签号。
  • 可以删除字段。
  • 可以添加新字段,但必须使用全新的标签号(即,在此 protocol buffer 中从未使用的标签号,即使是被删除的字段也未曾使用过的标签号)。

(这些规则有一些例外,但很少使用。)

如果你遵循这些规则,旧代码将愉快地读取新消息并简单地忽略任何新字段。对于旧代码来说,已删除的单个字段将简单地具有其默认值,而已删除的重复字段将为空。新代码也将透明地读取旧消息。

然而,请记住,旧消息中不会存在新字段,因此你需要对默认值进行合理处理。使用特定类型的默认值:对于字符串,默认值为空字符串。对于布尔值,默认值为 false。对于数值类型,默认值为零。