ArchUnit: Testen der statischen Architektur in Java 19.10.2017, 10:19 Uhr

Architekturregeln

Wie Sie in Java Ihre Architektur am Leben erhalten.
Die ArchUnit-Bibliothek steht unter der Apache-Lizenz auf GitHub zum Download zur Verfügung
Wenn man sich den Ist-Zustand gewachsener Software-Systeme ansieht und sich von den Teams oder verantwortlichen Architekten die Soll-Architektur des Systems erklären lässt, so wird man häufig gravierende Abweichungen feststellen. Die Gründe sind verschiedener Natur; manchmal konnten die Konzepte nicht leisten, was benötigt wurde, manchmal wurden Features unter Zeitdruck entwickelt, oder neue Entwickler waren mit den Konzepten noch nicht ausreichend vertraut.
Bei einem großen System lassen sich Verletzungen der Architektur auch nicht mal eben nebenher reparieren. Der Verbleib der Verletzungen im Code verleitet Entwickler wiederum, diese Muster an anderer Stelle zu kopieren, wodurch sich Architekturverletzungen auf Code-Basis krebsartig ausbreiten und eine Reparatur im Laufe der Zeit immer teurer machen.
Der Autor auf der JVM-Con in Köln
JVM-Con, die Konferenz für Java-Entwickler.
Wer ArchUnit gerne live sehen möchte, ist herzlich zu meinem Vortrag auf der JVM-Con Ende November in Köln eingeladen (http://jvm-con.de), wo ich tiefer auf die Möglichkeiten der Syntax(-Erweiterung) und den konkreten Einsatz im Projektalltag eingehen, sowie hands-on die Implementierung von Regeln von Grund auf demonstrieren werde.
Im Bereich der Feature-Entwicklung haben wir über die letzten Jahrzehnte große Fortschritte gemacht und als Industrie begriffen, dass wir ohne ein Gerüst aus automatisierten Tests die Funktionalität der Software nicht kontinuierlich und effizient sicherstellen können. Die These dieses Artikels lautet, dass wir diese Erkenntnis wo möglich auch auf die Architektur der Software anwenden sollten, also unsere Architekturregeln in einer programmatisch verständlichen Art festzuhalten und kontinuierlich das System dagegen zu validieren.
Konkret möchte ich dies für Java-Projekte erläutern, wo ich in den letzten Jahren an der Migration eines großen Legacy-Systems mitarbeiten durfte. Auch hier war die Ist-Architektur der neuen Teile über die Jahre hinweg wesentlich von der Soll-Architektur abgewichen. Das System umfasst mehrere Millionen Zeilen Code, und über die Zeit, in der ich im Projekt war, gab es signifikante Fluktuationen in den Entwicklungsteams. Als Konsequenz wurde beschlossen, die Einhaltung der Architektur programmatisch und kontinuierlich durch den Continuous Integration Server validieren zu lassen. Aus den Erfahrungen und Problemen, welche ich beim Einsatz von freien Tools (was eine Randbedingung war) dabei gesammelt habe, ist über die letzten Jahre hinweg die Open-Source-Library ArchUnit entstanden, welche ich hier  vorstellen möchte.

Kernkonzepte

Bei der Erstellung unserer Architektur-Test-Suite bin ich auf einige Probleme gestoßen, die ArchUnit im Gegensatz dazu durch seine Kernkonzepte zu verhindern versucht.
Für unsere Tests war es nicht ausreichend, bloße Paketabhängigkeiten zu prüfen. Es musste möglich sein, Annotationen, Vererbung, Sichtbarkeit etc., mit einfließen zu lassen. In der Konsequenz konnten unsere Regeln nur durch Kombination von mehreren Tools und teilweise gar nicht programmatisch sichergestellt werden.
Die Verwendung verschiedener Tools/Sprachen hatte wiederum eine steile Lernkurve zur Folge. Um die Funktionsweise der Test-Suite zu verstehen, musste man mit Java Reflection, AspectJ, Groovy, Maven und dem Jenkins Command Line Parser vertraut sein.
AspectJ besitzt beispielsweise eine sehr prägnante Syntax in Form von Pointcuts:
 
public pointcut isService(): within(*..*Service);
 
public pointcut isInServicePackage(): within(*..service..*);
 
declare error: isService() && !isInServicePackage():
        "Services must reside in a package '..service..'. Violation in " +
"[{joinpoint.sourcelocation.sourcefile}:{joinpoint.sourcelocation.line}].";
 
Für Tests mittels Reflection ist das hingegen oft nicht gegeben, oder es ist zumindest nötig, einige Infrastruktur mühsam selbst zu erstellen.

Konsequenz für ArchUnit

ArchUnit versucht, die Vorteile der verschiedenen Ansätze zu vereinen, ohne deren jeweilige Nachteile in Kauf zu nehmen. Tests lassen sich in reinem Java schreiben und als einfache Unit-Tests ausführen, womit die Lernkurve flacher ist. Entwickler müssen keine neue Sprache lernen, und es müssen keine weiteren komplexen Infrastrukturkomponenten eingeführt werden, wie extra Compiler, Build-Schritte, Datenbanken oder Ähnliches.
Weiterhin bietet ArchUnit eine Fluent-API, welche prägnant wie AspectJ ist, dabei aber Unterstützung der Sprachelemente durch die IDE ermöglicht. Zu guter Letzt ermöglicht ArchUnit, die Kernsyntax um eigene Konstrukte zu erweitern, die sich prinzipiell auf beliebige aus dem Bytecode entnommene Attribute stützen können. Wie dies im Detail aussieht, stelle ich im Folgenden vor.

Aufbau von ArchUnit

Die Struktur von ArchUnit besteht aus mehreren Schichten, deren zwei wichtigste hier erläutert werden.
Die Basis von ArchUnit orientiert sich an der Java Reflection API. Dies hat den Vorteil, dass sie den meisten Entwicklern vertraut ist. Core-Objekte sind nach ihrem Reflection-Pendant benannt, mit zusätzlichem Java-Präfix. So existieren zum Beispiel JavaClass, JavaMethod, JavaField, aber auch neue Konzepte, wie JavaMethodCall, welches Methodenaufrufe repräsentiert. Grundsätzlich verhalten sich diese Objekte wie eine erweiterte Reflection-API. So bietet einem JavaClass zum Beispiel die Methode getSimpleName(), welche sich wie erwartet verhält. Auf der anderen Seite ist es allerdings auch möglich, Dinge wie javaClass.getAccessesToSelf() abzufragen, welches einem wiederum Zugriffe im Bytecode auf die jeweilige Klasse liefert.
Der Core-Layer beinhaltet ebenfalls den ClassFileImporter, welcher dazu dient, Bytecode in eine programmatisch verständliche Form zu bringen. Ein Beispiel für einen möglichen Test zeigt das folgende Listing:

// Der ClassFileImporter kann zahlreiche Quellen
// von Classpath bis File URL importieren
JavaClasses classes = new ClassFileImporter().importJar(new JarFile("/some/Path"));
 
Set<JavaClass> services = new HashSet<>();
for (JavaClass clazz : classes) {
    // Wir filtern die Klassen, deren vollqualifizierter Name
    // den Paketinfix '.service.' enthält
    if (clazz.getName().contains(".service.")) {
services.add(clazz);
    }
}
 
for (JavaClass service : services) {
    // Wir iterieren über alle Zugriffe, die von der Klasse ausgehen
    for (JavaAccess<?> access : service.getAccessesFromSelf()) {
        String targetName = access.getTargetOwner().getName();
 
        // Fehlschlag, falls Zugriff auf ein Ziel mit ".controller." im Namen
        if (targetName.contains(".controller.")) {
            String message = String.format(
"Service %s accesses Controller %s in line %d",
service.getName(), targetName, access.getLineNumber());
Assert.fail(message);
        }
    }
}

Eine reine Verwendung der Core-Klassen für Architekturtests ist zwar möglich, allerdings fehlt hier die Ausdrucksstärke. Um diese zu gewährleisten, bietet ArchUnit eine höhere Abstraktionsschicht, welche eine Fluent-API und damit IDE-Unterstützung liefert.
Die API baut auf den im letzten Absatz beschriebenen Core-Klassen auf, führt allerdings einige höhere Konzepte ein:
  • ArchRule: Architekturelle Konzepte werden als Regeln festgehalten
  • DescribedPredicate: Ein Prädikat zur Auswahl relevanter Klassen
  • ArchCondition: Eine Bedingung, die ausgewählte Klassen erfüllen müssen
 
Durch eine Kombination dieser Elemente lassen sich statische Architektur-Constraints nun beschreiben und automatisch sicherstellen, denn prinzipiell sind die meisten Regeln folgendermaßen aufgebaut:
 
classes that $PREDICATE should $CONDITION
 
Im Code könnte sich diese Idee zum Beispiel in folgender Form wiederfinden:
 
ArchRule rule =
classes().that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
 
In diesem Beispiel wäre das Prädikat resideInAPackage("..service.."), welches die zu untersuchenden Klassen auf solche einschränkt, die ein Paket service im vollqualifizierten Klassennamen haben (die Syntax mit den ".." orientiert sich hier an AspectJ).
Die Bedingung an diese Klassen wäre wiederum
 
onlyBeAccessed().byAnyPackage("..controller..", "..service..")
 
das heißt, die ausgewählten Klassen dürfen nur von Klassen aufgerufen werden, welche im vollqualifizierten Klassennamen ein Paket controller oder service enthalten.
Hat man eine Regel in der obigen Form definiert, so lässt sie sich einfach gegen importierte JavaClasses auswerten:
 
JavaClasses classes = new ClassFileImporter().importPackage("com.myapp");
ArchRule rule = // siehe oben
rule.check(classes);
 
Bei einem Fehlschlag erhält man eine detaillierte Fehlermeldung, inklusive Zeilennummer:
 
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] -
Rule 'classes that reside in a package '..service..' should only be accessed by any package ['..controller..', '..service..']' was violated:
Method <com.myapp.dao.EvilDao.callService()> calls method <com.myapp.service.SomeService.doSomething()> in (EvilDao.java:14)
 
Typischerweise stößt eine vorgefertigte API früher oder später an ihre Grenzen, da jedes Projekt seine Eigenheiten hat, und man nicht alle beliebigen Architekturkonzepte vorab erahnen kann. Daher bietet die API eine beliebig erweiterbare Signatur der folgenden Form:
 
// eine beliebige Implementierung des Prädikats zum Filtern relevanter Klassen
DescribedPredicate<JavaClass> myCustomPredicate = // ...
// eine beliebige Implementierung der Bedingung an diese Klassen
ArchCondition<JavaClass> myCustomCondition = // ...
ArchRule rule = classes().that(myCustomPredicate).should(myCustomCondition);
 
Momentan bietet ArchUnit optimierte Unterstützung für JUnit 4. Hier existiert ein spezieller Runner, welcher die importierten Klassen nach URLs cacht, so dass Klassen für mehrere Tests nicht jedes Mal erneut importiert werden müssen. Auch muss man das Auswerten der Regeln in diesem Fall nicht selbst durchführen. Es reicht, ArchRule-Felder in folgender Weise zu deklarieren:
 
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = "com.myapp")
public class MyArchitectureTest {
 
    @ArchTest
    public static final ArchRule firstRule =
classes().that().areAnnotatedWith(PayLoad.class)
.should().beAssignableTo(Serializable.class);
 
    @ArchTest
    public static final ArchRule secondRule = // ...
 
}
 
Beginnt man, Regeln mit einem Tool wie ArchUnit zu schreiben, fallen einem ganz natürlich immer weitere Anwendungsfälle auf. Beispielsweise lässt sich fehlerhafte Nutzung von Third-Party-Libraries einfach für die Zukunft vermeiden (im Allgemeinen möchte man Fehler ja nur einmal machen). Oder gewisse Code-Smells lassen sich aus dem Projekt verbannen, indem man gezielt gegen diese Muster testet.

Lebendighalten der Architektur

Der größte Wert entsteht aber durch das Lebendighalten der Architektur. Während sich der Code-Stand unter normalen Umständen stillschweigend von der gewünschten Soll-Architektur und den gewünschten Konzepten wegentwickelt, schlagen beim Einsatz von ArchUnit tatsächlich explizit Tests im CI-Server an. Und durch diese Testfehlschläge muss man sich wirklich mit der Abweichung auseinandersetzen. Hier kann es sowohl sein, dass man die bestehenden Konzepte aus Unaufmerksamkeit verletzt hat. Es kann aber auch durchaus sein, dass die gewünschten Konzepte für den Anwendungsfall nicht mehr ausreichen. In einem solchen Fall möchte man sich allerdings explizit überlegen, wie die bestehenden Konzepte erweitert werden müssen, um derartige Anwendungsfälle abdecken zu können, anstatt willkürliche Muster entstehen zu lassen, die dann an anderer Stelle ohne hinreichenden Grund einfach übernommen werden und sich so im System ausbreiten.
Durch eine konsistente und saubere Struktur des Systems, hält man dieses nicht nur auf lange Sicht wartbar, man ermöglicht auch wechselnden Entwicklern, sich wesentlich schneller einarbeiten zu können und weniger Überraschungen mit unbekanntem Code zu erleben.

Fazit

ArchUnit (http://www.archunit.org) ist eine Open Source Library, welche viele Möglichkeiten mitbringt, um architekturelle Konzepte auf Dauer sicherzustellen und lebendig zu dokumentieren. Es eignet sich besonders gut für größere agile Projekte, in denen Architekturverantwortung oft verteilt ist, und viele Teams parallel an einer sich stetig weiterentwickelnden Code-Basis arbeiten. ◼
 



Das könnte Sie auch interessieren