Über Abwärtskompatibilität

Insbesondere in den ersten Wochen ihrer Entstehung befindet sich Software in einem permanenten Veränderungsprozess: Neue Features kommen hinzu, vorhandene werden angepasst und nicht mehr benötigte Funktionen werden gegebenenfalls wieder entfernt. Doch auch nach der Inbetriebnahme entwickelt sich eine Software noch weiter, sei es durch das Beheben von Fehlern oder durch neue Anforderungen des Kunden. In der Praxis steht man dann häufig vor folgendem Problem: Wie kann sichergestellt werden, dass die neue Version der Software auch mit Daten voriger Versionen umgehen kann? Im Folgenden werden drei Anwendungsfälle beschrieben, in denen der Abwärtskompatibilität besondere Aufmerksamkeit gewidmet werden muss.

Interne Datenhaltung

Die meisten Desktop-Anwendungen speichern in der ein oder anderen Form einige anwendungsrelevante Daten lokal auf der Festplatte. Darunter fallen beispielsweise einfache Benutzereinstellungen, die der Benutzer jederzeit ändern kann und die in einer entsprechenden Konfigurationsdatei gesichert werden. In komplexeren Szenarien kann auch eine lokale Datenbank zum Persistieren von Informationen notwendig sein. Sollte sich die Struktur der zu speichernden Daten in einer neuen Version der Software ändern, so muss sichergestellt sein, dass die Anwendung auch noch mit der alten Struktur umgehen kann.

Angenommen, unsere Anwendung verfügt über eine Einstellung, um in einer Liste bestimmte Elemente von hoher Priorität farblich hervorzuheben. Diese Einstellung wird zunächst als boolesche Variable in den Anwendungseinstellungen abgelegt. Ist die Einstellung gesetzt, werden die Elemente hervorgehoben. Doch was, wenn es künftig nicht mehr nur Elemente mit hoher Priorität gibt, sondern noch weitere Prioritätsstufen hinzukommen? Wenn der Anwender entscheiden können soll, ab welcher Prioritätsstufe ein Element hervorgehoben wird?

In diesem Falle würden wir wohl eine neue Einstellung hinzufügen, die nicht nur „wahr“ oder „falsch“ enthalten kann, sondern eine Einstellung, welche die vom Anwender auszuwählende Prioritätsstufe speichert, ab der ein Element hervorgehoben werden soll. Wir wollen aber auch die bisher vom Anwender ausgewählte Einstellung erhalten, daher müssen wir sie irgendwie in die neue Variable übertragen. Wir migrieren die Einstellungen der alten Softwareversion also in das Format der neuen Version: Steht die Einstellung auf „wahr“, wird die entsprechende Prioritätsstufe in die neue Einstellungsvariable übernommen. Andernfalls bleibt diese einfach leer. Sobald die Migration durchlaufen wurde gilt nur noch die neu hinzugekommene Einstellungsvariable.

Die Migration in diesem Beispiel ist sehr simpel gehalten. Je nach Szenario kann ein Migrationsmechanismus sehr umfangreich sein: Es muss stets bedacht werden, dass eine Migration unter bestimmten Umständen auch fehlschlagen kann. Zudem werden die lokalen Daten bei einer Migration nachhaltig verändert. Ein Zurückgehen auf eine frühere Version der Anwendung ist dann nicht mehr so einfach möglich. Das alles muss bei dem Update auf die neue Version bedacht werden.

Der Umgang mit externen Daten

Eine Software greift häufig auch auf externe Daten zu und verändert diese. In diesem Abschnitt beschäftigen wir uns aber nicht mit Daten in einer Datenbank, sondern mit Dateien, die nicht nur innerhalb unserer Software verwendet werden, sondern die sowohl von unserer Anwendung als auch von Drittsystemen geöffnet und geschrieben werden können. Im vorigen Fall hatten wir noch die Hoheit über das verwendete Format und können jederzeit bestimmen ob und wie wir eine Migration durchführen. Das ist jedoch nicht immer der Fall. Als Beispiel seien hier Word- oder Excel-Dateien genannt: Das Format dieser Dateien hat stets eine bestimmte Struktur und kann von verschiedensten Anwendungen geöffnet und interpretiert werden. Es handelt sich also um eine Art Standard. Wenn unsere Software eine Word-Datei öffnen und bearbeiten kann, müssen wir stets darauf achten, diesen Standard einzuhalten und die Datei nicht für andere Anwendungen unlesbar zu machen.

Microsoft selbst liefert hier ein gutes Beispiel für Abwärtskompatibilität: Seit Microsoft Office 2007 gibt es zwei Formate, mit denen die Office-Programme umgehen können. Standardmäßig werden neue Dokumente im neuen Format gespeichert (.docx, .xlsx), doch auch Dateien im alten Format (.doc, .xls) lassen sich noch öffnen und bearbeiten. Die Office-Produkte fahren also zweigleisig und können sowohl mit dem alten als auch mit dem neuen Format umgehen. Dabei werden die Dateien in der alten Struktur nicht einfach in das neue Format migriert, sondern können weiterhin im alten Format abgespeichert werden. Andernfalls könnten Anwender, die mit verschiedenen Versionen von Word arbeiten, nicht gemeinsam an einer Datei arbeiten. Software muss also nicht nur über Abwärtskompatibilität zu eigenen früheren Versionen verfügen, sondern muss, je nach Anwendungsfall, auch kompatibel zu älteren externen Daten sein.

Client-Server-Anwendungen

Abwärtskompatibilität ist auch in Client-Server-Szenarien ein wichtiges Thema, insbesondere da die Bereitstellung einer neuen Serverversion und die Verteilung neuer Client-Versionen häufig asynchron verläuft. Wann (und ob überhaupt) ein Client ein Update erhält lässt sich nicht immer sagen. Der Server muss also sowohl mit aktuellen als auch mit älteren Client-Versionen umgehen können.

Werden neue Features hinzugefügt ist dies recht unproblematisch: Serverseitig werden neue Schnittstellen für das Feature ergänzt und die neuen Client-Versionen rufen diese Schnittstelle auf. Wenn jedoch Prozesse verändert werden, die auch ältere Clients anstoßen können, muss man die Unterschiede der einzelnen Versionen beachten.

Ein Beispiel: Im Server existiert eine REST-Schnittstelle zum Speichern eines beliebigen Objektes. In der neuen Client-Version wird ein Flag zum Objekt hinzugefügt, dass vom User bei Bedarf gesetzt werden kann. Im entsprechenden DTO zum Speichern des Objektes wird eine boolesche Variable ergänzt, die den Zustand des Flags repräsentiert („Flag gesetzt“ oder „Flag nicht gesetzt“). Der Server interpretiert diese Variable und speichert sie entsprechend mit. Für neue Clients ist das Problem also recht simpel gelöst. Alte Client-Versionen könnten hier aber Probleme machen. Da sie in diesem Beispiel dieselbe Schnittstelle des Servers benutzen, jedoch das neue Flag gar nicht kennen bleibt der Request an den Server unverändert. Es wird keine boolesche Variable mitgesendet.

Wie geht der Server nun damit um? In einer typischen Konfiguration werden nicht mitgesandte Felder serverseitig dann einfach mit dem entsprechenden Standardwert des Datentyps befüllt. Wenn die boolesche Variable also nicht im Request vorhanden ist befüllt der Server sie einfach mit „falsch“. Ohne weitere Anpassungen würde der Wert so auch in die Datenbank übernommen werden. Daraus ergibt sich: Neue Clients können das Flag zwar setzen, doch sobald ein älterer Client das Objekt speichert wird das Flag unabsichtlich immer wieder zurückgesetzt. Die einfachste Lösung für dieses Problem: Man macht die boolesche Variable im DTO nullable, sodass sie im Request optional ist. Wird sie also nicht explizit mitgeschickt beträgt ihr Wert standardmäßig null. Der Server kann nun unterscheiden, ob das Flag gesetzt, zurückgesetzt oder unverändert bleiben soll.

In Client-Server-Szenarien gibt es unzählige Konstellationen die zu Problemen führen könnten und die separat betrachtet werden müssen. Ist eine neue Schnittstelle der sauberere Weg oder passe ich doch die bisherige Schnittstelle an? Kann ich das DTO gefahrlos anpassen? Wie gehen alte Clients damit um, wenn in der Response zu einem Request nun ein ganz anderer Wert steht als vorher? Unter Umständen ist die beste Lösung auch, ältere Clients komplett auszusperren und nur Anfragen von halbwegs aktuellen Clients zu akzeptieren. Manchmal muss man alte Zöpfe abschneiden um nicht unzählige Altlasten berücksichtigen zu müssen.

Maßnahmen…

In den vorigen Abschnitten haben wir verschiedene Konstellationen betrachtet, in denen Software abwärtskompatibel sein muss. Das Schöne ist: In all diesen Szenarien lässt sich die Abwärtskompatibilität durch automatisierte Tests sicherstellen. Es sollen interne Daten migriert werden? Dann schreib einen Test, der die Daten von der alten Struktur in die neue migriert. Der Algorithmus zum Lesen und Schreiben externer Dateien verändert sich? Schreib einen Test, der sowohl Dateien im alten als auch im neuen Format interpretiert. Eine serverseitige Schnittstelle ändert sich? Schreib einen Test, bei dem die Schnittstelle mit einem Request eines alten Clients und einem Request eines aktuellen Clients aufgerufen wird. So lässt sich schnell sicherstellen, dass es keine Probleme zu früheren Versionen der Software gibt. Ganz abgesehen davon, dass Tests sowieso sinnvoll sind.

…und wie man sie umgeht

Als Anekdote hier noch ein Beispiel aus der Praxis, bei dem uns Tests leider nicht vor einem Fehler bewahrt haben.

Eine Schnittstelle erwartete einen Wert aus einer Enumeration. Diese Enumeration war vergleichsweise groß und sollte verkleinert werden, also wurden nicht mehr benötigte Werte aus der Enumeration gelöscht (Stichwort Altlasten). Das war problemlos möglich, da diese Werte nirgendwo in der Software mehr verwendet wurden. Ein Schnittstellen-Test nutzte zwar noch einen der gelöschten Werte, der wurde aber angepasst und durch einen der noch existierenden Werte ersetzt. Alles funktionierte. Erst nach der Produktivsetzung der neuen Version stellte sich heraus, dass ein älteres Drittsystem (welches die angepasste Schnittstelle konsumierte) eben genau den gelöschten Wert benötigte, der zuvor noch im „korrigierten“ Test benutzt worden war. Sie Software war mit sich selbst zwar abwärtskompatibel geblieben, nicht jedoch mit dem Drittsystem.

Die Moral: Wenn ein Test die Abwärtskompatibilität mit einem alten Drittsystem sicherstellen soll, dokumentiere das sicherheitshalber noch in einem Kommentar!