Building and testing Java 9 applications with Gradle

Cédric Champeau (@CedricChampeau), Gradle

Who am I

speaker {
    name 'Cédric Champeau'
    company 'Gradle Inc'
    oss 'Apache Groovy committer',
    successes 'Static type checker',
              'Static compilation',
              'Traits',
              'Markup template engine',
              'DSLs'
        failures Stream.of(bugs),
        twitter '@CedricChampeau',
        github 'melix',
        extraDescription '''Groovy in Action 2 co-author
Misc OSS contribs (Gradle plugins, deck2pdf, jlangdetect, ...)'''
}

Agenda

  • What are Java 9 modules?

  • Migrating an existing app to modules

  • Running tests

  • (bonus!) multi-release jars

  • (extra time!) minimal runtime images

JPMS aka Modules for Java

  • Modularity for the Java platform

    • Reliable configuration (goodbye classpath)

    • Strong encapsulation (goodbye com.sun)

  • Enforce strict boundaries between modules

    • compile-time

    • run-time

Java 9 modules

Declaring a module

module-info.java
module com.foo.bar {
    requires org.baz.qux;
    exports com.foo.bar.alpha;
    exports com.foo.bar.beta;
}

A module

  • Declares dependencies onto other modules

    • optionally transitive

  • Declares a list of packages it exports

  • will be found on modulepath

  • optionally declares services

Why it’s going to break your apps

  • Exports are package based

  • A non-exported package is not visible to consumers

  • 2 modules cannot export the same packages

  • 2 modules cannot have the same internal packages

Death to split packages.

Gradle support for Java 9

  • Good news: it works!

  • Sad news: limited (but in progress)

What Gradle supports

  • Running on Java 9

  • Cross-compiling

    • Gradle runs on JDK 8

    • Gradle builds using JDK 9

  • No built-in support for modulepath

  • But we can help!

Warning

  • Some plugins don’t work

    • eg FindBugs

The Java Library plugin

The Java Library plugin

  • Introduced in Gradle 3.4

  • Aimed at Java libraries

  • Separates API and implementation dependencies

API dependencies

  • Form part of the API of the library

  • Typically: method parameters, interfaces, superclasses, …​

Implementation dependencies

  • Used "internally"

  • Shouldn’t leak to compile classpath of consumers

  • Implementation detail

Usage

// This component has an API and an implementation
apply plugin: 'java-library'

dependencies {
   api project(':model')
   implementation 'com.google.guava:guava:18.0'
}

The StoryTeller application

  • Built with Java 8 (hence no modules)

  • 6 "modules"

    • fairy - Entry point to the storyteller java application

    • tale - public Tale interface.

    • formula - makes it easy to weave a Tale

    • actors - represents the characters in a fairy tale.

    • pigs - produces an instance of Tale which represents the story of the three little pigs.

    • bears - produces an instance of Tale which represents the story of Goldilocks and the three bears.

Dependency graph

project graph

Fairy: StoryTeller

package org.gradle.fairy.app;

public class StoryTeller {
    public static void main(String[] args) {
        ServiceLoader<Tale> loader = ServiceLoader.load(Tale.class);
        if (!loader.iterator().hasNext()) {
            System.out.println("Alas, I have no tales to tell!");
        }
        for (Tale tale : loader) {
            tale.tell();
        }
    }
}

Fairy: build.gradle

dependencies {
    implementation project(':tale')

    runtimeOnly project(':pigs')
    runtimeOnly project(':bears')
}

Formula: build.gradle

dependencies {
    api project(':tale')
    api project(':actors')

    testImplementation 'junit:junit:4.12'
}

Strong encapsulation breakage

  • ModularityTest lives in Formula

  • Let’s see how encapsulation is broken

Test 1: should always pass

    @Test
    public void canReachActor() {
        Actor actor = Imagination.createActor("Sean Connery");
        assertEquals("Sean Connery", actor.toString());
    }

Test 2 : should fail

    @Test
    public void canDynamicallyReachDefaultActor() throws Exception {
        Class clazz = ModularityTest
            .class.getClassLoader()
            .loadClass("org.gradle.actors.impl.DefaultActor");
        Actor actor = (Actor) clazz.getConstructor(String.class)
            .newInstance("Kevin Costner");
        assertEquals("Kevin Costner", actor.toString());
    }

Test 3 : should fail

    @Test
    public void canReachDefaultActor() {
        Actor actor = new org.gradle.actors.impl.DefaultActor("Kevin Costner");
        assertEquals("Kevin Costner", actor.toString());
    }

Test 4 : fails already!

    @Test
    public void canReachGuavaClasses() {
        Set<String> strings = com.google.common.collect.ImmutableSet.of("Hello", "Goodbye");
        assertTrue(strings.contains("Hello"));
        assertTrue(strings.contains("Goodbye"));
    }

How does it map to modular Java concepts?

(warning: this is not one to one mapping)

  • implementation → requires

  • api → requires transitive

  • runtimeOnly → requires static

Source layout

  • src/main/java → your application sources *src/test/java → your test sources

  • We want to compile main and tests separately

    • It’s good for performance

    • It’s better for incrementality

    • Problem: one or two modules?

Let’s migrate!

Let’s migrate

  • Bottom-up approach

  • Fully compatible

  • Modules are consumable as regular jars

Let’s take advantage of that!

Migrate the actors module

Add the module-info descriptor

module org.gradle.actors {
    exports org.gradle.actors;
    requires guava;
}

Compiling the module

ext.moduleName = 'org.gradle.actors'

sourceCompatibility = 9
targetCompatibility = 9

compileJava {
    inputs.property("moduleName", moduleName)
    doFirst {
        options.compilerArgs = [
            '--module-path', classpath.asPath,
        ]
        classpath = files()
    }
}

Testing (1/2)

  • 2 possible approaches

    • a test module which reads the main module

    • patch module: easier, more compatible, faster

Testing (2/3)

compileTestJava {
    inputs.property("moduleName", moduleName)
    doFirst {
        options.compilerArgs = [
            '--module-path', classpath.asPath,
            '--add-modules', 'junit',
            '--add-reads', "$moduleName=junit",
            '--patch-module', "$moduleName=" +
               files(sourceSets.test.java.srcDirs).asPath,
        ]
        classpath = files()
    }
}

Testing (3/3)

test {
    inputs.property("moduleName", moduleName)
    doFirst {
        jvmArgs = [
            '--module-path', classpath.asPath,
            '--add-modules', 'ALL-MODULE-PATH',
            '--add-reads', "$moduleName=junit",
            '--patch-module', "$moduleName=" +
               files(sourceSets.test.java.outputDir).asPath,
        ]
        classpath = files()
    }
}

What about the other projects?

  • They are still regular jars

  • But we know we’re going to migrate them at some point

Automatic modules

  • When a "legacy" jar is added to module path

  • Java infers a module name from the file name (uh!)

  • Unless you reserve a module name

Add Automatic-Module-Name

Module Name

  • Add ext.moduleName = '…​' on each module

Configure the jar task

jar {
    inputs.property("moduleName", moduleName)
    manifest {
        attributes('Automatic-Module-Name': moduleName)
    }
}

Everything as a module

Cross-configure all projects

subprojects {
    afterEvaluate {
         ...
        compileJava {
            inputs.property("moduleName", moduleName)
            doFirst {
                options.compilerArgs = [
                    '--module-path', classpath.asPath,
                ]
                classpath = files()
            }
        }

        ...
    }
}

Remove Automatic-Module-Name

  • No longer required

Move to the new service infrastructure

  • Replace META-INF/services with module-info

module org.gradle.fairy.tale.bears {
    requires org.gradle.actors;
    requires transitive org.gradle.fairy.tale;
    requires org.gradle.fairy.tale.formula;

    provides org.gradle.fairy.tale.Tale
        with org.gradle.fairy.tale.bears.GoldilocksAndTheThreeBears;
}

Running the modular application

  • Apply the application plugin

  • But requires some tweaking…​

The run task

mainClassName = "$moduleName/org.gradle.fairy.app.StoryTeller"

run {
    inputs.property("moduleName", moduleName)
    doFirst {
        jvmArgs = [
            '--module-path', classpath.asPath,
            '--module', mainClassName
        ]
        classpath = files()
    }
}

We have good news!

Experimental Jigsaw plugin

plugins {
    id 'org.gradle.java.experimental-jigsaw' version '0.1.1'
}

Multi-release jars

Goal

  • Provide several versions of the same class for runtime

Disclaimer

Don’t do this at home

Cross-compilation

  • Running Gradle on Java 9 doesn’t mean you need to use it

  • You can target different compilers

Sources setup

  • src/main/java : shared sources

  • src/main/java9 : Java 9 specific sources

Add a source set

sourceSets {
   java9 {
      java {
       srcDirs = ['src/main/java9']
      }
   }
}

Configure language level

compileJava {
   sourceCompatibility = 8
   targetCompatibility = 8
}

compileJava9Java {
   sourceCompatibility = 9
   targetCompatibility = 9
}

Add dependency on shared sources

dependencies {
    java9Implementation files(sourceSets.main.output.classesDirs) {
       builtBy compileJava
    }
}

Configuring the MRjar

jar {
   into('META-INF/versions/9') {
      from sourceSets.java9.output
   }
   manifest.attributes(
      'Multi-Release': 'true',
      'Main-Class': 'com.acme.JdkSpecific'
   )
}

Setting up a run task

task run(type: JavaExec) {
   dependsOn jar
   classpath files(jar.archivePath)
   main = 'com.acme.JdkSpecific'
}

The --release flag

  • Fixes bootclasspath that no-one uses

  • Available since JDK 9

  • Compiles against the right API

  • Gradle will not add -source/-target

    • Only if --release is present

Configuring compile tasks

project.afterEvaluate {
   tasks.withType(JavaCompile) {
      def version = compat(sourceCompatibility)
      options.compilerArgs.addAll(['--release', version])
   }
}

Minimal runtime image

No built-in task (yet)

task jlink(type:Exec) {
   ext.outputDir = file("$buildDir/jlink")
   inputs.files(configurations.runtimeClasspath)
   inputs.files(jar.archivePath)
   outputs.dir(outputDir)
   dependsOn jar
   doFirst {
      outputDir.deleteDir()
      commandLine '$javaHome/bin/jlink',
           '--module-path',
           "$javaHome/jmods/:${configurations.runtimeClasspath.asPath}:${jar.archivePath}",
           '--add-modules', moduleName,
           '--output', outputDir

    }
}

Conclusion

Conclusion

Thanks!