EEN LIBRARY MIGREREN NAAR Java Module System

IT-ROCKSTAR SVEN HASTER

PART 1

Net zoals vele anderen heb ik de overstap naar Java 9+ een beetje laten liggen, vooral vanwege de overgang naar het nieuwe Java Platform Module System. Ik onderhoud een kleine library en ik was bang door een foute migratie problemen te veroorzaken voor de afnemers van mijn library.

Onlangs heb ik de tijd genomen om er in te duiken en deze blog post is bedoeld om anderen te helpen met de vraag: Hoe kan ik mijn library zo simpel mogelijk migreren naar een Jigsaw/JPMS module?

Ik ga in dit voorbeeld uit van een archetypische Maven setup (met src/main en src/test folders, test te runnen tijdens een build maar niet meepackagen in je artifact).

Er zijn vier stappen in de migratie naar Jigsaw/JPMS:

1. Zorg dat je packagestructuur in orde is

2. Kies en configureer een naam voor je module

3. Stap over naar Java 9+

4. Maak van je library een volwaardige module

Deze eerste stap is meer een controle van voorwaarden. Bij een goed opgezette library zou deze stap geen problemen moeten geven maar het is een goed idee om hier wat aandacht aan te besteden en te corrigeren waar nodig.
De eerste twee stappen kunnen op Java 8 gedaan worden maar je hebt Java 9+ nodig om een module descriptor (module-info) te kunnen gebruiken en een volwaardige module te worden.

Zorg dat je packagestructuur in orde is

Er zijn een aantal algemeen aanvaarde best practices over hoe je classes en packages structureert en met de introductie van het Java Platform Module System zijn deze nog belangrijker geworden. Java staat geen modules toe die deze regels breken en dit kan een probleem worden voor iedereen die jouw module probeert te gebruiken in een JPMS-applicatie.

De belangrijkste van deze regels is om packages niet te verdelen over modules (split packages). Elke package mag in slechts één module voorkomen en er mag geen andere module in de wereld zijn die classes in deze zelfde package bevat.

Dit betekent dat je library dus geen classes mag toevoegen aan het package van een andere library en al helemaal niet aan een core package (dus geen annotaties meer toevoegen aan javax.annotation).

De makkelijkste manier om dit voor elkaar te krijgen is door een ‘super-package’ te creëren voor elke module.

Dus als je domein example.nl is, en je module heet ‘mario’, dan zorg je er simpelweg voor dat elke packagenaam in je module begint met nl.example.mario.
Als een andere module ‘luigi’ heet, dan moeten alle packagenamen in die module beginnen met nl.example.luigi.

Zo is er geen enkele kans dat je een package verdeelt over je eigen modules en (er van uitgaande dat het domein dat je gebruikt van jou is) er is ook geen kans dat je packages deelt met de module van iemand anders. Bovendien maakt dit het ook heel makkelijk om je eigen unieke modulenaam te kiezen: je kunt simpelweg deze zelfde ‘super-package’ gebruiken. Dus ‘mario’ heet nl.example.mario, luigi’ heet nl.example.luigi, etc.

Stephen Colebourne schreef een blog post over de naamgeving van modules die meer detail bevat dan ik hier heb kunnen meegeven. Het Guava issue over hun eigen module naamgeving bevat ook veel informatie. Let alleen wel op, de situatie van Guava is een beetje specialer dan die van de meeste kleinere libraries.

Kies een modulenaam and stel deze in

Een automatische modulenaam instellen is de eerste echte stap naar het voldoen aan de regels van JPMS. Als je module een officiële naam hebt, kunnen andere modules hun afhankelijkheid van jouw module aangeven door een dependency op jouw module aan te geven zonder bang te zijn dat de naam plotseling verandert.

Tegelijkertijd kunnen andere modules de API van jouw module zelf opnieuw implementeren door dezelfde modulenaam te gebruiken. Zolang de modulenaam niet verandert zijn hun artifacts dan uitwisselbaar met die van jou.

Nadat je een modulenaam hebt gekozen in het daadwerkelijk instellen hiervan een eitje. Je voegt simpelweg een Automatic-Module-Name entry toe aan de MANIFEST.MF van je jar. Als je maven gebruikt is dat eenvoudig te doen door de maven-jar-plugin als volgt in te stellen:

<build>

  <pluginManagement>

    <plugins>

      <plugin>

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

        <artifactId>maven-jar-plugin</artifactId>

        <version>3.1.2</version>

        <configuration>

          <archive>

            <manifestEntries>

              <Automatic-Module-Name>com.example.mario</Automatic-Module-Name>

            </manifestEntries>

          </archive>

        </configuration>

      </plugin>

    </plugins>

  </pluginManagement>

</build>

Dit kun je al doen terwijl je code nog op Java 8 gecompileerd wordt. Op deze manier is je library klaar om gebruikt te worden op JDK9+ zonder dat je de java versie van je sourcecode hoeft op te hogen.

Overstappen naar Java 9

Voordat je verder kunt gaan met de migratie naar JPMS en een module-info kunt aanmaken, moet je overstappen naar Java 9. Java 8 is namelijk niet compatibel met module descriptors. Dit is ook een goed moment om even te controleren of je geen afhankelijkheid hebt van jdk internals.

Simpelweg compileren op Java 9+ gaat je al helpen om de plekken te vinden waar je onverhoopt gebruik maakt van dergelijke jdk internals. Zorg ervoor dat je de nieuwe release-flag gebruikt, vooral als je compileert op een JDK met een andere major version. De compiler zorgt er dan voor dat de juiste set van JDK internal dependencies beschikbaar is voor je applicatie.

<build>

  <pluginManagement>

    <plugins>

      <plugin>

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

        <artifactId>maven-jar-plugin</artifactId>

        <configuration>

          <source>9</source>

          <target>9</target>

          <release>9</release>

          <encoding>UTF-8</encoding>

        </configuration>

      </plugin>

  </pluginManagement>

</build>

PART 2

Je kunt ook gebruiken maken van jdeps om je eigen project samen met al zijn afhankelijkheden te controleren. Dit doe je met behulp van de--jdk-internals. Nicolai Parlog beschreef hoe je dit doet in zijn blogpost over migreren naar Java 11. Hij schreef ook een maven plugin die jdeps gebruikt om de build te laten falen als het afhankelijkheden van JDK internals vindt.

Een volwaardige module worden

Zodra je library Java 9+ is ben je klaar met deze voorbereidingen. Als je library geen externe afhankelijkheden heeft kun je direct een module-info.java schrijven en gebruiken door deze op het hoogste niveau binnen de src/main/java folder te plaatsen.

module nl.example.mario

{

  exports nl.example.mario.api;

  exports nl.example.mario.api.pipe;

}

Deze module descriptor verklaart dat het artifact de module nl.example.mario bevat en alle publieke classes in de nl.example.mario.api en nl.example.mario.api.pipe packages aanbiedt als z’n publieke API.

Als jouw code zelf weer een afhankelijkheid heeft op classes van een andere module, bijvoorbeeld nl.example.princess.peach, moet je jouw dependency op de publieke API van deze module aangeven.

module nl.example.mario

{

  exports nl.example.mario.api;

  exports nl.example.mario.api.pipe;

 

  requires nl.example.princess.peach;

}

Als je classes van een andere module aanbiedt als onderdeel van jouw publieke API moet je een transitive dependency op die module aangeven. Stel bijvoorbeeld dat je de volgende class hebt:

package nl.example.mario.api.pipe;

 

import nl.example.princess.peach.jumps.FloatingJump;

 

public class Pipe

{

  //...

  public void tryFloat(FloatingJump jumpDescriptor)

  {

    //...

  }

}

Nu bevat jouw API een transitieve afhankelijkheid op nl.example.princess.peach.jumps.FloatingJump class. Als andere classes jouw module willen gebruiken moeten ze deze class tot hun beschikking hebben.

De makkelijkste manier om voor elkaar te krijgen is door een transitive dependency op deze module op te nemen. Op deze manier is de publieke API van de nl.example.princess.peach module beschikbaar voor gebruikers van jouw module, zonder dat ze zelf een dependency op deze module hoeven op te nemen.

module nl.example.mario

{

  exports nl.example.mario.api;

  exports nl.example.mario.api.pipe;

 

  requires transitive nl.example.princess.peach;

}

Een ander voorbeeld: bij de migratie van mijn eigen library moest ik een transitieve afhankelijkheid op com.fasterxml.jackson.databind opnemen. Ik bied namelijk een ObjectMapperFactory class aan als onderdeel van mijn publieke API. Zoals de naam al suggereert bevat deze class een aantal factory methods die een voorgeconfigureerde ObjectMapper opleveren. Hiermee was de ObjectMapper class, en dus de dependency op com.fasterxml.jackson.databind, ook onderdeel van mijn publieke API geworden.

Als alle afhankelijkheden van je module zelf ook volwaardige modules zijn is dit stuk redelijk eenvoudig. Je kan de modulenamen direct vanuit hun module descriptors lezen en je kunt er op vertrouwen dat ze hun transitieve dependencies ook netjes als zodanig hebben aangegeven.

Als sommige van je dependencies geen volwaardige modules zijn wordt het allemaal een beetje complexer, afhankelijk van hoe ver deze andere libraries al zijn gekomen richting JPMS.

Als ze een Automatic-Module-Name hebben gekozen en ingesteld kun je er redelijk zeker van zijn dat tenminste deze naam niet zomaar zal veranderen van versie tot versie. Ze hebben echter geen module descriptor en hebben dus ook hun transitieve dependencies niet als zodanig aangegeven. Dit betekent dat je er zelf achter zou moeten komen welke hun eventuele transitieve dependencies zijn en deze zelf als dependency van je module op zult moeten nemen. Daarbij moet je in je achterhoofd houden dat deze dependencies kunnen veranderen.

Of en in hoeverre je deze extra onderhoudslast op je wilt nemen is een vraag waar je goed over na moet denken. Het kan natuurlijk alleszins meevallen maar in zijn algemeenheid kun je stellen dat hoe meer dependencies jouw dependencies zelf weer hebben, en hoe dieper hun dependency tree, des te complexer dit onderhoud zal worden.

Als sommige van je (transitieve) dependencies nog niet eens een modulenaam hebben aangegeven middels zou ik zelf niet meer proberen nog een fatsoenlijke module descriptor op te stellen. Dit heeft geen zin als je niet kunt vertrouwen op de modulenamen van je dependencies. Ik zou adviseren slechts een Automatic-Module-Name voor je eigen library in te stellen. Dit betekent helaas wel dat je library pas een volwaardige module zal zijn nadat al je dependencies een Automatic-Module-Name hebben aangegeven in hun MANIFEST.MF.

Deze afhankelijkheid van de voortgang van externe libraries, waar over je geen controle hebt, is een van de grotere problemen met de adoptie van JPMS en hierdoor ook van Java 9. Wees trots dat je de eerste stappen hebt gezet om je eigen library te migreren door minimaal je Automatic-Module-Name aan te geven en op deze manier de onderhouders van andere libraries, die zelf afhankelijk zijn van jouw module, geholpen hebt om in overeenstemming met JPMS te komen.

Module dependencies vs maven dependencies

Let op dat er een verschil bestaat tussen module dependencies en maven dependencies. Dependencies in de module descriptor gaan over toegankelijkheid (of zichtbaarheid). Als je een bepaalde module, of de publieke API hiervan, beschikbaar wilt hebben voor de classes van jouw module zul je nog steeds een dependency op moeten nemen in je pom.xml op het artifact dat deze module bevat.

Met andere woorden, maven dependencies gaan over welke artifacts je beschikbaar wilt hebben. Module dependencies gaan over tot welke classes (of packages) je toegang wilt hebben.

Een dependency op een bepaalde module kan door verschillende artifacts worden vervuld. Om een voorbeeld te geven, het javax.servlet-api artifact en het jboss-servlet-api artifact bevatten dezelfde set van classes en zouden daarom ook dezelfde modulenaam moeten hebben. Iets als javax.servlet waarschijnlijk.

Als je hier moeite mee hebt, kun je het zien als verschillende artifacts die dezelfde module kunnen implementeren, net zoals verschillende classes dezelfde interface kunnen implementeren.