Tutorial: Protocol Buffers in ASP.NET Core
1. Einleitung
Protocol Buffers (Protobuf) ist eine Methode zur Serialisierung strukturierter Daten. Protobuf umfasst eine Schnittstellenbeschreibungssprache, um die Strukturen der Daten zu definieren und Compiler zur Generierung der Strukturen und (De-)Serialisierung. Es gibt mittlerweile Protobuf Compiler für C#, C, C++, Go, Objective-C, Java, Python, Swift und Ruby.
Google hat Protobuf ursprünglich für die interne Verwendung entwickelt und im Juli 2008 veröffentlicht. Es gibt mittlerweile drei Versionen, die nicht miteinander kompatible sind: Google intern (<2008), proto2 und proto3.
Die Entwurfsziele für Protobuf sind Einfachheit und Leistung. Protobuf wurde so konzipiert, dass es kleiner und schneller ist als XML und JSON. XML und JSON sind nicht für den Datenaustausch zwischen zwei verschiedenen Plattformen optimiert.
Protocol Buffers bietet viele Vorteile:
- Sprach- und plattformneutral
- Einfachheit
- Effizient: optimiert für Bandbreite (bis zu 10-mal kleiner)
- Schnell: optimiert für schnelle (De-) Serialisierung (20- bis 100-mal schneller)
- Eindeutige Versionierung der Strukturen
- Erzeugt Stubs die aus einfachen Klassen bestehen (POCO)
Protobuf wird in gRPC verwendet.
2. Wie es funktioniert…
Warum ist Protobuf so schnell und kompakt?
- Kontext ist getrennt von den Daten
- Binäres Transportformat (statt Text wie bei XML und JSON)
2.1 Kontext getrennt von den Daten
In XML und JSON enthält jeder Eintrag den Kontext und die Daten.
Beispiel XML:
<Person> <FirstName>Peter</FirstName> <LastName>Muster</LastName> </Person>
Beispiel JSON:
{ “firstName”: “Peter”, “lastName”: “Muster” }
Bei Protobuf werden die Datenstrukturen (‘message’) in einer Konfigurationsdatei definiert (*.proto). Der Kontext ist in den Datenstrukturen definiert:
Message Person{ String first_name = 1; String last_name = 2; }
Aus dieser Datei werden durch den Compiler die dazugehörigen Klassen generiert und die Methoden zur Serialisierung und Deserialisierung.
2.2 Binäres Transportformat
Eine detaillierte Beschreibung der binären Kodierung würde den Ramen dieses Blogs sprengen, dazu wird zur originalen Quelle verwiesen: https://developers.google.com/protocol-buffers/docs/encoding.
Jedes Feld hat folgendes Format:
{field_number << 3 | field_type} + {length of data} + {data}
Nehmen wir als Beispiel die minimale Struktur Person:
message Person{ string name = 1; }
Es gibt eine Instanz mit folgendem Inhalt:
person.name = “Erik Stroeken”
Die binäre Darstellung für ‘name’ sieht dann so aus:
0a 0d 45 72 69 6b 20 53 74 72 6f 65 65 6e => {10} + {13} + {Erik Stroeken}
Die Felder werden wie folgt interpretiert:
{field_type}: 10 => 0000 1010 => 2
{length of data}: 13
{data}: ‘Erik Stroeken’
3. Protobuf Messages
Die Strukturen der Protobuf Messages werden in einer separaten Textdatei (*.proto) definiert und dann in die Sprache kompiliert, in der die Nachrichten verwendet werden.
Die Namen der Messages sollten CamelCase sein (z.B. ‘message SongServerRequest’).
Jede Nachricht hat vier Felder:
- Rule
- Type
- Name
- Tag
In der ‘proto2’ Version von Protobuf war es noch möglich Standardwerte für jedes Feld zu definieren. Das geht nicht mehr in ‘proto3’: der default Wert ist der Standardwert vom Typ (0 oder string.Empty).
3.1 Message Feld ‘Rule’
message Customer {
int32 id = 1;
string username = 2;
repeated google.protobuf.Any details = 3;
}
Für Rule gibt es nur zwei Möglichkeiten:
- {keine} – wird als optional interpretiert.
- repeated – Array von Elementen mit dem gleichen Typ
Bei wiederholten Feldern (z.B.’repeated string keys = 1′) werden am besten pluralisierte Namen verwenden.
‘repeated’ Felder sind read-only. Man kann Elemente hinzufügen oder löschen, aber nicht die ganze Kollektion ersetzen.
Der Protobuf-Compiler für C# übersetzt ‘repeated’ in den Typ RepeatedField<T>. Dieser Typ ist wie List<T> mit ein paar extra Methoden wie Add() ausgestattet, welche eine Sammlung entgegennimmt zur Verwendung in Collection-Initialisatoren.
3.2 Message Feld ‘Type’
message Customer {
int32 id = 1;
string username = 2;
repeated google.protobuf.Any details = 3;
}
Es gibt folgende Typen in Protobuf:
- Scalar Typ
- Enumeration
- Message Typ
- Typ ‘one of’
- Typ ‘map’
- Typ ‘Any’
3.2.1 Scalar Typ
Mögliche Werte:
- string
- bool
- bytes
- float
- double
- int32, int64, uint32, uint64, (sint32, sint64, fixed32, fixed64, sfixed32, sfixed64)
Die Typen mit den Präfixen ‘s’ oder ‘sfixed’ sind nur Varianten zur Optimierung.
3.2.2 Enumerator
Enumeratortypnamen sind alle CamelCase, Enumeratorwertnamen sind alle UPPER_CASE. Der nach 0 benannte Enumeratorwert sollte mit nnn_UNDEFINED = 0; enden. Ein Enumerator kann innerhalb einer anderen Message definiert werden. Der Scope ist dann nur die Message.
message Customer { enum CustomerType{ UNDEFINED = 0; REGULAR = 1; MEMBER = 2; SPONSOR =3; } CustomerType customer_type = 1 }
Google-Richtlinien bevorzugen globale Aufzählungstypen mit dem Typ Namen als Präfix in jedem Wertefeld.
enum PhoneType { PHONETYPE_UNDEFINED = 0; PHONETYPE_MOBILE = 1; PHONETYPE_HOME = 2; PHONETYPE_WORK = 3; } enum Gender { GENDER_UNDEFINED = 0; GENDER_MALE = 1; GENDER_FEMALE = 2; }
Der Protobuf-Compiler für C # generiert die folgenden C # enum Typen:
public enum PhoneType { [pbr::OriginalName("PHONETYPE_UNDEFINED")] Undefined = 0, [pbr::OriginalName("PHONETYPE_MOBILE")] Mobile = 1, [pbr::OriginalName("PHONETYPE_HOME")] Home = 2, [pbr::OriginalName("PHONETYPE_WORK")] Work = 3, } public enum Gender { [pbr::OriginalName("GENDER_UNDEFINED")] Undefined = 0, [pbr::OriginalName("GENDER_MALE")] Male = 1, [pbr::OriginalName("GENDER_FEMALE")] Female = 2, }
3.3.3 Message Typ
Diese Felder definieren verschachtelte Messages mit dem Scope des Eltern Message.
message Customer { message Address { … } Repeated Address addresses = 1 }
3.3.4 Type ‘one of’
‘one of’ ist wie ‘union’ in Sprachen wie Pascal, in denen nur ein Feld einen Wert haben kann. ‘one of’ wird aus Gründen der Effizienz eingesetzt:
- Nur ein Feld kann einen Wert enthalten
- Wenn Sie ein Feld festlegen, werden alle anderen Felder gelöscht
- Effizientere Speichernutzung (wenn möglich)
message Customer { one of access_type { string email = 1; string username = 2; } }
3.3.5 Type ‘map’
‘map’ ist ein einfaches Dictionary mit int oder string Typen.
message Customer { map<string, string> email_addresses = 1; }
3.3.6 Type ‘Any’
‘Any’ verhält sich wie Variant in Basic oder var in C#.
message Customer { int32 id = 1; string username = 2; repeated google.protobuf.Any details = 3; }
3.4 Feld ‘Name’
message Customer {
int32 id = 1;
string username = 2;
google.protobuf.Any repeated details = 3;
}
Es gelten folgende Namenskonventionen:
- Alles Kleinbuchstaben (z. B. “access_type”)
- Verwende einen Unterstrich als Trennzeichen
Feldnamen werden für jede Sprache im richtigen Stil kompiliert.
3.5 Feld ‘Tag’
message Customer {
int32 id = 1;
string username = 2;
google.protobuf.Any repeated details = 3;
}
Numerische Platzhalter des Feldes:
-
- Muss innerhalb einer Nachricht eindeutig sein.
- Muss eine Ganzzahl sein
- Anfangen mit 1
- Muss ein Integer sein
- Reservierte Nummer 19000 .. 19999 (internen Gebrauch) nicht verwenden
- Die Nummer sollten so klein wie möglich sein.
- Keine Nummer verwenden von zuvor gelöschten Feldern (siehe Versionsverwaltung).
3.6 GUID ist nicht unterstützt
Implementieren als string oder wie beschrieben in https://github.com/protocolbuffers/protobuf/issues/2224.
3.7 Grosse Inhalte (z.B. Bilder, Dateien)
Grosse Inhalte bis 1 kB kann man als byte Array definieren. Grössere Inhalte sollten gestreamed werden (‘chuncking’). Streaming wird in gRPC in beide Richtungen unterstützt. In der unteren Message wird ‘imageChunk’ gestreamed.
message PersonImageMessage { int32 personId = 1; ImageType imageType = 2; bytes imageChunk = 3; }
4. Die ‘proto’-Datei
Proto-Dateien werden am Besten in einem Verzeichnis ‘Protos’ gespeichert. Enumeratoren werden gemäss Richtlinien von Google global definiert und sind im Beispiel unten ausgelagert in der Datei ‘enums.proto’.
Die Datei ‘enums.proto’:
syntax = "proto3"; option csharp_namespace = "Addressbook.Services"; enum Gender { GENDER_UNDEFINED = 0; GENDER_MALE = 1; GENDER_FEMALE = 2; } enum PhoneType { PHONETYPE_UNDEFINED = 0; PHONETYPE_MOBILE = 1; PHONETYPE_HOME = 2; PHONETYPE_WORK = 3; }
Die ‘option csharp_namespace’ definiert den Namensraum für die generierten Klassen. Es ersetzt das mehr generische ‘package package_name’.
Hier ist der Inhalt für ‘addressBook.proto’:
syntax = "proto3"; import "enums.proto"; import "google/protobuf/timestamp.proto"; option csharp_namespace = "Addressbook.Services"; message Person { int32 id = 1; string name = 2; int32 age = 3; string email = 4; Gender gender = 5; message PhoneNumber { string number = 1; PhoneType phoneType = 2; } repeated PhoneNumber phone_numbers = 6; google.protobuf.Timestamp last_updated = 7; } message AddressBook { repeated Person persons = 1; } message AddressBookMessage { AddressBook addressBook = 1; }
Mit ‘import’ werden andere Proto-Dateien importiert.
4. Versionierung
4.1 Reservierte Tags
Im unteren Beispiel wird das Feld ‘name’ aufgeteilt in ‘first_name’ und ‘last_name’. Danach wird ein neues Feld ‘email’ hinzugefügt mit dem Tag 2. Wenn sich jetzt ein neuer Client mit einem alten Server verbindet, wird das Feld ‘email’ mit dem Inhalt vom Feld ‘name’ abgefüllt.
int32 id = 1;
string name = 2;
}
int32 id = 1;
string email = 2;
string first_name = 3;
string last_name = 4;
}
Nach der Revision soll das Schlüsselwort ‘reserved’ verwendet werden, um dem Entwickler und Compiler mitzuteilen, dass 2 nicht mehr verwendet werden darf.
int32 id = 1;
string name = 2;
}
int32 id = 1;
string email = 5;
string first_name = 3;
string last_name = 4;
reserved 2;
}
Gültige Definitionen:
- reserved 1;
- reserved 1,2 ,3;
- reserved 5 to 10;
- reserved 1,2,3,5 to 10;
4.2 Reservierte Felder
Gleicher UseCase wie oben, aber jetzt wird der Namen wiederverwendet.
int32 id = 1;
string full_name = 5;
}
int32 id = 1;
string full_name = 5;
string first_name = 3;
string last_name = 4;
}
Aus der Protobuf-Sicht gibt es kein Problem, aber wenn der Inhalt zu JSON serialisiert wird, wird ein Konflikt entstehen wenn ein neuer Client sich mit einem alten Server verbindet.
int32 id = 1;
string whole = 2;
}
int32 id = 1;
string whole_name = 5;
string first_name = 3;
string last_name = 4;
reserved “full_name”;
}
Gültige Definitionen:
- reserved «field_name»;
- reserved «field_name», «other_name»
5. Kompilieren
Es gibt zwei Wege um die proto-Datei zu kompilieren:
- Über Visual Studio 2019
- Manuell mit dem Tool von Google
5.1 Visual Studio 2019
Ab .NET Core 3.0 ist gRPC und Protobuf integriert. Die Kompilierung der proto-Dateien geschieht daher automatisch bei jeder Build-Aktion.
- Installiere Visual Studio 2019 Community, Professional oder Ultimate.
- Installiere .NET Core 3.1 (nicht .NET Framework 4.8).
- Kreiere die proto-Dateien im Projekt wie oben beschrieben.
- Installiere NuGet Package ‘Grpc.AspNetCore (v2.25.0)’.
- Selektiere die proto-Dateien und setzt die Build Aktion auf ‘Protobuf compiler’.
Nach dem Kompilieren befinden sich die cs-Dateien in ‘object\Debug\netcoreapp3.0\’:
5.2 Manuell Kompilieren (nicht empfohlen)
Installiere NuGet Paketen ‘Google.Protobuf’ und ‘Google.Protobuf.Tools’.
Die NuGet-Pakete warden hier installiert:
C:\Users\[User]\.nuget\packages\google.protobuf\3.10.1
C:\Users\[User]\.nuget\packages\google.protobuf.tools\3.10.1
Entweder setzte die Path Variable zu:
C:\Users\[User]\.nuget\packages\google.protobuf.tools\3.10.1\tools\windows_x64
Oder kopiere ‘protoc.exe’ und das Unterverzeichnis ‘google’ in die Solution:
C:\Users\[User]\.nuget\packages\google.protobuf.tools\3.10.1\tools\windows_x64\protoc.exe
C:\Users\[User]\.nuget\packages\google.protobuf.tools\3.10.1\tools\windows_x64\google
Führe dann folgender Befehl aus (achtung: –csharp hat zwei ‘-‘ Zeichen):
protoc –csharp_out=ProtocolBuffers ProtocolBuffers\addressbook.proto
6. (De-)Serialisierung
Die generierte Klasse ‘Person’ kann jetzt instanziiert und abgefüllt werden.
Person person = new Person { Name = "Jan Muster", Age = 50, Gender = Gender.Male, Email = "jan.muster@noser.com", PhoneNumbers = { new PhoneNumber {Number = "00 41 76 417 12 87", PhoneType = PhoneType.Mobile}, new PhoneNumber {Number = "00 41 44 741 22 16", PhoneType = PhoneType.Home}, new PhoneNumber {Number = "00 41 41 455 45 45", PhoneType = PhoneType.Work} } };
Zur Serialisierung und Deserialisierung gib es statische und extension Methoden.
6.1 (De-)Serialization von\zu byte array
// Serializing person to byte array and back. byte[] personByteArray = person.ToByteArray(); Person personFromByteArray = Person.Parser.ParseFrom(personByteArray);
6.2 (De-)Serialization von\zu file
// Writing person to disk and reading it again. using (FileStream output = File.Create("person.dat")) { person.WriteTo(output); } using (FileStream input = File.OpenRead("person.dat")) { Person personToDisk = Person.Parser.ParseFrom(input); }
6.3 (De-)Serialization von\zu JSON
// Serializing person to JSON and back. string jsonMessage = Google.Protobuf.JsonFormatter.Default.Format((IMessage)person); IMessage message = (IMessage)Activator.CreateInstance(typeof(Person)); Person personFromJSON = (Person)Google.Protobuf.JsonParser.Default.Parse(jsonMessage, message.Descriptor);
6.4 (De-)Serialization von\zu XML
Macht keinen Sinn und gibt es auch nicht.
Fazit
Protobuf ist schnell zu lernen und dank der Integration in Visual Studio 2019 einfach anzuwenden. Protocol Buffer ist die Technologie, die verwendet wird für die Serialisierung und Deserialisierung von DTOs in gRPC. In den nächsten Blogs wird gRPC und ASP.NET Core ausführlich erklärt.