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 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 檔案中的定義很簡單:您為要序列化的每個資料結構新增一個訊息,然後為訊息中的每個欄位指定名稱和類型。在我們的範例中,定義訊息的 .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,這些 API 新增到為 Java 的 Protocol Buffer 產生的現有 API。這確保以 Java 和 Kotlin 混合編寫的程式碼庫可以與相同的 Protocol Buffer 訊息物件互動,而無需任何特殊處理或轉換。

目前不支援其他 Kotlin 編譯目標的 Protocol Buffer,例如 JavaScript 和 native。

編譯 addressbook.proto 會在 Java 中為您提供以下 API

  • AddressBook 類別
    • 從 Kotlin 來看,它具有 peopleList : List<Person> 屬性
  • Person 類別
    • 從 Kotlin 來看,它具有 nameidemailphonesList 屬性
    • 具有 numbertype 屬性的 Person.PhoneNumber 巢狀類別
    • Person.PhoneType 巢狀列舉

但也產生以下 Kotlin API

  • addressBook { ... }person { ... } 工廠方法
  • 具有 phoneNumber { ... } 工廠方法的 PersonKt 物件

您可以在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。對於數值類型,預設值為零。