Protocol Buffer 基礎:Kotlin

一份針對 Kotlin 程式設計師的 Protocol Buffers 基礎入門。

本教程為 Kotlin 程式設計師提供了使用 Protocol Buffers(採用 proto3 版本的 Protocol Buffers 語言)的基礎入門。透過一個簡單的示例應用程式,它將向您展示如何:

  • .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 個整數編碼為“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 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" 標記標識了該欄位在二進位制編碼中使用的唯一“標籤”(tag)。標籤號 1-15 比較大的數字少用一個位元組來編碼,因此作為一種最佳化,您可以決定將這些標籤用於常用或重複的元素,而將標籤 16 及以上的數字留給不常用的可選元素。重複欄位中的每個元素都需要重新編碼標籤號,因此重複欄位特別適合進行這種最佳化。

如果某個欄位未設定值,則會使用預設值:數字型別為零,字串為空字串,布林值為 false。對於嵌入的訊息,預設值始終是該訊息的“預設例項”或“原型”,其所有欄位都未設定。呼叫訪問器獲取未顯式設定的欄位值時,總是返回該欄位的預設值。

如果一個欄位是 repeated,該欄位可以重複任意次數(包括零次)。重複值的順序將在協議緩衝區中保留。可以把重複欄位看作是動態大小的陣列。

您可以在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 是對現有為 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 的定義。如果您希望新的緩衝區向後相容,並且舊的緩衝區向前相容——您幾乎肯定希望如此——那麼您需要遵守一些規則。在新版本的 Protocol Buffer 中:

  • 您*絕不能*更改任何現有欄位的標籤號。
  • 您*可以*刪除欄位。
  • 您*可以*新增新欄位,但必須使用新的標籤號(即,在此協議緩衝區中從未使用過的標籤號,即使是被刪除的欄位用過的也不行)。

(這些規則有一些例外,但很少使用。)

如果您遵守這些規則,舊程式碼將能夠愉快地讀取新訊息,並簡單地忽略任何新欄位。對於舊程式碼來說,被刪除的單數(singular)欄位將只有其預設值,而被刪除的重複(repeated)欄位將為空。新程式碼也將透明地讀取舊訊息。

但是,請記住,新欄位在舊訊息中是不存在的,因此您需要對預設值進行合理的處理。系統會使用特定型別的預設值:對於字串,預設值是空字串。對於布林值,預設值是 false。對於數字型別,預設值是零。