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).

...
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2020.0.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    ...
  </dependencies>
<dependencyManagement>
...
Listing 1: Einbinden einer BOM im Projekt

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.

.
├── .mvn
│   └── wrapper
│       ├── MavenWrapperDownloader.java
│       ├── maven-wrapper.jar
│       └── maven-wrapper.properties
├── mvnw
├── mvnw.cmd
└── pom.xml
Listing 2: Dateisystem nach der Generierung des Maven Wrappers

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.

...
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <version>3.0.0-M3</version>
  <dependencies>
    <dependency>
      <groupId>com.github.ferstl</groupId>
      <artifactId>pedantic-pom-enforcers</artifactId>
      <version>2.0.0</version>
    </dependency>
  </dependencies> ...
</plugin>
...
Listing 3: Einbinden der pendantic-pom-enforces im maven-enforcer-plugin
...
<plugin>
  ...
  <executions>
    <execution>
      <id>enforce</id>
      <goals>
        <goal>enforce</goal>
      </goals>
      <configuration>
        <rules>
          ...
          <compound implementation="com.github.ferstl.maven.pomenforcers.CompoundPedanticEnforcer">
            <enforcers>POM_SECTION_ORDER,DEPENDENCY_MANAGEMENT_ORDER,DEPENDENCY_ORDER,DEPENDENCY_CONFIGURATION,DEPENDENCY_ELEMENT,PLUGIN_MANAGEMENT_ORDER,PLUGIN_CONFIGURATION,PLUGIN_ELEMENT</enforcers>
          </compound>
          ...
        </rules>
        <fail>true</fail>
      </configuration>
    </execution>
  </executions>
</plugin>
...
Listing 4: Meine präferierten Regeln aus pedantic-pom-enforcers

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:

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.

...
[INFO] --- maven-enforcer-plugin:3.0.0-M3:enforce (enforce) @ ...---
[WARNING] Rule 1: com.github.ferstl.maven.pomenforcers. CompoundPedanticEnforcer failed with message:

###################################################
# COMPOUND: One does not simply write a POM file! #
###################################################

Please fix these problems:

PLUGIN_CONFIGURATION: One does not simply configure plugins!
============================================================

Use <pluginManagement> to configure plugin dependencies:
- org.apache.maven.plugins:maven-enforcer-plugin:
...
Listing 5: Fehlermeldung von pedantic-pom-enforcers bei Verstößen

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.

...
  <plugin>
    <groupId>pl.project13.maven</groupId>
    <artifactId>git-commit-id-plugin</artifactId>
    <version>4.0.0</version>
    <executions>
      <execution>
        <goals>
          <goal>revision</goal>
        </goals>
      </execution>
    </executions>
    <configuration>
      <generateGitPropertiesFile>true</generateGitPropertiesFile>
    </configuration>
  </plugin>
...
Listing 6: Beispielhafte Konfiguration des git-commit-id-maven-plugin
...
#Generated by Git-Commit-Id-Plugin
#Thu Jun 17 08:30:15 CEST 2021
git.branch=main
git.build.host=mvitz-MacBookPro-16.local
git.build.time=2021-06-17T08\:30\:15+0200
git.build.user.email=[email protected]
git.build.user.name=Michael Vitz
git.build.version=0.1.0-SNAPSHOT
git.closest.tag.commit.count=
git.closest.tag.name=
git.commit.id=5584e537b9f59dca7674db3a5be7d8f72167a41b
git.commit.id.abbrev=5584e53
git.commit.id.describe=5584e53-dirty
git.commit.id.describe-short=5584e53-dirty
git.commit.message.full=Initial commit
git.commit.message.short=Initial commit
git.commit.time=2021-06-17T08\:29\:37+0200
git.commit.user.email=[email protected]
git.commit.user.name=Michael Vitz
git.dirty=true
git.local.branch.ahead=NO_REMOTE
git.local.branch.behind=NO_REMOTE
git.remote.origin.url=Unknown
git.tags=
git.total.commit.count=1
...
Listing 7: Inhalt der generierten git.properties-Datei

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.

Fazit

In diesem Artikel haben wir uns fünf weitere Themen rund um Maven angeschaut.

Durch die Nutzung von einer oder mehrerer Bill of Materials ist es für uns leichter, die passende Version von Bibliotheken, die aus mehreren Modulen bestehen, zu managen. Gleichzeitig ist auch sichergestellt, dass wir wirklich diese Version erhalten, auch wenn eine andere Abhängigkeit eigentlich eine ältere Version haben möchte.

Reproduzierbare Builds helfen uns dabei zu verifizieren, ob eine JAR-Datei wirklich aus einem bestimmten Stand des Codes gebaut wurde. Außerdem können diese auch bei der Optimierung von Schichten und deren Cache in Containern helfen. Um reproduzierbare Builds mit Maven zu erhalten, müssen wir dabei das Property project.build.outputTimestamp setzen und aktuelle Versionen der gängigen Plug-ins nutzen.

Mittels dem Maven Wrapper wird neben der pom.xml auch die zu nutzende Version von Maven versioniert und die Installation dieser Version erleichtert. Aktuell befindet sich der Wrapper noch in einem externen Plug-in, in Zukunft wird er allerdings Bestandteil des Kerns von Maven.

Mit den pedantic-pom-enforcers können wir das maven-enforcer-plugin um Regeln erweitern, die diversen Aspekte, vor allem Reihenfolgen, der pom.xml beim Build prüfen und bei Verstößen den Build abbrechen.

Zu guter Letzt haben wir mit dem git-commit-id-maven-plugin noch ein Maven-Plug-in kennengelernt, mit dem wir während des Builds Informationen aus Git in eine Property-Datei schreiben lassen können. Diese können wir dann wiederum zur Laufzeit auslesen und die Informationen innerhalb der Anwendung, beispielsweise auf der Hilfeseite oder zum Melden von Fehlern, nutzen.

Ich hoffe, auch dieses Mal war mindestens ein neues Thema für jeden dabei. Ich persönlich halte es für sinnvoll und kann deswegen nur empfehlen, sich neben der Sprache Java und diversen Frameworks und Bibliotheken auch ein solides Wissen über mindestens ein Build-Tool zu erarbeiten. Diese gehören heute schließlich, wie auch Versionsverwaltung, zum Standard für jedes Projekt.