Wider das Wollknäuel – Entflechtung gewachsener Software-Strukturen
Bei einem neuen Projekt auf der grünen Wiese kann und sollte man sich von vornherein auf eine geeignete Architektur festlegen und diese in entsprechende (Maven-)Module mit jeweils eigenen Abhängigkeiten gießen. Bei so manchem gewachsenen oder gewucherten Erbstück ist das nachträglich nicht mehr möglich. Die Struktur dieser Schätze gilt es zu erkennen und laufend zu verbessern. In diesem Artikel erfahren Sie, wie man das systematisch bewerkstelligt. Dafür kommt eine kleine Java-Bibliothek zum Einsatz, mit der man Abhängigkeiten zwischen Klassen prüfen kann.
Vorgeschichte
Anfang der 2000er Jahre hatte ich als Junior-Entwickler in einem klassischen EJB-2.0-Projekt die Aufgabe, (Unit-)Tests zu schreiben, um nachträglich irgendwie die Codequalität zu verbessern. Diese Aufgabe stellte sich als äußerst schwierig heraus, da alles miteinander verwoben war und man keine Teilfunktionalität testen konnte, ohne die ganze Anwendung hochzufahren. Die Anwendung lief eher schlecht als recht, es gab ständig irgendwelche Probleme, Fehler waren schwer nachzuvollziehen und vermeintliche Fehlerbehebungen führten zu neuen Problemen. Daher wurde ein externer Berater hinzugezogen. Dieser hatte zusammen mit dem Architekten die Struktur des Projekts herausgearbeitet und einen Lösungsweg aufgezeigt. Daraus ist ein umfangreiches Refactoring-Projekt entstanden, bei dem vor allem zyklische Abhängigkeiten aufgelöst wurden.
Zur Überwachung des Fortschritts wurde ein Open-Source-Tool mit dem Namen Dependometer eingesetzt [1]. Ich habe das Tool in Folgeprojekten verwendet, aber bald festgestellt, dass im stressigen Projektalltag niemand die Zeit hat, komplizierte HTML-Reports zu analysieren. Ideal wäre eine rote Ampel in der CI-Umgebung, die auf Architekturverletzungen hinweist, ähnlich einem fehlgeschlagenen Unittest. Es ist also nichts naheliegender, als solche Architekturprüfungen als Unittest umzusetzen. Geeignete Bibliotheken dafür waren allerdings Mangelware und brachten selbst wieder Abhängigkeitskonflikte mit sich. Ich habe mich deshalb entschlossen, selbst eine Bibliothek für diesen Zweck zu schreiben, die keinerlei Abhängigkeiten hat und sich mit einer sehr alten Java-Version (Java 6) begnügt, damit sie wirklich bei jedem Erbstück eingesetzt werden kann. Natürlich läuft die Bibliothek auch mit neueren Java-Versionen bis hin zu Java 16.
Dessert
Dessert ist eine Java-Bibliothek, mit der man Abhängigkeiten zwischen Klassen prüfen kann [2]. Der Name setzt sich aus Dependency und Assert zusammen. Eine umfangreiche Software muss man genauso wie eine Torte in kleine Stücke schneiden, damit sie handhabbar wird. In Dessert heißen diese Stücke Slice. Es existieren unterschiedliche Ausprägungen davon:
Die wichtigsten sind Classpath, Clazz und Root. Der Classpath entspricht der ganzen Torte und ist somit das größtmögliche Stück. Er enthält alle Klassen, die in einer Anwendung verwendet werden können. Die Clazz ist das kleinstmögliche Stück und entspricht einer einzelnen Klasse oder genau genommen einer .class-Datei. Eine Root ist die Wurzel einer Verzeichnisstruktur, also ein Klassen-Verzeichnis oder ein JAR-Archiv.
Um Dessert einzusetzen, ist lediglich die entsprechende Maven-Dependency notwendig:
<dependency>
<groupId>de.spricom.dessert</groupId>
<artifactId>dessert-core</artifactId>
<version>0.4.2</version>
<scope>test</scope>
</dependency>
Im Folgenden zeige ich auf, wie man Schritt für Schritt gängige Architekturprobleme auflöst und eventuelle Rückschritte mittels Architekturtests offensichtlich macht. Die Beispiele basieren auf der Spring PetClinic [3]. Diese Anwendung ist zwar mustergültig und frei von Architekturproblemen, aber zum Aufzeigen der Vorgehensweise an einem echten Praxisbeispiel dennoch geeignet.
Keine internen Klassen verwenden
Die interne API des JDK befindet sich in den Packages, die mit sun oder com.sun beginnen. Neuere Java-Versionen lassen die Verwendung von Klassen aus diesen Packages nicht mehr zu. Auch andere Bibliotheken haben häufig internal Packages, deren Verwendung man tunlichst vermeiden sollte, da es dafür keine Kompatibilitätsgarantien gibt. Ein Unittest, der genau das sicherstellt, könnte so aussehen:
package org.springframework.samples.petclinic.architecture;
import de.spricom.dessert.slicing.Classpath;
import de.spricom.dessert.slicing.Slice;
import org.junit.jupiter.api.Test;
import static de.spricom.dessert.assertions.SliceAssertions.dessert;
public class ArchitectureTests {
private final static Classpath cp = new Classpath();
@Test
void detectUsageOfInternalApis() {
Slice myCompanyCode =
cp.slice("org.springframework.samples.petclinic..*");
Slice jdkInternalApis =
cp.slice("sun..*").plus(cp.slice("com.sun..*"));
Slice otherInternalApis = cp.slice("..internal..*");
dessert(myCompanyCode)
.usesNot(jdkInternalApis, otherInternalApis);
}
}
Ausgangspunkt bei jedem Dessert-Test ist der Classpath. Diesen schneidet man mit der slice-Methode in geeignete Stücke. Am einfachsten geht das über die Klassennamen. Die Wildcard * steht für eine Zeichenkette ohne . dazwischen und .. steht für eine Zeichenkette, die mit . beginnt und endet. Slices können mit den Methoden plus, minus und slice zu neuen Slices kombiniert werden. Die statische Methode dessert leitet, analog zu dem assertThat bei AssertJ, einen Assertion-Ausdruck ein [4].
Duplikate vermeiden
Jedes JAR-Archiv hat eine eigene Verzeichnisstruktur. Dadurch, dass jede Klasse nur über ihren voll qualifizierten Klassennamen identifiziert wird, können unterschiedliche Implementierungen ein und derselben Klasse auf dem Classpath liegen. Der Classloader durchsucht den Classpath sequentiell und verwendet den ersten Treffer. Die Reihenfolge der Classpath-Einträge kann in unterschiedlichen Umgebungen voneinander abweichen, so dass unterschiedliche Implementierungen einer Klasse verwendet werden. Das führt manchmal zu extrem schwer nachvollziehbaren Fehlern. Am besten stellt man schon im Vorfeld sicher, dass die Abhängigkeiten kein Duplikat einer Klasse enthalten.
Im Gegensatz zum Classloader durchforstet Dessert immer den kompletten Classpath und berücksichtigt alle Treffer. Die Classpath-Methode duplicates liefert alle Duplikate auf dem Classpath. Das folgende Code-Fragment prüft, ob es Duplikate gibt und listet diese zusammen mit den zugehörigen JAR-Archiven auf:
@Test
void detectDuplicates() {
Slice duplicates = cp.duplicates().minus("module-info");
Map<String, List<Clazz>> duplicatesByName = duplicates.getClazzes().stream()
.collect(Collectors.groupingBy(Clazz::getName));
for (List<Clazz> list : duplicatesByName.values()) {
System.out.printf("%s: %s%n", list.get(0).getName(),
list.stream().map(Clazz::getRootFile).map(File::getName)
.collect(Collectors.joining(", ")));
}
assertThat(duplicates.getClazzes()).isEmpty();
}
Die module-info-Klassen, die in jedem JAR-Archiv vorkommen, das der Java9-Modulspezifikation entspricht, werden hier explizit nicht berücksichtigt.
Package-Zyklen auflösen
Wechselseitige Abhängigkeiten zwischen eng verwandten Klassen sind häufig unvermeidbar. Größere Zyklen sind hingegen problematisch: Um eine Klasse aus einem Zyklus zu verwenden, müssen alle anderen darin verwickelten Klassen verfügbar sein. Das erschwert Änderungen, Wiederverwendung und Tests, obschon für letzteres Mocking-Frameworks die Situation entschärfen. Wenn mehrere solcher Zyklen ineinander verschlungen sind, verhält sich die Architektur wie ein Wollknäuel: Sie sieht zwar auf den ersten Blick ordentlich aus, aber wehe, man möchte sie auseinanderziehen.
Ob Package-Zyklen vorliegen, stellt man mit Dessert folgendermaßen fest:
@Test
void detectPackageCycles() {
Root petclinic = cp.rootOf(PetClinicApplication.class);
dessert(petclinic.partitionByPackage()).isCycleFree();
}
Die erste Zeile ist ein Beispiel für einen speziellen Slice: Die Methode rootOf liefert hier einen Slice mit allen Klassen des JAR-Archivs bzw. des Klassenverzeichnisses, in welchem sich die PetClinicApplication-Klasse befindet. Die Slice-Methode partitionByPackage liefert eine Map, die jedem Packagenamen einen korrespondierenden Slice zuordnet. Schließlich prüft isCycleFree, ob bei der zuvor übergebenen Menge an Slices ein Zyklus vorliegt. Diese Menge kann als Aufzählung, als Collection oder als Map übergeben werden.
Sollten Zyklen gefunden werden, kann der Sache mit der bereits vorgestellten usesNot-Prüfung auf den Grund gegangen werden.
Refactorings simulieren
Wenn Package-Zyklen vorliegen, ist es ratsam, erst zu prüfen, ob ein mögliches Refactoring wirklich das gewünschte Ergebnis erzielt oder sogar neue Package-Zyklen einführt. Man kann mit plus und minus das Verschieben von Klassen simulieren und so prüfen, ob das Resultat zyklenfrei ist.
Das owner-Package der PetClinic enthält mehrere Pet-Klassen und einen VisitController. Die folgende Simulation prüft, was passieren würde, wenn man für die Pet-Klassen ein eigenes Package anlegen und den VisitController in das bereits vorhandene visit-Package verschieben würde.
@Test
void simulateRefactoringCycles() {
Root petclinic = cp.rootOf(PetClinicApplication.class);
SortedMap<String, PackageSlice> packages = petclinic.partitionByPackage();
Map<String, Slice> simulation = new TreeMap<>(packages);
String prefix = "org.springframework.samples.petclinic";
move(simulation, petclinic.slice("..owner.Pet*"),
prefix + ".owner", prefix + ".pet");
move(simulation, petclinic.slice("..owner.Visit*"),
prefix + ".owner", prefix + ".visit");
dessert(simulation).isCycleFree();
}
private void move(Map<String, Slice> sim, Slice slice, String from, String to) {
Slice diff = sim.get(from).slice(slice);
sim.put(to, sim.getOrDefault(to, Slices.EMPTY_SLICE).plus(diff).named(to));
sim.put(from, sim.get(from).minus(diff).named(from));
}
Der diff-Slice aus der move-Methode enthält nur diejenigen Klassen aus dem übergebenen slice, die auch in dem from-Package vorkommen.
Diese Simulation führt zu folgendem Ergebnis:
java.lang.AssertionError: Cycle:
org.springframework.samples.petclinic.visit,
org.springframework.samples.petclinic.pet,
org.springframework.samples.petclinic.visit
Das Refactoring ist demnach keine gute Idee, weil dadurch ein neuer Package-Zyklus eingeführt würde.
Layer
Eine klassische Schichtenarchitektur bei einer typischen Webanwendung besteht aus den in Abb. 2 gezeigten Schichten.
Schichten sind etwas aus der Mode gekommen. Ein Architekt, der etwas auf sich hält, muss heutzutage Sechsecke malen. Man spricht von der hexagonalen Architektur oder Ports and Adapters. Dort liegt die Kernfunktionalität (Domain) in der Mitte und ist umgeben von Adaptern. Die Ports sind einfache Java-Interfaces, die in der Domain definiert und von den Adaptern genutzt oder implementiert werden. Tatsächlich handelt es sich wieder um eine Schichtenarchitektur (s. Abb. 3).
Der Application-Layer stöpselt alles zusammen. Persistence und Presentation sind zwei unterschiedliche Adapter. Durch diesen Ansatz wird die Kernfunktionalität (Domain) unabhängig von der Infrastruktur und kann sehr einfach getestet und wiederverwendet werden.
Ein einfacher Test für eine Schichtenarchitektur könnte so aussehen:
@Test
void checkLayers() {
Slice petclinic = cp.rootOf(PetClinicApplication.class);
Slice base = petclinic.slice("..petclinic.system|model..*");
Slice business = petclinic.slice("..petclinic.owner|vet|visit..*");
Slice app = petclinic.slice("..petclinic.*");
dessert(app, business, base).isLayeredRelaxed();
assertThat(petclinic.minus(app, business, base).getClazzes()).isEmpty();
}
Mit isLayeredStrict sind nur Zugriffe auf die direkt darunterliegende Schicht erlaubt, während isLayeredRelaxed Zugriffe auf alle tieferliegenden Schichten zulässt. Die letzte Zeile prüft, ob die Schichten vollständig sind, also jede Klasse einer Schicht zugeordnet wurde.
Architektur festlegen
Die strukturelle Architektur entsteht, indem man diese Schichten zusätzlich vertikal zerschneidet (s. Abb. 4).
Die physikalische Architektur ist die tatsächliche Package-Struktur. Idealerweise gibt es eine einfache Abbildungsvorschrift zwischen struktureller Architektur und physikalischer Architektur der Form:
de.mycompany.<product>.<layer>.<module>…
Jedes so abgegrenzte Modul erfüllt nicht nur einen unterschiedlichen Zweck, sondern hat auch unterschiedliche Abhängigkeiten. Diese sollte man immer explizit festlegen und fortlaufend prüfen. Neue Abhängigkeiten sind ein Hinweis darauf, dass in einem Modul Funktionalität gelandet ist, die dort nicht hingehört. Dessert bietet die usesOnly-Assertion an, um so etwas zu prüfen.
Es kann sinnvoll sein, für jedes Modul ein oder mehrere Interface-Slices zu definieren, welche die Klassen beinhalten, die von anderen Modulen verwendet werden dürfen. Die internen Klassen können dann jederzeit ohne Abstimmung geändert werden.
Bei externen Abhängigkeiten möchte man oft die Verwendung auf einzelne Bereiche einer Bibliothek einschränken. Beispielsweise sollte der Einsatz von Reflection auf dedizierte Bereiche der Anwendung begrenzt sein, andernfalls ist Refactoring schwierig. Es bietet sich daher an, geeignete Slices für externe Abhängigkeiten und Teile des JDK zu definieren und diese zu prüfen. In größeren Projekten kann man diese zur Wiederverwendung in separate Klassen auslagern.
Ein Test, der die strukturelle Architektur einer Anwendung mitsamt aller externer Abhängigkeiten prüft, könnte etwa so aussehen:
@Test
void checkModules() {
Root petclinic = cp.rootOf(PetClinicApplication.class);
// modules
Slice base = petclinic.slice("..model|system..*");
Slice owners = petclinic.slice("..owner|visit..*");
Slice vets = petclinic.slice("..vet..*");
Slice application = petclinic.slice("..petclinic.*");
// module interfaces
Slice baseIfc = base.slice("..model.Person|*Entity");
// external dependencies
Slice javaBase = cp.slice("java.lang|io|util|time|text.*");
Slice jpa = cp.slice("javax.persistence.*");
Slice validation = cp.rootOf(Valid.class);
Slice jaxb = cp.packageOf(XmlElement.class);
Slice cache = cp.slice("java.lang.invoke.*")
.plus(cp.slice("javax.cache..*"))
.plus(cp.sliceOf(JCacheManagerCustomizer.class));
Slice springCore = cp.rootOf(Configuration.class)
.plus(cp.rootOf(Autowired.class))
.plus(cp.rootOf(StringUtils.class));
Slice springWeb = cp.rootOf(RequestMapping.class)
.plus(cp.rootOf(ModelAndView.class));
Slice springData = cp.slice("org.springframework.data|dao|transaction..*");
Slice springBoot = cp.slice("org.springframework.boot..*");
// module dependencies
dessert(base)
.usesOnly(javaBase, jpa, validation, springCore, springWeb, cache);
dessert(owners)
.usesOnly(baseIfc, javaBase, jpa, validation,
springCore, springWeb, springData);
dessert(vets)
.usesOnly(baseIfc, javaBase, jpa, jaxb,
springCore, springWeb, springData);
dessert(application).usesOnly(javaBase, springCore, springBoot);
}
In gewachsenen Strukturen sind Vorüberlegungen zur strukturellen Architektur selten. Ich begegne oft technischen Packages mit Namen wie entities, model, enums, services etc., in denen flach die Klassen unterschiedlichster Domänen herumliegen. Manchmal geben wenigstens noch die Namen einen gewissen Aufschluss über deren Zugehörigkeit. Bevor man hier anfängt, aufzuräumen, sollte man sich erst mal bewusst machen, womit man es zu tun hat. Mit Dessert kann man die Klassen dieser Packages unterschiedlichen Modulen zuordnen, indem man entsprechende Slices definiert. Danach kann man deren Abhängigkeiten festlegen und prüfen. Erst wenn diese Vorarbeit geleistet wurde und entsprechende Klarheit besteht, sollten Änderungen an der physischen Paketstruktur vorgenommen werden.
Modularisierung der Anwendung
Nachdem all diese Vorarbeiten geleistet wurden, kann die Anwendung nun tatsächlich in viele kleine Module mit jeweils eigenen Abhängigkeiten zerlegt werden. Dessert wird dadurch überflüssig. Ob man diesen Schritt wirklich machen möchte, will gut überlegt sein. Viele Module bringen ebenfalls einiges an Overhead mit und wenn sich etwas ändert, ist der Anpassungsaufwand mitunter so hoch, dass mit der Zeit ganz sicher irgendwelche Funktionalität in einem Modul landet, die dort nicht hingehört. Wer seine Dessert-Tests konsequent pflegt, hat von einer Modularisierung erst Vorteile, wenn die Software tatsächlich zerlegt werden muss. Beispielsweise um die Systemlast zu verteilen oder weil Teile in eine andere Software integriert werden sollen.