Maven en Java 9 – De mythe dat Maven niet werkt onder Java 9 ontkracht

Ondanks dat de release van Java 9 is uitgesteld, kunnen we wel met redelijke zekerheid vaststellen wat we kunnen verwachten. Een deel daarvan zal ook van toepassing zijn op Maven. Vandaar een uitgebreid artikel over een aantal van deze nieuwe features en hoe Java 9 zal gaan werken in combinatie met Maven.

 Robert Scholte

 

Compile for Older Platform Versions

Stel je een applicatie voor waarbij ieder argument naar de console wordt weggeschreven:

 

public static void main( String… args ) {

  Arrays.asList( args ).stream().forEach( System.out::println );

}

 

Hier wordt gebruik gemaakt van een method reference, een feature die is geïntroduceerd in Java 8. Om deze reden zal voor de maven-compiler-plugin de source/target aangepast moeten worden; voor allebei is namelijk de default waarde nog altijd 1.5. Dit kan door het configureren van de maven-compiler-plugin of door gebruik te maken van de bijbehorende properties:

 

<properties>

  <maven.java.source>1.8</maven.java.source>

  <maven.java.target>1.8</maven.java.target>

</properties>

 

Met de jar verkregen via een “mvn package” kunnen we controleren of het werkt:

 

> “%JAVA_8%\bin\java” -cp target\jep247-0.0.1-SNAPSHOT.jar nl.sourcegrounds.Main 1 2 3

1

2

3

 

We kunnen ook vereisen dat het onder Java 7 moet kunnen draaien. Daarvoor veranderen we de waarden van listing 2 in “1.7”. Als je dit in Eclipse doet (na een Project update), krijg je te zien dat de syntax niet meer geaccepteerd wordt. Eclipse komt met een optie om dit te fixen. Wijzig hiervoor de project compliance en JRE naar 1.8. Helaas is dat geen optie.

Als we de code een beetje herschrijven, gebeurt er iets interessants:

 

public static void main( String… args ) {

  Arrays.asList( args ).stream().forEach( x -> System.out.println( x ) );

}

 

In dit geval komt Eclipse met een tweede optie: converteer naar een anonymous class. Het resultaat is in dat geval:

 

public static void main( String… args ) {

  Arrays.asList( args ).stream().forEach( new Consumer<String>() {

    public void accept( String x ) {

      System.out.println( x );

    }

  } );

}

 

Wederom maken we een jar en voeren de controle uit of het werkt:

 

> “%JAVA_7%\bin\java” -cp target\jep247-0.0.1-SNAPSHOT.jar nl.sourcegrounds.Main 1 2 3

Exception in thread “main” java.lang.NoClassDefFoundError: java/util/function/Consumer

 

Een foutmelding? Dit blijkt inderdaad te kloppen, want de Consumer-class werd geïntroduceerd in Java 8. Als we dit met JDK 7 hadden gecompileerd, hadden we meteen de volgende foutmelding gekregen:

 

[ERROR]  src/main/java/nl/sourcegrounds/Main.java:[10,30] cannot find symbol

  symbol:   method stream()

  location: interface java.util.List<java.lang.String>

 

Het moge duidelijk zijn dat de waarden van source en target enigszins misleidend zijn, evenals de fix die Eclipse biedt. De Java 8 syntax wordt wel herkend, maar tijdens het compileren worden de classes en methods van de actieve JDK, vaak bepaalt door JAVA_HOME, gebruikt. De JDK houdt geen rekening met wanneer classes en methods toegevoegd zijn.

Met Java 9 wordt de compiler uitgebreid met de –release parameter (let op de dubbele dash), waarbij –release N hetzelfde effect heeft als source N -target N -bootclasspath <bootclasspath-from-N>. Op deze manier wordt wel gegarandeerd dat de code met versie N compileert en draait. Deze optie kan op 2 manieren geconfigureerd worden in de maven-compiler-plugin: via de <release>-parameter of via de maven.java.release property.

Maven heeft dit probleem al jaren onderkend en heeft daar een combinatie van oplossingen voor:

  • Toolchains: met toolchains kun je refereren naar meerdere JDKs op je system. Met behulp van een versie-string kun je aangeven met welke JDK je de code wilt compileren. Hierdoor is de JRE van Maven gescheiden van de JDK voor het compileren.
  • Animal-sniffer: dit project bevat een grote set aan artifacts met JDK-signatures. Door de animal-sniffer-maven-plugin te combineren met een specifieke signature wordt gecontroleerd of de sourcecode alleen gebruik maakt van classes, methods, etc. die voldoen aan de desbetreffende JDK.
  • enforceBytecodeVersion enforcer-rule: deze rule is gerelateerd aan het class-version probleem. Deze rule controleert of alle dependencies compatible zijn met een specifieke java-versie door per class de ‘major version number of the class file format’ te controleren.

Java 9 support niet alle versies van Java. De release-parameter kan alleen de waardes 6, 7, 8 en 9 hebben. Mocht je tot de onfortuinlijke groep developers behoren die nóg oudere programmatuur moet onderhouden, dan zal je gebruik moeten maken van toolchains en/of animal-sniffer om de compatibiliteit te kunnen (blijven) garanderen.

Als je de maven-compiler-plugin met een release-parameter configureert, dan wordt hiermee de waarden voor source en target overschreven, ongeacht de waarde. De plugin controleert niet met welke JDK gecompileerd wordt. Met andere woorden, als je release=6 gebruikt én gebruik maakt van bijvoorbeeld JDK8, dan wordt dit niet vertaald naar source/target=1.6. De reden is dat je bij gebruik van de release-parameter de aanname moet kunnen doen dat het inderdaad ook draait onder Java 6, en dat bereik je niet met source/target combinatie. De release-parameter wordt meegegeven als argument voor de compiler en als die niet ondersteund wordt, dan faalt de build.

Onderschat deze feature niet. In elke nieuwe major JDK versie en zelfs in uitzonderlijke gevallen bij minor JDK versies zitten nieuwe classes en methodes. Het gebruik hiervan kan onopgemerkt je code incompatible maken voor een specifieke versie.

 

Java Platform Module System

Dit onderdeel, beter bekend als project Jigsaw, is zonder twijfel dé belangrijkste nieuwe feature van Java 9. Er wordt ondertussen al gewerkt aan verschillende boeken om dit tot in detail uit te leggen.  In dit artikel licht ik een klein onderdeel uit wat een belangrijk discussiepunt is geweest tijdens de laatste publieke reviewperiode en wat mede voor de laatste vertraging heeft gezorgd.

Allereerst een korte introductie over de module descriptor, een nieuw source-bestand dat bijdraagt aan deze feature. Met Java 9 kan je een module-info.java aan de root van een sourcefolder toevoegen welke metadata bevat over deze jar, waaronder het benoemen van álle afhankelijkheden die nodig zijn te compileren en/of te draaien. Als blijkt dat een afhankelijkheid mist, dan wordt niet eens een poging gedaan om te compileren of te runnen.

Op de manier zoals het hier beschreven staat, kan je alleen maar een module descriptor aan je project toevoegen als al jouw afhankelijkheden ook een module descriptor hebben (met andere woorden bottom-up). Dit is weliswaar heel zuiver, maar volgens het Jigsaw-team zou het te lang duren voordat men gebruik kan maken van deze features. Vandaar dat het concept van automodules is bedacht. Een automodule verkrijgt zijn naam op basis van de naam van de jar. Dit heeft het Maven-team vele kopzorgen bezorgd. De belangrijkste kritiekpunten:

  • Een automodule is minder uniek dan de groupId+artifactId van Maven;
  • Als de modulename in de descriptor af zal wijken van de automodule name, dan kun je het project niet meer compileren.

Om het probleem wat inzichtelijker te maken, is hier een voorbeeld van een standaard proces, waarbij achtereenvolgens een aantal libraries gereleased worden. Deze hebben in de meeste gevallen ook een module descriptor gekregen:

 

Stage 1: a-1.0.jar

Geen module descriptor

Stage 2: b-1.0.jar

 module org.baz.b {

   requires a; // filename based automodule for a-1.0.jar

 }

Stage 3: a-2.0.jar

  module com.foo.a {}

Stage 4: c-1.0.jar

 module org.baz.c  {

   requires com.foo.a // module name for a-2.0.jar

 }

Stage 5: d-1.0.jar

 module foo.d {

    requires org.baz.b;

    requires org.baz.c;

 }

 

Helaas compileert d-1.0 niet, omdat org.baz.b en org.baz.c dezelfde dependency gebruiken (a:jar), maar elk met een andere naam ernaartoe refereren.

Als Maven de dependency a-1.0.jar kiest, dan mist module org.baz.c de vereiste module com.foo.a;

Als Maven de dependency a-2.0.jar kiest, dan mist module org.baz.b de vereiste module a;

Het vervelende is dat project foo.d niks anders kan doen dan zijn eigen module-descriptor te verwijderen. Als we een schuldige aan moeten wijzen, dan is het org.baz.b. Dit is namelijk een library én het refereert naar een automodule. De compiler houdt dit niet tegen, omdat er valide redenen zijn om dit wel te doen. Vandaar dat de vuistregel is: in geval van een library voeg alleen een module descriptor toe als álle afhankelijkheden een definitieve module name hebben.

Vrij recentelijk is besloten om toch een attribuut beschikbaar te maken in de MANIFEST.MF waarmee deze situatie overbrugd kan worden. Dit is wat org.baz.b had moeten doen:

 

Stage 2: b-1.0.jar

META-INF/MANIFEST.MF [Automatic-Module-Name: org.baz.b]

 

Hiermee zorgt org.baz.b ervoor dat zijn afnemers, zoals foo.d, in ieder geval kunnen refereren naar deze library alsof het een volwaardige module is. Org.baz.b moet er wel rekening mee houden dat deze jar jigsaw-ready is.

Met de toevoeging van dit attribuut is het niet alleen bottom-up, maar ook mid-up, waardoor hopelijk module descriptors sneller hun intrede kunnen doen in het nieuwe Java ecosysteem.

 

Project folder layout

Als je de specs en de daarbij gebruikte voorbeeldcode leest, dan zal je het volgende tegen kunnen komen:

 

src/com.foo.bar/module-info.java

src/com.foo.bar/com/foo/bar/Main.java

src/com.foo.baz/module-info.java

src/com.foo.baz/com/foo/baz/BazGenerator.java

 

Dit doet vermoeden dat je voortaan altijd de module-name als extra folder moet toevoegen aan de source-folder. Dit is een onterechte aanname. In het geval van Maven heeft een module naast een unieke naam ook een unieke groupId+artifactId nodig, dus zal je nooit meerdere Java-modules binnen één Maven module/Maven project compileren. En omdat slechts één module gecompileerd hoeft te worden, bestaat geen noodzaak voor het toevoegen van de module als extra subfolder. Als het voorbeeld vertaald moet worden naar een Maven folder layout, zou het er als volgt uit zien:

 

pom.xml

bar/pom.xml

bar/src/main/java/module-info.java

bar/src/main/java/com/foo/bar/Main.java

baz/pom.xml

baz/src/main/java/module-info.java

baz/src/main/java/com/foo/baz/BazGenerator.java

 

Voor een eventuele migratie naar Java 9 is het dus niet noodzakelijk om je folderstructuur aan te passen, maar voeg je slechts de module-info.java toe.

 

Deprecated APIs and command line arguments

Het volgende codevoorbeeld is afkomstig uit de Javadoc van javax.xml.bind.Marshaller

 

JAXBContext jc = JAXBContext.newInstance( “com.acme.foo” );

Unmarshaller u = jc.createUnmarshaller();

Object element = u.unmarshal( new File( “foo.xml” ) );

Marshaller m = jc.createMarshaller();

 

Dit is een standaard manier om zowel een Marshaller als een Unmarshaller aan te maken. Echter, het blijkt met Java 9 minder standaard te zijn geworden als we dit gaan compileren met release = 9. In dat geval krijgen we namelijk de volgende foutmelding:

 

[ERROR] COMPILATION ERROR :

[INFO] ————————————————————-

[ERROR] ….java:[5,17] package javax.xml.bind is not visible

  (package javax.xml.bind is declared in module java.xml.bind, which is not in the module graph)

 

Noem het cynisch, maar in de bijbehorende Javadoc staat dat de java.xml.bind module geïntroduceerd is met Java 9 én gemarkeerd is als deprecated sinds Java 9. Deze ‘java extension’ maakte tót Java 9 onderdeel uit van de JRE, maar nu Java 9 modulair is opgezet, biedt het ruimte om deze te verwijderen. Het idee is dat developers in de toekomst gebruik zullen maken van de javax.xml.bind:jaxb-api dependency.

De classes zijn nog wel aanwezig in Java 9 en er bestaat een optie om deze module expliciet aan de compiler mee te geven. Daarvoor moet het volgende aan de compiler meegegeven worden: –add-modules java.xml.bind

De maven-compiler-plugin heeft geen aparte “addModules” parameter, maar in plaats daarvan kan je gebruik van de compilerArgs parameter. Let wel op dat het hier om twee losse argumenten gaat. Het volgende zal dus niet werken:

 

<plugin>

  <groupId>org.apache.maven.plugins</groupId>

  <artifactId>maven-compiler-plugin</artifactId>

  <version>3.6.1</version>

  <configuration>

    <release>9</release>

    <compilerArgs>

      <arg>–add-modules java.xml.bind</arg> <!– WRONG –>

    </compilerArgs>

  </configuration>

</plugin>

 

In dit geval zal de build falen met de volgende mededeling:

 

[INFO] ————————————————————-

[ERROR] COMPILATION ERROR :

[INFO] ————————————————————-

[ERROR] javac: invalid flag: –add-modules java.xml.bind

Usage: javac <options> <source files>

use –help for a list of possible options

 

De reden is dat in dit geval “–add-modules java.xml.bind” als één flag gezien wordt, terwijl het twee losse argumenten zouden moeten zijn. Om java.xml.bind als module toe te voegen, zal je dit als twee aparte argumenten moeten configureren, dus als:

 

<compilerArgs>

  <arg>–add-modules</arg><arg>java.xml.bind</arg>

</compilerArgs>

 

Op deze manier slaagt de build wel.

 

Conclusie

Er circuleren verschillende lijstjes met verkeerde aannames over Java 9. Met stip bovenaan deze lijst staat de bewering dat Maven niet werkt onder Java 9. Deze mythe kan meteen ontkracht worden. Maven 3.0 en alle daaropvolgende versies werken prima onder Java 9! Als er issues zijn, dan zijn deze altijd gerelateerd aan een plugin. Probeer in dat geval te upgraden naar de meest recente versie van de plugin en controleer of hiermee het probleem verholpen is.

De maven-compiler-plugin 3.6.x-reeks bevat een aantal Java 9 specifieke features en is opgeleverd zodat tal van projecten al op tijd konden experimenteren met Java 9. In de loop van de tijd zijn een aantal specificaties gewijzigd, waardoor bijvoorbeeld 3.6.0 niet meer zal werken. Zodra Java 9 officieel uitgebracht wordt, zal versie 3.7.0 komen. Deze zal voldoen aan de definitieve specificaties.

Op https://s.apache.org/maven-j9 wordt bijgehouden welke plugins issues hadden met Java 9 en in welke versie deze verholpen zijn. Ook staat hier een duidelijke lijst met third party dependencies die soms blokkerend zijn om een plugin Java 9 compatible te krijgen. Maven is al lang succesvol bezig met het ondersteunen van Java 9 features en is ondertussen klaar voor gebruik.