JPMS Migration Playground
This tutorial presents a complex scenario of migrating a non-modular project to JPMS. Inspired by a scenario I previously faced where one of my transitive dependencies was in-accessible (and not required).
Scenario Walkthrough
Project bar is the missing artifact used by foo. It is configured to be deployed to foo's lib folder as a non-modular jar.
Project foo is the artifact we require in our project, baz. It is configured to be deployed to baz's lib folder as a non-modular jar.
To work around the obvious compliation error of trying to compile foo
when bar
is inaccessible, we used the flatten plugin to strip foo's pom from its dependencies so that bar will not be known at compile time to whoever uses foo.
Basically, baz depends on foo, without depending on bar and the code compiles successfully. 😁
It will, however, throw a NoClassDefFoundError
exception if we were to try an access bar's classes. This is demonstrated in BazTest.java.
Pre-JPMS code is in this commit.
JPMS: foo as Automatic Module
We can get foo
in our modulepath, implicitly making it an automatic module
by explicitly requiring it by its unstable name. 😵
Demonstrated in this commit.
Note that when compiling, the compiler will inform us of the automatic module
usage:
[INFO] Required filename-based automodules detected: [foo-0.0.1.jar]. Please don't publish this project to a public artifact repository!
This means our module won't be a pure module. And we won't be able to make the best out of JPMS
. Features like jlink
don't work with automatic modules
. 😞
Migrating foo
We use the moditect plugin to tweak foo
. However, in our case, we can't access bar
. Although our project, baz
, doesn't require it, it's required by foo
.
For clarification:
- Our project in the works is baz.
- The non-modular jar foo is given to us (without the source files).
- The non-modular jar bar is not given to us, nor do we require it.
We aim to make foo
modular while ignoring bar
so that baz
can truly leverage JPMS
features while requiring foo
.
We're using the junit-platform plugin for testing.
Create a module-info descriptor
The first step is to create a module-info
descriptor for foo
. This can be achieved in two ways.
From the command line (note the --ignore-missing-deps
because we don't have bar
):
jdeps --generate-module-info .\target\descs --ignore-missing-deps .\lib\com\example\foo\0.0.1\foo-0.0.1.jar
This will create .\target\descs\foo\module-info.java
:
module foo {
exports com.example.foo;
}
The same can also be achieved in build time using the moditect
plugin. Better yet, we can now give the module a stable name.
By convention, let's name it com.example.foo
:
<plugin>
<groupId>org.moditect</groupId>
<artifactId>moditect-maven-plugin</artifactId>
<version>1.0.0.RC1</version>
<executions>
<execution>
<id>generate-module-info</id>
<phase>initialize</phase>
<goals>
<goal>generate-module-info</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/descs</outputDirectory>
<modules>
<module>
<artifact>
<groupId>com.example</groupId>
<artifactId>foo</artifactId>
<version>0.0.1</version>
</artifact>
<moduleInfo>
<name>com.example.foo</name>
</moduleInfo>
</module>
</modules>
<jdepsExtraArgs>--ignore-missing-deps</jdepsExtraArgs>
</configuration>
</execution>
</executions>
</plugin>
Create a modular jar
Now that we have our module-info
descriptor ready, we can create a new modular jar with it.
This is easily achived with the moditect
plugin:
<plugin>
<groupId>org.moditect</groupId>
<artifactId>moditect-maven-plugin</artifactId>
<version>1.0.0.RC1</version>
<executions>
<execution>
<id>add-module-info</id>
<phase>initialize</phase>
<goals>
<goal>add-module-info</goal>
</goals>
<configuration>
<modules>
<module>
<artifact>
<groupId>com.example</groupId>
<artifactId>foo</artifactId>
</artifact>
<moduleInfoFile>${project.build.directory}/descs/com.example.foo/module-info.java</moduleInfoFile>
</module>
</modules>
</configuration>
</execution>
</executions>
</plugin>
This will create a modified version foo-0.0.1.jar
in target\modules
. The modified version will include the following module-info
descriptor and will qualify as a named module.
module com.example.foo {
exports com.example.foo;
}
Use the modular jar
To use our new modular jar, we update the requires
statement in the source files of the baz
project. From foo
, the automatic module
unstable name, to com.example.foo
, the named module
stable name.
// original using an automatic module
module com.example.baz {
requires foo;
}
// modified using the new named module
module com.example.baz {
requires com.example.foo;
}
A modification is also required in the test sources descriptor:
// original using an automatic module
open module com.example.baz {
requires foo;
}
// modified using the new named module
open module com.example.baz {
requires com.example.foo;
requires org.junit.jupiter.api;
}
Note that for the test descriptor, we needed to add a requires
directive for reading org.junit.jupiter.api
.
We didn't need to do so before because as an automatic module
, foo
could read all the other modules from the modulepath. So it bridged between the modules com.example.baz
and org.junit.jupiter.api
, so we didn't need to explicitly make com.example.baz
read org.junit.jupiter.api
.
Now that com.example.foo
is a named module
, com.example.baz
needs to explicitly require org.junit.jupiter.api
.
Failing to do so will result in a compilation error:
[ERROR] .../src/test/java/com/example/baz/BazTest.java:[3,32] package org.junit.jupiter.api is not visible
Add the new module to the Modulepath
This is where it gets tricky. Typically, we would configure the compiler to include the new module like so:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>--upgrade-module-path</arg>
<arg>${project.build.directory}/modules</arg>
</compilerArgs>
</configuration>
</plugin>
This, unfortunately, will not work in our case. 😟
The original modulepath is constructed from the classpath, every monolithic jar is treated as part of the unnamed module
. This means our jar will exist twice on the modulepath, as part of the unnamed module
and as a named module
named com.example.foo
.
When trying to compile the test classes that access the module, we'll get the obvious error:
[ERROR] .../src/test/java/module-info.java:[1,6] module com.example.baz reads package com.example.foo from both com.example.foo and foo
To avoid the above error, we cannot do upgrade-module-path
. We must reconstruct the modulepath from scratch, excluding the original jar.
Create a new Classpath
First, we use the dependency plugin to create a temporary file with the classpath content excluding foo
in target\fixedClasspath.txt:
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>create-classpath-file</id>
<goals>
<goal>build-classpath</goal>
</goals>
<configuration>
<outputFile>${project.build.directory}/fixedClasspath.txt</outputFile>
<excludeArtifactIds>foo</excludeArtifactIds><!-- every aritifact recreated with moditect should be listed here -->
</configuration>
</execution>
</executions>
</plugin>
Create a new Modulepath
We then use the gmavenplus plugin to execute a small Groovy script creating a property containing the fixed modulepath.
We use the os plugin to create the os.detected.name
property.
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.6.2</version>
</extension>
And execute a Groovy script creating a property named modulePath
containing the content of the fixed classpath and the named module we previously created:
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.12.0</version>
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-ant</artifactId>
<version>3.0.7</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<executions>
<execution>
<phase>process-sources</phase>
<goals>
<goal>execute</goal>
</goals>
<configuration>
<scripts>
<script><![CDATA[
def delimiter = project.properties['os.detected.name'] == 'windows' ? ';' : ':'
def file = new File("$project.build.directory/fixedClasspath.txt")
project.properties.setProperty 'modulePath', file.text + delimiter + "$project.build.directory/modules"
]]></script>
</scripts>
</configuration>
</execution>
</executions>
</plugin>
Patch compiler with new Modulepah
As simple as:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>--module-path</arg>
<arg>${modulePath}</arg>
</compilerArgs>
</configuration>
</plugin>
Patch testing Classpath
Next, we must exclude the original monolithic jar foo
and include the modular one on the test classpath. This can be achieved with junit-platform plugin tweaks:
<plugin>
<groupId>de.sormuras.junit</groupId>
<artifactId>junit-platform-maven-plugin</artifactId>
<version>1.1.0</version>
<extensions>true</extensions>
<configuration>
<executor>JAVA</executor>
<tweaks>
<additionalTestPathElements>
<element>${project.build.directory}/modules/foo-0.0.1.jar</element>
</additionalTestPathElements>
<dependencyExcludes>
<exclude>com.example:foo</exclude>
</dependencyExcludes>
</tweaks>
</configuration>
</plugin>
That's it.
Everything now compiles, and all the tests pass.
😀
The key added value here is that both the project baz
and the local dependency foo
are now named modules
, although we are still missing bar
, of course.
We can also use moditect
to make foo
stop exposing packages related to bar
so we won't be able to access them by mistake.
😎
I had fun playing around with JPMS
; I hope you did, too.
You can check out the code for this playground in Github.
Top comments (0)