注意:此网站已被弃用。该网站将在 2023 年 1 月 31 日后关闭,而流量将重定向到位于 https://protobuf.dev 的新网站。在此期间,我们仅会针对 protobuf.dev 进行更新。

协议缓冲区基础知识:Kotlin

使用集合让一切井井有条 根据您的偏好保存内容并对其进行分类。

本教程简要介绍了 Kotlin 程序员如何使用 proto3 版本的协议缓冲区语言,了解如何使用协议缓冲区。通过演示如何创建简单的示例应用,您可以了解如何

  • .proto 文件中定义消息格式。
  • 使用协议缓冲区编译器。
  • 使用 Kotlin Protocol Buffer 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 代码库的示例目录中找到完整的示例。

定义协议格式

如需创建地址簿应用,您需要先创建 .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 {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  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 类型 - 在这里,您希望指定电话号码可以是 MOBILEHOMEWORK 之一。

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

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

如果字段为 repeated,则该字段可以重复任意次数(包括零)。重复值的顺序将在协议缓冲区中保留。将重复字段视为动态大小的数组。

协议缓冲区语言指南中,您可以找到编写 .proto 文件的完整指南,包括所有可能的字段类型。但不要寻找与类继承类似的工具:协议缓冲区不会这样做。

编译协议缓冲区

现在,您已经有了 .proto,接下来需要生成生成 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,您需要在 .proto 上运行协议缓冲区编译器 protoc

  1. 如果您尚未安装编译器,请下载软件包并按照自述文件中的说明操作。
  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,这些 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.MOBILE
      "home" -> Person.PhoneType.HOME
      "work" -> Person.PhoneType.WORK
      else -> {
        println("Unknown phone type.  Using home.")
        Person.PhoneType.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.MOBILE -> "Mobile"
        Person.PhoneType.HOME -> "Home"
        Person.PhoneType.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())
  }
}

扩展协议缓冲区

很快或在您发布使用协议缓冲区的代码之后,您肯定会想要“改进”协议缓冲区的定义。如果您希望新缓冲区可向后兼容,而旧缓冲区为向前兼容,并且您几乎肯定希望这样做,则需要遵循一些规则。在新版本的协议缓冲区中:

  • 不得更改任何现有字段的代码编号。
  • 可以删除字段。
  • 可以添加新字段,但必须使用新的标记编号(即,从未在此协议缓冲区中使用的标记编号,即使是已删除的字段也不例外)。

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

如果您遵循这些规则,旧代码会欣然接受新消息,而只会忽略任何新字段。对于旧代码而言,单数字段已被删除,就只有默认值,而已删除的重复字段将为空。新代码还会透明地读取旧消息。

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