Teil 1: Weiterführende Themen zum Umgang mit Maven
Heutige Softwareentwicklung ist komplex. Neben den reinen Kenntnissen über die verwendete Programmiersprache, wie deren Syntax, Idiome und Standardbibliothek, müssen wir noch eine große Menge an weiteren Themen kennen, anwenden und kombinieren können. Unter anderem gehört hier auch das im Projekt verwendete Tooling dazu.
Bei Tooling denken wir häufig im ersten Moment an die von uns genutzte IDE oder unseren Editor. Häufig vergessen wir dabei die zentrale Rolle, die Build-Tools in Projekten haben. Neben der Verwaltung von Abhängigkeiten sorgen diese erst dafür, dass aus dem von uns geschriebenen Quellcode eine lauffähige Anwendung entsteht. Auch deswegen ist eine Investition in Wissen hier, meiner Meinung nach, immer eine gute Idee.
Nachdem wir uns deswegen in der letzten Kolumne bereits vier Themen rund um Maven angeschaut haben, gehen wir nun in eine zweite Runde. In dieser wollen wir noch fünf weitere Themen rund um Maven betrachten.
Bill of Materials
Wie bereits letztes Mal erläutert, besteht einer der Hauptaspekte von Maven
darin, die Abhängigkeiten von Projekten zu verwalten. Maven kennt hierzu mit
dependencies
und dependencyManagement
zwei Sektionen, in die wir
Abhängigkeiten eintragen können.
Innerhalb von dependencies
werden dabei die realen Abhängigkeiten unseres
Projekts definiert. Hierzu geben wir mit groupId
, artifactId
und version
(GAV) eine eindeutige Bezeichnung für die Abhängigkeit an. Neben dieser können
wir zusätzlich per scope
definieren, an welcher Stelle in unserem
Build-Prozess wir diese Abhängigkeit brauchen und ob diese Abhängigkeiten auch
zur Laufzeit vorhanden sein müssen. Zudem gibt es mittels classifier
auch noch
die Möglichkeit, verschiedene Varianten unter derselben GAV anzubieten.
Im Gegensatz dazu können wir die dependencyManagement
-Sektion dazu nutzen, die
Version einer Abhängigkeit zu fixieren. Unabhängig davon, ob diese Abhängigkeit
nun eine direkte oder transitive von uns ist, es wird immer die dort definierte
Version verwendet. Außerdem werden die hier für version
und scope
definierten Werte automatisch zum neuen Standard für diese groupId
und
artifactId
. Wir können beides also innerhalb der dependencies
-Sektion
weglassen. Ich persönlich empfehle jedoch in den meisten Fällen, auf eine
Definition von scope
zu verzichten. Innerhalb der dependencies
-Sektion ist
nämlich anschließend nicht mehr eindeutig sichtbar, welchen scope eine
Abhängigkeit hat, wenn dieser nicht explizit hingeschrieben wird.
Neben der eigenen Deklaration in der dependencyManagement
-Sektion können wir
in dieser jedoch auch eine andere POM importieren. Dadurch entsteht derselbe
Effekt, als hätten wir die dependencyManagement
-Sektion dieser POM in unsere
Sektion kopiert. Hierzu müssen wir bei unserer Deklaration den scope
auf
import
und den type
auf pom
setzen (s. Listing 1).
Eine POM, die absichtlich für diesen Anwendungsfall geschrieben wurde, wird Bill
of Materials (BOM) genannt. Dieser Ansatz ist vor allem dann sinnvoll, wenn wir
eine Abhängigkeit zu einem Projekt haben, das aus mehreren Artefakten besteht,
und wir aufeinander abgestimmte Versionen beziehen möchten.
Spring Cloud beispielsweise veröffentlicht hierzu, in einem
sogenannten Release Train, verschiedene Versionen von all seinen Modulen und
bietet uns mit der Abhängigkeit
org.springframework.cloud:spring-cloud-dependencies
eine BOM an.
Reproduzierbarer Build
Neben Sicherheitslücken in unserem Code stellen auch Abhängigkeiten ein Angriffsziel dar. Bisher wurde hierzu vor allem darauf gesetzt, dass wir aus Versehen einen falschen Namen zu einer Abhängigkeit definieren oder dass wir eine Abhängigkeit, die nur bei uns intern vorhanden ist, aus Versehen über ein öffentliches Repository beziehen. Noch ein wenig effektiver wäre es aber, wenn es geschafft wird, ein eigenes Artefakt unter den Koordinaten einer bekannten Abhängigkeit abzulegen.
Um dies zu verhindern, schreibt Sonatype, der aktuelle Betreiber von Maven Central, deswegen vor, dass Abhängigkeiten, die wir dorthin pushen wollen, wenigstens mittels GPG signiert werden. Somit könnten wir für jedes Artefakt, das wir von dort beziehen, mittels dieser prüfen, ob dieses von jemanden hochgeladen wurde, der in Besitz der Signatur ist. Leider findet diese Prüfung aktuell von Maven noch nicht automatisch statt.
Die Signatur allein stellt allerdings nicht sicher, dass das bezogene Artefakt aus dem Codestand entstanden ist, aus dem es behauptet, entstanden zu sein. Hierzu müssen wir den Code in der passenden Version selbst bauen. Anschließend können wir prüfen, ob beide Dateien identisch sind. Ist eine solche Überprüfung möglich, sprechen wir deswegen auch von einem reproduzierbaren Build.
Das Tückische an diesem Vergleich ist jedoch, dass wir Binärdateien vergleichen.
Hier führen minimale Abweichungen, beispielsweise die Änderungszeitstempel von
sich in einer JAR-Datei befindlichen class
-Dateien, dazu, dass die Artefakte
nicht mehr identisch sind. Auch viele der möglichen Einträge, vor allem
Zeitstempel, Versionen und Informationen zum genutzten System, in der Datei
META-INF/MANIFEST.MF
sind ein Grund dafür, dass JAR-Dateien abweichen können.
Doch bereits seit einiger Zeit ist es auch mit Maven möglich, mit wenig Aufwand einen reproduzierbaren Build zu erreichen. Gerade für Bibliotheken, die wir veröffentlichen, würde ich diesen Aufwand sogar empfehlen. Um dies also zu erreichen, müssen wir die drei folgenden Punkte beachten.
Erstens müssen wir die Property project.build.outputTimestamp
auf einen
beliebigen fixen Wert setzen. Das Format ist dabei entweder ein
ISO 8601 formatierter String oder eine Zahl. Anschließend nutzen die
gängigen Maven-Plug-ins diesen Wert als Änderungsdatum für von ihnen erzeugte
Dateien. Somit kann es hier keine Abweichungen geben, auch wenn wir den Build
zur Verifikation zu einem anderen Zeitpunkt laufen lassen.
Zweitens müssen wir dafür sorgen, diverse Maven-Plug-ins in einer passenden,
aktuellen Version zu nutzen. Nur so kümmert sich beispielsweise das
maven-jar-plugin darum, sich ändernde Einträge nicht mehr in
das MANIFEST.MF
zu schreiben. Eine Liste der minimal benötigten Versionen ist
im Maven Guide für reproduzierbare Builds zu finden.
Drittens müssen wir darauf achten, dasselbe Betriebssystem und die identische Major-Version des JDKs für den Build zu nutzen. Werden alle drei Punkte beachtet, ist es anschließend möglich zu verifizieren, dass eine JAR-Datei wirklich aus einem bestimmten Stand unseres Quellcodes entstanden ist.
Neben Bibliotheken kann so eine reproduzierbare JAR-Datei auch in Anwendungen helfen. Wird diese in einem Container verpackt, wird bei Änderungen, die nicht den Quellcode unserer Anwendung betreffen, eine identische JAR-Datei entstehen und dadurch kann der Container die Schicht, in der unsere Datei gelandet ist, vom vorherigen Build wiederverwenden.
Maven Wrapper
Beim Thema reproduzierbare Builds muss ich immer an eines meiner ersten Projekte nach dem Studium denken. Das Mantra dieses Projekts war es, möglichst alles, was für den Build notwendig ist, mit in der Versionsverwaltung zu haben. Dazu gehörte neben dem Quellcode und dessen Abhängigkeiten auch das Build Tool. Somit war sichergestellt, dass jeder dieses in der passenden Version nutzte und nicht vorher, nach Anleitung, die passende Version installieren musste.
Das nächste Mal ist mir dieses Konzept Jahre später mit Gradle und dessen Gradle Wrapper begegnet. Hierzu wird nicht das gesamte Build-Tool selber mit in der Versionsverwaltung verwaltet, sondern lediglich ein kleines Skript, das bei Nutzung eine bestimmte Version, ähnlich wie die sonstigen Abhängigkeiten, herunterlädt und an einen definierten Ort installiert. Nach diesem, einmaligen, Download wird diese Installation anschließend zur Ausführung genutzt.
Für Maven steht mit dem Maven Wrapper von takari eine
analoge Lösung zur Verfügung. Um diese zu nutzen, müssen wir mittels
mvn -N io.takari:maven:0.7.7:wrapper
das Skript in einem bestehenden Projekt
generieren. Dabei entstehen die in Listing 2 gezeigten Dateien und
Verzeichnisse. Anschließend nutzen wir anstelle des Befehls mvn
das Skript
mvnw
oder auf Windows mvnw.cmd
. Dieses unterstützt dabei sämtliche Optionen
wie mvn
selbst.
Neben dem Skript wird zwingend auch die Datei maven-wrapper.properties
benötigt. In dieser befindet sich die URI vom JAR des Maven Wrappers selbst und
die der Maven Distribution. Das Skript führt dabei einen Java-Prozess aus, der
über das maven-wrapper.jar
Maven anstößt. Sollte die Datei maven-wrapper.jar
nicht vorhanden sein, versucht das Skript diese über curl
oder wget
beziehungsweise mittels Powershell unter Windows herunterzuladen. Sollte keines
dieser Tools verfügbar sein, versucht es zuletzt, die Datei
MavenWrapperDownloader.java
zu kompilieren, und nutzt anschließend die
erzeugte class
-Datei für das Herunterladen.
Da mich persönlich die Versionierung einer etwa 50 KB großen JAR-Datei nicht
stört, würde ich empfehlen, neben den Skripten und der
maven-wrapper.properties
auch die Datei maven-wrapper.jar
mit in die
Versionsverwaltung zu nehmen. Mit dieser Kombination ist sichergestellt, dass
das Skript, außer Maven selbst, nichts herunterladen muss. Da wir die Datei
MavenWrapperDownloader.java
dann nicht mehr brauchen, kann diese entfernt
werden.
Standardmäßig konfiguriert der Maven Wrapper die Version von Maven, mit der wir
den Wrapper generieren. Möchten wir eine spezielle Version nutzen, können wir
entweder händisch die URI in der maven-wrapper.properties
ändern oder bei der
Generierung die Option -Dmaven=<version>
nutzen.
Da der Maven Wrapper mittlerweile so weit verbreitet ist, wird aktuell daran gearbeitet, diesen „nativ“ in die nächste Version von Maven zu integrieren. Das hierdurch entstandene maven-wrapper-plugin kann dazu auch theoretisch bereits genutzt werden, allerdings nur in Verbindung mit einem Snapshot oder Nightly Build von Maven 4.
Pedantische POMs
Grundsätzlich lässt sich über Code und dessen Formatierung streiten. Schließlich
hat die reine Formatierung bei Java, abseits von Multi-Line-Strings, und auch
innerhalb der pom.xml
keinen Effekt. Trotzdem kann es sinnvoll sein, sich in
einem Projekt auf eine Art der Formatierung zu einigen. Der Effekt, innerhalb
einer Codebasis oder über mehrere Projekte hinweg eine gleiche Formatierung zu
haben, verringert die Zeit und den kognitiven Aufwand, den wir benötigen, um uns
im Code zurechtzufinden.
Bereits in der letzten Kolumne haben wir das maven-enforcer-plugin
kennengelernt. Etwas unbekannter ist jedoch, dass sich dieses Plug-in über
Abhängigkeiten erweitern lässt. Eine der Erweiterungen, die ich dabei häufig
einsetze, sind die pedantic-pom-enforcers. Diese Erweiterung bietet eine
Reihe von neuen Regeln an, mit denen wir prüfen können, ob unsere pom.xml
einer bestimmten Formatierung genügt.
Um die Erweiterung einzubinden, definieren wir innerhalb der
maven-enforcer-plugin-Deklaration einen dependencies
-Eintrag zur gewünschten
Version (s. Listing 3). Anschließend können wir die von der Erweiterung zur
Verfügung gestellten neuen Regeln verwenden, indem wir diese innerhalb der
configuration
-Sektion einer execution
des maven-enforcer-plugin definieren.
In Listing 4 ist die von mir bevorzugte Wahl aus Regeln zu sehen.
Die Regel POM_SECTION_ORDER
prüft, dass die Elemente in der Hauptsektion der
pom.xml
, wie parent
, groupId
, artifactId
, version
, dependencies
und
weitere, der Reihenfolge entsprechen, die in der
Codekonvention von Maven empfohlen wird. Kurz
zusammengefasst entspricht diese dem Schema:
- parent
- GAV-Koordinaten des Moduls
- weitere Metainformationen zum Modul, wie beispielsweise name und licenses
- Repositories für Dependencies und Plug-ins und das distributionManagement
- modules im Falle eines Multi-Module-Projekts
- properties
- dependencies
- Konfiguration des Build innerhalb von build
- Definition von profiles
Wollen wir von dieser Reihenfolge bewusst abweichen, ist auch das möglich. Die Dokumentation zur Regel hilft uns hierbei und listet auch die standardmäßig geprüfte Reihenfolge noch einmal auf. Da jedoch auch Tools wie SonarQube Regeln zur Prüfung der Reihenfolge haben, würde ich empfehlen, möglichst beim Standard zu bleiben.
Die Regeln DEPENDENCY_MANAGEMENT_ORDER
, DEPENDENCY_ORDER
und
PLUGIN_MANAGEMENT_ORDER
sorgen dafür, dass wir die Kindelemente in den
dependencyManagement
-, dependencies
- und pluginManagement
-Sektionen
alphabetisch sortieren. Alphabetisch bedeutet in diesem Fall nach der groupId
und im Falle von identischer groupId
anschließend nach artifactId
.
Mit DEPENDENCY_CONFIGURATION
und PLUGIN_CONFIGURATION
wird sichergestellt,
dass wir den Großteil der Konfiguration von Dependencies und Plug-ins innerhalb
der jeweiligen Managementsektion vornehmen. Für Dependencies bedeutet das, dass
die Angabe der version
und von exclusions
im dependencyManagement
definiert werden muss. Für Plug-ins heißt das, dass die globale configuration
und weitere dependencies
in pluginMangement
stehen müssen. Dadurch werden
die Sektionen, in denen wir unsere wirklichen Abhängigkeiten und Plug-ins
definieren, kürzer und damit auch ein wenig übersichtlicher.
Zuletzt sorgen die beiden Regeln DEPENDENCY_ELEMENT
und PLUGIN_ELEMENT
noch
dafür, dass auch die Reihenfolge der Elemente in dependency
und plugin
immer
gleich ist. Die Reihenfolge dabei ist im Standard groupId
, artifactId
,
version
und anschließend der Rest. Auch hier lässt sich die Reihenfolge bei
Bedarf ändern und die genaue Reihenfolge ist in der Dokumentation von
DEPENDENCY_ELEMENT
und
PLUGIN_ELEMENT
zu finden.
Verstoßen wir nun gegen eine dieser Regeln, bricht unser Build ab und wir sehen eine Fehlermeldung, wie in Listing 5 beispielhaft zu sehen.
Informationen aus Git
In vielen meiner Projekte gab es früher oder später die Anforderung, visuell zur Laufzeit identifizieren zu können, um welchen Stand der Software es sich aktuell handelt. Nutzen wir Git als Versionsverwaltung, bietet es sich an, Informationen wie beispielsweise den Commit-Hash, aus dem die Anwendung gebaut wurde, zu nutzen. Um diesen während der Laufzeit zur Verfügung zu haben, müssen wir die Information aus Git während des Builds mit in unser fertiges Artefakt übertragen, da in der Regel das Repository zur Laufzeit nicht mehr zur Verfügung steht.
Genau hierzu können wir das git-commit-id-maven-plugin verwenden. Dieses
konfigurieren wir beispielsweise wie in Listing 6 zu sehen. Bauen wir
anschließend unser Projekt, wird das Plug-in automatisch eine Datei
git.properties
in target/classes
anlegen. Listing 7 zeigt dabei den
beispielhaften Inhalt dieser Datei. Wir können nun zur Laufzeit mithilfe von
java.util.Properties
diese Datei auslesen und somit an die benötigten
Informationen gelangen.
Natürlich lässt sich auch dieses Plug-in noch weiter konfigurieren. Beispielsweise können wir konfigurieren, welche Properties überhaupt in die Datei geschrieben werden sollen. Die Dokumentation ist hierzu sehr ausführlich und erklärt alle Optionen vollumfänglich. Außerdem werden dort auch alle zur Verfügung stehenden Properties noch einmal aufgelistet und erklärt.
Zusätzlich ist es möglich, bei bestimmten Bedingungen den Build abbrechen zu lassen. So können wir beispielsweise sicherstellen, dass der Build nur dann erfolgreich ist, wenn er auf einem Tag ausgeführt wird oder es keine geänderten Dateien gibt.