Die Entwicklung von Datenstrukturen in einer kontinuierlich betriebenen Anwendung mit einer NoSQL-Datenbank kann eine Herausforderung sein. Dies sind einige der Erfahrungen, die wir bisher bei der Entwicklung und dem Betrieb von AODocs gemacht haben, einem cloud-nativen, serverlosen Dokumentenmanagementsystem, das von Millionen von Nutzern auf der ganzen Welt verwendet wird.
Ich will nicht sagen, dass ich die Entwicklung klassischer Java EE-Anwendungen mit relationalen Datenbanken vermisse, aber bestimmte Dinge waren zweifellos einfacher.
Bei vielen Cloud-nativen Anwendungen mit NoSQL-Datenbanken funktioniert dieser Ansatz nicht wirklich.
Da es bei uns keine Wartungsstillstände gibt, wird es Zeiträume geben, in denen mehrere Anwendungsversionen auf dieselbe Datenbank zugreifen, auch wenn diese sehr kurz sind. Wir führen auch schrittweise Rollouts von Hauptversionen durch, bei denen zwei Anwendungsversionen wochenlang gleichzeitig in Betrieb sind. Aber selbst wenn wir nur eine neue Hotfix-Version einspielen, wird es einige Minuten geben, in denen Anfragen der alten Version noch ausgeführt werden und die neue Version ebenfalls ihren Dienst aufgenommen hat. Dies ist unvermeidlich, ohne dass es zu einer tatsächlichen Ausfallzeit kommt.
Die beiden Anwendungsversionen müssen die Daten in kompatibler Weise verwalten, sowohl rückwärts als auch vorwärts. Wenn eine Anfrage der neuen Version die Daten auf die neue Art und Weise aktualisiert, muss die alte Version weiterhin in der Lage sein, sie zu lesen. Wenn die alte Version dann die Daten mit dem alten Schema überschreibt, muss die neue Version sie immer noch lesen können und normal arbeiten.
Jede Änderung an der Datenstruktur muss sehr sorgfältig geplant und koordiniert werden. Selbst eine kleine Änderung wird in der Regel unter Berücksichtigung von 3 Anwendungsversionen durchgeführt. In der Version N wird die neue Funktion den Kunden zum ersten Mal zur Verfügung gestellt. Um die Kompatibilität zu gewährleisten, müssen wir in der Regel mit den Vorarbeiten für die vorherige Version beginnen.
Wir arbeiten derzeit mit Java 8 in App Engine, mit Cloud Datastore. In diesem Setup wird normalerweise Objectify verwendet, die De-facto-Standardbibliothek für ORM-Mapping mit Java.
Nehmen wir an, wir haben eine einfache Entität:
Das boolesche Feld, das Sie oben sehen, speichert, ob der angegebene Benutzer Benachrichtigungen von unserer Anwendung erhalten möchte. (Die @Data-Annotation stammt aus Lombok.)
Nehmen wir an, wir möchten unsere Erinnerungsfunktion so weiterentwickeln, dass die Benutzer die Erinnerungsfrequenz auf täglich einstellen können. Eine Möglichkeit, dies darzustellen, besteht darin, das boolesche Feld in ein Enum zu ändern, das drei Werte unterstützt: KEINE, EINMAL, TÄGLICH.
Hinweis: Dies ist kein tatsächliches Beispiel. Diese Darstellung soll nicht korrekt sein, sondern ist absichtlich falsch. Dieses spezielle Beispiel kann auch auf andere Weise modelliert werden, die keine Schemaänderung, sondern nur eine Schemaerweiterung erfordert. Aber nicht alle Modelländerungen lassen sich ohne Schemaänderung umsetzen, und oft ist es besser, das Schema zu aktualisieren, als an einem schlechteren Datenmodell festzuhalten.
Wir werden also ein Schema-Update-Mapping haben, das wie folgt aussieht:
In Version vN-1 müssen wir in der Lage sein, Entitäten zu lesen, die nach der neuen Struktur geschrieben wurden. Eine Möglichkeit, dies zu tun, ist in diesem Beispiel die Verwendung von @AlsoLoad, das von Objectify bereitgestellt wird:
Wenn dieser Code in Version vN-1 auf eine Entität der neuen Struktur trifft, wendet er ein Standard-Rückwärts-Mapping an. Beachten Sie, dass Objectify Felder aus der zugrundeliegenden Entität löscht, wenn keine entsprechenden Java-Felder deklariert sind; wenn also diese Version die Entität speichert, wird das Feld reminderMode wieder null sein.
In der Version vN können wir uns dann in erster Linie auf das neue Feld verlassen, aber wir müssen immer noch in der Lage sein, Entitäten aus dem alten Schema zu lesen.
Sie haben wahrscheinlich bemerkt, dass unsere Rückwärtsabbildung der Aufzählung auf boolesche Werte nicht vollständig ist. Wenn ein Kunde diesen Wert in der neuen Version auf DAILY setzt, geht dieser Wert bei einer Aktualisierung der Entität in einer alten Version einfach verloren, und der Benutzer wird wieder zu ONCE-Erinnerungen zurückkehren.
Dies lässt sich nicht immer vollständig vermeiden; die Lösung hängt vom konkreten Fall ab.
Wenn das Risiko oder der Schweregrad eines unerwünschten Verhaltens während dieser Rollout-Phase, in der zwei Anwendungsversionen gleichzeitig in Betrieb sind, hoch ist, können wir Funktionskennzeichen verwenden, damit die Kunden die neue Funktion erst nutzen können, wenn der Versions-Rollout abgeschlossen ist.
Das Beispiel mit den Erinnerungen könnte eigentlich in zwei Versionen migriert werden. Wir könnten es vermeiden, die Migration in vN-1 vorzubereiten, wenn der Code in vN sowohl die booleschen als auch die enum-Felder deklariert. In diesem Fall:
Dieser Ansatz wird häufiger angewandt, wenn es sich um eine rein technische (nicht benutzerorientierte) oder eine funktional äquivalente Datenstrukturänderung handelt.
Das Codebeispiel für unseren Fall:
In diesem speziellen Fall ist keine Änderung des Codes für vN-1 erforderlich. Dies lässt sich nicht immer erreichen. Die allgemeine Regel ist, dass wir sicherstellen müssen, dass vN-1 richtig funktioniert, wenn es eine von vN geschriebene Entität liest. Wenn wir z. B. einen neuen möglichen Enum-Wert zu einem enum-typisierten Feld hinzufügen, müssen wir diesen Enum-Wert irgendwie in der vorherigen Version hinzufügen und behandeln, sonst bekommen wir eine Ausnahme beim Lesen der Entität.
Nehmen wir an, wir haben eine Entität, die von Kunden bearbeitet werden kann. Irgendwann stellen wir fest, dass der Anzeigename dieser Entität eindeutig sein sollte, so dass die Benutzer nicht zwei Entitäten mit demselben Namen haben können. Die Regel der 3 Versionen gilt auch hier.
In vN-1 gibt es nichts zu tun, um Kompatibilität zu gewährleisten. (Dies kann davon abhängen, wie wir die Regeln interpretieren, aber fangen wir hier an.)
vN beginnt mit der Anwendung der Einheitsbedingung. Das Problem dabei ist, dass vN-1 immer noch doppelte Namen erstellen kann und dass wir die Daten noch nicht migriert haben, was bedeutet, dass jedes zuvor erstellte Duplikat noch vorhanden ist. Je nachdem, wo genau wir die Einzigartigkeitsprüfung anwenden, kann dies problematisch sein, wenn die Einzigartigkeitsprüfung auch Systemaktionen für diese Entität verhindert.
Ein anderer Ansatz besteht darin, die Einzigartigkeitsprüfung nur dann zuzulassen, wenn es einen Benutzer gibt, der den Wert aktualisiert. Dieser ist nicht immer leicht zu identifizieren. Im Falle eines Frontends über einen regulären REST-API-Aufruf können wir beispielsweise nicht sicher sein, ob es am anderen Ende einen tatsächlichen Benutzer gibt, der in der Lage ist, unsere Fehlermeldung sinnvoll zu bearbeiten, oder ob wir mit unserem Fehler einen Geschäftsintegrationsprozess blockieren würden.
Wenn vN vollständig ausgerollt ist, können wir einen Migrationsprozess durchführen, der problematischen Entitäten automatisch einen eindeutigen Namen zuweist.
In vN+1 können wir die "erleichterten" Regeln bereinigen und die Einzigartigkeitsregel vollständig anwenden.
Das klingt alles sehr mühsam. Und das ist es auch. Und oft ist es nicht nur umständlich, sondern auch komplex. Obwohl das allgemeine Muster gilt, ist jeder Fall ein wenig anders und erfordert eine sehr sorgfältige Überlegung, Gestaltung und Ausführung.
Wir haben mehrere Kontrollpunkte in verschiedenen Teilen unseres Entwicklungsprozesses eingebaut, um sicherzustellen, dass alle Änderungen, die sich auf die Kompatibilität auswirken könnten, bemerkt und ordnungsgemäß verwaltet werden.