Von Johannes Seitz
Voraussichtliche Lesedauer: 10 Minuten
IT-Sanierung: Riesige Änderungen in Babyschritten
Es ist schnell passiert: Man hat sich in einer Legacy-Codebasis in eine Ecke manövriert, aus der man kaum noch herauskommt. Dann kompiliert der Code nicht, die Tests sind kaputt und man sieht mehr rote Marker in der IDE als einem lieb ist. In diesem Stadium kann man den Code meist nicht mit dem aktuellen Stand…
Es ist schnell passiert: Man hat sich in einer Legacy-Codebasis in eine Ecke manövriert, aus der man kaum noch herauskommt. Dann kompiliert der Code nicht, die Tests sind kaputt und man sieht mehr rote Marker in der IDE als einem lieb ist. In diesem Stadium kann man den Code meist nicht mit dem aktuellen Stand aus dem SCM integrieren, ohne Dinge schlimmer zu machen. Ein riesiger Merge-Konflikt bahnt sich an. Zudem blockiert man mit eventuell den „flow“ neuer Features, wenn man die großen Änderungen in einer großen, langen Session am Stück durchführen will.
Verbringt die Hälfte eines Teams den Großteil eines Sprints nur mit dem großen Umbau und den daraus resultierenden Merge-Problemen, die beide schwer zu planen sind, ist das Sprint-Ziel akut in Gefahr.
Wie wir sehen, bringen große Änderungen an der Codebasis einige Herausforderungen mit sich. Viele Teams scheuen diese deshalb. Das muss aber nicht sein, denn eine Reihe von Techniken kann dem großen Refactoring die Gefahr nehmen. Es wird dafür in viele kleine Teilschritte zerlegt, die alle mit dem auslieferungsfähigen Zustand des Systems enden.
Kent Beck nennt diese Art des Refactorings ein „resumable refactoring“ also ein fortsetzbares Refactoring, da es an vielen Stellen abgebrochen, eingecheckt und zu einem späteren Zeitpunt fortgesetzt werden kann. Im Folgenden werden zwei solcher Techniken vorgestellt: Branch by Abstraction und Parallel Change.
Branch by Abstraction
Das Vorgehen Branch by Abstraction ist anwendbar, wenn einige Teile der Codebasis eine bestimmte Library, ein bestimmtes Framework oder eine andere Menge an Klassen verwenden.
Zunächst wird ein abstrakteres Konstrukt (wie ein Interface oder eine abstrakte Basisklasse) eingeführt, um die alte Implementierung zu kapseln. Die Stellen, an der die alte Implementierung direkt verwendet wird, können dann Stück für Stück auf die Verwendung der Abstraktion umgestellt werden.
Dieses Vorgehen hat einige Vorteile: Neu erstellte Klassen können so über die Abstraktion weiterhin die „alte“ Implementierung verwenden, ohne sich direkt an diese zu koppeln. Eine neue Implementierung für die abzulösende(n) Klassen(n) kann außerdem schon während der Nutzung der alten Implementierung erstellt werden. Wenn die Codebasis bisher keine Verwendung von Dependency Injection macht, ist außerdem mit der Umstellung auf die Abstraktion ein Schritt in diese Richtung getan und eine wesentlich bessere Testbarkeit der benutzenden Klassen erreicht. Wird im weitesten Sinne die Infrastruktur (Datenbank, Web Interface etc.) der Applikation durch die Abstraktion gekapselt, ist außerdem ein erster Schritt in Richtung einer hexagonalen Architektur getan.
Sind alle Aufrufe auf die Abstraktion umgestellt, kann die alte Implementierung problemlos gelöscht werden. In diesem Moment fragen wir uns vielleicht: Ist die neue Implementierung wirklich korrekt?
Die Abstraktion schafft hier eine Stelle, an der wir das Verhalten der neuen Implementierung mit dem Verhalten der alten Implementierung vergleichen und Diskrepanzen feststellen können. Gerade bei komplizierten Subsystemen, zum Beispiel Berechnungskernen ohne Tests, erlaubt das Vorgehen erst dann auf die neue Implementierung umzuschalten, wenn sie sich mit hoher Sicherheit wie die alte Implentierung verhält. Stellt sich die neue Implementierung als fehlerhaft heraus, kann sie ohne viel Aufwand und sogar zur Laufzeit auf die alte umgeschaltet werden. Die Abstraktion ermöglicht also ein Feature Toggle.
Dabei kann diese Prüfung durch einen Contract Test in der Entwicklungsumgebung erfolgen, oder durch eine vergleichende Klasse, die beide Implementierungen in der Produktionsumgebung verwendet. Dabei wird zunächst nur die Antwort der alten Implementierung persistiert, falls die Antworten voneinander abweichen. Wenn eine Abweichung vorliegt, wird sie als Bug erfasst.
Trotz all dieser Vorteile hat Branch by Abstraction einen offensichtlichen Nachteil: Durch die Duplikation der abstrahierten Funktion muss jede Änderung an der/den abzulösende(n) Klasse(n) auch in der neuen Implementierung nachgezogen werden. Es verdoppelt sich also im schlimmsten Fall der Pflegeaufwand. Der höhere Pflegeaufwand ist gegenüber der gewonnenen Sicherheit abzuwägen.
Parallel Change
Das Muster Parallel Change ist auf den ersten Blick sehr ähnlich zu Branch by Abstraction. Beiden Ansätzen ist gemeinsam, dass sie eine alte Implementierung parallel zur neuen Implementierung in der Codebasis pflegen. Mittel- bis langfristiges Ziel ist, die alte Implementierung zu ersetzen.
Bei der Parallel-Change-Technik ist die Ausgangssituation jedoch eine andere. Hier planen wir eine Veränderung, die nicht rückwärtskompatibel ist, etwa die Einführung eines Value Objects, wie es Danilo Sato im verlinkten Artikel beschreibt. Aber auch die Ergänzung der API um weiterere Pflichtparameter oder die Migration zu einer neuen Implementierung kann hier Auslöser sein. Betrifft die Änderung eine Methode mit vielen Nutzern oder eine Published API ist es ratsam, die Parallel-Change-Technik in Betracht zu ziehen.
Die alte Methode wird dabei als deprecated markiert, neben der alten Methode wird eine neue Methode angelegt. Falls notwendig, kann die neue Implementierung neue Felder innerhalb der zu ändernden Klasse einführen oder die Daten der alten Variante bei Zugriff in die neue Variante konvertieren. Für die Aufrufe der neuen API sind diese dann verfügbar, während die Klassen, die die alte Implementierung verwenden, dafür erst auf die neue Implementierung gehoben werden müssen. Die dabei auftretende Duplikation in Datenhaltung und Code wird für den Übergangszeitraum bewusst geduldet.
Durch die deprecation-warnings des Compilers und der IDE ist es einfach, die Aufrufe quasi „im Vorübergehen“ nach und nach auf die neue Variante umzustellen. Neuer Code darf jetzt nur noch gegen die neue Variante der beiden Methoden programmiert werden.
Sobald alle Aufrufe der alten, als deprecated markierten Methode auf die neue Methode umgestellt wurden, kann die alte Methode problemlos entfernt werden. Dabei werden eventuell Felder und Methoden der Klasse mitgelöscht, die nur die alte Implementierung verwendet hat.
Auch diese Technik hat den Vorteil, dass eine große Änderung in kleinen Schritten durchgeführt werden kann. Dadurch kann die Änderung mit geringem Zeitaufwand und „im Grünen“, also bei funktionierendem Build und Test, durchgeführt werden. Außerdem wird das Refactoring durch die Technik zur Verantwortlichkeit des gesamten Teams und dadurch parallelisierbar ohne die Gefahr großer Mergekonflikte.
Ein Plädoyer für Babyschritte
Die vorgestellten Techniken sind am Anfang etwas ungewohnt, vor allem weil sie bewusst das DRY-Prinizip verletzen. Dennoch ist die eingeführte Duplikation oftmals das geringere Übel gegenüber einer langen, unberechenbaren Phase mit dutzenden Compile– und Testfehlern. In einer solchen Situation ist es deshalb ratsam, nochmal einen Schritt zurück zu machen und die Änderungen mit den in diesem Artikel beschriebenen Techniken erneut zu versuchen.
Eine Gefahr der beschriebenen Methoden ist es, dass die Änderungen zwar angestoßen, aber niemals abgeschlossen werden. Dadurch verschlimmert sich die Codebasis natürlich zunehmend statt besser zu werden. Ich rate deshalb dazu, gerade bei vielen laufenden Änderungen an der Codebasis diese in einer dem Team transparenten Form zu tracken und regelmäßig zu besprechen.
Dieser Blogbeitrag ist der zweite Teil meiner Serie über Techniken der IT-Sanierung. In Teil eins beschäftige ich mich mit dem Golden Master Test. In Teil 3 beschreibt Thomas Pilot, wie man große Änderungen strukturiert.