Teil 2: Noch mehr Themen rund um Maven
Ich kann mich noch an meine ersten Schritte mit Java erinnern. Eine .java-Datei hier und eine dort und anschließend wurde per javac auf einer Konsole kompiliert. Im nächsten Schritt sprang mir dann die IDE, beziehungsweise genau genommen der Java-Editor, zur Hilfe und ich konnte meinen Code von dort aus kompilieren und starten.
Heute kann ich mir auch das kleinste Projekt nicht mehr ohne den Einsatz eines Build-Tools vorstellen. Mein persönlicher Favorit ist hierbei Maven, auch wenn es mit Gradle mindestens eine Alternative gibt, die genauso gut einsetzbar ist.
Neben dem Kompilieren und Bauen meines Codes hilft vor allem die Verwaltung von externen Abhängigkeiten, die heute in so gut wie jedem Projekt eingesetzt werden. Die Nutzung von Maven ist dabei schnell gelernt und auch die ersten Schritte zur eigenen pom.xml sind in der Regel, mit ein wenig Dokumentation oder Hilfe, machbar.
Als nächsten Schritt empfehle ich dann, wenn das Interesse oder die Notwendigkeit besteht, sich tiefer mit Maven zu beschäftigen. Kurzfristig mag dies unnötig erscheinen. Ich bin jedoch der Meinung, dass ein tieferes Verständnis, eigentlich eines jeden Tools, langfristig von Vorteil ist. Gerade bei Problemen mit diesem oder beim Erlernen eines ähnlichen Tools zahlt sich das Verständnis schnell aus. Deshalb wollen wir uns in diesem Artikel vier unterschiedliche Themen mit Maven anschauen, die ich als wichtig erachte.
Das perfekte Kommando
Bei der Nutzung von Maven wird vielfach der Befehl mvn clean install
als
Erstes gelernt und fortan wie automatisch ausgeführt. Dass dieser in den meisten
Fällen zu längeren Build-Zeiten führt und es einen vielfach optimaleren Befehl
gibt, ist häufig unbekannt. Betrachten wir das ganze doch mal mit ein wenig mehr
Details.
Beim Aufruf von mvn übergeben wir, in der Regel, eine oder mehrere sogenannte Phasen. Anhand der Phase erkennt Maven, welcher Lebenszyklus ausgeführt werden soll. Im Standard bringt Maven dabei die drei Lebenszyklen clean, default und site mit. Jeder dieser Lebenszyklen ist wiederum in Phasen unterteilt. Diese werden der Reihe nach ausgeführt, bis wir die angegebenen Phasen durchlaufen haben, und stoppen anschließend.
Im Falle von mvn clean install führen wir also zuerst alle Phasen inklusive clean, die zum Lebenszyklus clean gehören, aus und direkt anschließend alle Phasen inklusive install im Lebenszyklus default. Die Phasen selber verrichten dabei keine Arbeit, sondern diese wird von Maven-Plug-ins erledigt. Dazu binden diese ein oder mehrere Goals an Phasen. Standardmäßig bringt Maven dabei bereits ein Subset von Plug-ins und gebundenen Goals mit.
Beim Befehl mvn clean install sorgen wir also durch das Goal clean des maven-clean-plugin dafür, dass das gesamte target-Verzeichnis aller Module entfernt wird. Dadurch müssen beim Durchlaufen des default-Lebenszyklus durch die Angabe von install sämtliche Artefakte, wie unsere kompilierten Class- oder JAR-Dateien, neu erzeugt werden. Prinzipiell ist dagegen wenig einzuwenden. Es entsteht ein sauberer Build, bei dem die Gefahr, durch alte Dateien in Probleme zu laufen, nahezu nicht gegeben ist. Allerdings erhöhen sich dadurch, dass alles neu erzeugt werden muss, natürlich auch die benötigte Zeit und der Ressourcenverbrauch. In den meisten Projekten und Fällen ist dies nicht nötig und wir brauchen nicht jedes Mal die clean-Phase zu durchlaufen.
Eine der Ausnahmen, in die ich persönlich schon reingelaufen bin, betrifft die
Kombination aus einem Multi-Modul-Projekt und dem direkten Zugriff auf statische
Konstanten eines anderen Moduls. Angenommen, wir haben ein Multi-Modul-Projekt
mit den beiden Modulen lib und app. Im lib-Modul wird dabei eine Klasse
Version
mit einer Konstanten public static final String VERSION = "1.0.0"
definiert. Das Modul app wiederum hat eine Abhängigkeit zu lib und greift
auf die Konstante VERSION, wie in Listing 1 zu sehen, zu.
Führen wir nun nach einem ersten Build die main-Methode aus dem app-Modul
mit java -cp app/target/app.jar app.App
aus, wird die Versionsnummer 1.0.0
ausgegeben. Ändern wir nun die Version im lib-Modul auf 2.0.0, führen
mvn install
aus und starten die Anwendung erneut, wird immer noch 1.0.0
anstatt der erwarteten 2.0.0 ausgegeben. Das liegt daran, dass der
Java-Compiler benutzte Konstanten in diversen Fällen offen einbaut (inlined).
Zur Laufzeit wird also nicht mehr die Konstante aus Version genutzt, sondern
der Compiler hat quasi direkt den Aufruf System.out.println("1.0.0");
kompiliert.
In dieser Konstellation erkennt Maven, genauer gesagt das maven-compiler-plugin, nicht, dass die Klasse App erneut kompiliert werden müsste. Würde der Compiler hier die Konstante nicht inlinen, wäre das auch kein Problem, denn dann würde zur Laufzeit erst der Wert aus dem lib-Modul gelesen.
Kommen wir nun zu install. Bereits die Beschreibung der Phase install in der Referenz zu den Lebenszyklen „install the package into the local repository, for use as a dependency in other projects locally.“ lässt es erahnen. Die install-Phase ist speziell dafür da, das oder die Module des Projekts in das lokale Maven-Repository zu kopieren. So lange also keines unserer Module von anderen Projekten benötigt wird, ist diese Phase nicht notwendig und benötigt nur zusätzliche Zeit und Festplattenplatz. Beides ist zwar mit modernen Festplatten in der Regel kein Problem mehr, aber trotzdem können wir hierauf verzichten. Denn innerhalb eines Multi-Modul-Projekts können die Module auch ohne install auf andere Module zugreifen. Als Alternative zu install bieten sich deswegen verify oder package, wenn es keine konfigurierten Integrationstests gibt, als Alternativen an.
clean kann also zumindest bei Projekten mit nur einem Modul immer weggelassen
werden und auch install brauchen wir bei der Entwicklung von Anwendungen fast
nie. Ich persönlich nutze deswegen schon sehr lange anstelle von
mvn clean install
den Befehl mvn verify
.
Natürlich hängen die Menge der eingesparten Zeit und auch die möglichen Probleme, dadurch, dass nicht jedes Mal ein sauberer Build ausgeführt wird, vom konkreten Projekt ab und sollten deshalb auch innerhalb dieses evaluiert werden. Wie üblich gibt es auch hier keine Patentlösung.
User Properties
Bei der Nutzung von Maven müssen wir eher früher als später auch das ein oder andere Plug-in konfigurieren. Beispielsweise nutzt das maven-compiler-plugin standardmäßig noch, je nach Plug-in-Version, Java 5 oder 6 zur Kompilierung. Da heute jedoch meistens Java 8, 11 oder neuer eingesetzt wird, müssen wir also zumindest dieses Plug-in konfigurieren.
Eine Möglichkeit hierzu besteht darin, das Plug-in innerhalb der build plugins-Sektion einzutragen und dabei die beiden Konfigurationsoptionen source und target zu setzen (s. Listing 2).
Es gibt jedoch eine zweite, deutlich kürzere Variante. Maven-Plug-ins können zur Konfiguration nämlich sogenannte User Properties anbieten. Diese Properties lassen sich beim Aufruf von Maven als -D-Argumente übergeben oder innerhalb der properties-Sektion der pom.xml setzen. Passenderweise sind genau die beiden Konfigurationsoptionen zur Java-Version auch gleichzeitig User Properties. Wir können also auch, anstatt das Plug-in wie in Listing 2 zu definieren, die beiden Properties maven.compiler.source und maven.compiler.target setzen (s. Listing 3).
Der einzige Nachteil der User Properties besteht darin, dass sich diese Werte beim Aufruf von mvn, beispielsweise mit -Dmaven.compiler.source=10 und -Dmaven.target.source=10 überschreiben lassen können. Wollen wir dies definitiv verhindern, müssen wir doch zur in Listing 2 gezeigten expliziten, aber auch längeren Konfiguration des Plug-ins greifen.
User Properties werden von nahezu allen Maven-Plug-ins angeboten und ermöglichen somit eine generell kürzere pom.xml. Ich persönliche nutze deswegen fast immer User Properties, da ich die so kürzere pom.xml besser lesbar finde, als alle Plug-ins immer komplett zu definieren.
Dependencies
Wie bereits in der Einleitung erwähnt, hat Maven neben dem Ausführen von Goals noch einen zweiten Kernpunkt, nämlich das Verwalten von Abhängigkeiten. In der Zeit vor Maven mussten wir unsere Abhängigkeiten in der Regel noch händisch aus dem Internet herunterladen, an den passenden Ort legen und selber sicherstellen, dass stets auch die benötigten Abhängigkeiten unserer Abhängigkeiten, sogenannte transitive Abhängigkeiten, in der passenden Version vorhanden waren.
Heute müssen wir hierzu in unserer pom.xml lediglich einen Eintrag wie in Listing 4 vornehmen. Maven weiß nun während des Builds, anhand der Koordinaten groupId, artifactId, version und type (standardmäßig jar), automatisch, wo es diese runterladen muss und auch welche transitiven Abhängigkeiten benötigt werden.
Besonders spannend wird die Verwaltung von Abhängigkeiten, wenn es gleichzeitig mehrere Abhängigkeiten zu einer Bibliothek in verschiedenen Versionen gibt. Java selber kann schließlich, im Gegensatz zum Beispiel zu OSGi, nicht gleichzeitig verschiedene Versionen derselben Klasse laden. Bei einem solchen Konflikt sorgt deswegen bereits Maven dafür, dass nur eine Version ausgewählt und im Build zur Verfügung gestellt wird. Heute denken wir beim Lösen von solchen Versionskonflikten schnell an die Regeln von semantischer Versionierung. Diese gab es jedoch zur Zeit der initialen Implementierung von Maven noch nicht. Aus diesem Grund kann uns die von Maven gewählte Strategie schnell überraschen, vor allem, wenn wir diese nicht kennen.
Maven nutzt zur Auswahl der Version bei einem Konflikt in erster Linie die Entfernung zwischen unserer pom.xml und der Stelle, an der die Definition der Abhängigkeit getätigt wurde. Hängen wir beispielsweise direkt von commons-lang3 in Version 3.11 und einer Bibliothek my-lib-a ab und my-lib-a wiederum von commons-lang3 3.12.0, dann gewinnt 3.11, da diese direkt bei uns definiert wurde und somit näher ist als die Definition in my-lib-a, die ein Abhängigkeitslevel tiefer passiert ist.
Im Falle eines Konflikts auf der gleichen Ebene nutzt Maven dann im zweiten Schritt die Reihenfolge der Deklaration, um diesen zu lösen. Angenommen wir hängen von my-lib-a und my-lib-b ab. Beide hängen wiederum von commons-lang3 ab, my-lib-a von 3.12.0 und my-lib-b von 3.11. Definieren wir nun die Abhängigkeit zu my-lib-a oberhalb von my-lib-b innerhalb der dependencies-Sektion wird Version 3.12.0 gewählt. Bei anderer Reihenfolge wählt Maven Version 3.11.
Welche Version Maven letztlich wirklich gewählt hat, lässt sich mit dem maven-dependency-plugin herausfinden. Dieses stellt uns das Goal tree zur Verfügung, das einen grafischen Baum aller Abhängigkeiten ausgibt. Setzen wir dabei die Option verbose, beispielsweise durch die Angabe von -Dverbose beim Aufruf von Maven, werden uns auch die Stellen angezeigt (s. Listing 5), an denen eine Bibliothek aufgrund eines Konflikts verworfen wurde. Da die verbose-Option jedoch mit Version 3.0 entfernt wurde, müssen wir im Zweifelsfall dafür sorgen, eine ältere Version des Plug-ins, beispielsweise 2.10, auszuführen.
Das maven-dependency-plugin hilft uns jedoch leider meistens erst, wenn das Kind in den Brunnen gefallen ist und wir ein konkretes Problem haben. Um erst gar nicht in diese Situation zu geraten, können wir das maven-enforcer-plugin nutzen. Das Plug-in prüft vor dem eigentlichen Build eine Menge von definierten Regeln ab und bricht den Build im Falle von Verstößen mit einem Fehler ab. Dabei gibt es mit der Dependency Convergence-Regel eine, die uns in genau diesem konkreten Fall hilft. Konfigurieren wir das Plug-in, beispielsweise wie in Listing 6 zu sehen, erhalten wir bei einem Build mit dem oben beschriebenen Konflikt eine Ausgabe ähnlich zu der in Listing 7.
Wir müssen Maven nun explizit mitteilen, welche Version genutzt werden soll, indem wir beispielsweise selber eine Abhängigkeit zu commons-lang3 in der gewünschten Version definieren oder die dependencyManagement-Sektion nutzen.
Durch den Einsatz von Bill of Materials oder Parent POMs entstehen noch weitaus kompliziertere Möglichkeiten für Konflikte, und deren Auflösung in Maven kann selbst bei viel Maven-Erfahrung überraschen. Zur Vertiefung bietet sich bei Bedarf der Artikel „Maven Dependencies Pop Quiz – Result“ von Andres Almiray an.
Toolchains
Ein weiteres relevantes, aber wenig bekanntes Feature von Maven sind Toolchains. Das maven-compiler-plugin nutzt beispielsweise standardmäßig das JDK zum Kompilieren des Quellcodes, mit dem wir Maven ausführen. Dies ist unproblematisch, solange wir ein JDK nutzen, das die benötigte Version von Java kompilieren kann. Sobald dies nicht mehr gegeben ist, schlägt unser Build beim Start des Kompilierungsvorganges fehl.
Gerade durch die beschleunigte Entwicklung des JDKs sammeln sich jedoch schnell eine ganze Menge von JDKs und Versionen auf unserem Rechner an. Eine mögliche Lösung ist es nun, vor dem Build auf eine passende Version zu wechseln. Etwas besser ist es jedoch, die in Maven vorhandenen Toolchains zu nutzen.
Mit einer Toolchain können wir Maven mitteilen, dass er für den Build ein bestimmtes Tool, hier ein JDK, in einer bestimmten Version nutzen und sich nicht auf Pfadeinträge oder Ähnliches stützen soll. Hierzu konfigurieren wir im ersten Schritt das maven-toolchain-plugin in unserer pom.xml (s. Listing 8).
Wenn wir nun das Projekt bauen, wird uns Maven mit einer Fehlermeldung mitteilen, dass es versucht hat, die Toolchain zu nutzen, jedoch keine passende Toolchain für das JDK in Version 11 gefunden hat. Diese vom konkreten Rechner abhängige Konfiguration müssen wir also im zweiten Schritt auch noch vornehmen. Hierzu müssen wir in der Datei ~/.m2/toolchains.xml einen Eintrag ähnlich zu Listing 9 tätigen.
Von nun an können wir das Projekt bauen, egal welche Java-Version wir zur Ausführung von Maven nutzen. Es wird stets das in der Toolchain konfigurierte JDK genutzt. Neben dem maven-compiler-plugin werden Toolchains auch von weiteren Plug-ins unterstützt. Neben der Kompilierung erfolgt somit beispielsweise auch die Ausführung von Tests mit dem konfigurierten JDK.