Stack to the Future - How I migrated an automated build from Maven to Gradle.

tohaker

Miles Bardon

Posted on September 20, 2019

Stack to the Future - How I migrated an automated build from Maven to Gradle.

Introduction

When I was introduced to the GazePlay project, I jumped at the chance to try to fix some of the issues on it, and just get to grips with contributing to an Open Source project that was really doing some good.

One problem; I couldn't build it

The project had a Maven build system, and everything hinged on it. From build and test, through to releases, everything was written in huge XML files, which (in my humble opinion) makes it difficult to read and understand. There was a huge dependency on the developer working on the project having the JavaFX libraries on their global class-path. Not only this, but the project was built on Java 8, with an out-of-date version of JavaFX (i.e. the one included in older patches of Oracle Java 8).

This was not a sustainable development environment, so I took it upon myself that my first contribution would be to leverage the power of Gradle to simplify the build, and the new features and support of OpenJDK 11 and OpenJFX 11. The intention was to allow any developer to clone the project and run it with little-to-no setup. This article is a guide to how I acheived this, and is in no way a definitive guide to migration.

Build, Test and Dependencies

The first step in this task was to get gradle to do most of the hard work. By running the command gradle init in a directory with a Maven project, you can get Gradle to create the wrapper scripts, properties files and a build.gradle file for the equivalent pom.xml file in the root and inner projects.

Here is my new root build.gradle file:

/*
 * This file was generated by the Gradle 'init' task.
 */

allprojects {
    group = 'com.github.schwabdidier'
    version = '1.6.2-SNAPSHOT'
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'maven-publish'

    repositories {
        mavenLocal()
        maven {
            url = 'http://jcenter.bintray.com'
        }

        maven {
            url = 'https://jitpack.io'
        }

        maven {
            url = 'http://repo.maven.apache.org/maven2'
        }

        maven {
            url = 'https://raw.github.com/agomezmoron/screen-recorder/mvn-repo'
        }
    }

    dependencies {
        compileOnly 'com.google.code.findbugs:findbugs:3.0.1'
    }

    sourceCompatibility = '1.8'

    publishing {
        publications {
            maven(MavenPublication) {
                from(components.java)
            }
        }
    }

    tasks.withType(JavaCompile) {
        options.encoding = 'UTF-8'
    }
}

And here is one of the subproject build.gradle files:

/*
 * This file was generated by the Gradle 'init' task.
 */

dependencies {
    compile 'com.github.agomezmoron:screen-recorder:0.0.3'
    compile project(':gazeplay-commons')
    compile project(':gazeplay-data')
    ...
}

What this doesn't do, however, is migrate the Maven Plugins. In particular, to get the build working, I had to add in the javafx and lombok annotation processing plugins to the root build.gradle. License reporting was also required, so I included this too.

plugins {
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.8'
    id 'io.freefair.lombok' version '4.0.1'
    id 'com.github.hierynomus.license-report' version '0.15.0'
}

The final steps to get a build working was to add the plugins to each subproject recursively, and configure them as needed.

Handily, the Gradle initialisation had already provided me with the subproject block and I just had to add this configuration to it;

subprojects {
    apply plugin: 'java'
    apply plugin: 'maven-publish'
    apply plugin: 'org.openjfx.javafxplugin'
    apply plugin: 'io.freefair.lombok'
    apply plugin: 'com.github.hierynomus.license-report'

    javafx {
        version = "11.0.2"
        modules = [ 'javafx.controls', 'javafx.swing', 'javafx.media', 'javafx.web' ]
    }
   ...
}

Note: At this stage I had set my JAVA_HOME to point to my local installation of AdoptOpenJDK 11. As this requires a modular version of OpenJFX, I had to go through all the project imports and determine which modules to include. This wasn't mimicked in the Maven POM as that just needed to import JavaFX Controls.

A few library upgrades, swapping out deprecated APIs and some documentation later, the build was succeeding and I could open my PR! Huzzah!

I could test the Gradle configuration worked by running gradlew run to run the application from the commandline. The games worked as expected, and I could move onto the next stage.

Release the hounds Jars

One of the key goals of this task was to be able to bundle releases to users of the application, in the form of a launchable .jar file. Fortunately, Gradle recognised all my dependencies and added their Jars to the lib folder when I ran the distZip task from the distribution plugin. What the launch jar couldn't do was FIND any of the dependencies!

To fix this, I had to adjust the jar manifest configuration to add both the Main-Class to launch, and the Class-Path for all the runtime dependencies.

jar {
    manifest {
        attributes (
            "Main-Class": 'net.gazeplay.GazePlayLauncher',
            "Class-Path": configurations.runtime.collect { it.getName() }.join(' ')
        )
    }
}

By collecting all the runtime configurations, and iterating through them all, I could getName and join them together with a space.

Now, launching the created gazeplay-project-1.6.2-SNAPSHOT.jar would actually load the other Jars into the classpath and allow the games to run!

Custom Packaging Task

Since packaging the app for release required a few steps, I decided to create my own custom task in a new .gradle file.

import org.apache.tools.ant.filters.ReplaceTokens

task packageApp(dependsOn: ['assembleDist', 'downloadLicenses', 'scriptsForRelease']) {
    tasks.findByName('assembleDist').mustRunAfter('downloadLicenses')
    tasks.findByName('assembleDist').mustRunAfter('scriptsForRelease')
}

task scriptsForRelease(type: Copy) {
    from "${rootDir}/gazeplay-dist/src/main/bin"
    into "${rootDir}/build/scripts"
    filter(
        ReplaceTokens, tokens: [VERSION: project.version, NAME: project.name]
    )

    outputs.upToDateWhen { false } // Forces the task to rerun every time, without requiring a clean.
}

The packageApp task downloads all the licenses for the dependencies, carries out the scriptsForRelease task to substitute environment variables for the project version and name, and then packages these all up into a Zip and Tar archive.

Gradle Release Plugin

By default, Gradle does not included a direct equivalent to the maven-release-plugin that the project had been using up to this point. The release plugin is designed to ensure all files are commited and pushed to master, and then tags the commit with the version number in the project. Finally, it increments this version and commits everything again, ready for further development.

For this project, I went for the most popular Gradle iteration of the plugin; the Researchgate Release plugin. All that was needed to setup the plugin was specify that the packageApp task was executed before the release ran; beforeReleaseBuild.dependsOn packageApp.

Creating a custom JRE

The main feature of JDK 9 was the introduction of the modular JDK and JRE. This is of course prevalent in Open JDK 11, and is key to creating a custom JRE for distribution along with the app. To do this, I opted to use the Badass Runtime Plugin, which wraps the jlink command with a Gradle Task. By defining the exact modules the application requires to run, it's possible to generate a JRE as a build task, with the included jre task.

Here is a snippet of the modules included in the JRE created:

ext.jreModules=['java.base',\
  'java.compiler',\
  'java.datatransfer',\
  'java.desktop',\
  'java.instrument',\
  'java.logging',\
  'java.management',\
  'java.management.rmi',\
  'java.naming',\
...

All that is left to do is configure the runtime plugin as so:

runtime {
    options = ['--compress', '2', '--no-header-files', '--no-man-pages']
    modules = jreModules
    jreDir = file("gazeplay-dist/src/jre")
}

The jlink command options are specified to keep the folder as small as possible, and the directory is set to match where the JRE 1.8 was located, for consistency with the existing project.

Conclusion

It may not have been the most glamourous project, but I learnt a whole lot about Gradle, custom tasks and third-party plugins. Additionally, I'm no expert on custom JREs, or the modularity of Java 9+, but this project gave me the step up needed to start investigating and learning more about it. In the end, I hope this article helps someone solve that little annoying problem they have, or is just an interesting read for someone going through the same process.

For more details on everything I've discussed here, feel free to check out the PR with all the code.

💖 💪 🙅 🚩
tohaker
Miles Bardon

Posted on September 20, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related