Grundlagen von Protocol Buffers: Kotlin

Eine grundlegende Einführung für Kotlin-Programmierer zur Arbeit mit Protocol Buffers.

Dieses Tutorial bietet eine grundlegende Einführung für Kotlin-Programmierer zur Arbeit mit Protocol Buffers, wobei die proto3-Version der Protocol Buffers-Sprache verwendet wird. Anhand eines einfachen Beispielprogramms wird gezeigt, wie Sie

  • Nachrichtenformate in einer .proto-Datei definieren.
  • Den Protocol Buffer-Compiler verwenden.
  • die Kotlin-Protocol-Buffer-API zum Schreiben und Lesen von Nachrichten verwenden.

Dies ist kein umfassender Leitfaden zur Verwendung von Protocol Buffers in Kotlin. Ausführlichere Referenzinformationen finden Sie im Language Guide für Protocol Buffer, im API-Referenzhandbuch für Kotlin, im Leitfaden für generierten Kotlin-Code und im Encoding-Referenzhandbuch.

Das Problemfeld

Das Beispiel, das wir verwenden werden, ist eine sehr einfache "Adressbuch"-Anwendung, die Kontaktdaten von Personen lesen und in eine Datei schreiben kann. Jede Person im Adressbuch hat einen Namen, eine ID, eine E-Mail-Adresse und eine Telefonnummer.

Wie serialisiert und ruft man strukturierte Daten wie diese ab? Es gibt mehrere Möglichkeiten, dieses Problem zu lösen

  • Verwenden Sie kotlinx.serialization. Dies funktioniert nicht sehr gut, wenn Sie Daten mit Anwendungen teilen müssen, die in C++ oder Python geschrieben sind. kotlinx.serialization hat einen protobuf-Modus, dieser bietet jedoch nicht alle Funktionen von Protocol Buffers.
  • Man kann sich einen Ad-hoc-Weg ausdenken, um die Datenpunkte in einen einzigen String zu kodieren – z. B. 4 ganze Zahlen als „12:3:-23:67“ kodieren. Dies ist ein einfacher und flexibler Ansatz, obwohl er das Schreiben von einmaligen Kodierungs- und Parsing-Codes erfordert und das Parsen einen geringen Laufzeitaufwand verursacht. Dies funktioniert am besten für die Kodierung sehr einfacher Daten.
  • Serialisieren Sie die Daten in XML. Dieser Ansatz kann sehr attraktiv sein, da XML (sozusagen) menschenlesbar ist und es Bindungsbibliotheken für viele Sprachen gibt. Dies kann eine gute Wahl sein, wenn Sie Daten mit anderen Anwendungen/Projekten teilen möchten. XML ist jedoch bekanntermaßen speicherintensiv, und die Kodierung/Dekodierung kann Anwendungen eine enorme Leistungseinbuße bescheren. Außerdem ist die Navigation durch einen XML-DOM-Baum erheblich komplizierter als die normale Navigation durch einfache Felder in einer Klasse.

Protocol Buffers sind die flexible, effiziente, automatisierte Lösung, um genau dieses Problem zu lösen. Mit Protocol Buffers schreiben Sie eine .proto-Beschreibung der Datenstruktur, die Sie speichern möchten. Daraus erstellt der Protocol Buffer-Compiler eine Klasse, die die automatische Kodierung und das Parsen von Protocol Buffer-Daten in einem effizienten Binärformat implementiert. Die generierte Klasse bietet Getter und Setter für die Felder, die eine Protocol Buffer ausmachen, und kümmert sich um die Details des Lesens und Schreibens der Protocol Buffer als Einheit. Wichtig ist, dass das Protocol Buffer-Format die Idee unterstützt, das Format im Laufe der Zeit so zu erweitern, dass der Code Daten, die mit dem alten Format kodiert wurden, weiterhin lesen kann.

Wo man den Beispielcode findet

Unser Beispiel ist eine Reihe von Befehlszeilenanwendungen zur Verwaltung einer Adressbuch-Datendatei, die mit Protocol Buffers kodiert ist. Der Befehl add_person_kotlin fügt einen neuen Eintrag zur Datendatei hinzu. Der Befehl list_people_kotlin analysiert die Datendatei und gibt die Daten auf der Konsole aus.

Das vollständige Beispiel finden Sie im Beispielverzeichnis des GitHub-Repositorys.

Definieren Ihres Protokollformats

Um Ihre Adressbuchanwendung zu erstellen, müssen Sie mit einer .proto-Datei beginnen. Die Definitionen in einer .proto-Datei sind einfach: Sie fügen eine Nachricht für jede Datenstruktur hinzu, die Sie serialisieren möchten, und geben dann einen Namen und einen Typ für jedes Feld in der Nachricht an. In unserem Beispiel ist die .proto-Datei, die die Nachrichten definiert, addressbook.proto.

Die .proto-Datei beginnt mit einer Paketdeklaration, die hilft, Namenskonflikte zwischen verschiedenen Projekten zu vermeiden.

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

Als Nächstes haben Sie Ihre Nachrichten-Definitionen. Eine Nachricht ist einfach eine Aggregation, die eine Reihe von typisierten Feldern enthält. Viele einfache Standarddatentypen sind als Feldtypen verfügbar, darunter bool, int32, float, double und string. Sie können Ihren Nachrichten auch weitere Struktur verleihen, indem Sie andere Nachrichtentypen als Feldtypen verwenden.

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;
}

In dem obigen Beispiel enthält die Person-Nachricht PhoneNumber-Nachrichten, während die AddressBook-Nachricht Person-Nachrichten enthält. Sie können sogar Nachrichtentypen definieren, die in andere Nachrichten verschachtelt sind – wie Sie sehen, ist der PhoneNumber-Typ in Person definiert. Sie können auch enum-Typen definieren, wenn Sie möchten, dass eines Ihrer Felder einen Wert aus einer vordefinierten Liste hat – hier möchten Sie angeben, dass eine Telefonnummer entweder PHONE_TYPE_MOBILE, PHONE_TYPE_HOME oder PHONE_TYPE_WORK sein kann.

Die Markierungen " = 1", " = 2" bei jedem Element identifizieren den eindeutigen "Tag", den dieses Feld in der Binärkodierung verwendet. Tag-Nummern von 1-15 erfordern ein Byte weniger zur Kodierung als höhere Nummern. Daher können Sie diese Tags als Optimierung für häufig verwendete oder wiederholte Elemente verwenden und die Tags 16 und höher für weniger häufig verwendete optionale Elemente übrig lassen. Jedes Element in einem wiederholten Feld erfordert eine erneute Kodierung der Tag-Nummer. Wiederholte Felder sind daher besonders gute Kandidaten für diese Optimierung.

Wenn ein Feldwert nicht gesetzt ist, wird ein Standardwert verwendet: Null für numerische Typen, eine leere Zeichenfolge für Zeichenfolgen, false für boolesche Werte. Für eingebettete Nachrichten ist der Standardwert immer die "Standardinstanz" oder das "Prototyp" der Nachricht, bei der keine Felder gesetzt sind. Das Aufrufen des Accessors zum Abrufen des Werts eines Feldes, das nicht explizit gesetzt wurde, gibt immer den Standardwert dieses Feldes zurück.

Wenn ein Feld repeated ist, kann das Feld beliebig oft (einschließlich null Mal) wiederholt werden. Die Reihenfolge der wiederholten Werte wird in der Protocol Buffer beibehalten. Betrachten Sie wiederholte Felder als dynamisch dimensionierte Arrays.

Einen vollständigen Leitfaden zum Schreiben von .proto-Dateien – einschließlich aller möglichen Feldtypen – finden Sie im Protocol Buffer Language Guide. Suchen Sie jedoch nicht nach Einrichtungen, die der Klassenvererbung ähneln – Protocol Buffers tun das nicht.

Kompilieren Ihrer Protocol Buffers

Nachdem Sie nun eine .proto-Datei haben, müssen Sie als Nächstes die Klassen generieren, die Sie zum Lesen und Schreiben von AddressBook (und damit Person und PhoneNumber) Nachrichten benötigen. Dazu müssen Sie den Protocol Buffer-Compiler protoc auf Ihrer .proto-Datei ausführen.

  1. Wenn Sie den Compiler noch nicht installiert haben, laden Sie das Paket herunter und befolgen Sie die Anweisungen in der README-Datei.

  2. Führen Sie nun den Compiler aus und geben Sie das Quellverzeichnis (wo sich der Quellcode Ihrer Anwendung befindet – das aktuelle Verzeichnis wird verwendet, wenn Sie keinen Wert angeben), das Zielverzeichnis (wo der generierte Code gespeichert werden soll; oft dasselbe wie $SRC_DIR) und den Pfad zu Ihrer .proto-Datei an. In diesem Fall würden Sie aufrufen

    protoc -I=$SRC_DIR --java_out=$DST_DIR --kotlin_out=$DST_DIR $SRC_DIR/addressbook.proto
    

    Da Sie Kotlin-Code wünschen, verwenden Sie die Option --kotlin_out – ähnliche Optionen sind für andere unterstützte Sprachen verfügbar.

Beachten Sie, dass Sie sowohl --java_out als auch --kotlin_out verwenden müssen, um Kotlin-Code zu generieren. Dies generiert ein Unterverzeichnis com/example/tutorial/protos/ in Ihrem angegebenen Java-Zielverzeichnis, das einige generierte .java-Dateien enthält, sowie ein Unterverzeichnis com/example/tutorial/protos/ in Ihrem angegebenen Kotlin-Zielverzeichnis, das einige generierte .kt-Dateien enthält.

Die Protocol Buffer API

Der Protocol-Buffer-Compiler für Kotlin generiert Kotlin-APIs, die zu den vorhandenen APIs für Protocol Buffers für Java hinzugefügt werden. Dies stellt sicher, dass Codebasen, die in einer Mischung aus Java und Kotlin geschrieben sind, mit denselben Protocol-Buffer-Nachrichtenobjekten ohne spezielle Handhabung oder Konvertierung interagieren können.

Protocol Buffers für andere Kotlin-Kompilierungsziele wie JavaScript und Native werden derzeit nicht unterstützt.

Die Kompilierung von addressbook.proto ergibt die folgenden APIs in Java

  • die Klasse AddressBook
    • die von Kotlin aus die Eigenschaft peopleList : List<Person> hat
  • die Klasse Person
    • die von Kotlin aus die Eigenschaften name, id, email und phonesList hat
    • die verschachtelte Klasse Person.PhoneNumber mit den Eigenschaften number und type
    • die verschachtelte Enum Person.PhoneType

aber auch die folgenden Kotlin-APIs generiert

  • die Factory-Methoden addressBook { ... } und person { ... }
  • ein Objekt PersonKt mit einer Factory-Methode phoneNumber { ... }

Sie können mehr über die Details dessen, was genau generiert wird, im Leitfaden für generierten Kotlin-Code lesen.

Eine Nachricht schreiben

Lassen Sie uns nun versuchen, Ihre Protocol Buffer-Klassen zu verwenden. Das Erste, was Ihre Adressbuchanwendung tun können sollte, ist das Schreiben von persönlichen Details in Ihre Adressbuchdatei. Dazu müssen Sie Instanzen Ihrer Protocol Buffer-Klassen erstellen und befüllen und sie dann in einen Ausgabestrom schreiben.

Hier ist ein Programm, das ein AddressBook aus einer Datei liest, basierend auf Benutzereingaben eine neue Person hinzufügt und das neue AddressBook wieder in die Datei schreibt. Die Teile, die direkt Code aufrufen oder darauf verweisen, der vom Protocol Compiler generiert wurde, sind hervorgehoben.

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)
  }
}

Eine Nachricht lesen

Natürlich wäre ein Adressbuch nicht sehr nützlich, wenn man keine Informationen daraus abrufen könnte! Dieses Beispiel liest die Datei, die vom obigen Beispiel erstellt wurde, und gibt alle darin enthaltenen Informationen aus.

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())
  }
}

Erweitern eines Protocol Buffers

Früher oder später, nachdem Sie den Code veröffentlicht haben, der Ihre Protocol Buffer verwendet, werden Sie zweifellos die Definition des Protocol Buffers „verbessern“ wollen. Wenn Sie möchten, dass Ihre neuen Buffer abwärtskompatibel sind und Ihre alten Buffer vorwärtskompatibel sind – und das wollen Sie fast sicherlich –, dann gibt es einige Regeln, die Sie befolgen müssen. In der neuen Version des Protocol Buffers

  • Sie dürfen die Tag-Nummern vorhandener Felder nicht ändern.
  • Sie dürfen Felder löschen.
  • Sie dürfen neue Felder hinzufügen, müssen aber neue Tag-Nummern verwenden (d. h. Tag-Nummern, die in dieser Protocol Buffer noch nie verwendet wurden, auch nicht von gelöschten Feldern).

(Es gibt einige Ausnahmen zu diesen Regeln, aber sie werden selten verwendet.)

Wenn Sie diese Regeln befolgen, liest alter Code neue Nachrichten problemlos und ignoriert einfach alle neuen Felder. Für alten Code haben einzelne Felder, die gelöscht wurden, einfach ihren Standardwert, und gelöschte wiederholte Felder sind leer. Neuer Code liest ebenfalls alte Nachrichten transparent.

Beachten Sie jedoch, dass neue Felder in alten Nachrichten nicht vorhanden sind. Sie müssen also etwas Sinnvolles mit dem Standardwert tun. Ein typspezifischer Standardwert wird verwendet: für Zeichenfolgen ist der Standardwert die leere Zeichenfolge. Für boolesche Werte ist der Standardwert false. Für numerische Typen ist der Standardwert Null.