/gradle-modules-plugin

This Gradle plugin helps working with the Java Platform Module System

Primary LanguageJavaMIT LicenseMIT

Build Status

Introduction

This Gradle plugin help with the creation of applications and libraries that make use of the Java Platform Module System. The plugin is published in the Gradle plugin repository. It makes building, testing and running modules seamless from the Gradle perspective. It sets up compiler and jvm settings with flags such as --module-path, so that you can build, test and run JPMS modules without manually setting up your build files.

💡 When using this plugin, you should not set the --module-path compiler option explicitly. Also, you should disable the modularity.inferModulePath option introduced in Gradle 6.4: modularity.inferModulePath.set(false)

The plugin is designed to work in repositories that contain multiple modules. The plugin currently supports:

  • Compiling modules
  • Testing module code with whitebox tests (traditional unit tests)
  • Testing modules blackbox (testing module boundaries and services)
  • Running/packaging modular applications using the application plugin

The plugin supports the following test engines:

  • JUnit 5
  • JUnit 4
  • TestNG
  • Since 1.7.0 Spock 2 with Groovy 3
  • Since 1.7.0 AssertJ
  • Since 1.7.0 Mockito
  • Since 1.7.0 EasyMock

An example application using this plugin is available here.

Compatability

Plugin Version Gradle Versions Java Version Kotlin Version Notes
- -> 1.8.12 5.+ -> 7.5.+ 11+ 1.0.+ -> 1.6.+
1.8.12 -> 1.8.13 5.+ -> 7.5.+ 11+ 1.0.+ -> 1.9.+ Adds support for Kotlin 1.7 and above.
1.8.14 5.+ -> 7.6.+ 11+ 1.0.+ -> 1.9.+ Fixes compatibility issue with Gradle 7.6
1.8.15 -> + 5.+ -> 8.6.+ 11+ 1.6.+ -> 1.9.+ Fixes compatibility issues with Gradle 8.0.
Use JUnit v5.8.0 or above if using Gradle 8

Setup

For this guide we assume the following directory structure:

.
├── build.gradle[.kts]
├── gradle
├── greeter.api
├── greeter.javaexec
├── greeter.provider
├── greeter.provider.test
├── greeter.provider.testfixture
├── greeter.runner
├── greeter.startscripts
└── settings.gradle
  • greeter.api: Exports an interface
  • greeter.javaexec: Applications that can be started with ModularJavaExec tasks
  • greeter.provider: Provides a service implementation for the interface provided by greeter.api
  • greeter.provider.test: Blackbox module test for greeter.provider
  • greeter.provider.testfixture: Blackbox module test with test fixtures for greeter.provider
  • greeter.runner: Main class that uses the Greeter service, that can be started/packaged with the application plugin
  • greeter.startscripts: Applications with start scripts generated by ModularCreateStartScripts tasks

The main build file should look as follows:

Groovy DSL
plugins {
    id 'org.javamodularity.moduleplugin' version '1.8.15' apply false
}

subprojects {
    apply plugin: 'java'
    apply plugin: "org.javamodularity.moduleplugin"

    version "1.0-SNAPSHOT"

    sourceCompatibility = 11
    targetCompatibility = 11

    repositories {
        mavenCentral()
    }

    test {
        useJUnitPlatform()

        testLogging {
            events 'PASSED', 'FAILED', 'SKIPPED'
        }
    }

    dependencies {
        testImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.1'
        testImplementation 'org.junit.jupiter:junit-jupiter-params:5.3.1'
        testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.3.1'
    }
}
Kotlin DSL
plugins {
    id("org.javamodularity.moduleplugin") version "1.8.15" apply false
}

subprojects {
    apply(plugin = "java")
    apply(plugin = "org.javamodularity.moduleplugin")

    version = "1.0-SNAPSHOT"

    java {
        toolchain {
            languageVersion.set(JavaLanguageVersion.of(11))
        }
    }

    repositories {
        mavenCentral()
    }

    test {
        useJUnitPlatform()

        testLogging {
            events("PASSED", "FAILED", "SKIPPED")
        }
    }

    dependencies {
        testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.1")
        testImplementation("org.junit.jupiter:junit-jupiter-params:5.3.1")
        testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.3.1")
    }
}

The most important line in this build file is: apply plugin: "org.javamodularity.moduleplugin" which enables the module plugin for all subprojects. The subprojects typically don't need extra configuration in their build files related to modules.

To build the project simply run ./gradlew build like you're used to.

Creating a module

The only thing that makes a module a module is the existence of module-info.java. In a module-info.java you'll need to define the module name, and possibly declare your exports, dependencies on other modules, and service uses/provides. A simple example is the following. The module name is greeter.api and the module exports a package examples.greeter.api.

module greeter.api {
    exports examples.greeter.api;
}

For Gradle, just make sure the plugin is applied. This will make sure the correct compiler flags are used such as --module-path instead of -cp.

Setting the module version

Since 1.7.0 By default, the plugin uses the value of the project version to set the version of the module. You can configure a different module version using the modularity.moduleVersion method:

Groovy DSL
modularity.moduleVersion '1.2.3'
Kotlin DSL
modularity.moduleVersion("1.2.3")

If no project version is specified and you don't call the modularity.moduleVersion method, the module is created without a version.

Module dependencies

When a module depends on another module, the dependency needs to be declared in two different places. First it needs to be declared in the dependencies section of the gradle build file.

Groovy DSL
dependencies {
    implementation project(':greeter.api') //Example of dependency on another module in the project
    implementation "com.fasterxml.jackson.core:jackson-databind:2.9.5" //Example of an external dependency
}
Kotlin DSL
dependencies {
    implementation(project(":greeter.api")) //Example of dependency on another module in the project
    implementation("com.fasterxml.jackson.core:jackson-databind:2.9.5") //Example of an external dependency
}

Next, it needs to be defined in module-info.java.

module greeter.provider {
    requires greeter.api; //This is another module provided by our project
    requires java.net.http; //This is a module provided by the JDK
    requires com.fasterxml.jackson.databind; //This is an external module
}

Note that the coordinates for the Gradle dependency are not necessarily the same as the module name!

Why do we need to define dependencies in two places!?

We need the Gradle definition so that during build time, Gradle knows how to locate the modules. The plugin puts these modules on the --module-path. Next, when Gradle invokes the Java compiler, the compiler is set up with the correct --module-path so that the compiler has access to them. When using the module system the compiler checks dependencies and encapsulation based on the requires, exports and opens keywords in module-info-java. These are related, but clearly two different steps.

MonkeyPatching the module

There are times when explicit modular settings may be needed on compile, test, and run tasks. You have the option to specify these settings using a moduleOptions extension on the target task, for example:

Groovy DSL
compileJava {
    moduleOptions {
        addModules = ['com.acme.foo']
    }
}
Kotlin DSL
tasks.compileJava {
  extensions.configure<CompileModuleOptions> {
    addModules = listOf("com.acme.foo")
  }
}

The following options are supported by the moduleOptions extension:

  • addModules: Maps to --add-modules. Value is of type List<String>, e.g, ['com.acme.foo'].
  • addReads: Maps to --add-reads. Value is of type Map<String, String>, e.g, ['module1': 'module2'].
  • addExports: Maps to --add-exports. Value is of type Map<String, String>, e.g, ['module1/package': 'module2'].
  • addOpens: Maps to --add-opens. Value is of type Map<String, String>, e.g, ['module1/package': 'module2'] (available only for test and run tasks).

Note that multiple entries matching the same left hand side may be added to addReads, addOpens, and addExports but no value accumulation is performed, the last entry overrides the previous one. If you need to combine multiple values then you must do so explicitly. The following block resolves to --add-reads module1=module3

Groovy DSL
compileJava {
    moduleOptions {
        addReads = [
            'module1': 'module2',
            'module1': 'module3'
        ]
    }
}
Kotlin DSL
tasks.compileJava {
  extensions.configure<CompileModuleOptions> {
    addReads = mapOf(
      "module1", "module2",
      "module1", "module3"
    )
  }
}

Whereas the following block resolves to --add-reads module1=module2,module3

Groovy DSL
compileJava {
    moduleOptions {
        addReads = [
            'module1': 'module2,module3'
        ]
    }
}
Kotlin DSL
tasks.compileJava {
  extensions.configure<CompileModuleOptions> {
    addReads = mapOf(
      "module1", "module2,module3"
    )
  }
}

Whitebox testing

Whitebox testing is your traditional unit test, where an implementation class is tested in isolation.

Typically, we would have a structure as follows:

.
├── build.gradle
└── src
    ├── main
    │   ├── java
    │   │   ├── examples
    │   │   │   └── greeter
    │   │   │       └── Friendly.java
    │   │   └── module-info.java
    │   └── resources
    └── test
        ├── java
        │   └── examples
        │       └── greeter
        │           └── FriendlyTest.java
        └── resources

This poses a challenge for the module system, because the whole point of encapsulation is to hide implementation classes! A class that is not exported can't be accessed from outside the module. In the example above we have another problem, the main and test code uses the same package structure (which is very common). However, The module system does not allow split packages.

We have two different options to work around this:

  • Run whitebox tests on the classpath (ignore the fact that we're in the module world)
  • Patch the module so that it contains the code from both the main and test sources.

Either option is fine. By default, the plugin will automatically set up the compiler and test runtime to run on the module path, and patch the module to avoid split packages.

How does it work?

Essentially, the plugin enables the following compiler flags:

  • --module-path containing all dependencies
  • --patch-module to merge the test classes into the modules
  • --add-modules to add the test runtime (JUnit 5, JUnit 4, TestNG, Spock, AssertJ, Mockito, and EasyMock are supported)
  • --add-reads for the test runtime. This way we don't have to require the test engine in our module.
  • --add-opens so that the test engine can access the tests without having to export/open them in --module-info.java.

The plugin also integrates additional compiler flags specified in a module-info.test file. For example, if your tests need to access types from a module shipping with the JDK (here: java.scripting). Note that each non-comment line represents a single argument that is passed to the compiler as an option.

module-info.test
// Make module visible.
--add-modules
  java.scripting

// Same "requires java.scripting" in a regular module descriptor.
--add-reads
  greeter.provider=java.scripting

See src/test/java/module-info.test and src/test/java/examples/greeter/ScriptingTest.java in test-project/greeter.provider for examples.

Fall-back to classpath mode

If for whatever reason this is unwanted or introduces problems, you can enable classpath mode, which essentially turns off the plugin while running tests.

Groovy DSL
test {
    moduleOptions {
        runOnClasspath = true
    }
}
Kotlin DSL
tasks {
    test {
        extensions.configure(TestModuleOptions::class) {
            runOnClasspath = true
        }
    }
}

You can also enable classpath mode, which essentially turns off the plugin while running tests.

Groovy DSL
compileTestJava {
    moduleOptions {
        compileOnClasspath = true
    }
}
Kotlin DSL
tasks {
  compileTestJava {
        extensions.configure(CompileTestModuleOptions::class) {
            compileOnClasspath = true
        }
    }
}

Blackbox testing

It can be very useful to test modules as a blackbox. Are packages exported correctly, and are services provided correctly? This allows you to test your module as if you were a user of the module. To do this, we create a separate module that contains the test. This module requires and/or uses the module under test, and tests it's externally visible behaviour. In the following example we test a module greeter.provider, which provides a service implementation of type Greeter. The Greeter type is provided by yet another module greeter.api.

The test module would typically be named something similar to the module it's testing, e.g. greeter.provider.test. In src/main/java it has some code that looks like code that you would normally write to use the module that's being tested. For example, we do a service lookup.

package tests;

import examples.greeter.api.Greeter;

import java.util.ServiceLoader;

public class GreeterLocator {
    public Greeter findGreeter() {
        return ServiceLoader.load(Greeter.class).findFirst().orElseThrow(() -> new RuntimeException("No Greeter found"));
    }
}

In src/test/java we have our actual tests.

package tests;

import examples.greeter.api.Greeter;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;

class GreeterTest {
    @Test
    void testLocate() {
        Greeter greeter = new GreeterLocator().findGreeter();
        assertFalse(greeter.hello().isBlank());
    }
}

Because we clearly want to run this code as a module, we also need to have a module-info.java.

import examples.greeter.api.Greeter;

module greeter.provider.test {
    requires greeter.api;

    uses Greeter;
}

As we've discussed before, we also need to configure the --module-path so that the compiler knows about the greeter.api module, and the JVM also starts with the greeter.provider module available. In the build.gradle we should add dependencies to do this.

Groovy DSL
dependencies {
    implementation project(':greeter.api')
    runtimeOnly project(':greeter.provider')
}
Kotlin DSL
dependencies {
    implementation(project(":greeter.api"))
    runtimeOnly(project(":greeter.provider"))
}

Using the Application plugin

Typically, you use the application plugin in Gradle to run the application from Gradle and, more importantly, package it in a distributable zip/tar. To work with modules correctly JVM needs to be configured with the correct arguments such as --module-path to use the module path instead of the classpath. The plugin takes care of all that automatically.

When starting a main class from a module, the module name needs to be provided. To make this easier, the plugin injects a variable $moduleName in the build script.

With Gradle 6.4 or newer, you should use the new syntax introduced by the Application plugin:

Groovy DSL
apply plugin: 'application'
application {
    mainClass = "examples.Runner"
    mainModule = moduleName
}
Kotlin DSL
apply(plugin = "application")
application {
    mainClass.set("examples.Runner")
    mainModule.set(moduleName)
}

When using older versions of Gradle (before 6.4) you can include the module name in the mainClassName property:

Groovy DSL
apply plugin: 'application'
mainClassName = "$moduleName/examples.Runner"
Kotlin DSL
apply(plugin = "application")
mainClassName.set("$moduleName/examples.Runner")

As usual, you can still set extra JVM arguments using the run configuration.

Groovy DSL
run {
    jvmArgs = [
            "-XX:+PrintGCDetails"
    ]

    applicationDefaultJvmArgs = [
            "-XX:+PrintGCDetails"
    ]
}
Kotlin DSL
(run) {
    jvmArgs = listOf("-XX:+PrintGCDetails")
    applicationDefaultJvmArgs = listOf("-XX:+PrintGCDetails")
}

Using the ModularJavaExec task

The application plugin can handle only one executable application. To start multiple applications, you typically need to create a JavaExec task for each executable application. The module plugin offers a similar task named ModularJavaExec, which helps to execute modular applications. This task automatically configures the JVM with the correct arguments such as --module-path. It exposes the same properties and methods as the JavaExec task, the only difference being that the module name should also be provided when setting the main property.

Groovy DSL
task runDemo1(type: ModularJavaExec) {
    group = "Demo"
    description = "Run the Demo1 program"
    main = "greeter.javaexec/demo.Demo1"
    jvmArgs = ["-Xmx128m"]
}
Kotlin DSL
tasks.create<ModularJavaExec>("runDemo1") {
    group = "Demo"
    description = "Run the Demo1 program"
    main = "greeter.javaexec/demo.Demo1"
    jvmArgs = listOf("-Xmx128m")
}

Using the ModularCreateStartScripts task

If you have several application classes in the same Gradle project, you may want to create a distribution that provides separate start scripts for each of them. To help you with this, the plugin offers a ModularCreateStartScripts task, which automatically configures the start scripts with the correct JVM arguments for modular applications.

Each ModularCreateStartScripts task needs an associated ModularJavaExec task that provides information about the application to be started. The typical way to create a distribution containing multiple start scripts is:

  • designate one of the application classes as primary and assign its name to the mainClassName property of the application plugin
  • for each of the remaining application classes:
    • configure a ModularJavaExec task for running the application class
    • configure a ModularCreateStartScripts task associated with the above ModularJavaExec task.

Suppose we have a project with two application classes: MainDemo and Demo1. If we designate MainDemo as primary, a possible build script will look like this:

Groovy DSL
import org.javamodularity.moduleplugin.tasks.ModularJavaExec
import org.javamodularity.moduleplugin.tasks.ModularCreateStartScripts

plugins {
    id 'application'
    id 'org.javamodularity.moduleplugin'
}

dependencies {
    implementation project(':greeter.api')
    runtimeOnly project(':greeter.provider')
}

application {
    mainClassName = "greeter.startscripts/startscripts.MainDemo"
    applicationName = "demo"
    applicationDefaultJvmArgs = ["-Xmx128m"]
}

task runDemo1(type: ModularJavaExec) {
    group = "Demo"
    description = "Run the Demo1 program"
    main = "greeter.startscripts/startscripts.Demo1"
    jvmArgs = ["-Xmx128m"]
}

task createStartScriptsDemo1(type: ModularCreateStartScripts) {
    runTask = tasks.runDemo1
    applicationName = 'demo1'
}

installDist.finalizedBy tasks.createStartScriptsDemo1
Kotlin DSL
import org.javamodularity.moduleplugin.tasks.ModularJavaExec
import org.javamodularity.moduleplugin.tasks.ModularCreateStartScripts

plugins {
    id("application")
    id("org.javamodularity.moduleplugin")
}

dependencies {
    implementation(project(":greeter.api"))
    runtimeOnly(project(":greeter.provider"))
}

application {
    mainClassName.set("greeter.startscripts/startscripts.MainDemo")
    applicationName.set("demo")
    applicationDefaultJvmArgs = listOf("-Xmx128m")
}

val runDemo1 = tasks.create<ModularJavaExec>("runDemo1") {
  group = "Demo"
  description = "Run the Demo1 program"
  main = "greeter.startscripts/startscripts.Demo1"
  jvmArgs = listOf("-Xmx128m")
}

val createScripts = tasks.create<ModularCreateStartScripts>("createStartScriptsDemo1") {
  runTask = runDemo1
  applicationName = "demo1"
}

installDist.finalizedBy(createScripts)

If you have more than two application classes, it's advisable to create programmatically the ModularJavaExec and ModularCreateStartScripts tasks, as shown in the greeter.startscripts project.

The ModularCreateStartScripts task introduces the mandatory property runTask, which indicates the associated ModularJavaExec task. Additionally, it exposes the same properties and methods as the CreateStartScripts task. However, you don't need to set the properties mainClassName, outputDir, classpath, or defaultJvmOpts, because they are automatically set by the plugin, based on the configuration of the associated runTask.

Patching modules to prevent split packages

The Java Platform Module System doesn't allow split packages. A split package means that the same package exists in multiple modules. While this is a good thing, it can be a roadblock to use the module system, because split packages are very common in (older) libraries, specially libraries related to Java EE. The module system has a solution for this problem by allowing to "patch" modules. The contents of a JAR file can be added to a module, by patching that module, so that it contains classes from both JARs. This way we can drop the second JAR file, which removes the split package.

Patching a module can be done with the --patch-module module=somelib.jar syntax for the different Java commands (javac, java, javadoc, ...). The plugin helps making patching easy by providing DSL syntax. Because patching typically needs to happen on all tasks the patch config is set in the build.gradle file directly.

In this example, the java.annotation module is patched with the jsr305-3.0.2.jar JAR file. The plugin takes care of the following:

  • Adding the --patch-module to all Java commands
  • Removing the JAR from the module path
  • Moving the JAR to a patchlibs folder for distribution tasks

Recommended approach

Since 1.7.0 The recommended way to patch modules is by means of the modularity.patchModule function:

Groovy DSL
modularity.patchModule("java.annotation", "jsr305-3.0.2.jar")
Kotlin DSL
modularity.patchModule("java.annotation", "jsr305-3.0.2.jar")

The patchModule method can be called more than once for the same module, if the module needs to be patched with the content of several JARs.

Legacy approach

Legacy 1.x Before 1.7.0, patching modules was possible only by setting patchModules.config:

Groovy DSL
patchModules.config = [
        "java.annotation=jsr305-3.0.2.jar"
]
Kotlin DSL
patchModules.config = listOf(
        "java.annotation=jsr305-3.0.2.jar"
)

For compatibility reasons, this way of patching modules is still supported in newer releases, but it is discouraged. Note that this method does not allow patching a module with more than one JAR.

Compilation

Compilation to a specific Java release

You might want to run your builds on a recent JDK (e.g. JDK 12), but target an older version of Java, e.g.:

You can do that by setting the Java compiler --release option (e.g. to 6 for Java 6, etc.). Note that when you build using:

  • JDK 11: you can only target Java 6-11 using its --release option,
  • JDK 12: you can only target Java 7-12 using its --release option,
  • etc.

Finally, note that JPMS was introduced in Java 9, so you can't compile module-info.java to Java release 6-8 (this plugin provides a workaround for that, though — see below).

Concluding, to configure your project to support JPMS and target:

and the plugin will take care of setting the --release option(s) appropriately.

Separate compilation of module-info.java

If you need to compile the main module-info.java separately from the rest of src/main/java files, you can enable moduleOptions.compileModuleInfoSeparately option on compileJava task. It will exclude module-info.java from compileJava and introduce a dedicated compileModuleInfoJava task.

Typically, this feature would be used by libraries which target JDK 6-8 but want to make the most of JPMS by:

  • providing module-info.class for consumers who put the library on module path,
  • compiling module-info.java against the remaining classes of this module and against other modules (which provides better encapsulation and prevents introducing split packages).

This plugin provides an easy way to do just that by means of its modularity.mixedJavaRelease function, which implicitly sets compileJava.moduleOptions.compileModuleInfoSeparately = true and configures the --release compiler options.

For example, if your library targets JDK 8, and you want your module-info.class to target JDK 9 (default), put the following line in your build.gradle(.kts):

Groovy DSL
modularity.mixedJavaRelease 8
Kotlin DSL
modularity.mixedJavaRelease(8)

Note that modularity.mixedJavaRelease does not configure a multi-release JAR (in other words, module-info.class remains in the root directory of the JAR).

Improve Eclipse .classpath-file

When applying the eclipse-plugin (among others) a task called "eclipseClasspath" is added by that plugin creating a .classpath-file. The .classpath-file created by that task doesn't take into account modularity. As described by the documentation it is possible to configure the .classpath-file via configuration hooks.

Since 1.7.0 The gradle-modules-plugin provides the modularity.improveEclipseClasspathFile() method, which configures the .classpath-file via configuration hooks:

Groovy DSL
modularity.improveEclipseClasspathFile()
Kotlin DSL
modularity.improveEclipseClasspathFile()

Examples on how and where Gradle's eclipse-plugin could (and should) be improved and how a .classpath-file is affected if the feature is enabled are available on GitHub.

Limitations

Please file issues if you run into any problems or have additional requirements!

Requirements

This plugin requires JDK 11 or newer to be used when running Gradle.

The minimum Gradle version supported by this plugin is 5.1. However, we strongly recommend to use at least Gradle 6.0, because there are a few special cases that cannot be handled correctly when using older versions.

Contributing

Please tell us if you're using the plugin on @javamodularity! We would also like to hear about any issues or limitations you run into. Please file issues in the GitHub project. Bonus points for providing a test case that illustrates the issue.

Contributions are very much welcome. Please open a Pull Request with your changes. Make sure to rebase before creating the PR so that the PR only contains your changes, this makes the review process much easier. Again, bonus points for providing tests for your changes.