Protobuf Nachrichten kompatibel erweitern

Im letzten Blogartikel habe ich Protobuf als Serialisierungsformat vorgestellt [1]. In diesem Artikel möchte ich mich mit einem Thema beschäftigen, auf das man im Laufe eines Projekts früher oder später kommt: Wie erweitere ich eine Protobuf Nachricht so, dass die Änderung kompatibel zur vorherigen Version der Nachricht bleibt? Diese Frage taucht typischerweise dann auf, wenn eine zu ändernde Nachricht zwischen zwei oder mehr Services ausgetauscht wird, diese Services bei einem Update aber nicht gleichzeitig aktualisiert werden (können). Nehmen wir ein einfaches Beispiel: Ich möchte in neues Feld in einem Nachrichtentyp hinzufügen. Zunächst mache ich das am Service, welcher die Nachricht sendet und etwas später mache ich das bei den empfangenden Services. Geht das einfach so? Diese und weitere ähnliche Fragen möchte ich mit diesem Artikel beantworten. Ich beziehe mich bei den Beispielen ausschließlich auf C# und die Protobuf Implementierung im Paket Google.Protobuf.

Einen Protobuf Nachrichtentyp als Basis

Als Basis verwenden wir den Nachrichtentyp aus dem letzten Artikel [1]. Es handelt sich dabei um eine sehr einfache Struktur mit zwei string Feldern, einem int32 Feld und einer Liste von strings. Nichts weltbewegendes also. Zusätzlich benenne ich die message als MyTestMessageOriginal.

message MyTestMessageOriginal 
{
  string firstName = 1;
  string lastName = 2;
  int32 age = 3;
  repeated string emails = 4;
}

Ändern des Namens des Nachrichtentyps (✓)

Fangen wir mit einem einfachen Beispiel an: Wir ändern den Namen des Nachrichtentyps. In der Praxis kommt das zwar vermutlich eher selten vor, kann aber im Rahmen von Refactorings durchaus Sinn machen. Anstelle von MyTestMessageOriginal verwenden wir für den überarbeiteten Nachrichtentyp den Namen MyTestMessageUpdated. Nachfolgend der dafür relevante Ausschnitt aus der proto Datei.

message MyTestMessageUpdated
{
  string firstName = 1;
  string lastName = 2;
  int32 age = 3;
  repeated string emails = 4;
}

Mit folgendem Coding testen wir, ob eine als MyTestMessageOriginal Nachricht wieder als MyTestMessageUpdated gelesen werden kann.

// Prepare original object
var original = new MyTestMessageOriginal();
original.FirstName = "Test FirstName";
original.LastName = "Test LastName";
original.Age = 8;
original.Emails.Add("test@test.com");
original.Emails.Add("test@test.de");

// Protobuf serialization
using var memStream = new MemoryStream(original.CalculateSize());
original.WriteTo(memStream);
var serializedBytes = memStream.ToArray();

// Deserialize using updated object
var updated = new MyTestMessageUpdated();
updated.MergeFrom(serializedBytes);

// Asserts
Assert.Equal(original.FirstName, updated.FirstName);
Assert.Equal(original.LastName, updated.LastName);
Assert.Equal(original.Age, updated.Age);
Assert.Equal(original.Emails, updated.Emails);

Der Test läuft erfolgreich ohne Fehler durch. Das heißt, dass der Name der Nachricht jederzeit geändert werden kann. Der Grund dahinter ist auch sehr einfach: Ähnlich wie bei json wird der Name des Nachrichtentyps nicht mit serialisiert.

Hinzufügen eines Felds (✓)

Kommen wir zu einem eher häufigeren Fall. Es kommen Anforderungen hinzu und man muss einen Nachrichtentyp erweitern. Für diesen Test erweitern wir den Nachrichtentyp um das Feld street vom Typ string.

message MyTestMessageUpdated
{
  string firstName = 1;
  string lastName = 2;
  int32 age = 3;
  repeated string emails = 4;
  string street = 5;
}

Mit folgendem Coding testen wir, ob eine als MyTestMessageOriginal Nachricht wieder als MyTestMessageUpdated gelesen werden kann.

// Prepare original object
var original = new MyTestMessageOriginal();
original.FirstName = "Test FirstName";
original.LastName = "Test LastName";
original.Age = 8;
original.Emails.Add("test@test.com");
original.Emails.Add("test@test.de");

// Protobuf serialization
using var memStream = new MemoryStream(original.CalculateSize());
original.WriteTo(memStream);
var serializedBytes = memStream.ToArray();

// Deserialize using updated object
var updated = new MyTestMessageUpdated();
updated.MergeFrom(serializedBytes);

// Asserts
Assert.Equal(original.FirstName, updated.FirstName);
Assert.Equal(original.LastName, updated.LastName);
Assert.Equal(original.Age, updated.Age);
Assert.Equal(original.Emails, updated.Emails);
Assert.Equal(string.Empty, updated.Street);

Der Test läuft erfolgreich ohne Fehler durch. Das heißt, dass es jederzeit möglich ist, neue Felder hinzuzufügen. Die neuen Felder bekommen dabei einfach den Standardwert zugewiesen. In älteren Versionen von Protobuf (vor Version 3) konnte man einzelne Felder noch als required markieren. Ein neues Feld mit required hätte natürlich zu einem Fehler geführt. Mit Protobuf in Version 3 gibt es einen solchen Fall nicht mehr, da das Schlüsselwort required entfernt wurde. Mehr Infos bez. dem Grund, warum diese Schlüsselwörter entfernt wurden, siehe unter [2].

Entfernen eines Felds (✓)

Nun zu einem wieder etwas seltenerem Fall. Wir entfernen ein Feld. Das kann Sinn machen, wenn aufgrund geänderter Anforderungen einige Sachen nicht mehr notwendig sind. Für dieses Beispiel habe ich das Feld age entfernt. Gemäß den Best Practices unter [3] habe ich hier das entfernte Feld durch ein reserviertes Feld (Schlüsselwort reserved) ersetzt. Das sorgt als Hinweis für andere Entwickler dafür, dass das Feld nicht plötzlich für einen anderen Zweck genutzt wird und dann noch Werte aus der vorherigen Bedeutung bekommt.

message MyTestMessageUpdated 
{
  string firstName = 1;
  string lastName = 2;

  // Field 'age' was removed
  // Placing a 'reserved' field is optional, but highly recommended
  //   see https://protobuf.dev/programming-guides/dos-donts/ -> Do Reserve Tag Numbers for Deleted Fields
  reserved 3;             
  
  repeated string emails = 4;
}

Mit folgendem Coding testen wir, ob eine als MyTestMessageOriginal Nachricht wieder als MyTestMessageUpdated gelesen werden kann. Das entfernte Feld testen wir natürlich nicht mehr, da es im überarbeiteten Nachrichtentyp nicht mehr definiert ist.

// Prepare original object
var original = new MyTestMessageOriginal();
original.FirstName = "Test FirstName";
original.LastName = "Test LastName";
original.Age = 8;
original.Emails.Add("test@test.com");
original.Emails.Add("test@test.de");

// Protobuf serialization
using var memStream = new MemoryStream(original.CalculateSize());
original.WriteTo(memStream);
var serializedBytes = memStream.ToArray();

// Deserialize using updated object
var updated = new MyTestMessageUpdated();
updated.MergeFrom(serializedBytes);

// Asserts
Assert.Equal(original.FirstName, updated.FirstName);
Assert.Equal(original.LastName, updated.LastName);
Assert.Equal(original.Emails, updated.Emails);

Der Test läuft erfolgreich ohne Fehler durch. Das heißt, dass es jederzeit möglich ist, Felder zu entfernen. Der Grund: Protobuf liest alle Felder aus dem Stream, zu denen es Felder im Nachrichtentyp gibt. Alle anderen werden einfach übersprungen. Es gibt auch keine Exception, welche auf das Fehlen eines Felds hinweist.

Umbenennen von Feldern (✓)

Nun schauen wir uns einen Fall an, auf den wir typischerweise nach einem Refactoring stoßen. Wir haben die Namen der Felder überarbeitet. In diesem Beispiel habe ich die Namen einfach vom englischen ins Deutsche übersetzt. So wird etwa aus dem Feld firstName das Feld vorName.

message MyTestMessageUpdated
{
  string vorName = 1;
  string nachName = 2;
  int32 alter = 3;
  repeated string emails = 4;
}

Mit folgendem Coding testen wir, ob eine als MyTestMessageOriginal Nachricht wieder als MyTestMessageUpdated gelesen werden kann.

// Prepare original object
var original = new MyTestMessageOriginal();
original.FirstName = "Test FirstName";
original.LastName = "Test LastName";
original.Age = 8;
original.Emails.Add("test@test.com");
original.Emails.Add("test@test.de");

// Protobuf serialization
using var memStream = new MemoryStream(original.CalculateSize());
original.WriteTo(memStream);
var serializedBytes = memStream.ToArray();

// Deserialize using updated object
var updated = new MyTestMessageUpdated();
updated.MergeFrom(serializedBytes);

// Asserts
Assert.Equal(original.FirstName, updated.VorName);
Assert.Equal(original.LastName, updated.NachName);
Assert.Equal(original.Age, updated.Alter);
Assert.Equal(original.Emails, updated.Emails);

Der Test läuft erfolgreich ohne Fehler durch. Das heißt, dass es jederzeit möglich ist, Felder umzubenennen. Der Grund dafür ist auch sehr einfach. Protobuf verwendet zur Identifizierung der Felder nicht den Namen, sondern den Index (den Wert hinter dem =).

Typ eines Felds ändern (✓ / ᳵ)

Schauen wir uns nun an, was passiert, wenn die Typen von Feldern verändert werden. Auch hierfür kann es Gründe geben, welche aus dem Refactoring oder aus geänderten Anforderungen entstehen. In diesem Beispiel habe ich das Feld age vom Typ int32 auf string geändert.

message MyTestMessageUpdated 
{
  string firstName = 1;
  string lastName = 2;
  string age = 3;
  repeated string emails = 4;
}

Mit folgendem Coding testen wir, ob eine als MyTestMessageOriginal Nachricht wieder als MyTestMessageUpdated gelesen werden kann.

// Prepare original object
var original = new MyTestMessageOriginal();
original.FirstName = "Test FirstName";
original.LastName = "Test LastName";
original.Age = 8;
original.Emails.Add("test@test.com");
original.Emails.Add("test@test.de");

// Protobuf serialization
using var memStream = new MemoryStream(original.CalculateSize());
original.WriteTo(memStream);
var serializedBytes = memStream.ToArray();

// Deserialize using updated object
var updated = new MyTestMessageUpdated();
updated.MergeFrom(serializedBytes);

// Asserts
Assert.Equal(original.FirstName, updated.FirstName);
Assert.Equal(original.LastName, updated.LastName);
Assert.Equal(original.Age.ToString(), updated.Age); // <-- Breaks here, because protobuf can't read the changed type
Assert.Equal(original.Emails, updated.Emails);

Die Nachricht kann erfolgreich deserialisiert werden, der Test schlägt aber fehl. Grund dafür ist, dass das Feld age nicht mehr gelesen und mit dem Standardwert (ein Leerstring) befüllt wird. Allgemein sieht Protobuf Typänderungen nicht so gerne, dazu auch ein Hinweis in den Best Practices [3] unter “Don’t change the type of a field”. Es gibt einige Typänderungen, welche kompatibel sind. Es handelt sich dabei aber nur um wenige Ausnahmen.

Einen enum Member entfernen (✓ / ᳵ)

Im letzten Beispiel möchte ich noch auf Enumerationen eingehen. Hierfür habe ich auch den originalen Nachrichtentyp nochmals erweitert. Nachfolgend beide Varianten, Original und Updated.

// Original

message MyTestMessageOriginal 
{
  string firstName = 1;
  string lastName = 2;
  int32 age = 3;
  repeated string emails = 4;
  PreferredWayOfCommunication preferredWayOfCommunication = 5;
}

enum PreferredWayOfCommunication {
  UNSPECIFIED = 0;
  EMAIL = 1;
  PHONE = 2;
  POST = 3;
}

// Updated

message MyTestMessageUpdated
{
  string firstName = 1;
  string lastName = 2;
  int32 age = 3;
  repeated string emails = 4;
  PreferredWayOfCommunicationUpdated preferredWayOfCommunication = 5;
}

enum PreferredWayOfCommunicationUpdated {
  UNSPECIFIED = 0;
  PHONE = 2;
  POST = 3;
}

Mit folgendem Coding testen wir, ob eine als MyTestMessageOriginal Nachricht wieder als MyTestMessageUpdated gelesen werden kann.

// Prepare original object
var original = new MyTestMessageOriginal();
original.FirstName = "Test FirstName";
original.LastName = "Test LastName";
original.Age = 8;
original.Emails.Add("test@test.com");
original.Emails.Add("test@test.de");
original.PreferredWayOfCommunication = PreferredWayOfCommunication.Email;

// Protobuf serialization
using var memStream = new MemoryStream(original.CalculateSize());
original.WriteTo(memStream);
var serializedBytes = memStream.ToArray();

// Deserialize using updated object
var updated = new MyTestMessageUpdated();
updated.MergeFrom(serializedBytes);

// Asserts
Assert.Equal(original.FirstName, updated.FirstName);
Assert.Equal(original.LastName, updated.LastName);
Assert.Equal(original.Age, updated.Age);
Assert.Equal(original.Emails, updated.Emails);
Assert.Equal((int)original.PreferredWayOfCommunication, (int)updated.PreferredWayOfCommunication);

Der Test läuft erfolgreich ohne Fehler durch. Dieses Verhalten ist aber womöglich gar nicht erwartet? Wir haben in der originalen Nachricht einen Enum-Wert gesetzt, welchen es in der aktualisierten Variante gar nicht mehr gibt. Trotzdem erhalten wir den gleichen Wert nach der Deserialisierung. Der Grund dahinter ist der, dass C# in einem Enum alle Werte des darunter liegenden Typs (hier int) abbilden kann – unabhängig davon, ob sie jetzt in der Enum definiert sind, oder nicht. Protobuf setzt fehlende Enum-Werte hier nicht auf “Unspecified”, sondern übernimmt einfach den Wert aus der gesendeten Nachricht.

Fazit

An den gezeigten Beispielen lässt sich gut erkennen, dass Protobuf sehr tolerant mit Änderungen an den Nachrichtentypen umgeht. Das ist grundsätzlich erstmal positiv. Es führt aber auch insbesondere auf der Empfängerseite zu einer gewissen Verantwortung. Nur weil eine Nachricht empfangen wurde, heißt das noch lange nicht, dass sie auch valide ist. So können etwa jederzeit erwartete Felder fehlen (=Defaultwert) oder ein Enum-Wert auf einem nicht definierten Wert stehen. Daher ist es wichtig, ankommende Nachrichten gründlich zu prüfen, bevor sie weiterverarbeitet werden. Auf der Seite des Senders muss demgegenüber darauf geachtet werden, dass Änderungen so vorgenommen werden, dass auch ältere Stände bei den Empfängern weiterhin mit der Protobuf Nachricht umgehen können.

Downloads

  1. Quellcode der Beispiele aus diesem Artikel
    https://www.rolandk.de/files/2023/HappyCoding.ProtobufSerialization_2023-04.zip

Verweise

  1. Protobuf in C# serialisieren und deserialisieren
    https://www.rolandk.de/wp-posts/2023/03/protobuf-in-c-serialisieren-und-deserialisieren
  2. Grund für das Entfernen von required und optional in Protobuf 3
    https://stackoverflow.com/questions/31801257/why-required-and-optional-is-removed-in-protocol-buffers-3
  3. Proto Best Practices
    https://protobuf.dev/programming-guides/dos-donts/

Ebenfalls interessant

  1. Typisierten HttpClient mit NSwag generieren
    https://www.rolandk.de/wp-posts/2022/11/typisierten-httpclient-mit-nswag-generieren
  2. TCP-/IP Kommunikation testen per MessageCommunicator
    https://www.rolandk.de/wp-posts/2020/11/tcp-ip-kommunikation-testen-per-messagecommunicator

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.