协议缓冲区基础:Kotlin

一篇面向 Kotlin 程序员的 Protocol Buffers 入门介绍。

本教程为 Kotlin 程序员提供了使用协议缓冲区的基础介绍,使用的是 proto3 版本的协议缓冲区语言。通过创建一个简单的示例应用程序,它将向您展示如何:

  • .proto 文件中定义消息格式。
  • 使用 Protocol Buffer 编译器。
  • 使用 Kotlin 协议缓冲区 API 来写入和读取消息。

这不是一份在 Kotlin 中使用协议缓冲区的综合指南。有关更详细的参考信息,请参阅协议缓冲区语言指南Kotlin API 参考Kotlin 生成代码指南编码参考

问题领域

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

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

  • 使用 kotlinx.serialization。如果您需要与用 C++ 或 Python 编写的应用程序共享数据,这种方式效果不佳。kotlinx.serialization 有一个protobuf 模式,但这不提供协议缓冲区的全部功能。
  • 您可以发明一种临时的方式将数据项编码为单个字符串——例如将 4 个整数编码为 "12:3:-23:67"。这是一种简单灵活的方法,但它需要编写一次性的编码和解析代码,并且解析会带来少量的运行时成本。这种方法最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法可能非常有吸引力,因为 XML(在某种程度上)是人类可读的,并且有许多语言的绑定库。如果您想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML 是出了名的空间密集型,对其进行编码/解码会给应用程序带来巨大的性能损失。此外,导航 XML DOM 树比通常情况下导航类中的简单字段要复杂得多。

协议缓冲区是解决此问题的灵活、高效、自动化的解决方案。使用协议缓冲区,您需要编写一个 .proto 文件来描述您希望存储的数据结构。协议缓冲区编译器会根据该文件创建一个类,该类实现了使用高效二进制格式对协议缓冲区数据进行自动编码和解析。生成的类为构成协议缓冲区的字段提供了 getter 和 setter,并负责处理将协议缓冲区作为一个单元进行读写的细节。重要的是,协议缓冲区格式支持随着时间的推移扩展格式,使得代码仍然可以读取用旧格式编码的数据。

在哪里找到示例代码

我们的示例是一组命令行应用程序,用于管理一个使用协议缓冲区编码的地址簿数据文件。命令 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,该字段可以重复任意次数(包括零次)。重复值的顺序将在协议缓冲区中保留。可以把重复字段看作是动态大小的数组。

您可以在协议缓冲区语言指南中找到编写 .proto 文件的完整指南,包括所有可能的字段类型。但不要去寻找类似于类继承的功能——协议缓冲区不支持这个。

编译你的 Protocol Buffers

现在您有了 .proto 文件,接下来需要做的是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,您需要在您的 .proto 文件上运行协议缓冲区编译器 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 的协议缓冲区编译器生成的 Kotlin API 是对现有为 Java 协议缓冲区生成的 API 的补充。这确保了用 Java 和 Kotlin 混合编写的代码库可以与相同的协议缓冲区消息对象进行交互,而无需任何特殊处理或转换。

目前不支持用于其他 Kotlin 编译目标(如 JavaScript 和原生)的协议缓冲区。

编译 addressbook.proto 会在 Java 中为您提供以下 API:

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

但同时也会生成以下 Kotlin API:

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

您可以在Kotlin 生成代码指南中阅读更多关于具体生成内容的细节。

写入消息

现在让我们尝试使用您的协议缓冲区类。您希望您的地址簿应用程序能够做的第一件事是将个人详细信息写入您的地址簿文件。为此,您需要创建并填充协议缓冲区类的实例,然后将它们写入输出流。

这是一个程序,它从文件中读取一个 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

在您发布使用协议缓冲区的代码之后,迟早会想“改进”协议缓冲区的定义。如果您希望新的缓冲区向后兼容,并且旧的缓冲区向前兼容——您几乎肯定希望如此——那么您需要遵循一些规则。在新版本的协议缓冲区中:

  • 您*绝不能*更改任何现有字段的标签号。
  • 您*可以*删除字段。
  • 您*可以*添加新字段,但必须使用新的标签号(即,在此协议缓冲区中从未使用过的标签号,即使是被删除的字段用过的也不行)。

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

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

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