Blog: Java, Software-Entwicklung & mehr

Legacy Code - Wie funktioniert das System?

09.11.2016 Michael Mosmann

Softwareprodukte beginnen meist nur einmal auf der grüne Wiese. Dann gibt es nur neuen Code, man muss keine bestehenden Systeme beachten. Im Laufe der Zeit kommen immer mehr Funktionen hinzu, der Code wird komplexer, die Anforderungen wachsen und die Systeme, die stabil laufen geraten aus dem Fokus.

Nach einiger Zeit muss an dem bisher stabil laufenden System eine Erweiterung vorgenommen werden. Da stellt sich plötzlich die Frage: wie funktioniert das System? Die Entwickler, die das System ursprünglich entworfen und entwickelt haben, sind vielleicht nicht mehr im Projekt, das Wissen wurde nicht weitergegeben, weil es wichtigere Dinge gab.

Jetzt muss man da ran.

Das Kennenlernen

Bevor man Anpassungen vornimmt, muss man natürlich herausfinden, an welcher Stelle die Anpassung notwendig ist und wie sie sich in das bestehende System eingliedert. Dazu muss man im Zweifel sehr viel fremden Code lesen. Oft wird das naheliegende vergessen, daher hier eine kleine Aufzählung, an welcher Stelle man vielleicht nach Informationen suchen sollte:

  • Die Kollegen befragen, was sie über das System wissen.
  • Herausfinden, welche Technologien zum Einsatz kommen.
  • In den internen Informationssystemen (Wiki, Ticketsysteme, Blogs, etc.) nach dem System oder häufig benutzten Schlüsselworten suchen.
  • Sich den vorhandenen Code ansehen. Dabei sind die Tests (hoffentlich gibt es welche) ebenso spannend, wie der Programmcode selbst.

Kontaktaufnahme

Wenn man sich ein erstes Bild gemacht hat, wie das System funktionieren könnte, besteht eine Möglichkeit darin, das System auf dem Entwicklungsrechner zum Laufen bringen. Dabei kann man dann versuchen, die zu erweiternde Funktion der Software in ihrer aktuellen Form zu benutzen. Wenn die Software kein UI im klassischen Sinne hat, sollte man nicht davor zurückschrecken die Funktionen über Serviceaufrufe auszulösen. Bekommt man ein erwartetes Ergebnis, hat man das Problemfeld schon sehr weit eingeschränkt.

Wenn der Patient unkooperativ ist...

Leider gibt es kein Mindestmaß an Informationen, die immer über ein System zur Verfügung stehen. In den meisten Fällen hat man aber den Quellcode in einer hoffentlich aktuellen Version, so das man sich über den Code in die Funktionsweise einarbeiten kann. Manchmal gibt es keinerlei Test oder, noch schlimmer, Tests, die Fehler als richtig verkaufen (aktiv: nur falsche Ergebnisse lassen den Test grün werden, passiv: der Test prüft uninteressante Dinge, leitet aber den Beobachter auf eine falsche Fährte).

Die ersten gemeinsamen Schritte

Gehen wir von einem System aus, wo nur der aktuelle Quelltext zur Verfügung steht und Änderungen an diesem in Produktion übernommen werden können. Das bedeutet, dass man vielleicht besser nicht auf gut Glück Anpassungen am Code vornimmt, auch wenn man z.B. einen offensichtlichen Fehler behebt, denn bestehender Code könnte auf dem Fehler aufbauen.

Man muss also darüber nachdenken, was man ändern kann, ohne das es zu eine Verhaltensänderung der Software kommt

Schreiben von Tests

Man kann immer Tests schreiben. Nur wenn die Software hervorragend wartbar ist, kann es sein, dass man durch einen weiteren Test eher etwas verliert als gewinnt. Durch das Schreiben von Tests zu bestehenden Funktionen kann man a) Annahmen überprüfen und b) Teile des Systems sehr gut kennen lernen. Der geschriebene Test dient dann sofort als Gedankenstütze für sich selbst und andere. Gute Tests sind eine aktuelle Dokumentation der Software.

Wenn man bei einem Test feststellt, das die Anwendung bisher fehlerhaft reagiert hat, sollte man herausfinden, ob das in jedem Fall so war oder ob es nur manchmal zu diesen Fehlern kommt. Wenn man Glück hat, gibt es dafür bereits ein langlaufendes Bug-Ticket, dass man dann schließen kann. Wenn der Fehler jedes mal auftritt, sollte man klären, ob da nicht ein Missverständnis vorliegt (die Methode kann auch schlicht den falschen Namen tragen).

Code kaputt machen

Oft unterschätzt, aber in manchen Fällen sehr hilfreich: man baut absichtlich Fehler ein. Diese können dann zur Laufzeit oder davor zu Problemen führen. Damit kann man z.B. in komplexen Architekturen die auf sehr viel Magie setzten, herausfinden, ob z.B. eine Methode überhaupt aufgerufen wird und aus welcher Ecke dieser Aufruf kommt. Nicht immer kann eine IDE die Informationen korrekt zusammenziehen.

Manchmal reicht es, wenn man den Stacktrace beim Aufruf ausgeben kann, ohne dass das System dabei in einen Fehler läuft.

Informationsextraktion

Manchmal befinden sich Informationen nicht nur im Code sondern auch in Konfigurationsdateien. Die Komplexität der gegenseitigen Abhängigkeiten kann sehr groß werden. Manchmal kann man aber durch überschaubaren Aufwand aus diesen Daten Metadaten extrahieren und durch geeignete Tools darstellen (graphviz). In einem Projekt haben wir hierarchische Konfigurationsdateien erfasst und die Abhängigkeiten visualisiert. Damit diese Information auch allen anderen beteiligten Entwicklern zur Verfügung stehen, sollten sie Bestandteil des Projektes werden.

Verbesserung des bestehenden Codes

Es gibt verschiedene mehr oder weniger nebenwirkungsfreie Methoden um z.B. die Verständlichkeit von Java-Code zu verbessern.

Annotationen

Die einfachste Möglichkeit sind Annotationen. Diese kann man an verschiedenen Codestellen anbringen und so konfigurieren, dass sie zur Laufzeit nicht sichtbar sind. Es könnte ja sein, dass ein Teil der Architekturmagie mit diesen Annotationen nichts anfangen kann.

Die Vorteile gegenüber Kommentaren sind folgende:

  • Man kann Annotationen strukturieren, Attribute vergeben und zu Pflichtfeldern machen.
  • Moderne IDEs können die Annotationen schnell im Quelltext finden.
  • Es stehen einige Refactorings zur Verfügung, ein Umbenennen ist auf diese Weise mühelos.

Wenn man geprüft hat, ob eigene Annotationen auch zur Laufzeit kein Problem sind, kann man die Einstellung entsprechend ändern und dann zur Laufzeit diese Annotationen auswerten und z.B. für das Logging heranziehen.

Interfaces

Auch wenn es mit Java8 einige neue Möglichkeiten zum Einsatz von Interfaces gibt, kommt man mit den einfachen Eigenschaften bereits sehr weit.

Anders als Annotationen sind Interfaces zur Laufzeit immer sichtbar. Selbst wenn eine Klasse ein Interface ohne Methode implementiert, kann davon Verhalten beeinflusst werden, wenn Architekturbestandteile diese Informationen auswerten.

Der einfachste Anwendungsfall liegt darin, dass man Klassen mit einem Interface markiert. Das kann z.B. notwendig werden, wenn der Name der Klasse nichts mit dem Verhalten zu tun hat, ein Umbenennen aber nicht ohne Schmerzen möglich ist. Ein Interface mit dem richtigen Namen erleichtert das Wiederfinden. Auf diese Weise kann man die neu gewonnenen fachlichen Informationen an der richtigen Stelle im Code platzieren.

Gottklassen

Nicht selten wird auf eine Abstraktion über ein Interface verzichtet. Indem man ein Interface erstellt, dass alle Methoden der Implementierung bereitstellt, kann man eine erste Struktur in den Code bringen. Das Schreiben von Tests wird erleichtert, weil man für den Test eine andere Implementierung wählen kann.

Nicht nur auf diesem Wege kommt ein Interface zu vielen Methoden. Der Prozess ist ganz natürlich, schnell noch eine Methode hinzugefügt und die Anpassung ist erledigt. In den Tests sieht man dann regelmäßig Mock-Frameworks, die verhindern, dass man für den Test Implementierungen dieser Interfaces selbst schreiben muss und auffällt, dass man von den 20 Methoden genau eine benötigt.

Ein erster Schritt besteht dann darin, das Interface aufzuteilen. Wenn das ursprüngliche Interface so ausssieht:


public interface Iterator<E> {
	boolean hasNext();
	E next();
	void remove();
}

Dann kann es nach dem Umbau so aussehen:


public interface ReadOnlyIterator<E> {
	boolean hasNext();
	E next();
}

public interface MutableIterator<E> extends ReadOnlyIterator<E> {
	void remove();
}

@Deprecated
public interface Iterator<E> extends MutableIterator<E> {
}

Dadurch erhält man zwar eine ganze Reihe neuer Interfaces, aber gleichzeitig verbessert sich die Testbarkeit von Code, da man viel kleinere Funktionseinheiten betrachten muss (Tipp: Wenn ein Interface nur noch eine Methode hat, kann man mit Java8 in Unit-Tests auf Mock-Frameworks verzichten und einfach ein Lambda als Mock benutzen).

Klassen statt Strings

Wenn man Methoden mit mehr als einem Parameter benutzt und die verschiedenen Parameter besitzen den gleichen Typ, kann es leicht passieren, dass man Parameter verwechselt. Solche Fehler sind manchmal sehr schwer zu finden. Es kann sich lohnen, für bestimmte Parameter einen eigenen Typ einzuführen. Statt einem Parameter email vom Typ String erstellt man eine Klasse EMail, die einen String als einzigen Wert beinhaltet. Dann ersetzt man an einer interessanten Stelle in der Methodensignatur den Typ String durch EMail. Jetzt sollten ein paar Fehlermeldung auftauchen, die man rekursiv durch ersetzen des Parametertyps oder durch erzeugen einer Instanz des Typs lösen kann. Unter Umständen kann man aber auch die Refactoring-Funktionen der IDE nutzen.

Ausgehend von diesem Beispiel:


public interface Mailer {
	public boolean canSendEmailTo(String email);
        public void sendEmail(String email, String message, LocalDateTime timeStamp);
}

public class MailService {

        private Mailer mailer;

        public boolean sendSomeMail(String email, String message) {
	        if (mailer.canSendEmailTo(email)) {
		        mailer.sendEmail(email, message, LocalDateTime.now());
		        return true;
               	}
	        return false;
        }
}

public class Application {

        private MailService mailService;

        public String reqisterUser(String email) {
	        if (mailService.sendSomeMail(email, "Welcome:)")) {
		        return "You will get an e-mail.";
	        }
	        return "Could not send e-mail.";
        }
}

Führen wir folgendes Refactoring durch. Wir erstellen eine EMail-Klasse:


public class EMail {
	private String asString;

	public EMail(String asString) {
		this.asString = asString;
	}

	public String asString() {
		return asString;
	}
}

Zuerst verpacken wir den String in eine EMail-Instanz und packen die gleich wieder aus:


public boolean sendSomeMail(String email, String message) {
	EMail wrappedEmail = new EMail(email);
	String unwrappedEmail = wrappedEmail.asString();

	if (mailer.canSendEmailTo(unwrappedEmail)) {
		mailer.sendEmail(unwrappedEmail, message, LocalDateTime.now());
		return true;
	}
	return false;
}

Dann extrahieren wir eine neue Methode an der richtigen Schnittkante:


public boolean sendSomeMail(String email, String message) {
	return sendSomeMail(new EMail(email), message);
}

private boolean sendSomeMail(EMail wrappedEmail, String message) {
	String unwrappedEmail = wrappedEmail.asString();

	if (mailer.canSendEmailTo(unwrappedEmail)) {
		mailer.sendEmail(unwrappedEmail, message, LocalDateTime.now());
		return true;
	}
	return false;
}

Dann können wir auf der ersten Methode die Refactoring-Funktion Inline aufrufen und ersetzten so alle Aufrufer durch Code, der einen String in eine EMail-Instanz verpackt. Diesen Prozess wiederholt man so lange, bis nur noch die Codestellen übrig bleiben, auf die man keinen Einfluss hat.

Dieses Vorgehensmodel funktioniert auch dann, wenn man mehr als ein Parameter weiterreicht (z.B. Vorname, Name).

Neuimplementierungen

Manchmal bleibt nichts anderes übrig, als Code vollständig zu ersetzten. Da kann es sich anbieten, dass man für Codestellen mit sehr hoher Komplexität Tests schreibt, die jede Parameterkombination testen und die Rückgabewerte prüfen. Den Test adaptiert man dann auf den neuen Code (eine Code-Kopie ist hier durchaus sinnvoll) und passt dann den Code solange an, bis alle Tests grün sind. Wenn man nichts übersehen hat, sollte sich der Code genau so verhalten wie der Code, den man abschaffen möchte.

Wenn die Komplexität zu hoch ist und der Code derart untestbar ist, dass all die Möglichkeiten nicht zur Verfügung stehen, muss man wohl oder übel auf Sicht programmieren. Bisher konnte aber immer ein Weg gefunden werden, die kritischen Codestellen durch Tests so zu stabilisieren, dass man das Risiko von unerkannten Verhaltensänderungen minimieren konnte.

Es gibt sicher noch unzählige Möglichkeiten, wie man an das Problem herangehen kann. Dabei darf man ruhig sehr kreativ sein. Wenn der Testcode jede erdenkliche Form von "schmutzigen Tricks" benutzen muss, um ein Refactoring möglich zu machen, ist das immer besser, als ohne Netz und doppelten Boden unverständlichen Code anzupassen. Die "schmutzigen Tests" sollten nach dem erfolgreichen Umbau aber durch saubere Tests ersetzt werden. Sonst läuft man früher oder später in die nächste Falle.