Skip to content

Protobuf

Offizielle Website: Protocol Buffers | Google Developers

Einführung

Offizielles Tutorial: Protocol Buffer Basics: Go | Protocol Buffers | Google Developers

Protocol Buffers ist ein 2008 von Google open-source veröffentlichter, sprachunabhängiger, protokollunabhängiger und erweiterbarer Mechanismus zur Serialisierung strukturierter Daten. Es ist beim Packen und Entpacken schneller und wird hauptsächlich im RPC-Bereich für die Kommunikation eingesetzt. Damit kann definiert werden, wie Daten strukturiert werden, und dann kann mit speziell generiertem Quellcode strukturierte Daten einfach in verschiedene Datenströme geschrieben und aus ihnen gelesen werden. Es ist für verschiedene Sprachen einsetzbar. Im Folgenden wird Protocol Buffers als protobuf bezeichnet.

protobuf ist relativ weit verbreitet, besonders im Go-Bereich. gRPC verwendet es als Serialisierungsmechanismus für die Protokollübertragung.

Syntax

Schauen wir uns zunächst ein Beispiel an, um zu sehen, wie eine protobuf-Datei ungefähr aussieht. Insgesamt ist die Syntax sehr einfach und kann in zehn bis zwanzig Minuten erlernt werden. Hier ist ein Beispiel für eine Datei namens search.proto. Die Dateierweiterung von protobuf ist .proto.

protobuf
syntax = "proto3";

message SearchRequest {
  string query = 1;
  string number = 2;
}

message SearchResult {
  string data = 1;
}

service SearchService {
  rpc Search(SearchRequest) returns(SearchResult);
}
  • Die erste Zeile syntax = "proto3"; gibt an, dass die Syntax von proto3 verwendet wird. Standardmäßig wird die Syntax von proto3 verwendet.
  • Die Deklaration mit message ähnelt einer Struktur und ist die grundlegende Struktur in proto.
  • In SearchRequest sind drei Felder definiert, jedes Feld hat einen Namen und einen Typ.
  • In service ist ein Dienst definiert. Ein Dienst enthält eine oder mehrere RPC-Schnittstellen.
  • Eine RPC-Schnittstelle muss genau einen Parameter und einen Rückgabewert haben. Ihre Typen müssen message sein, keine Basistypen.

Außerdem ist zu beachten, dass jede Zeile in einer proto-Datei mit einem Semikolon enden muss.

Kommentare

Der Kommentarstil ist identisch mit Go.

protobuf
syntax = "proto3";

/* Kommentar
 * Kommentar */
message SearchRequest {
  string query = 1; //Kommentar
  string number = 2;
}

Typen

Typmodifizierer können nur in message erscheinen, nicht allein stehen.

Basistypen

proto TypeGo Type
doublefloat64
floatfloat32
int32int32
int64int64
uint32uint32
uint64uint64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
boolbool
stringstring
bytes[]byte

Arrays

Wenn man vor einem Basistyp den Modifizierer repeated hinzufügt, bedeutet das, dass es sich um einen Array-Typ handelt, der in Go einem Slice entspricht.

protobuf
message Company {
  repeated string employee = 1;
}

Map

In Protobuf wird der Map-Typ wie folgt definiert:

map<key_type, value_type> map_field = N;

key_type muss eine Zahl oder Zeichenfolge sein, value_type hat keine Typbeschränkung. Hier ein Beispiel:

protobuf
message Person {
  map<string, int64> cards = 1;
}

Felder

Tatsächlich ist Proto kein traditioneller Schlüssel-Wert-Typ. In der deklarierten proto-Datei erscheinen keine konkreten Daten. Nach dem = jedes Feldes sollte eine eindeutige Nummer innerhalb der aktuellen message stehen. Diese Nummern dienen dazu, diese Felder im binären Nachrichtenkörper zu identifizieren und zu definieren. Die Nummerierung beginnt bei 1. Nummern von 1-15 belegen 1 Byte, 16-2047 belegen zwei Bytes. Daher sollten häufig vorkommende Feldern nach Möglichkeit Nummern von 1-15 zugewiesen werden, um Platz zu sparen. Außerdem sollte etwas Platz für Felder reserviert werden, die später häufig vorkommen könnten.

Die Felder in einer message sollten den folgenden Regeln folgen:

  • singular: Standardmäßig ist dies der Feldtyp. In einer wohlgeformten message kann es 0 oder 1 solcher Felder geben, d.h. dasselbe Feld kann nicht mehrfach existieren. Die folgende Deklaration würde einen Fehler verursachen.

    protobuf
    syntax = "proto3";
    
    message SearchRequest {
      string query = 1;
      string number = 2;
      string number = 3;//Feld wiederholt
    }
  • optional: Ähnlich wie singular, aber es kann explizit überprüft werden, ob ein Feldwert gesetzt wurde. Es kann zwei Situationen geben:

    • set: Wird serialisiert
    • unset: Wird nicht serialisiert
  • repeated: Felder dieses Typs können 0-mal oder mehrmals erscheinen. Wiederholte Werte werden in Reihenfolge beibehalten (im Grunde ist es ein Array, das mehrfaches Auftreten desselben Typs erlaubt und in der Reihenfolge des Auftretens beibehält, also mit Index).

  • map: Schlüssel-Wert-Paar-Typ, deklariert wie folgt:

    protobuf
    map<string,int32> config = 3;

Reservierte Felder

Das Schlüsselwort reserve kann reservierte Felder deklarieren. Nach der Deklaration reservierter Feldnummern können diese nicht mehr als Nummern oder Namen für andere Felder verwendet werden, und es wird beim Kompilieren ein Fehler auftreten. Die offizielle Antwort von Google lautet: Wenn in einer proto-Datei in einer neuen Version einige Nummern gelöscht wurden, könnten andere Benutzer in Zukunft diese gelöschten Nummern wiederverwenden. Wenn jedoch zur alten Version zurückgekehrt wird, stimmen die Nummern der Felder nicht mehr überein, was zu Fehlern führt. Reservierte Felder können zur Kompilierzeit eine Erinnerungsfunktion übernehmen und daran erinnern, dass Sie dieses reservierte Feld nicht verwenden dürfen, sonst wird die Kompilierung fehlschlagen.

protobuf
syntax = "proto3";

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  reserved "a"; //Deklariert ein Feld mit spezifischem Namen als reserviertes Feld
  reserved 1 to 2; //Deklariert eine Nummernsequenz als reserviertes Feld
  reserved 3,4; //Deklaration
}

Dadurch wird diese Datei nicht kompiliert werden.

Veraltete Felder

Wenn ein Feld veraltet ist, kann es wie folgt geschrieben werden:

protobuf
message Body {
  string name = 1 [deprecated = true];
}

Aufzählungen (Enums)

Sie können Aufzählungskonstanten deklarieren und sie als Feldtypen verwenden. Beachten Sie, dass das erste Element einer Aufzählung Null sein muss, da der Standardwert eines Aufzählungselements das erste Element ist.

protobuf
syntax = "proto3";

enum Type {
  GET = 0;
  POST = 1;
  PUT = 2;
  DELETE = 3;
}

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  Type type = 5;
}

Wenn es innerhalb der Aufzählungselemente Elemente mit demselben Wert gibt, können Sie Aufzählungsaliase verwenden:

protobuf
syntax = "proto3";

enum Type {
  option allow_alias = true; //Muss die Konfigurationsoption zum Zulassen von Aliasen aktivieren
  GET = 0;
  GET_ALIAS = 0; //Alias für das GET-Aufzählungselement
  POST = 1;
  PUT = 2;
  DELETE = 3;
}

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  Type type = 5;
}

Verschachtelte Nachrichten

protobuf
message Outer {                  // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

In einer message können verschachtelt weitere message deklariert werden, genau wie bei verschachtelten Strukturen.

Package

Sie können einer protobuf-Datei einen optionalen Paketmodifizierer hinzufügen, um Namenskonflikte zwischen Protokollnachrichtentypen zu verhindern.

protobuf
package foo.bar;
message Open { ... }

Dann können Sie den Paketnamen verwenden, wenn Sie Felder vom Nachrichtentyp definieren:

protobuf
message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

Import

Importieren ermöglicht es mehreren protobuf-Dateien, Definitionen zu teilen. Die Syntax lautet wie folgt, wobei die Dateierweiterung beim Importieren nicht weggelassen werden darf.

protobuf
import "a/b/c.proto";

Beim Importieren werden immer relative Pfade verwendet. Dieser relative Pfad bezieht sich nicht auf den relativen Pfad zwischen der importierenden und der importierten Datei, sondern hängt vom Scan-Pfad ab, der beim Generieren des Codes durch den protoc-Compiler angegeben wurde. Angenommen, es gibt folgende Dateistruktur:

pb_learn
│  common.proto

├─monster
│      monster.proto

└─player
        health.proto
        player.proto

Wenn wir nur den Code im Verzeichnis player generieren müssen und beim Scan-Pfad nur das Verzeichnis player angegeben wurde, kann der gegenseitige Import zwischen health.proto und player.proto direkt den einzelnen Dateinamen verwenden, z.B. importiert player.proto health.proto.

protobuf
import "health.proto";

Wenn player.proto zu diesem Zeitpunkt common.proto oder Dateien im Verzeichnis monster importiert, schlägt die Kompilierung fehl. Die folgende Schreibweise ist also völlig falsch, da der Compiler diese Dateien nicht finden kann.

go
import "../common.proto"; // Falsche Schreibweise

TIP

Übrigens sind die Symbole .. und . in Importpfaden nicht erlaubt.

Angenommen, beim Kompilieren wird pb_learn als Scan-Pfad angegeben, dann können Dateien aus anderen Verzeichnissen über relative Pfade importiert werden. Der tatsächliche Importpfad ist die relative Adresse der absoluten Adresse dieser Datei bezogen auf pb_learn. Hier ein Beispiel, wie player.proto andere Dateien importiert:

protobuf
import "common.proto";
imrpot "monster/monster.proto";
import "player/health.proto";

Selbst health.proto im selben Verzeichnis muss jetzt relative Pfade verwenden. In einem Projekt erstellen wir im Allgemeinen einen separaten Ordner für alle protobuf-Dateien und geben diesen beim Kompilieren als Scan-Pfad an. Alle Importvorgänge in diesem Verzeichnis basieren dann auf dessen relativem Pfad.

TIP

Wenn Sie den GoLand-Editor verwenden, kann ein selbst erstelltes protobuf-Verzeichnis standardmäßig nicht aufgelöst werden, was zu roten Fehlermarkierungen führt. Damit GoLand es erkennt, müssen Sie den Scan-Pfad manuell festlegen. Das Prinzip ist genau dasselbe wie oben beschrieben. Die Einstellungsmethode ist wie folgt, öffnen Sie folgende Einstellungen:

File | Settings | Languages & Frameworks | Protocol Buffers

Fügen Sie in Import Paths manuell den Scan-Pfad hinzu. Dieser Scan-Pfad sollte mit dem beim Kompilieren angegebenen Pfad übereinstimmen.

Any

Der Typ Any ermöglicht es Ihnen, Nachrichten als eingebetteten Typ zu verwenden, ohne deren proto-Definition zu benötigen. Wir können direkt die von Google definierten Typen importieren, die bereits integriert sind und nicht manuell geschrieben werden müssen.

protobuf
import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

Google hat viele weitere Typen vordefiniert. Weitere Informationen finden Sie unter protobuf/ptypes at master · golang/protobuf (github.com). Dazu gehören hauptsächlich:

  • Wrapper für Basistypen
  • Zeittypen
  • Duration-Typ

Die entsprechenden protobuf-Definitionen finden Sie im Verzeichnis include des protoc-Compilers.

OneOf

Die offizielle Dokumentation gibt hierfür eine sehr komplizierte Erklärung. Einfach ausgedrückt bedeutet es, dass ein Feld bei der Übertragung mehrere mögliche Typen haben kann, aber letztendlich nur ein Typ verwendet wird. Innerhalb von oneof sind keine Felder mit dem Modifizierer repeated erlaubt. Das ähnelt dem union in der Sprache C.

protobuf
message Stock {
    // Stock-specific data
}

message Currency {
    // Currency-specific data
}

message ChangeNotification {
  int32 id = 1;
  oneof instrument {
    Stock stock = 2;
    Currency currency = 3;
  }
}

Service

Das Schlüsselwort service kann einen RPC-Dienst definieren. Ein RPC-Dienst enthält mehrere RPC-Schnittstellen, die wiederum in unäre Schnittstellen und Streaming-Schnittstellen unterteilt sind.

protobuf
message Body {
  string name = 1;
}

service ExampleService {
  rpc DoSomething(Body) returns(Body);
}

Streaming-Schnittstellen werden weiter in unidirektionale Streaming- und bidirektionale Streaming-Schnittstellen unterteilt, normalerweise modifiziert durch das Schlüsselwort stream. Hier ein Beispiel:

protobuf
message Body {
  string name = 1;
}

service ExampleService {
  // Client-Streaming
  rpc DoSomething(stream Body) returns(Body);
  // Server-Streaming
  rpc DoSomething1(Body) returns(stream Body);
  // Bidirektionales Streaming
  rpc DoSomething2(stream Body) returns(stream Body);
}

Streaming bedeutet, dass über eine Verbindung langfristig gegenseitig Daten gesendet werden, anstatt wie bei unären Schnittstellen einfach nur eine Frage und eine Antwort zu haben.

Empty

empty ist tatsächlich eine leere message, die in Go einer leeren Struktur entspricht. Sie wird selten zur Modifikation von Feldern verwendet, sondern hauptsächlich um anzuzeigen, dass eine RPC-Schnittstelle keine Parameter benötigt oder keinen Rückgabewert hat.

protobuf
syntax = "proto3";

import "google/protobuf/empty.proto";

service EmptyService {
  rpc Do(google.protobuf.Empty) returns(google.protobuf.Empty);
}

Option

option wird normalerweise verwendet, um einige Verhaltensweisen von protobuf zu steuern. Um beispielsweise das Paket für die Go-Quellcodegenerierung zu steuern, kann wie folgt deklariert werden:

protobuf
option go_package = "github/jack/sample/pb_learn;pb_learn"

Vor dem Semikolon steht der Importpfad für andere Quelldateien nach der Codegenerierung, nach dem Semikolon der Paketname der entsprechenden generierten Datei.

Es kann einige Optimierungen vornehmen. Folgende Werte sind verfügbar und können nicht wiederholt deklariert werden:

  • SPEED: Höchster Optimierungsgrad, generiert den größten Code, dies ist der Standardwert.
  • CODE_SIZE: Reduziert das Volumen des generierten Codes, verwendet aber Reflexion für die Serialisierung.
  • LIFE_RUNTIME: Kleinster Codeumfang, aber es fehlen einige Funktionen.

Hier ein Anwendungsbeispiel:

protobuf
option optimize_for = SPEED;

Darüber hinaus kann option auch message und enum Metainformationen hinzufügen, die über Reflexion abgerufen werden können. Dies ist besonders nützlich für die Parametervalidierung.

Kompilierung

Kompilierung bedeutet Codegenerierung. Oben haben wir nur die protobuf-Datei definiert. Zur tatsächlichen Verwendung muss sie in Quellcode einer bestimmten Sprache umgewandelt werden. Dies erledigen wir mit dem protoc-Compiler, der mehrere Sprachen unterstützt.

Installation

Laden Sie den Compiler von protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com) herunter. Holen Sie sich die neueste Release-Version, normalerweise eine komprimierte Datei.

protoc-25.1-win64
│  readme.txt

├─bin
│      protoc.exe

└─include
    └─google
        └─protobuf
            │  any.proto
            │  api.proto
            │  descriptor.proto
            │  duration.proto
            │  empty.proto
            │  field_mask.proto
            │  source_context.proto
            │  struct.proto
            │  timestamp.proto
            │  type.proto
            │  wrappers.proto

            └─compiler
                    plugin.proto

Nach dem Download fügen Sie das bin-Verzeichnis zu den Umgebungsvariablen hinzu, um den Befehl protoc verwenden zu können. Überprüfen Sie danach die Version. Wenn die Ausgabe korrekt erfolgt, war die Installation erfolgreich.

bash
$ protoc --version
libprotoc 25.1

Der heruntergeladene Compiler unterstützt standardmäßig keine Go-Sprache, da die Go-Codegenerierung eine separate ausführbare Datei ist, während andere Sprachen alle zusammengefasst sind. Installieren Sie daher das Go-Plugin, um die protobuf-Definition in Go-Quellcode zu übersetzen.

bash
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

Wenn Sie auch gRPC-Dienstcode generieren müssen, installieren Sie zusätzlich folgendes Plugin:

bash
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Überprüfen Sie nach der Installation die Versionen:

bash
$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.3.0

$ protoc-gen-go --version
protoc-gen-go.exe v1.31.0

Diese Plugins sind ebenfalls eigenständige Binärdateien, können aber nur über protoc aufgerufen werden und nicht direkt ausgeführt werden.

(this program should be run by protoc, not directly)

Darüber hinaus gibt es viele andere Plugins, wie z.B. Plugins zur Generierung von openapi-Schnittstellendokumentation. Interessierte können selbst danach suchen.

Generierung

Nehmen wir wieder das vorherige Beispiel mit folgender Struktur:

pb_learn
│  common.proto

├─monster
│      monster.proto

└─player
        health.proto
        player.proto

Für die Codegenerierung müssen insgesamt drei Parameter angegeben werden:

  1. Scan-Pfad: Gibt an, wo der Compiler nach protobuf-Dateien suchen soll und wie Importpfade aufgelöst werden
  2. Ausgabepfad: Wo die kompilierten Dateien abgelegt werden sollen
  3. Zieldateien: Welche Dateien kompiliert werden sollen

Stellen Sie vor dem Start sicher, dass die go_package-Einstellung in der protobuf-Datei korrekt ist. Mit protoc -h können Sie die unterstützten Parameter anzeigen. Am häufigsten verwendet werden -I oder --proto_path, die mehrfach verwendet werden können, um mehrere Scan-Pfade anzugeben, zum Beispiel:

bash
$ protoc --proto_path=./pb_learn --proto_path=./third_party

Die Angabe des Scan-Pfads allein reicht nicht aus. Es müssen auch der Ausgabepfad und die Ziel-protobuf-Dateien angegeben werden. Da hier Go-Dateien generiert werden, verwenden wir den Parameter --go_out, der vom zuvor heruntergeladenen protoc-gen-go-Plugin unterstützt wird.

bash
$ cd pb_learn

$ protoc --proto_path=. --go_out=. common.proto

$ ls
common.pb.go  common.proto  monster/  player/

Der Parameter von --go_out ist der angegebene Ausgabepfad. . bedeutet der aktuelle Pfad, und common.proto ist die zu kompilierende Datei. Um gRPC-Code zu generieren (vorausgesetzt das gRPC-Plugin ist installiert), können Sie den Parameter --go-grpc_out hinzufügen. Wenn in der protobuf-Datei kein service definiert ist, wird keine entsprechende Datei generiert.

bash
$ protoc --proto_path=. --go_out=. --go-grpc_out=. common.proto

$ ls
common.pb.go  common.proto  common_grpc.pb.go  monster/  player/

common.pb.go ist die generierte protobuf-Typdefinition, common_grpc.pb.go ist der generierte gRPC-Code, der auf ersterem basiert. Ohne die Definition in der entsprechenden Sprache kann auch kein gRPC-Code generiert werden.

Um alle protobuf-Dateien in diesem Verzeichnis zu kompilieren, können Sie den Platzhalter * verwenden, zum Beispiel:

bash
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.proto

Um alle Dateien einzuschließen, können Sie den Platzhalter ** verwenden, wie ./**/*.proto.

bash
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.proto

Diese Methode funktioniert jedoch nur in Shells, die solche Platzhalter unterstützen. Unter Windows unterstützen beispielsweise cmd oder PowerShell diese Schreibweise nicht:

powershell
D> protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./**/*.proto
Invalid file name pattern or missing input file "./**/*.proto"

Glücklicherweise unterstützt gitbash viele Linux-Befehle und ermöglicht auch unter Windows diese Syntax. Um nicht jedes Mal dieselben Befehle schreiben zu müssen, können Sie diese in ein makefile einfügen:

makefile
.PHONY: all

proto_gen:
  protoc --proto_path=. \
       --go_out=paths=source_relative:. \
       --go-grpc_out=paths=source_relative:. \
       ./**/*.proto ./*.proto

Sie werden bemerken, dass paths=source_relative:. hinzugefügt wurde. Dies legt den Pfadmodus für die Dateigenerierung fest. Es gibt folgende Optionen:

  • paths=import: Dies ist der Standardwert. Dateien werden im durch import angegebenen Verzeichnis generiert. Dies kann auch ein Modulpfad sein. Wenn es beispielsweise eine Datei protos/buzz.proto gibt und paths=example.com/project/protos/fizz angegeben wird, wird schließlich example.com/project/protos/fizz/buzz.pb.go generiert.
  • module=$PREFIX: Bei der Generierung wird das Pfadpräfix entfernt. Im obigen Beispiel, wenn das Präfix example.com/project angegeben wird, wird schließlich protos/fizz/buzz.pb.go generiert. Dieser Modus wird hauptsächlich verwendet, um direkt im Modul zu generieren.
  • paths=source_relative: Die generierten Dateien behalten im angegebenen Verzeichnis dieselbe relative Struktur wie die protobuf-Dateien.

Nach dem Doppelpunkt : folgt der angegebene Ausgabepfad.

|  common.proto
|  common.pb.go

├─monster
│      monster.pb.go
│      monster.proto

└─player
        health.pb.go
        health.proto
        health_grpc.pb.go
        player.pb.go
        player.proto

Reflexion

Durch options können enum und message erweitert werden. Importieren Sie zuerst "google/protobuf/descriptor.proto":

protobuf
import "google/protobuf/descriptor.proto";

extend google.protobuf.EnumValueOptions {
  optional string string_name = 123456789;
}

enum Integer {
  INT64 = 0[
    (string_name) = "int_64"
  ];
}

Dies entspricht dem Hinzufügen von Metainformationen zu diesem Aufzählungswert. Für message gilt dasselbe Prinzip:

protobuf
import "google/protobuf/descriptor.proto";

extend google.protobuf.MessageOptions {
  optional string my_option = 51234;
}

message MyMessage {
  option (my_option) = "Hello world!";
}

Dies entspricht der Reflexion in protobuf. Nach der Codegenerierung kann über Descriptor darauf zugegriffen werden:

go
func main() {
  message := pb_learn.MyMessage{}
  message.ProtoReflect().Descriptor().Options().ProtoReflect().Range(func(descriptor protoreflect.FieldDescriptor, value protoreflect.Value) bool {
    fmt.Println(descriptor.FullName(), ":", value)
    return true
  })
}

Ausgabe:

my_option:"Hello world!"

Diese Methode kann mit dem Hinzufügen von Tags zu Strukturen in Go verglichen werden - es ist ein ähnliches Konzept. Basierend auf dieser Methode kann auch eine Parametervalidierungsfunktion implementiert werden, indem Regeln in options geschrieben und über Descriptor überprüft werden.

Golang by www.golangdev.cn edit