Tips de uso de maven para aplicaciones jee

2021/11/10

A continuación varios tips relativos al uso de maven para aplicaciones jakarta ee:

Creación de archivo pom.xml

Una de las formas usuales de crear un archivo pom es mediante arquetipo de maven. En mi experiencia el uso de arquetipos no es aconsejable, ya que o están desactualizados o es muy difícil de encontrar uno que cumpla con todos los criterios deseados para un proyecto. Entre instanciar un arquetipo y tener que reescribir el archivo pom y las clases del proyecto, o escribir un pom de cero prefiero la última opción.

A continuación se describirá un archivo pom de ejemplo que cuenta con las siguientes características:

  1. Se genera un artefacto war
  2. Se utilizan las especificaciones jakarta ee 8 (jee8)
  3. Se utilizan las especificaciones microprofile 4.1 (mp4.1)
  4. Ya que utilizará wildfly como servidor de aplicaciones se busca que las versiones de las librerías provistas estén disponibles, de forma tal de no tener que especificar la versión. Esto aplica tanto a la especificación jee8 como a la especificación mp4.1
  5. Se utiliza el plugin fmt-maven-plugin para formatear el código utilizando la guía de estilo para java de google al generar el build.
  6. Se utiliza el plugin reproducible-build-maven-plugin para que el war generado sea reproducible.
  7. Se utiliza el plugin maven-enforcer-plugin para asegurarse que se están usando las versiones de java y maven esperadas.

A continuación el archivo pom.xml de ejemplo (demo):

<?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>uy.jumapico.demo</groupId>
    <artifactId>demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging> ❶
    <name>Demo</name>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <failOnMissingWebXml>false</failOnMissingWebXml>
        <!-- versions of dependencies -->
        <version.wildfly-bom>25.0.0.Final</version.wildfly-bom>
        <!-- versions of plugins -->
        <version.reproducible-build-maven-plugin>0.14</version.reproducible-build-maven-plugin>
        <version.fmt-maven-plugin>2.12</version.fmt-maven-plugin>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency> ❹
                <groupId>org.wildfly.bom</groupId>
                <artifactId>wildfly-jakartaee8-with-tools</artifactId>
                <version>${version.wildfly-bom}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency> ❹
                <groupId>org.wildfly.bom</groupId>
                <artifactId>wildfly-microprofile</artifactId>
                <version>${version.wildfly-bom}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <dependencies>
        <dependency> ❷
            <groupId>jakarta.platform</groupId>
            <artifactId>jakarta.jakartaee-api</artifactId>
            <version>8.0.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency> ❸
            <groupId>org.eclipse.microprofile</groupId>
            <artifactId>microprofile</artifactId>
            <version>4.1</version>
            <type>pom</type>
            <scope>provided</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <!-- enforce jdk and maven versions -->
            <plugin> ❼
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-enforcer-plugin</artifactId>
                <version>${version.maven-enforcer-plugin}</version>
                <configuration>
                    <rules>
                        <requireMavenVersion>
                            <version>[3.8.3]</version>
                        </requireMavenVersion>
                        <requireJavaVersion>
                            <version>[11,12)</version>
                        </requireJavaVersion>
                    </rules>
                </configuration>
                <executions>
                    <execution>
                        <id>enforce-versions</id>
                        <goals>
                            <goal>enforce</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <!-- format code -->
            <plugin> ❺
                <groupId>com.coveo</groupId>
                <artifactId>fmt-maven-plugin</artifactId>
                <version>${version.fmt-maven-plugin}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>format</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <!-- make build reproducible -->
            <plugin> ❻
                <groupId>io.github.zlika</groupId>
                <artifactId>reproducible-build-maven-plugin</artifactId>
                <version>${version.reproducible-build-maven-plugin}</version>
                <executions>
                    <execution>
                        <id>strip-jar</id>
                        <phase>package</phase>
                        <goals>
                            <goal>strip-jar</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

Nota: en el archivo anterior no se están configurando los tests.

Instalación de maven-wrapper

maven-wrapper permite descargar y ejecutar una versión específica de maven para un proyecto dado. Su ventaja radica en poder fijar la versión de maven a utilizar, lo que implicitamente afecta las versiones de los plugins utilizados por defecto.

A continuación los pasos a seguir para instalar maven-wrapper. Se busca instalar en un proyecto existente (ya hay un archivo pom.xml) solo el script mvnw que descarga la versión de maven 3.9.6:

$ mvn wrapper:wrapper -Dtype=only-script -Dmaven=3.9.6
$ rm mvnw.cmd  # -- opcionalmente se borra el script de PowerShell

Prueba de descarga de dependencias

Para probarse que se descargan correctamente las dependencias necesarias del proyecto se puede crear un archivo similar a settings-local-repo.xml que defina un directorio alternativo para el cache local y utilizarlo durante la invocación de maven:

<settings>
    <localRepository>${HOME}/.m2-alter</localRepository>
</settings>

Para utilizar la preferencia basta hacer:

$ mvn -s settings-local-repo.xml clean package

Creación de imagen de contenedores

La forma más sencilla para crear un contenedor con la aplicación es construir el war localmente y luego agregarlo al servidor de aplicaciones. Ya que se está utilizando wildfly, utilizamos la documentación en la página de la imagen de wildfly en dockerhub.

A continuación el Dockerfile (Dockerfile.simple):

FROM docker.io/jboss/wildfly:25.0.0.Final
COPY --chown=jboss: ./target/*.war /opt/jboss/wildfly/standalone/deployments/

Y los pasos para construir la imagen:

$ ./mvnw clean package
$ podman build -f Dockerfile.simple -t localhost/demo:latest .

Creación de imagen usando un multi-stage build

El caso anterior tiene “detalles”, o directamente problemas:

Los problemas anteriores pueden subsanarse utilizando un Dockerfile que utilice multi-stage builds.

Además, se puede mejorar enormemente la velocidad del build teniendo en cuenta la opción –layers de podman build (habilitada por defecto), la cual utiliza un caché de las imagenes intermedias durante el build. Para ello debe separarse la invocación ./mvnw package en dos etapas: ./mvnw dependency:go-offline que descarga todas las dependencias del proyecto y ./mvnw package que genera el war a partir del código.

A continuación el Dockerfile (Dockerfile.multi):

FROM docker.io/library/openjdk:11.0.12-jdk-slim-bullseye AS build
# -- setup build user --
# A user is created to launch the build process.
# The user UID is arbitrary, not commonly used by regular users.
# The home of this user is used to build the app; we need to fix permissions.
RUN useradd -u 7685 -r -g users -m -s /sbin/nologin -c "Builder user" builder
WORKDIR /home/builder/app
RUN chown -R builder:users /home/builder/app && chmod -R 755 /home/builder/app
USER builder

# -- build artifact --
# Use the fact that with cached layers we can speed up the build downloading
# first the maven dependencies: copy maven-wrapper and pom.xml
# to download the dependencies in one layer.
COPY --chown=builder:users ./.mvn ./.mvn
COPY --chown=builder:users ./pom.xml ./mvnw .
RUN ./mvnw -B dependency:go-offline
# Copy the code to build the package (the dependencies are already downloaded).
COPY --chown=builder:users ./src ./src
RUN ./mvnw -B -o package

# -- build final image --
FROM docker.io/jboss/wildfly:25.0.0.Final
COPY --from=build --chown=jboss: /home/builder/app/target/*.war /opt/jboss/wildfly/standalone/deployments/

También se agrega un archivo .dockerignore, ya que se van a estar copiando directorios (.mvn):

.mvn/wrapper/maven-wrapper.jar

Para que lo anterior funcione deben realizarse dos modificaciones al archivo pom.xml.

El primer cambio requerido es actualizar la versión del maven-dependency-plugin utilizado para descargar las dependencias, ya que la versión por defecto que es obtenida del superpom base no es la última y tiene bugs que impiden una correcta descarga de las dependencias. Como ejemplo, el superpom base de maven 3.8.3 utiliza la versión 2.8 para el plugin cuando la versión actual es la 3.2.0.

A continuación la diferencia del pom.xml anterior y el primer cambio:

--- demo/pom.xml
+++ demo-offline/pom-v1.xml
@@ -16,6 +16,7 @@
         <!-- versions of dependencies -->
         <version.wildfly-bom>25.0.0.Final</version.wildfly-bom>
         <!-- versions of plugins -->
+        <version.maven-enforcer-plugin>3.0.0</version.maven-enforcer-plugin>
         <version.reproducible-build-maven-plugin>0.14</version.reproducible-build-maven-plugin>
         <version.fmt-maven-plugin>2.12</version.fmt-maven-plugin>
     </properties>
@@ -53,6 +54,15 @@
         </dependency>
     </dependencies>
     <build>
+        <pluginManagement>
+            <plugins>
+                <!-- Update version due issues with `dependency:go-offline` -->
+                <plugin>
+                    <artifactId>maven-dependency-plugin</artifactId>
+                    <version>3.2.0</version>
+                </plugin>
+            </plugins>
+        </pluginManagement>
         <plugins>
             <!-- enforce jdk and maven versions -->
             <plugin>

El segundo cambio es un hack, ya que si se intenta construir la imagen actualizando solamente la versión de maven-dependency-plugin (utilizando un Dockerfile que utilice el pom-v1) se obtiene el siguiente error:

$ mkdir src
$ podman build --layers=false -f Dockerfile.multi-v1 -t localhost/demo:latest .
...
[1/2] STEP 10/10: RUN ./mvnw -B -o -f pom-v1.xml package
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------------< uy.jumapico.demo:demo >------------------------
[INFO] Building Demo 1.0-SNAPSHOT
[INFO] --------------------------------[ war ]---------------------------------
[WARNING] The POM for jakarta.inject:jakarta.inject-api:jar:1.0.3 is missing, no dependency information available
[WARNING] The POM for org.eclipse.microprofile.jwt:microprofile-jwt-auth-api:jar:1.2.1 is missing, no dependency information available
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  0.559 s
[INFO] Finished at: 2021-11-11T03:32:50Z
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal on project demo: Could not resolve dependencies for project uy.jumapico.demo:demo:war:1.0-SNAPSHOT: The following artifacts could not be resolved: jakarta.inject:jakarta.inject-api:jar:1.0.3, org.eclipse.microprofile.jwt:microprofile-jwt-auth-api:jar:1.2.1: Cannot access central (https://repo.maven.apache.org/maven2) in offline mode and the artifact jakarta.inject:jakarta.inject-api:jar:1.0.3 has not been downloaded from it before. -> [Help 1]
[ERROR]
...

Nota se crea el directorio src para evitar que el build falle con el mensaje Error: error building at STEP "COPY --chown=builder:users ./src ./src": checking on sources under ".../demo-offline": copier: stat: "/src": no such file or directory.

Para solucionar esto se agregan las dependencias faltantes, o no resueltas correctamente por el plugin maven-dependency-plugin, con scope de test. De esta forma no son agregados como dependencias del war y tampoco se resuelven dependencias transitivas.

A continuación nuevamente la diferencia y el archivo pom.xml completo (esta vez el definitivo):

--- demo-offline/pom-v1.xml
+++ demo-offline/pom.xml
@@ -52,6 +52,25 @@
             <type>pom</type>
             <scope>provided</scope>
         </dependency>
+
+        <!-- HACK: Errors after `dependency:go-offline` using `-o` -->
+        <!--
+            [WARNING] The POM for jakarta.inject:jakarta.inject-api:jar:1.0.3 is missing, no dependency information available
+            [WARNING] The POM for org.eclipse.microprofile.jwt:microprofile-jwt-auth-api:jar:1.2.1 is missing, no dependency information available
+            ...
+            [ERROR] Failed to execute goal on project demo: Could not resolve dependencies for project uy.jumapico.demo:demo:war:1.0-SNAPSHOT: The following artifacts could not be resolved: jakarta.inject:jakarta.inject-api:jar:1.0.3, org.eclipse.microprofile.jwt:microprofile-jwt-auth-api:jar:1.2.1: Cannot access central (https://repo.maven.apache.org/maven2) in offline mode and the artifact jakarta.inject:jakarta.inject-api:jar:1.0.3 has not been downloaded from it before. -> [Help 1]
+        -->
+        <dependency>
+            <groupId>jakarta.inject</groupId>
+            <artifactId>jakarta.inject-api</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.microprofile.jwt</groupId>
+            <artifactId>microprofile-jwt-auth-api</artifactId>
+            <scope>test</scope>
+        </dependency>
+
     </dependencies>
     <build>
         <pluginManagement>

Por ultimo, para construir la imagen se utiliza:

$ podman build -f Docker.multi -t localhost/demo:latest .

Uso de mirror durante la creación de imagen con multi-stage build

Una opción recomentable al momento de utilizar un pipeline de CI/CD es descargar las dependencias de maven de un mirror local. Esto tiene dos razones:

  1. Se acelera la velocidad de descarga de las dependencias
  2. Nos aseguramos que el build no falla debido a fallas en las conexiones a los repositorios externos.

Nota: aquí se utilizará el mirror configurado siguiendo las instrucciones del post Configurar mirror local de maven.

En la página Using Mirrors for Repositories se indica la forma que debe tener el archivo de settings que indique como definir el mirror de un repositorio.

A continuación el contenido del archivo settings.xml:

<?xml version="1.0"?>
<settings>
    <mirrors>
        <mirror>
            <id>mirror-central</id>
            <name>Local mirror repository for central</name>
            <url>http://mvn-mirror.example.com:8081/repository/maven-central/</url>
            <mirrorOf>central</mirrorOf>
        </mirror>
    </mirrors>
</settings>

Este archivo lo utilizaremos en el Dockerfile al invocar maven wrapper con la opción -s settings.xml.

A continuación la diferencia entre el Dockerfile anterior y el que utiliza el mirror local:

--- demo-offline/Dockerfile.multi	2021-11-10 20:51:04.315527817 -0300
+++ demo-mirror/Dockerfile.multi	2021-11-10 21:03:05.339638205 -0300
@@ -10,14 +10,14 @@

 # -- build artifact --
 # Use the fact that with cached layers we can speed up the build downloading
-# first the maven dependencies: copy maven-wrapper and pom.xml
+# first the maven dependencies: copy maven-wrapper, pom.xml and settings.xml
 # to download the dependencies in one layer.
 COPY --chown=builder:users ./.mvn ./.mvn
-COPY --chown=builder:users ./pom.xml ./mvnw .
-RUN ./mvnw -B dependency:go-offline
+COPY --chown=builder:users ./mvnw ./pom.xml ./settings.xml .
+RUN ./mvnw -B -s settings.xml dependency:go-offline
 # Copy the code to build the package (the dependencies are already downloaded).
 COPY --chown=builder:users ./src ./src
-RUN ./mvnw -B -o package
+RUN ./mvnw -B -o -s settings.xml package

 # -- build final image --
 FROM docker.io/jboss/wildfly:25.0.0.Final

También debe modificarse el archivo maven-wrapper.properties para que se descargue maven del mirror:

$ sed -i 's#https://repo.maven.apache.org/maven2#http://mvn-mirror.example.com:8081/repository/maven-central#' \
    .mvn/wrapper/maven-wrapper.properties

Una vez realizados los cambios anteriores conviene verificar que efectivamente se esten descargando los artefactos del mirror. Para ello lo mejor es construir la imagen del contenedor y revisar el log, ya que si utilizamos maven directamente en el sistema este utiliza los artefactos que se encuentren en el caché local ($HOME/.m2):

$ podman build -f Dockerfile.multi -t localhost/demo:latest . |& tee build.out
...
Successfully tagged localhost/demo:latest
...

Una vez creada la imagen exitosamente se obtienen los mirrors utilizados:

$ grep -oP 'Download[^ ]* from \K[^:]*' build.out  | sort -u
mirror-central

Otra forma de corroborar lo anterior es filtrar por la url del mirror:

$ grep 'Download[^ ]*' build.out | grep -v 'http://mvn-mirror.example.com:8081/'
- Downloader started
- Downloading to: /home/builder/app/.mvn/wrapper/maven-wrapper.jar