Protocol Buffer 基础知识:Kotlin

Kotlin 程序员使用 Protocol Buffer 的基本入门介绍。

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

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

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

问题域

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

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

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

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

示例代码在哪里

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

您可以在 GitHub 存储库的 examples 目录中找到完整的示例。

定义您的协议格式

要创建您的地址簿应用程序,您需要从 .proto 文件开始。.proto 文件中的定义很简单:您为您要序列化的每个数据结构添加一个消息,然后为消息中的每个字段指定名称和类型。在我们的示例中,定义消息的 .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 Buffer 不这样做。

编译您的 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,这些 API 添加到为 Java 的 Protocol Buffer 生成的现有 API。这确保了用 Java 和 Kotlin 混合编写的代码库可以与相同的 Protocol Buffer 消息对象交互,而无需任何特殊的处理或转换。

目前不支持用于其他 Kotlin 编译目标(例如 JavaScript 和 native)的 Protocol Buffer。

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

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

但也生成以下 Kotlin API:

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

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

写入消息

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

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

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 的定义。如果您希望您的新缓冲区向后兼容,并且您的旧缓冲区向前兼容 - 而且您几乎肯定希望如此 - 那么您需要遵循一些规则。在新版本的 Protocol Buffer 中:

  • 绝不能更改任何现有字段的标签号。
  • 可以删除字段。
  • 可以添加新字段,但您必须使用新的标签号(即从未在此 Protocol Buffer 中使用过的标签号,甚至未被删除的字段使用过)。

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

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

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