Weg von Java EE - so gelingt die Migration

Donnerstag, 16.7.2020

JAVA-EE-Header-Blog-Serie_viadee

Im vorherigen Teil der Blogserie wurden wesentliche Teile der Java EE-Spezifikation im Hinblick auf mögliche Zukunftsoptionen betrachtet. In diesem Blogpost wird ein praxisnahes Vorgehensmodell illustriert, das eine nahtlose Migration weg von JEE-Application-Servern ermöglicht.

Java EE ist tot. Das ist seit Anfang 2018 bekannt, als Oracle angekündigt hat, das Projekt nicht mehr weiterzuführen. Stattdessen soll es unter dem Namen Jakarta EE von der Eclipse Foundation übernommen werden. Für die meisten Unternehmen, die Java EE nutzen, steht somit ein umfangreiches Migrationsprojekt am Horizont.

Mitte 2019 sind die Verhandlungen zwischen der Eclipse Foundation und Oracle zur Weiterentwicklung des javax-Namespaces im Kontext des Java EE-Nachfolgers Jakarta EE jedoch gescheitert. Nun steht die Jakarta Community vor einem Scherbenhaufen. War bisher Jakarta EE als neue Ziel-Technologie augenscheinlich die naheliegendste Lösung, stellt es nun ein schwer kalkulierbares Risiko dar. Eine Alternative, die sich im Enterprise-Umfeld bewährt hat, ist z. B. Spring Boot.

Was ist jedoch im Rahmen einer Migration zu beachten? Wie bringt man das Legacy-System in einen Zustand, der eine Ablösung überhaupt ermöglicht? Wie kann der Betrieb während der Migration gesichert werden? Wie wird sie in Einklang mit der Weiterentwicklung des Legacy-Systems gebracht? In diesem auf Erfahrungen aus verschiedenen Migrationsprojekten basierenden Artikel wird ein praxisnahes Vorgehensmodell illustriert, das eine nahtlose Migration weg von JEE-Application-Servern ermöglicht.

Notwendige Kriterien

Tobias Voß hat in seinem Blog-Beitrag bereits den aktuellen Zustand von Java EE beschrieben: Es ist tot oder liegt bestenfalls im Sterben. Nach und nach werden Unternehmen, die Java EE-Application-Server wie z. B. JBoss/Wildfly oder IBM WebSphere nutzen, sich der Realität eines auslaufenden Supports und der fehlenden Update-Fähigkeit stellen müssen. Die Konsequenz daraus: ein Migrationsprojekt.
Dabei steht ein Qualitätsziel immer im Vordergrund: Sicherung des Betriebs. Im Enterprise-Umfeld haben wir es meist mit heterogenen und oft unübersichtlichen Systemlandschaften zu tun, bei denen der Ausfall einzelner Komponenten zu wesentlichen Störungen im Betrieb führen kann.

Der Erfolg der Migration ist daher von folgenden Kriterien abhängig:

  • Iterativität → Umsetzung in kleinen Schritten, da die Größe und Unübersichtlichkeit des vorhandenen Systems eine "Big Bang"-Ablösung verhindert.
  • Kompatibilität → die "alte" und "neue" Welt müssen jederzeit kompatibel zueinander bleiben.
  • Testabdeckung → Vorher/Nachher-Tests reduzieren die Wahrscheinlichkeit für eine Beeinträchtigung des Betriebs.

Gemäß der Empfehlung von Tobias Voß wird Spring Boot als Migrationsziel gewählt.

Im Folgenden werden die wichtigsten Schritte bei der Migration einer umfangreichen Enterprise-Java-Anwendung von Java EE nach Spring Boot beschrieben.

Die Systemlandschaft, die abgelöst werden soll, wird im Folgenden Legacy-Umfeld genannt.

Durchführung

Zielgerichtete Dokumentation

Ein elementarer Schritt der Migration ist die zielgerichtete Dokumentation des aktuellen Zustands des Systems. Um ein System ändern zu können, muss man es erst verstehen.

Erfahrungsgemäß sind viele Systeme nicht ausreichend dokumentiert (oder die Dokumentation ist nicht aktuell genug), um eine reibungslose Migration zu garantieren.

Die Dokumentation muss dabei insofern zielgerichtet sein, als dass sie dem Zwecke der Sicherung des Betriebs während der Migration und der Gewährleistung von Kompatibilität zwischen bereits migrierten und noch nicht migrierten Komponenten dient.

Um eine Idee davon zu bekommen, auf welchem Abstraktionsniveau sich die Dokumentation dafür befinden muss, hilft es, sich für jede zu migrierende Komponente folgende Frage zu stellen: Welche anderen Komponenten sind von diesem Migrationsschritt betroffen?

Daraus lassen sich folgende Dokumentationsziele ableiten:
  1. Komponenten identifizieren → Was sind die derzeit vorhandenen in sich geschlossenen Artefakte? → Wer?
  2. Abhängigkeiten identifizieren → Welche Komponenten kommunizieren miteinander? → Mit wem?
  3. Kommunikationskanäle identifizieren → Wie kommunizieren die Komponenten miteinander (Technologie, Routing ...)? → Wie?


Als Mittel zur grafischen Darstellung der Dokumentation wird die EIP-Notation von Gregor Hohpe und Bobby Woolf empfohlen. Dabei handelt es sich um eine Visualisierungsbibliothek, die sich für die Darstellung von Enterprise Integration Patterns (Kommunikationsstrategien innerhalb einer heterogenen Systemlandschaft) eignen.

Beispiel für einen Dynamic Router in der EIP-Notation:

Dynamic Router

Abb. 1: Dynamic Router dargestellt in EIP-Notation

In dem Bild werden für die betrachteten Komponenten alle drei relevanten Fragen beantwortet:

Wer? → Komponenten A, B, C und der Dynamic Router

Mit wem? → A, B und C erhalten Nachrichten vom Dynamic Router

Wie? → Messaging (z. B. eine Queue)

Die Komponenten A, B und C lassen sich in diesem Beispiel relativ gefahrlos migrieren, da sie lediglich Nachrichten empfangen (nur auf den Control Channel muss geachtet werden). Bei der Migration des Dynamic Routers muss hingegen mit potenziellen Auswirkungen auf A, B und C gerechnet werden.

In diesem Beispiel sind die Komponenten A, B und C lose (via Messaging) über den Dynamic Router gekoppelt. D. h. eine Suche nach Referenzen in einer IDE hätte diese Abhängigkeit nicht aufgedeckt.

Vorbereitungsarbeiten in der Legacy-Umgebung

Unterteilung in API- und Impl-Artefakte

Nachdem die Beziehungen innerhalb des Systems offengelegt wurden, kann nun mit der eigentlichen Migration begonnen werden.

Dafür muss das System zunächst in einen Zustand gebracht werden, der den Umzug einzelner Komponenten in die neue Laufzeitumgebung erlaubt und dabei Kompatibilität mit dem Legacy-Umfeld gewährleistet, indem die zu migrierenden Komponenten in API und Implementierung aufgeteilt werden.

Das Ziel ist in Abb. 2 dargestellt.
Unterteilung in API und ImplAbb. 2: Unterteilung in API und Impl

Die Auftragsverwaltung und Banking-Komponenten wurden jeweils in zwei Artefakte aufgeteilt: API und Implementierung.

Dabei ist zu beachten, dass Implementierungsartefakte zur Compile-Zeit lediglich auf die APIs anderer Komponenten zugreifen dürfen.

Erst zur Laufzeit wird dann die Implementierung ausgeführt. Dadurch wird eine Entkoppelung der Laufzeitbelange erzielt, was wiederum einen Umzug von Banking ermöglicht, ohne dass die Auftragsverwaltung davon direkt betroffen ist, da die Schnittstelle (die API) vorerst gleich bleibt.

Wie ein Implementierungsartefakt zur Laufzeit aufgerufen wird, hängt von der Beschaffenheit der Legacy-Umgebung ab. In den meisten Fällen wird der IOC-Container des verwendeten JEE-Application-Servers den Aufruf der Implementierung (z. B. einer Enterprise-Java-Bean) steuern.

Im Folgenden wird erläutert, welche Inhalte ein API- bzw. ein Implementierungs-Artefakt haben.

Implementierung

Hier wird die eigentliche Geschäftslogik hinterlegt. Um sie aus dem Legacy-Umfeld herauszutrennen, muss sie zunächst über Interfaces abstrahiert werden.

Beispiel: Banking wird von Auftragsverwaltung verwendet, indem Letztere eine Methode im Banking direkt aufruft. Daraus folgt, dass Banking vor Auftragsverwaltung migriert werden muss. Allerdings ist das im ursprünglichen Zustand gar nicht möglich, da API und Implementierung noch nicht getrennt sind.

Die Geschäftslogik von Banking lässt sich so nicht in die neue Laufzeitumgebung migrieren, da sie sonst in der Auftragsverwaltung fehlen würde. Wenn Letztere jedoch nur auf ein Interface des Bankings verweist, kann die Implementierung ausgetauscht werden (z. B. durch einen HTTP-Call auf die neue Laufzeitumgebung, dazu später mehr), ohne dass die Geschäftslogik der Auftragsverwaltung dafür geändert werden muss.

In diesem Kontext muss eine wichtige Entscheidung getroffen werden: Wie groß ist ein Migrationsschritt? Entsprechend sollten nämlich auch die Interfaces des Bankings geschnitten werden. Der Inhalt eines Migrationsschrittes ist somit die Implementierung hinter einem dieser Interfaces.

Die Geschäftslogik landet letztlich im Implementierungs-Artefakt, die Interfaces dazu in der API vom Banking.

Wichtig: Rein technische Belange (besonders solche, die nur im Legacy-Umfeld lauffähig sind) haben in der Geschäftslogik nichts zu suchen. Falls also z. B. DataSources, Queue-Connections, JNDI-Kontexte o. Ä. direkt in der Geschäftslogik vorhanden sind, müssen diese auch über Interfaces abstrahiert werden. Diese Handlungsempfehlung ist weithin als "Separation of Concerns" bekannt (dazu später mehr).

API
Im Kontext von JEE-Anwendungen besteht die API aus Local- bzw. Remote-JEE-Interfaces und Transfertypen. Mit Transfertypen sind dabei die Ein- und Rückgabeparameter aus den Methoden-Signaturen der zu migrierenden Komponenten gemeint. Dabei ist zu beachten, dass die Transfertypen im Rahmen der Migration via Jackson in das JSON-Dateiformat serialisiert werden (mehr dazu im Abschnitt "Strangler Pattern"). Bei umfangreichen Objektbäumen (die ggf. auch Zyklen oder Selbstreferenzen enthalten) müssen die Transfertypen also entweder um Jackson-Annotations erweitert (z. B. @JsonIdentityInfo) oder in einfache POJOS (Java-Klassen, die frei sind von jeglichen Dritt-Abhängigkeiten) gemappt werden.

Extraktion eines Shared-Kernels
Die Erfahrung hat gezeigt, dass viele Legacy-Umgebungen eine sehr unübersichtliche Verstrickung vorhandener Komponenten haben (siehe Abb. 3). Systeme in einem solchen Zustand werden oft "Big Ball of Mud" genannt und haben gemein, dass sie schwer änderbar sind. Änderungen oder gar das Herauslösen einzelner Komponenten bringt das gesamte System in Gefahr, da die Auswirkungen aufgrund der unübersichtlichen Struktur nicht absehbar sind.
Big Ball of Mud
Abb. 3: Big Ball of Mud

Zum Zwecke einer Migration müssen die Abhängigkeiten in die Form eines gerichteten Graphen übertragen werden.

Eine Möglichkeit, dies zu erreichen, ist das Herauslösen eines gemeinsamen Kerns ("Shared-Kernel"), der aus Elementen besteht, die von mehreren Komponenten genutzt werden.

Dieser Shared-Kernel wird während der Migration der Legacy- und der neuen Laufzeitumgebung verwendet, muss also mit beiden Umgebungen kompatibel sein. Das Ziel ist in Abb. 4 dargestellt.
Shared Kernel
Abb. 4: Shared-Kernel

Nun kann zum einen eine sinnvolle Reihenfolge für die Migration der Komponenten ermittelt werden (siehe nächster Abschnitt "Strangler Pattern") und besser abgeschätzt werden, welche abhängigen Komponenten vom aktuellen Migrationsschritt betroffen sind.

Nach Abschluss der Migration sollte dieser Shared-Kernel aufgelöst werden, indem die beinhalteten Elemente in die Ziel-Komponenten integriert werden. Sollte der Shared-Kernel die Migration zu lange überleben, führt er zu einer engen Kopplung zwischen den einzelnen Komponenten, verschlechtert damit die Wartbarkeit und führt letztlich wieder zu einem Big Ball of Mud.

Strangler Pattern
Alle bisherigen Schritte haben in der Legacy-Umgebung stattgefunden. Mit Dokumentation und Aufteilung der zu migrierenden Komponenten in API und Implementierung sind nun die wesentlichen Voraussetzungen für die eigentliche Migration geschaffen.

Nun muss die Ziel-Umgebung aufgestellt werden. Wie bereits gesagt, handelt es sich dabei in diesem Beispiel um eine Spring-Boot-Anwendung.

Sobald eine leere Spring-Boot-Anwendung in allen Stages (Test, Qualitätssicherung, Prod) läuft, korrekt konfiguriert ist und per CI-Pipeline ausgerollt werden kann, beginnt die schrittweise Migration.

Martin Fowler schlägt ein allgemeines Vorgehensmodell dafür vor: das Strangler Pattern.

Kurz gesagt ist damit ein graduelles "Umschlingen" der neuen Umgebung um die alte gemeint, so lange bis die alte Umgebung nicht mehr benötigt wird und abgeschaltet werden kann.

Einen Erfahrungsbericht aus der Praxis zu einer JEE-Migration unter Verwendung des Strangler Patterns finden Sie in Pia Diedams Blogbeitrag.

Ausgangszustand und Schritt 1
Der Ausgangszustand ist die zu migrierende Legacy-Umgebung, deren Komponenten bereits in API und Implementierung unterteilt wurden. In Abb. 5 wird ein Beispiel skizziert, welches RMI zur Kommunikation zwischen den Komponenten verwendet.

Als erstes wird die Core-Komponente (genauer: das entsprechende Implementierungs-Artefakt) migriert. Core wurde ausgewählt, da keine Abhängigkeiten zu anderen Komponenten vorhanden sind (Core persistiert lediglich Daten in das Repository).

Als Kommunikationskanal zwischen Legacy- und neuer Laufzeitumgebung wird in diesem Beispiel SOA over HTTP verwendet. Da die API, die hier per HTTP aufgerufen wird, aus Kompatibilitätsgründen so nah wie möglich den Signaturen der einzelnen Methoden der Java-Interfaces der alten Komponenten angeglichen werden sollten, entspricht das Ergebnis eher einer allgemeinen serviceorientierten Architektur (SOA) als dem spezifischen REST-Stil. Implementiert werden kann die API in der neuen Laufzeitumgebung beispielsweise als Spring-RestController, der eingehende Aufrufe an die eigentliche Implementierung von Core delegiert.

Diese HTTP-Schnittstelle kann nun in der Legacy-Umgebung von den Komponenten Auftragsverwaltung und Banking verwendet werden, die bisher noch nicht umgezogen wurden.

Um höchstmögliche Kompatibilität zu gewährleisten, kann die "alte" Core- Komponente als Fassade verwendet werden, welche − anstatt wie bisher die Geschäftslogik selbst auszuführen − den Aufruf per HTTP (z. B. via Spring-RestTemplate) an die neue Laufzeitumgebung weiterleitet.

Strangler Pattern Schritt 1

Abb. 5: Strangler Pattern Schritt 1


Schritt 2
Im nächsten Schritt (Abb. 6) wird die Banking-Komponente migriert. Dies ist nun möglich, da sie lediglich auf die Core-Komponente verweist, die ja bereits migriert wurde.

In der neuen Laufzeitumgebung kann Banking direkt auf Core verweisen. Im Kontext von Spring Boot sind Banking und Core jeweils Spring Beans.

Für Banking wird wieder eine HTTP-Schnittstelle angelegt, die in der Legacy-Umgebung verwendet werden kann.
Strangler Pattern Schritt 2
Abb. 6: Strangler Pattern Schritt 2

Zielzustand

Im letzten Schritt wird die Auftragsverwaltung migriert. Statt die Banking- und Core-Komponenten weiter per HTTP aufzurufen, kann die Auftragsverwaltung nun auch direkt die Spring Beans verwenden (Abb. 7).

Die HTTP-Schnittstellen werden damit überflüssig. Wenn es keine weiteren externen Systeme gibt, welche die Schnittstellen benötigen, können sie nun zurückgebaut werden.

Die API und Impl-Artefakte von Auftragsverwaltung, Banking und Core können zu einem einzelnen Artefakt zusammengefasst werden, welches als WAR deployt oder direkt als Fat-JAR gestartet werden kann.
Strangler Pattern Zielzustand
Abb. 7: Strangler Pattern Zielzustand

Damit ist die Legacy-Umgebung nun überflüssig und kann abgeschaltet werden.

Migration als entwicklungsbegleitende Maßnahme

Es bleibt festzuhalten, dass Migrationen niemals im luftleeren Raum geschehen. Sie sind meist langwierig und müssen mit der laufenden Entwicklung in Einklang gebracht werden. Daher sollten neben dem hier vorgestellten Vorgehensmodell folgende Punkte beachtet werden:

  1. Während der ganzen Migration muss die Legacy-Umgebung kontinuierlich deploy- und (noch wichtiger) im Fehlerfall zurückrollbar bleiben.
  2. Die Anwendung wird während der Migration sowohl in der Legacy- als auch in der neuen Laufzeitumgebung weiterentwickelt. Das Team sollte sich dessen bewusst sein und sich dementsprechend koordinieren. Arbeitspakete müssen klar kommuniziert und Migrationsschritte transparent gemacht werden, sodass niemand sich die Frage stellen muss "Wo muss ich mein neues Feature nun eigentlich implementieren? In der alten oder der neuen Welt?"
  3. Eine Fülle an Integrationstests zur Sicherstellung des Betriebs sind von elementarer Bedeutung.
  4. Nicht-funktionale Anforderungen dürfen nicht ignoriert werden. In hochperformanten Systemen ist die Verwendung von HTTP für die Kommunikation zwischen Legacy- und neuer Laufzeitumgebung ggf. nicht schnell genug. Hier muss nach einer Alternative gesucht werden.
  5. Agile Methoden eignen sich besonders gut für die Umsetzung der Migration. Sie ist von Natur aus iterativ und kann daher in einzelne Sprints und kurze Feedback-Zyklen unterteilt werden.
  6. Es müssen einige Fallstricke beachtet werden (mehr dazu im Artikel von Pia Diedam).

 



Dieser Beitrag ist Teil einer Serie

Teil 1: Java EE ist tot – es lebe Spring (Boot)!
Teil 2: Im Griff der Würgefeige: Herausforderungen bei der Spring Migration
Teil 3: Meine JEE-API im Application-Server ist weg - was nun?


zurück zur Blogübersicht

Diese Beiträge könnten Sie ebenfalls interessieren

Keinen Beitrag verpassen – viadee Blog abonnieren

Jetzt Blog abonnieren!

Kommentare

Christian Nockemann

Christian Nockemann

Diplom-Wirtschaftsinformatiker Christian Nockemann arbeitet seit 2009 als IT-Berater und Software-Architekt bei der viadee IT-Unternehmensberatung. Sein Fokus liegt auf dem Design und der Entwicklung von Java-basierten Enterprise-Anwendungen. Eine besondere Bedeutung gibt er dabei Qualitätskriterien des Softwareerstellungsprozesses wie bspw. der Anwendung des Domain-Driven-Designs, dem sinnvollen Einsatz von Entwurfsmustern und der Einhaltung von Clean-Code-Richtlinien.

Christian Nockemann bei Xing  Christian Nockemann auf Twitter