DEV Community

loading...

JPMS Migration Playground

tomerfi profile image Tomer Figenblat ・7 min read

Playing around with modularizing monolithic jars

This repository was created for playing around with modularizing monolithic jars.

To be more precise,
I was trying to mimic a situation where I have a project depending on a monolithic jar from a local dependency, which in itself, depends on a non-existing dependency, which is of no use to my project.

My goal was to modularize the monolithic jar from the local dependency disregarding the non-existing dependency.

It might sound like a very specific end-case,
But... I bumped into this situation at work,
so I figured I'll give it a try. 😏

Join me, if nothing else, we'll get a better understanding of JPMS.
🤓

Project Walkthrough

bar is the missing artifact, used by foo.
It is configured to be deployed to foo's lib folder as a monolithic jar.

foo is the artifact needed by my project, baz.
It is configured to be deployed to my project, baz's lib folder as a monolithic jar.
I've used the flatten plugin to strip foo's pom from its dependencies,
so that bar will not be known at compile time to whomever 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

First Solution

The easiest solution is of course working on my project without leveraging JPMS. 😐

Demonstrated in this commit.

Second Solution

Another solution is to incorporate JPMS in my project, making sure foo is on the 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 me of the usage of an automatic module:

[INFO] Required filename-based automodules detected: [foo-0.0.1.jar]. Please don't publish this project to a public artifact repository!
Enter fullscreen mode Exit fullscreen mode

This will not suffice, I won't be able to make the best of JPMS.
Features like jlink don't work with automatic modules. 😞

Third Solution

The next solution, which is the one I'm writing about.
Is to modularize foo's jar, this is easily accomplished using the moditect plugin.
But it can be tricky since I don't have, nor do I need, bar, and I prefer doing most of the work in build time and not manually.

For testing I'm using the junit-platform plugin.

Let's dive in. 💪

Modularizing a monolithic jar

For clarification:

  • Our project in the works is baz.
  • The monolithic jar foo, is given to us (without the source files).
  • The monolithic jar bar, is not given to us, nor do we need it.

Our goal is to modularize the monolithic jar foo, so baz can truly leverage JPMS features.

Create a module-info descriptor

First things first, let's create a module-info descriptor for foo.
This can be accomplished in two ways.

From the command line (note the --ignore-missing-deps as 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
Enter fullscreen mode Exit fullscreen mode

This will create .\target\descs\foo\module-info.java:

module foo {
    exports com.example.foo;
}
Enter fullscreen mode Exit fullscreen mode

The same can also be accomplished 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>
Enter fullscreen mode Exit fullscreen mode

Create a modularized jar from the monolithic one

Now that we have our module-info descriptor ready,
We can create a new modularized jar with it.

This is easily accomplished 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>
Enter fullscreen mode Exit fullscreen mode

This will create a modified version foo-0.0.1.jar in target\modules,
The modified version will of course include the following module-info descriptor, and will qualify as a named module.

module com.example.foo {
    exports com.example.foo;
}
Enter fullscreen mode Exit fullscreen mode

Instruct our project to use the modularized jar

Simply 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Note that for the test descriptor, we needed to add a requires directive for reading org.junit.jupiter.api.

The reason we didn't need to do so before, is because as an automatic module, foo could read all the other modules from the modulepath.

Meaning, 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 legit 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
Enter fullscreen mode Exit fullscreen mode

Add the new module to the modulepath

Now, this is were it gets tricky.
Basically one would just configure the compiler to include the new module:

<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>--upgrade-module-path</arg>
            <arg>${project.build.directory}/modules</arg>
        </compilerArgs>
    </configuration>
</plugin>
Enter fullscreen mode Exit fullscreen mode

This, unfortunately, will not work in this 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, once inside the unnamed module, and once 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
Enter fullscreen mode Exit fullscreen mode

This is where things get complicated!

To avoid the above error, one cannot just upgrade-module-path.
One needs to reconstruct the modulepath from scratch.

First, we need to get the classpath content.

Retrieve the classpath

We can use the dependency plugin to create a temporary file represnting the classpath:

<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>
Enter fullscreen mode Exit fullscreen mode

We now have the target\fixedClasspath.txt file with the content of the classpath excluding the monolithic foo artifact.

Create a fixed modulepath

We can accomplish this by leveraging the gmavenplus plugin to execute a small groovy script.
To better accommodate both Windows and Non-Windows os families, we'll use the os plugin to create the os.detected.name.

<extension>
    <groupId>kr.motd.maven</groupId>
    <artifactId>os-maven-plugin</artifactId>
    <version>1.6.2</version>
</extension>
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

This will result in a new property named modulePath containing the fixed classpath concatenated with the target modules directory.

Configure the compiler with the new modulepath

Simply do:

<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>--module-path</arg>
            <arg>${modulePath}</arg>
        </compilerArgs>
    </configuration>
</plugin>
Enter fullscreen mode Exit fullscreen mode

Configure junit-platform plugin to see the new module

The junit-platform plugin requires some tweaking so it can see the new module:

<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>
Enter fullscreen mode Exit fullscreen mode

In a similar manner to the way the compiler plugin was patched,
In a much simpler way, we told junit-platform plugin to exclude the original monolithic foo and add the modularized one.

That's it.
Everything now compiles and all the tests pass.
😀

The key added value here is that now,
Both the project baz and the local dependency foo are named modules,
And, we are still missing bar of course.
Better yet, we can also use moditect to make foo stop exposing packages related to bar so we won't be able to access them.
😎

I had fun playing around with JPMS,
I hope you did too.

You can check out the code for this playground in Github.

👋 See you in the next blog post 👋

Discussion (0)

pic
Editor guide