Build Automatisierung mit eigenen Mojos verbessern

“Crikey, I’ve lost my mojo!”

Austin Powers

Das automatisierte Erstellen von Software hat eine lange Tradition. GNU Make erblickte 1976 das Licht der Welt, Apache Ant im Jahr 2000, Apache Maven 2002 und Gradle 2007. Dies sind nur die bekanntesten Tools zur Build Automatisierung, viele weitere sind bis heute entstanden und es werden wohl noch einige erfunden werden. Alle diese Tools haben ihre Vorzüge und Nachteile und je nach Projekt und Technologie bietet sich eine Tool besonders an.

Die Vorteile von Apache Maven sind Konvention vor Konfiguration (“Kennst Du einen, kennst Du alle”), die Auflösung von Abhängigkeiten, zentrale Repositories, eine Plug-in-Architektur und die Unterstützung vieler Systeme und Anwendungen über bestehende Plugins.

Häufig kommt ein Entwicklungsteam mit den Maven Standard Plugins aus. Hin und wieder gibt es aber den Moment, wo ein zusätzliches Plugin wünschenswert wäre.

Im Beitrag Trivial Pursuit – API MarkDown wurde ein kleines Programm zur Generierung einer API Dokumentation im Asciidoc Format vorgestellt. Angenehm wäre es, wenn dieses Programm nicht regelmäßig händisch aufgerufen werden müsste, sondern automatisch im Build Prozess.

Die folgende Plugin Definition in der eigenen Projekt pom.xml soll im Maven Zielverzeichnis eine openapi.adoc Datei zu erzeugen.

<plugin>
  <groupId>de.schegge</groupId>
  <artifactId>rest-markdown-maven-plugin</artifactId>
  <version>1.0-SNAPSHOT</version>
  <configuration>
    <input>${project.basedir}/src/main/resources/openapi.json</input>
  </configuration>
  <executions>
    <execution>
       <goals><goal>generate-markdown</goal></goals>
    </execution>
  </executions>
</plugin>

Damit ein entsprechendes Plugin zur Verfügung steht, muss ein passendes Maven Projekt erstellt werden.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>de.schegge</groupId>
  <artifactId>rest-markdown-maven-plugin</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>maven-plugin</packaging>

  <dependencies>
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-plugin-api</artifactId>
      <version>3.8.1</version>
    </dependency>
    <dependency>
      <groupId>org.apache.maven.plugin-tools</groupId>
      <artifactId>maven-plugin-annotations</artifactId>
      <version>3.6.1</version>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-project</artifactId>
      <version>2.2.1</version>
    </dependency>
  </dependencies>

 <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-plugin-plugin</artifactId>
        <version>3.6.0</version>
        <executions>
          <execution>
            <id>default-descriptor</id>
            <phase>process-classes</phase>
          </execution>
          <execution>
            <id>help-goal</id>
            <goals><goal>helpmojo</goal></goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

Zu beachten ist hier, dass die Artefakt ID des Projekt auf -maven-plugin endet und das die Zeile <packaging>maven-plugin</packaging> enthalten ist. Die Einhaltung der Namenskonvention erlaubt es gekürzte Namen auf der Kommandozeile zu verwenden und ohne die spezielle Paketierung wird das Plugin nicht als solches erkannt. Außerdem ist hier noch das maven-plugin-plugin Plugin (3x Plugin) aufgeführt, das für die Erstellung der notwendigen Plugin Strukturen verantwortlich ist.

Implementiert wird die Funktionalität eines Plugin in einer Java Klasse. Dabei entspricht eine Klasse üblicherweise einem Goal eines Plugins. Diese Java Klassen werden in Anlehnung an POJO (Plain Old Java Object) als MOJO bezeichnet.

/**
 * Generates a Asciidoc documentation from an OpenAPI JSON document.
 */
@Mojo(name = "generate-markdown", threadSafe = true, defaultPhase = LifecyclePhase.PREPARE_PACKAGE)
public class RestMarkdownMojo extends AbstractMojo {
   /**
   * The input JSON file.
   */
  @Parameter(defaultValue = "${project.build.directory}/openapi.json")
  private File input;

  /**
   * The output Asciidoc file.
   */
  @Parameter(defaultValue = "${project.build.directory}/openapi.adoc")
  private File output;

  public void execute() throws MojoExecutionException, MojoFailureException {
    Path inputPath = input.toPath();
    if (Files.notExists(inputPath)) {
       throw new MojoFailureException("Missing input: " + input);
    }

    Path outputPath = output.toPath();
    ApiDescription description = getApiDescription(inputPath, StandardCharsets.UTF_8);
    createMarkdown(description, outputPath, StandardCharsets.UTF_8);
  }
  // ...
}

Die hier dargestellte Java Klasse beinhaltet schon fast den gesamten Code der endgültigen Fassung des MOJO. Die execute() Methode erzeugt die Asciidoc Dokumentation, indem die ApiDescription aus dem input File ausgelesen wird und dann das Markdown in den output File geschrieben wird.

Die @Mojo Annotation an der Klasse erklärt diese Klasse nicht nur zum Mojo. Dank der Annotation sorgt das maven-plugin-plugin Plugin dafür, dass dieses Klasse verwendet wird bei dem Goal generate-markdown und ruft das Mojo standardmäßig in der prepare-package Phase auf. Diese Phase ist ganz praktisch für die Generierung der Dokumentation, denn die API ist schon kompiliert, die Tests wurden erfolgreich durchlaufen aber es wurde noch nichts zusammengepackt.

Die @Parameter Annotationen deklarieren die entsprechenden Membervariablen zu Parametern des MOJO. Die Parameter werden dann bei der Ausführung über die Plugin Konfiguration gesetzt. Über das Attribut defaultValue können die Parameter vorbelegt werden und dabei können Variablenersetzungen genutzt werden. In diesem Fall verweist "${project.build.directory}/openapi.json" auf die Datei /target/openapi.json. Im Sinne des Konvention vor Konfiguration gehen wir davon aus, dass die openapi.json Datei in einem vorhergehenden Schritt generiert wurde.

An dieser Stelle zeigt sich leider schon ein wenig Patina an der Maven Plugin API. Denn hier werden neue Standardklassen wie Path, LocalDate, Instant oder Optional nicht unterstützt.

Die execute() Methode wirft eine MojoFailureException, wenn die Eingabe Datei nicht gefunden wurde. Dies führt dazu, das eine entsprechende Fehlermeldung ausgegeben wird, aber der Build Prozess nicht unterbrochen wird. Soll der Build Prozess abgebrochen werden, dann muss eine MojoExecutionException geworfen werden. Dies geschieht beispielsweise in der Methode getApiDescription().

private ApiDescription getApiDescription(Path inputPath, Charset charset) throws MojoExecutionException {
  try {
    ObjectMapper objectMapper = new ObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
    return objectMapper.readValue(Files.newBufferedReader(inputPath, charset), ApiDescription.class);
  } catch (IOException e) {
    throw new MojoExecutionException("Cannot read API description: " + e.getMessage());
  }
}

Hier wird der Inhalt der Eingabedatei in eine Instanz der Klasse ApiDescription eingelesen. Tritt dabei eine IOException auf, dann wird der Build Prozess abgebrochen. Hier ist der Abbruch berechtigt, da der Inhalt der Datei nicht lesbar und vermutlich keine korrekte OpenApi Beschreibung ist.

Bei der Verwendung des neuen Plugins mit mvn clean package finden sich in der Maven Ausgabe folgende drei Zeilen.

[INFO] --- rest-markdown-maven-plugin:1.0-SNAPSHOT:generate-markdown (default) @ ancestor-service ---
[INFO] create markdown
[INFO] finished markdown

Das Plugin erledigt seine Aufgabe zwar schon, aber ein paar Kleinigkeiten sollen noch geändert werden. Zum einen soll die Ausgabe nur generiert werden, wenn die Eingabe aktueller als die Ausgabe ist. Ansonsten macht sich der Build Prozess unnötige Arbeit. Zusätzliche Parameter für das Encoding und für das Überspringen der Generierung sollen die Konfiguration abrunden.

/**
 * The character encoding.
 */
@Parameter(defaultValue = "${project.build.sourceEncoding}")
private String encoding;

/**
 * Skip the generation of the documentation.
 */
@Parameter
boolean skip;

Die beiden zusätzlichen Parameter werden als zusätzliche Membervariablen in die Klasse eingefügt. Der encoding Parameter wird mit dem Wert der ${project.build.sourceEncoding} vorbelegt, der in vielen Maven Projekten gesetzt ist.

private Charset getCharset() throws MojoExecutionException {
    try {
      return encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding);
    } catch (Exception e) {
      throw new MojoExecutionException(e.getMessage(), e);
    }
  }

Die Methode getCharset() liefert das gewünschte Charset. Wurde der encoding Parameter nicht gesetzt und liefert der defaultValue auch keinen Wert, dann wird auf StandardCharsets.UTF_8 zurückgegriffen.

private boolean isUptodate(Path inputPath, Path outputPath) throws MojoExecutionException {
  try {
    Instant lastModifiedInput = Files.getLastModifiedTime(inputPath).toInstant();
    Instant lastModifiedOutput = Files.getLastModifiedTime(outputPath).toInstant();
    return lastModifiedInput.isBefore(lastModifiedOutput);
  } catch (IOException e) {
    throw new MojoExecutionException(e.getMessage());
  }
}

Die isUptodate() Methode vergleicht die Ein- und Ausgabedateien anhand ihres LastModified Attributes.

Mit diesen Ergänzungen verändert sich die execute() Methode auf die folgende Variante.

public void execute() throws MojoExecutionException, MojoFailureException {
  if (skip) {
    getLog().info("Skip create markdown");
    return;
  }

  Path inputPath = input.toPath();
  if (Files.notExists(inputPath)) {
    throw new MojoFailureException("Missing input: " + input);
  }

  Path outputPath = output.toPath();
  if (Files.exists(outputPath) && isUptodate(inputPath, outputPath)) {
    getLog().info("Nothing to convert: " + input + " older than " + output);
    return;
  }

  Charset charset = getCharset();
  ApiDescription description = getApiDescription(inputPath, charset);
  createMarkdown(description, outputPath, charset);
}

Ist skip gesetzt, dann wird eine entsprechende Meldung ausgegeben und die Verarbeitung beendet. Existiert die Ausgabedatei schon und ist älter als die Eingabedatei, dann wird die Bearbeitung auch beendet.

In diesem Beitrag sind alle Parameter und die Klasse mit Javadocs versehen. Üblicherweise fehlen diese in den Beispielen, aber hier werden die Javadocs vom maven-plugin-plugin Plugin ausgelesen und für die Beschreibung verwendet.

Der Aufruf von mvn rest-markdown:help -Ddetail=true -Dgoal=generate-markdown liefert daher folgende Ausgabe.

rest-markdown:generate-markdown
  Generates a Asciidoc documentation from an OpenAPI JSON document.

  Available parameters:

    encoding (Default: ${project.build.sourceEncoding})
      The character encoding.

    input (Default: ${project.build.directory}/openapi.json)
      The input JSON file.

    output (Default: ${project.build.directory}/openapi.adoc)
      The output Asciidoc file.

    skip
      Skip the generation of the documentation.

Maven Plugins können aber auch Informationen zum aktuellen Build Prozess erhalten. Dafür soll das Plugin die Entwickler des Projektes als Autoren in die Asciidoc Beschreibung einfügen.

Die Entwickler eines Maven Projektes werden im developers Abschnitt der pom.xml aufgeführt.

<developers>
  <developer>
    <name>Jens Kaiser</name>
    <email>jens.kaiser@schegge.de</email>
    <roles><role>developer</role></roles>
  </developer>
</developers>

Auf diese Information kann ein MOJO über einen MavenProject Parameter zugreifen. Dieser wird, wie die anderen Parameter als Membervariable übergeben.

@Parameter(defaultValue = "${project}", required = true, readonly = true)
private MavenProject project;

In diesem Fall besitzt die Annotation die zusätzlichen Attribute required und readonly. Damit muss der Parameter verfügbar sein und zusätzlich darf er in der pom.xml nicht überschrieben werden.

In der execute() Methode können nun beliebige Informationen zum Maven Projekt abgefragt werden, u.a. auch die Entwickler.

String authors = project.getModel().getDevelopers().stream().map(Contributor::getName).collect(joining("; "));
getLog().info("Authors: " + authors);

Eigene Maven Plugins zu schreiben ist kein Hexenwerk und hilft im eigenen Projektbetrieb den Build Prozess optimal anzupassen. Leider ist die Maven Plugin API an manchen Stellen nicht mehr auf der Höhe der Zeit und sollte um aktuelle Datentypen und Frameworks ergänzt werden.

Leave a Comment