18 décembre 2010

Maven et les Jars exécutables

Un problème que l'on rencontre souvent avec des projets maven c'est d'essayer de générer en tant que livrable (package) un bundle d'un jar "exécutable" en faisant java -jar *.jar

Avec une application toute simple on tombe déjà sur de nombreux problèmes, prenons l'exemple d'une application qui fait un "hello world" en prenant soin d'utiliser au passage une librairie tierce (Google Guava dans notre exemple).



On a la classe suivante qui sert de point d'entrée de l'application :

package org.codingmojo;

import static com.google.common.base.Preconditions.checkNotNull;

/**
 * Hello world!
 */
public class App
{
   public static void main( String[] args )
   {
      if(args.length !=0) { checkNotNull(args[0]); }
      System.out.println( "Hello World!" );
   }
}

Notre projet, qui respecte les standards maven d'organisation du code, contient le pom.xml suivant :


   4.0.0

   <groupId>org. codingmojo</groupId>
   <artifactId>batch</artifactId>
   <version>1.0</version>
   <packaging>jar</packaging>
   <name>batch</name>

   <build>
      <plugins>
         <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
               <source>1.5</source>
               <target>1.5</target>
            </configuration>
         </plugin>
      </plugins>
   </build>

   <dependencies>
      <dependency>
         <groupId>com.google.guava</groupId>
         <artifactId>guava</artifactId>
         <version>r07</version>
      </dependency>
   </dependencies>
</project>

Pour créer notre jar exécutable via maven, la documentation du plugin jar fourni une partie de la solution à notre problème. En effet, il est possible de générer un jar qui pointe vers les dépendances déclarées dans le pom.xml du projet.

<plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
         <archive>
            <manifest>
               <addClasspath>true</addClasspath>
               <classpathPrefix>lib/</classpathPrefix>
               <mainClass>org.codingmojo.App</mainClass>
            </manifest>
         </archive>
      </configuration>
   </plugin>

Après avoir ajouté cette description du plugin jar au projet, si on lance un "mvn install" on obtient un jar dont le fichier manifest référence des librairies qui ne se trouvent pas dans $CLASSPATH/lib/*
Si on lance l'application à cette étape, on obtient donc à cette étape un joli :

> java -jar target/batch-1.0.jar test
> Exception in thread "main" java.lang.NoClassDefFoundError: com/google/common/base/Preconditions
at org.codingmojo.App.main(App.java:14)
Caused by: java.lang.ClassNotFoundException: com.google.common.base.Preconditions
at java.net.URLClassLoader$1.run(URLClassLoader.java:200)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:188)
at java.lang.ClassLoader.loadClass(ClassLoader.java:315)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:330)
at java.lang.ClassLoader.loadClass(ClassLoader.java:250)
at java.lang.ClassLoader.loadClassInternal(ClassLoader.java:398)
... 1 more

On a donc pour l'instant un jar qui sait qu'il a besoin de dépendances pour se lancer mais qui ne peut les trouver de manière simple (i.e. sans installation manuelle des dépendances au bon endroit).
Pour automatiser cette partie de la construction du livrable, une solution qui s'offre à nous passe par l'utilisation du plugin assembly de maven permettant à la fois de créer une archive zip (qui servira de livrable-with-dependencies) et d'injecter dans cette archive notre jar ainsi que ses dépendances bien placées.

Pour cela, plusieurs configurations sont nécessaires sur le projet, on ajoute une configuration au plugin assembly puis on décrit la manière dont il va construire le livrable (agréger les libs + le jar dans une nouvelle archive)

La configuration à ajouter au pom est assez succinte :


      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-assembly-plugin</artifactId>
        <configuration>
          <descriptors>
            <descriptor>src/main/assembly/assembly.xml</descriptor>
          </descriptors>
        </configuration>
        <executions>
          <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
              <goal>single</goal>
            </goals>
          </execution>
        </executions>
      </plugin>

Il faut ensuite créer le descripteur assembly.xml référencé par la configuration du plugin dans src/main/assembly :

 <assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
  <id>executable</id>
  <formats>
    <format>zip</format>
  </formats>
  <dependencySets>
    <dependencySet>
      <outputDirectory>/lib</outputDirectory>
      <excludes><exclude>${project.groupId}:${project.artifactId}:*</exclude></excludes>
      <unpack>false</unpack>
    </dependencySet>
  </dependencySets>
  <fileSets>
    <fileSet>
      <directory>${project.build.directory}</directory>
      <outputDirectory>/</outputDirectory>
      <includes>
        <include>*.jar</include>
      </includes>
    </fileSet>
  </fileSets>
</assembly>

Ce fichier explique au plugin qu'il faudra déplacer toutes les dépendances du projet dans un dossier lib (à l'exclusion du jar du module lui-même) puis déplacer le jar du module à la racine de l'archive. Le dossier de travail (par défaut "artifactId-version")

Une fois cette configuration ajoutée, il suffit de faire un mvn install puis de dezipper l'archive que l'on trouve dans le repertoire de build comme ceci, on peut ensuite lancer le jar sans opération manuelle :


> mvn clean install
[INFO] Scanning for projects...
[INFO] ------------------------------------------------------------------------
[INFO] Building batch
[INFO]    task-segment: [clean, install]
[INFO] ------------------------------------------------------------------------
[INFO] [clean:clean {execution: default-clean}]
[INFO] ...
[INFO] ...
[INFO] ...
[INFO] Installing /Users/XXX/Maven/batch/target/batch-1.0.jar to /Users/XXX/.m2/repository/org/codingmojo/batch/1.0/batch-1.0.jar
[INFO] Installing /Users/XXX/Maven/batch/target/batch-1.0-executable.zip to /Users/XXX/.m2/repository/org/codingmojo/batch/1.0/batch-1.0-executable.zip
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 5 seconds
[INFO] Finished at: Sat Dec 18 11:29:41 CET 2010
[INFO] Final Memory: 29M/79M
[INFO] ------------------------------------------------------------------------

> unzip target/batch-1.0-executable.zip 
Archive:  target/batch-1.0-executable.zip
   creating: batch-1.0/
  inflating: batch-1.0/batch-1.0.jar  
   creating: batch-1.0/lib/
  inflating: batch-1.0/lib/guava-r07.jar  

> java -jar batch-1.0/batch-1.0.jar test
Hello World!

4 commentaires:

  1. ...ou alors on peut utiliser One-Jar qui "bundle" les dépendances à l'intérieur même du JAR (en utilisant un classloader spécifique au runtime):
    http://code.google.com/p/onejar-maven-plugin/

    ou bien le maven-shade-plugin qui bundle les *classes* (et non les JARs):
    http://maven.apache.org/plugins/maven-shade-plugin/index.html

    RépondreSupprimer
  2. l'approche de onejar avec classloader spécifique au runtime me paraît bien sympathique :-) Je me le garde sous le coude pour les jours de refacto.

    Par contre l'approche "je mets toutes mes classes dans un seul et unique jar" du shade plugin me paraît trop restreinte et assez contraignante en terme de couplage code/libs dans le livrable

    Je prendrais comme exemple le Hamcrest embarqué dans l'une des dernières livraisons JUnit qui oblige ceux qui ne veulent pas de cette version à utiliser un junit-no-dep n'embarquant pas les classes Hamcrest - source "les castcodeurs" d'avril 2011.

    RépondreSupprimer
  3. J'utilisais l'approche qu'on retrouve généralement (tout dépacker dans un rep et tout repacker dans un jar) mais j'trouve ton approche bcp plus propre :)
    Je me garde aussi le onjar de coté qui à l'aire bien sympathique même si pas super documenté sur la config.

    Super intéressant ton site Ludo, j'me le rajoute dans mon netvibes ;)

    RépondreSupprimer
  4. Bonjour,

    Pour un projet "simple", le plugin assembly offre aussi une solution tout-en-un grâce à un "descriptorRef" inclus en natif par le plugin : http://maven.apache.org/plugins/maven-assembly-plugin/descriptor-refs.html#jar-with-dependencies.

    RépondreSupprimer