Grundlagen von Protocol Buffers: C#
Dieses Tutorial bietet eine grundlegende Einführung für C#-Programmierer in die Arbeit mit Protocol Buffers unter Verwendung der proto3-Version der Protocol Buffers-Sprache. Anhand eines einfachen Beispielprogramms wird gezeigt, wie Sie
- Nachrichtenformate in einer
.proto-Datei definieren. - Den Protocol Buffer-Compiler verwenden.
- die C#-Protocol Buffer API zum Schreiben und Lesen von Nachrichten verwenden.
Dies ist keine umfassende Anleitung zur Verwendung von Protocol Buffers in C#. Detailliertere Referenzinformationen finden Sie im Protocol Buffer Language Guide, im C# API Reference, im C# Generated Code Guide und im Encoding Reference.
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
- die .NET-Binärserialisierung mit
System.Runtime.Serialization.Formatters.Binary.BinaryFormatterund zugehörigen Klassen verwenden. Dies ist angesichts von Änderungen sehr fehleranfällig, in einigen Fällen teuer in Bezug auf die Datengröße und funktioniert auch nicht sehr gut, wenn Sie Daten mit Anwendungen austauschen müssen, die für andere Plattformen geschrieben wurden. - 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 Befehlszeilenanwendung zur Verwaltung einer Adressbuch-Datendatei, die mit Protocol Buffers kodiert ist. Der Befehl AddressBook (siehe: Program.cs) kann einen neuen Eintrag zur Datendatei hinzufügen oder die Datendatei parsen und die Daten auf der Konsole ausgeben.
Das vollständige Beispiel finden Sie im Beispielverzeichnis und im csharp/src/AddressBook Verzeichnis 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";
In C# werden Ihre generierten Klassen in einem Namespace platziert, der dem package-Namen entspricht, wenn csharp_namespace nicht angegeben ist. In unserem Beispiel wurde die Option csharp_namespace angegeben, um das Standardverhalten zu überschreiben. Daher verwendet der generierte Code den Namespace Google.Protobuf.Examples.AddressBook anstelle von Tutorial.
option csharp_namespace = "Google.Protobuf.Examples.AddressBook";
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.
Wenn Sie den Compiler noch nicht installiert haben, laden Sie das Paket herunter und befolgen Sie die Anweisungen in der README-Datei.
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 aufrufenprotoc -I=$SRC_DIR --csharp_out=$DST_DIR $SRC_DIR/addressbook.protoDa Sie C#-Code möchten, verwenden Sie die Option
--csharp_out– ähnliche Optionen sind für andere unterstützte Sprachen verfügbar.
Dies generiert Addressbook.cs in Ihrem angegebenen Zielverzeichnis. Um diesen Code zu kompilieren, benötigen Sie ein Projekt mit einer Referenz auf die Google.Protobuf-Assembly.
Die Adressbuchklassen
Die Generierung von Addressbook.cs liefert Ihnen fünf nützliche Typen
- Eine statische
Addressbook-Klasse, die Metadaten über die Protocol Buffer-Nachrichten enthält. - Eine
AddressBook-Klasse mit einer schreibgeschütztenPeople-Eigenschaft. - Eine
Person-Klasse mit Eigenschaften fürName,Id,EmailundPhones. - Eine
PhoneNumber-Klasse, die in einer statischenPerson.Types-Klasse verschachtelt ist. - Ein
PhoneType-Enum, das ebenfalls inPerson.Typesverschachtelt ist.
Sie können mehr über die Details dessen, was genau generiert wird, im C# Generated Code Guide lesen, aber größtenteils können Sie diese wie ganz normale C#-Typen behandeln. Ein wichtiger Punkt ist, dass alle Eigenschaften, die wiederholten Feldern entsprechen, schreibgeschützt sind. Sie können Elemente zur Sammlung hinzufügen oder daraus entfernen, aber Sie können sie nicht durch eine völlig separate Sammlung ersetzen. Der Sammlungstyp für wiederholte Felder ist immer RepeatedField<T>. Dieser Typ ähnelt List<T>, verfügt aber über einige zusätzliche Komfortmethoden, wie z. B. eine Add-Überladung, die eine Sammlung von Elementen akzeptiert, für die Verwendung in Sammlungsinitialisierern.
Hier ist ein Beispiel, wie Sie eine Instanz von Person erstellen könnten
Person john = new Person
{
Id = 1234,
Name = "John Doe",
Email = "jdoe@example.com",
Phones = { new Person.Types.PhoneNumber { Number = "555-4321", Type = Person.Types.PhoneType.Home } }
};
Beachten Sie, dass Sie mit C# 6 using static verwenden können, um die hässliche Person.Types-Schreibweise zu vermeiden
// Add this to the other using directives
using static Google.Protobuf.Examples.AddressBook.Person.Types;
...
// The earlier Phones assignment can now be simplified to:
Phones = { new PhoneNumber { Number = "555-4321", Type = PhoneType.HOME } }
Parsen und Serialisieren
Der eigentliche Zweck der Verwendung von Protocol Buffers ist die Serialisierung Ihrer Daten, damit sie anderswo geparst werden können. Jede generierte Klasse hat eine Methode WriteTo(CodedOutputStream), wobei CodedOutputStream eine Klasse in der Protocol Buffer-Laufzeitbibliothek ist. Normalerweise verwenden Sie jedoch eine der Erweiterungsmethoden, um in einen regulären System.IO.Stream zu schreiben oder die Nachricht in ein Byte-Array oder einen ByteString zu konvertieren. Diese Erweiterungsmethoden befinden sich in der Klasse Google.Protobuf.MessageExtensions. Wenn Sie also serialisieren möchten, benötigen Sie normalerweise eine using-Direktive für den Google.Protobuf-Namespace. Zum Beispiel
using Google.Protobuf;
...
Person john = ...; // Code as before
using (var output = File.Create("john.dat"))
{
john.WriteTo(output);
}
Das Parsen ist ebenfalls einfach. Jede generierte Klasse verfügt über eine statische Parser-Eigenschaft, die einen MessageParser<T> für diesen Typ zurückgibt. Dieser wiederum verfügt über Methoden zum Parsen von Streams, Byte-Arrays und ByteStrings. Um die gerade erstellte Datei zu parsen, können wir also verwenden
Person john;
using (var input = File.OpenRead("john.dat"))
{
john = Person.Parser.ParseFrom(input);
}
Ein vollständiges Beispielprogramm zur Pflege eines Adressbuchs (Hinzufügen neuer Einträge und Auflisten bestehender Einträge) unter Verwendung dieser Nachrichten ist im GitHub-Repository verfügbar.
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.
Reflexion
Nachrichten-Deskriptoren (die Informationen in der .proto-Datei) und Nachrichteninstanzen können mithilfe der Reflexions-API programmgesteuert untersucht werden. Dies kann bei der Erstellung generischen Codes wie eines anderen Textformats oder eines intelligenten Diff-Tools nützlich sein. Jede generierte Klasse hat eine statische Descriptor-Eigenschaft, und der Deskriptor für jede Instanz kann über die IMessage.Descriptor-Eigenschaft abgerufen werden. Als schnelles Beispiel, wie diese verwendet werden können, hier eine kurze Methode zum Drucken der obersten Felder jeder Nachricht.
public void PrintMessage(IMessage message)
{
var descriptor = message.Descriptor;
foreach (var field in descriptor.Fields.InDeclarationOrder())
{
Console.WriteLine(
"Field {0} ({1}): {2}",
field.FieldNumber,
field.Name,
field.Accessor.GetValue(message);
}
}