Leute, sortiert eure Spaghetti, sonst habt ihr ein Problem!
Ich rede natürlich nicht davon, in der Kantine die Bolognese auseinander zu nehmen (ja, ganz sicher, selbst die größten Nerds unter uns tun das nicht). Es geht selbstverständlich um Spaghetticode, den wir alle verhindern wollen. Aber was tun wir dagegen?
Der erste Feind der Spaghetti-Hasser war goto. Spätestens seit Dijkstras Paper Goto Statement Considered Harmful aus dem Jahr 1968 wissen wir, dass unkontrolliert durch die Codebasis springen, nicht dazu beiträgt, Code übersichtlich zu machen. Moderne Programmiersprachen haben i.d.R. kein goto mehr. Das heißt aber nicht, dass damit Spraghetticode generell der Vergangenheit angehört. Auch heute noch springen wir kreuz und quer in unserem Code herum — nur eben nicht mit goto, sondern mit Methodenaufrufen. Viel besser ist das aber auch nicht, insbesondere, da heutige Software ist viel größer ist als die vor 50 Jahren.
Wir brauchen also Struktur in unserer Software und genau deshalb ist eine der Hauptaufgaben der Softwarearchitektur, das Sortieren, Kategorisieren und Strukturieren von Codeteilen. Ja, da haben wir es: Softwarearchitekten sind zwar keine Erbsenzähler, aber Spaghettisortierer. Egal, ob der Architekt eine explizite Rolle ist oder die Aufgabe im Team geteilt wird — Spaghetti sortieren ist wichtig.
Nun muss man irgendwie sicher stellen, dass die Sortierung, die man sich überlegt hat, auch halbwegs der Realität entspricht bzw. man muss prüfen, ob der Code sich an diese Vorgaben hält. Man kann das zu einem gewissen Grad durch Dokumentation und Kommunikation tun und ganz verkehrt ist das auch nicht. Alle Entwickler im Team sollten wissen, wie der Code zu strukturieren ist und warum.
Mit der Zeit wird sich das Team aber verändern, neue Leute kommen dazu, andere gehen weg und irgendwann werden die, die sich die Struktur überlegt haben, nicht mehr da sein. Außerdem braucht es immer eine gewisse Zeit, bis sich Wissen im Team verteilt. Gut wäre also, wenn es eine Möglichkeit gäbe, eine gewisse Struktur automatisiert zu prüfen.
Zum Teil sind solche Prüfungen bereits in die Programmiersprachen eingebaut. Ah, diese Methode ist privat, die kann ich von außen nicht aufrufen und diese Klasse hier ist nur im selben Package sichtbar. Wenn ich das dennoch versuche, auf eine Klasse zu zu greifen, die nicht sichtbar ist, kriege ich einen Compilerfehler. Heutige IDEs bieten hier aber i.d.R. einen Quickfix an, der einfach die Sichtbarkeit erhöht. Ein Tastendruck genügt und eine package-private-Klasse wird public. Das kann verlockend sein.
Eine andere Möglichkeit ist, gewisse Dependencies als separate Artefakte ab zu bilden (jars, Java9-Module, .NET assemblies, Webservices, etc.). Das ist durchaus ein valider Ansatz, aber relativ schwergewichtig. Refactoring über Artefaktgrenzen hinweg ist aufwändig und wird deshalb viel seltener gemacht (was natürlich ziemlich kontraproduktiv ist). Man sollte sich in diesem Fällen also schon ziemlich sicher sein, dass die Struktur so passt.
Wir können also nicht alles in separate Artefakte packen und brauchen deshalb auch innerhalb von Artefakten eine gewisse Struktur. Und diese kann man mit Dependency-Tests prüfen. Wir haben lange Zeit PackageDependencyTests geschrieben, die in etwa folgendermaßen aussehen:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class PackageDependencyTest extends AbstractDependencyTest { private Package boundary = packageOf("com.acme.boundary"); private Package control = packageOf("com.acme.control"); private Package data = packageOf("com.acme.data"); private Package gateway = packageOf("com.acme.gateway"); @Override void specifyDependencies() { boundary.dependsOn(control); control.dependsOn(data); control.dependsOn(gateway); } } |
Dieser Test stellt sicher, dass Klassen in den entsprechenden Packages nur die genannten Dependencies aufweisen (keine mehr, keine weniger und keine Zyklen). Für kleine Artefakte ist das prima. Es ist lesbar, übersichtlich und hilfreich.
Leider skaliert das nicht gut. In einem unserer Services mit über 100kLOC hat der PackageDependencyTest alleine schon über 1200 Zeilen. Das ist alles andere als übersichtlich und es ist schwer zu prüfen, ob die spezifizierten Dependencies noch halbwegs sinnvoll sind. Außerdem ist der Weg des geringsten Widerstandes das blinde Eintragen einer neuen Dependency in den Test. Das Ganze wuchert und verliert seinen Sinn. Außerdem hatten wir das Problem, dass JDepend, die Library, die wir als Basis für unseren Test genommen haben, nicht mehr aktiv weiterentwickelt wird, und mit neueren Java-Versionen nicht mehr ohne weiteres klar kommt.
Es gibt eine handvoll Tools und Libraries, die als Alternativen in Frage gekommen wären, aber so richtig überzeugend war nichts davon. Manche davon sind unübersichtlich, andere haben eine fragwürdige Zukunftsperspektive oder sind irgendwie verkopft. Jetzt hab ich aber etwas gefunden, das um Größenordnungen besser ist: ArchUnit. Und ArchUnit ist wirklich großartig.
So sähe der obige Test in ArchUnit aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @RunWith(ArchUnitRunner.class) @AnalyzeClasses(packages = "com.acme") public class ArchitectureTest { @ArchTest public static final ArchRule shouldNotHaveDependencyCycles = slices().matching("com.acme.(**)").should().beFreeOfCycles(); @ArchTest public static final ArchRule shouldhaveLayeredArchitecture = layeredArchitecture() .layer("boundary").definedBy("..boundary..") .layer("control").definedBy("..control..") .layer("data").definedBy("..data..") .layer("gateway").definedBy("..gateway..") .whereLayer("boundary").mayNotBeAccessedByAnyLayer() .whereLayer("control").mayOnlyBeAccessedByLayers("boundary") .whereLayer("data").mayOnlyBeAccessedByLayers("control") .whereLayer("gateway").mayOnlyBeAccessedByLayers("control"); } |
Warum ist das jetzt anders oder besser? Wir müssen jetzt nicht mehr jedes einzelne Sub-Package definieren und dessen Dependencies einzeln aufschreiben. Vielmehr können mehrere Packages zu „slices“ zusammengefasst und beschrieben werden. Bisher hab ich ArchUnit nur in zwei Services mit je ca. 10–20kLOC eingesetzt. Genau genommen weiß ich also noch nicht, wie gut das wirklich auf Software skaliert, die um eine Größenordnung größer ist. Ich denke aber, das wird auch dann noch übersichtlich sein und das erachte ich schonmal als großen Vorteil.
Aber ArchUnit kann noch viel mehr. Constraints, die bisher nur Konvention waren, können auf einmal ausgedrückt werden:
1 2 3 4 | @ArchTest public static final ArchRule facadesShouldNotDependOnEachOther = noClasses().that().haveSimpleNameEndingWith("Facade") .should().dependOnClassesThat().haveSimpleNameEndingWith("Facade"); |
Anderes haben wir bisher über Reflection getestet und die Reflection-API ist nicht die aller lesbarste. Jetzt ist das aber ganz einfach:
1 2 3 4 5 6 | @ArchTest public static final ArchRule shouldHaveBoundariesAnnotated = classes().that().haveSimpleNameEndingWith("Boundary") .should().beAnnotatedWith(Api.class) .andShould().beAnnotatedWith(Path.class) .andShould().beAnnotatedWith(Sateless.class); |
ArchUnit untersucht den Bytecode, d.h. man kann auch Methodenaufrufe prüfen, was über Reflection und einfache Dependency-Analyse nicht möglich ist:
1 2 3 4 5 6 7 8 | @ArchTest public static final ArchRule shouldNotUseHamcrest = noClasses() .should().callMethod(Assert.class, "assertThat", String.class, Object.class, Matcher.class) .orShould().callMethod(Assert.class, "assertThat", Object.class, Matcher.class) .orShould().callMethod(MatcherAssert.class, "assertThat", String.class, Object.class, Matcher.class) .orShould().callMethod(MatcherAssert.class, "assertThat", Object.class, Matcher.class) .as("should not use hancrest asserts") .because("we want to use AssertJ"); |
Des Weiteren:
- Das Einbauen der Tests in die genannten beiden Services hat nicht länger als jeweils einen knappen halben Tag gedauert (inklusive Doku lesen). Man darf sich nur nicht abschrecken lassen, wenn der Test behauptet, x-hundert Violations gefunden zu haben. Dort wird jeder einzelne Methodenaufruf separat gezählt und auf der Console ausgegeben. Häufig reicht es schon, eine Klasse passend zu verschieben um ein paar hundert Violations weniger zu haben. Wenn man seine Architektur halbwegs im Griff hat, ist die Einführung von ArchUnit keine allzu große Hürde.
- ArchUnit ist, zumindest bei den Services, in denen ich das bisher eingesetzt habe, durchaus performant. Nach wenigen Sekunden Setup-Zeit (immerhin müssen alle Klassen geladen und geparst werden) dauern die meisten Tests einige Millisekunden und selbst der aufwändige Zyklentest ist mit unter 150ms durchaus schnell.
- ArchUnit ist leicht erweiterbar. Da versteht jemand sein Handwerk. Einfache Dinge sind einfach und out of-the-box möglich. Und für außergewöhnlichere Tests lassen sich leicht selbst Conditions und Predicates schreiben. Sehr gut.
- Schlechte Libraries bringen selbst nochmal transitive Dependencies mit, mit denen man sich rumschlagen muss. Aber wie schon gesagt: Da versteht jemand sein Handwerk. ArchUnit hat wenige Dependencies und diese werden sauber geshaded. Prima.
- Offiziell ist ArchUnit noch eine 0er-Version (aktuell 0.10.2), fühlt sich aber schon wie eine 1.0 an. Das Projekt wird seit 3 Jahren kontinuierlich weiterentwickelt und eine (mir unbekannte) Consulting-Firma sowie eine kleine Community stehen dahinter.
- Der Code sieht sauber und ordentlich aus, es gibt gute Doku und eine ganze Menge Beispielcode.
Kurz: Ich bin schwer begeistert. Guckt euch ArchUnit an.