Gradle POMs revisited

Close to two years ago I posted my thoughts on introducing a Maven-like structure for organizing and building Gradle projects. I dubbed those ramblings The Gradle POM and The Gradle SuperPOM. In truth the latter is a misnomer, instead it should had been "The Gradle Parent POM". These posts served as an introduction to a new project I had been working on: the Kordamp Gradle Plugin suite. The original goal for this project was to bring order to chaos found in the dozens of Open Source projects I actively maintain. Before Kordamp began, all these projects relied on the tried and tested way to build projects with Ant^h^hGradle: copy, replace, and pray it works. Builds were arranged with a central build file (build.gradle obviously) and dedicated secondary scripts for specific responsibilities such as code coverage, quality, release, docs, etc. For the most part this worked with many projects as they shared the same structure. However updates to a particular script had to be propagated to dozens of projects, and sometimes those changes were not isolated to a single block or script. You can imagine my reluctance to upgrade en masse whenever a new Gradle release came out offering a feature I was missing.

To be fair, the state of those scripts (the years of accumulated cruft) was not Gradle's direct responsibility, because since the early days when the plugin system was added to Gradle we had the building blocks to organize the code in a better way. However there was little guidance from the makers of Gradle in this regard, and let's face it, it's so tempting to "fix" a configuration issue with just "a pair" of lines of code that hacking on build scripts became second nature without any regards to tech debt nor future consequences.

Well those consequences arrived as mentioned earlier, keeping a large set of projects up to date with the latest features and trends was quite the challenge. I knew something had to change if I wanted those projects to continue to flourish and evolve. Thus I looked back at an old friend, Maven. What is it about Maven that people like? For the most part, a recognizable structure, a strict DSL that disallows hacking on the build file thus behavior must be provided by plugins alone. On that note, yes, you can cheat by using the Antrun and Groovy plugins but let's put them aside for a moment, shall we? That got me thinking, what if a similar structure were to be provided on top of Gradle in such way that if you needed to go down one level and wire up something directly you could still do it? The answer was yes, it can be done, and it can be realized with plugins. By the way, this approach has been tried several times already by community members, early Gradle users may recall the Nebula plugin suite from Netflix, which provided lots of small plugins to bridge the gap between Maven and Gradle.

The Gradle POM

The first step was to find the commonalities between all the affected projects and come up with "model" that expresses the configuration to be applied to every project. This is the Kordamp DSL which in Maven terms would be equivalent to a POM. At first it was just a central place for defining base project metadata as you find it in Maven, that is:

  • Project name
  • Project URL
  • Project organization (if any)
  • People involved with the project (developers, contributors, etc)
  • Useful links such as website, issue tracker, scm, that could be reused by several plugins
  • Any additional data needed to publish artifacts to Maven Central.

Plus additional behavior such as:

  • Generating a JAR with all sources.
  • Generating a JAR with javadoc.
  • Configuring the generated POM with data from the DSL.
  • Signing artifacts when needed.

As time passed by, new features were added to the DSL to support aggregate reports for code quality plugins, additional tasks that deliver insight into the build's configuration, sensible defaults for commonly used plugins, and more. However it became evident that as the DSL grew so would build files that relied on the DSL; also many of the settings were shared among projects, even those that are not part of the same multi-project build. Something had to be done to avoid adding cruft again, once more Maven came back with an answer.

The Gradle Parent POM

Maven projects can define common behavior in a POM and mark said POM as a parent, in this way the child POM inherits configuration from the parent POM. Gradle does not have the concept of configuration inheritance (other than shared project properties). Plugins are typically designed to be applied to a single project. You want that behavior on a child as well? Apply the plugin to the child. However it's possible to implement plugins that can be applied to a root project and in turn be applied to all children projects. Thus the Kordamp DSL gained the capability to define common configuration at the root and have it shared down to its children, giving children projects the option to override configuration when needed. Neat.

But we're still talking between the boundaries of a single multi-project build. Maven POMs can be shared with other builds, so that you may define a parent POM and have it provide behavior to a set of single or multi project builds. They do this by simply publishing the POM as an artifact that can be resolved and referenced. Gradle has something similar: plugins. The answer to sharing a "parent" POM in Gradle is to create a plugin that provides said parent POM configuration. We gain the same benefits and drawbacks of Maven parent POMs. Benefits such as common behavior, versioned changes. Drawbacks as an update to the parent means updating all children with the parent's new version. Oh well, I guess it's alright.

And this is where we were up the POM chain when I posted 2 years ago. There is however one missing POM: the Maven Super POM. This POM is the one at the top of all chains. It provides the very basic behavior that let's you write POM files such as the following one and still get a lot of behavior "out of the box":

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.acme</groupId>
    <artifactId>sample</artifactId>
    <version>0.0.0</version>
</project>

Naturally I wondered if the same could be done for Gradle. The answer was again plugins but probably not the ones you may have seen before.

The Gradle Super POM

Buried in the Gradle documentation is the fact that you can create and apply plugins for types other than Project, such as the Gradle instance which you'd typically configured inside an init script, and the Settings instance that's available on a settings script. The settings script is responsible for defining the most basic configuration for a build, such as the root project's name and any included sub projects. In a way it's like the root POM you find in a Maven project that also defines <module> entries (when it comes to a multi-project build). As time passed by this script gained more features such as the pluginManagement block (late Gradle 5 I believe) which lets you define plugins with behavior reminiscent to Maven's <pluginManagement>. There is another block that can be applied to this script: the buildscript block. This block is identical to the one you apply to a build.gradle file and as I gather it's been available for quite a long time but to my knowledge its use wasn't so widespread until recent times.

A Settings instance lets you configure the root project, also control which additional projects (and builds as well, see Composite Builds), and tweak the build itself by registering listeners on the Gradle instance. This is the answer I was seeking, adding plugins to settings to configure a whole build. First there was the need to define which projects could be included into a build if they follow certain conventions, that is the job of the org.kordamp.gradle.settings plugin. Additional plugins that can be applied to Settings appeared as well, such as the org.kordamp.gradle.properties plugin that lets you use YAML and TOML files for property sources besides gradle.properties, the org.kordamp.gradle.inline plugin which lets you execute plugins that are not directly defined in the build file (here's a recent post comparing this feature with Maven's), and the org.kordamp.gradle.insight plugin which at the moment provides summary reports (also recently discussed).

All these plugins and features can be combined in a single plugin that configures a build with your own conventions. In the case of the Kordamp projects a pair of plugins define the conventions required by all projects, the org.kordamp.gradle.kodamp-parentpom plugin which currently looks like this

package org.kordamp.gradle

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.compile.GroovyCompile
import org.gradle.api.tasks.compile.JavaCompile
import org.kordamp.gradle.plugin.base.ProjectConfigurationExtension
import org.kordamp.gradle.plugin.bintray.BintrayPlugin
import org.kordamp.gradle.plugin.project.java.JavaProjectPlugin

/**
 * @author Andres Almiray
 */
class KordampParentPomPlugin implements Plugin<Project> {
    void apply(Project project) {
        project.plugins.apply(JavaProjectPlugin)
        project.plugins.apply(BintrayPlugin)

        if (!project.hasProperty('bintrayUsername'))  project.ext.bintrayUsername  = '**undefined**'
        if (!project.hasProperty('bintrayApiKey'))    project.ext.bintrayApiKey    = '**undefined**'
        if (!project.hasProperty('sonatypeUsername')) project.ext.sonatypeUsername = '**undefined**'
        if (!project.hasProperty('sonatypePassword')) project.ext.sonatypePassword = '**undefined**'

        project.extensions.findByType(ProjectConfigurationExtension).with {
            release = (project.rootProject.findProperty('release') ?: false).toBoolean()

            info {
                vendor = 'Kordamp'

                links {
                    website      = "https://github.com/kordamp/${project.rootProject.name}"
                    issueTracker = "https://github.com/kordamp/${project.rootProject.name}/issues"
                    scm          = "https://github.com/kordamp/${project.rootProject.name}.git"
                }

                scm {
                    url                 = "https://github.com/kordamp/${project.rootProject.name}"
                    connection          = "scm:git:https://github.com/kordamp/${project.rootProject.name}.git"
                    developerConnection = "scm:git:git@github.com:kordamp/${project.rootProject.name}.git"
                }

                people {
                    person {
                        id    = 'aalmiray'
                        name  = 'Andres Almiray'
                        url   = 'https://andresalmiray.com/'
                        roles = ['developer']
                        properties = [
                            twitter: 'aalmiray',
                            github : 'aalmiray'
                        ]
                    }
                }

                credentials {
                    sonatype {
                        username = project.sonatypeUsername
                        password = project.sonatypePassword
                    }
                }

                repositories {
                    repository {
                        name = 'localRelease'
                        url  = "${project.rootProject.buildDir}/repos/local/release"
                    }
                    repository {
                        name = 'localSnapshot'
                        url  = "${project.rootProject.buildDir}/repos/local/snapshot"
                    }
                }
            }

            licensing {
                licenses {
                    license {
                        id = 'Apache-2.0'
                    }
                }
            }

            docs {
                javadoc {
                    excludes = ['**/*.html', 'META-INF/**']
                }
                sourceXref {
                    inputEncoding = 'UTF-8'
                }
            }

            bintray {
                enabled = true
                credentials {
                    username = project.bintrayUsername
                    password = project.bintrayApiKey
                }
                userOrg = 'kordamp'
                repo    = 'maven'
                name    = project.rootProject.name
                publish = (project.rootProject.findProperty('release') ?: false).toBoolean()
            }

            publishing {
                releasesRepository  = 'localRelease'
                snapshotsRepository = 'localSnapshot'
            }
        }

        project.allprojects {
            repositories {
                jcenter()
                mavenCentral()
            }

            normalization {
                runtimeClasspath {
                    ignore('/META-INF/MANIFEST.MF')
                }
            }

            dependencyUpdates.resolutionStrategy {
                componentSelection { rules ->
                    rules.all { selection ->
                        boolean rejected = ['alpha', 'beta', 'rc', 'cr'].any { qualifier ->
                            selection.candidate.version ==~ /(?i).*[.-]${qualifier}[.\d-]*.*/
                        }
                        if (rejected) {
                            selection.reject('Release candidate')
                        }
                    }
                }
            }
        }

        project.allprojects { Project p ->
            def scompat = project.findProperty('sourceCompatibility')
            def tcompat = project.findProperty('targetCompatibility')

            p.tasks.withType(JavaCompile) { JavaCompile c ->
                if (scompat) c.sourceCompatibility = scompat
                if (tcompat) c.targetCompatibility = tcompat
            }
            p.tasks.withType(GroovyCompile) { GroovyCompile c ->
                if (scompat) c.sourceCompatibility = scompat
                if (tcompat) c.targetCompatibility = tcompat
            }
        }
    }
}

And the org.kordamp.gradle.kordamp-parentbuild plugin which configures the build and applies the parent POM plugin to the root.

package org.kordamp.gradle

import enforcer.rules.BanDuplicateClasses
import enforcer.rules.DependencyConvergence
import enforcer.rules.EnforceBytecodeVersion
import enforcer.rules.RequireJavaVersion
import org.gradle.BuildAdapter
import org.gradle.api.Plugin
import org.gradle.api.initialization.Settings
import org.gradle.api.invocation.Gradle
import org.gradle.api.plugins.ExtraPropertiesExtension
import org.kordamp.gradle.plugin.enforcer.BuildEnforcerPlugin
import org.kordamp.gradle.plugin.enforcer.api.BuildEnforcerExtension
import org.kordamp.gradle.plugin.inline.InlinePlugin
import org.kordamp.gradle.plugin.insight.InsightPlugin
import org.kordamp.gradle.plugin.settings.ProjectsExtension
import org.kordamp.gradle.plugin.settings.SettingsPlugin

/**
 * @author Andres Almiray
 */
class KordampParentBuildPlugin implements Plugin<Settings> {
    void apply(Settings settings) {
        settings.plugins.apply(SettingsPlugin)
        settings.plugins.apply(InlinePlugin)
        settings.plugins.apply(InsightPlugin)
        settings.plugins.apply(BuildEnforcerPlugin)

        ExtraPropertiesExtension ext = settings.extensions.findByType(ExtraPropertiesExtension)
        String scompat = ext.has('sourceCompatibility') ? ext.get('sourceCompatibility') : ''
        String tcompat = ext.has('targetCompatibility') ? ext.get('targetCompatibility') : ''
        String javaVersion = scompat ?: tcompat

        settings.extensions.findByType(ProjectsExtension).with {
            layout      = 'two-level'
            directories = ['docs', 'subprojects', 'plugins']
        }

        settings.extensions.findByType(BuildEnforcerExtension).with {
            if (javaVersion) {
                rule(EnforceBytecodeVersion) { r ->
                    r.maxJdkVersion.set(javaVersion)
                }
                rule(RequireJavaVersion) { r ->
                    r.version.set(javaVersion)
                }
            }
            rule(DependencyConvergence)
            rule(BanDuplicateClasses) { r ->
                r.ignoreWhenIdentical = true
            }
        }

        settings.gradle.addBuildListener(new BuildAdapter() {
            @Override
            void projectsLoaded(Gradle gradle) {
                gradle.rootProject.pluginManager.apply(KordampParentPomPlugin)
            }
        })
    }
}

These two plugins let me configure a project with all the desired conventions and defaults, leaving the build files to define the missing bits or the data that does not follow the conventions, such as the Ezmorph project shows with its settings.gradle file

pluginManagement {
    repositories {
        jcenter()
        gradlePluginPortal()
        mavenLocal()
    }
    plugins {
        id 'org.kordamp.gradle.coveralls' version kordampPluginVersion
        id 'org.kordamp.gradle.guide'     version kordampPluginVersion
        id 'org.ajoberstar.git-publish'   version gitPluginVersion
    }
}

buildscript {
    repositories {
        gradlePluginPortal()
        jcenter()
        mavenLocal()
    }
    dependencies {
        classpath "org.kordamp.gradle:kordamp-parentbuild:$kordampBuildVersion"
    }
}
apply plugin: 'org.kordamp.gradle.kordamp-parentbuild'

rootProject.name = 'ezmorph'

And its build.gradle file

plugins {
    id 'org.kordamp.gradle.coveralls'
}

config {
    info {
        name          = rootProject.name
        description   = 'Simple Java library for transforming an Object to another Object'
        inceptionYear = '2006'
        tags          = ['ezmorph', 'converter', 'transformer']

        specification  { enabled = false }
    }

    docs {
        javadoc {
            enabled = true
            autoLinks {
                enabled = false
            }
        }
    }
}

allprojects {
    apply plugin: 'idea'
}

idea {
    project {
        jdkName project.sourceCompatibility
        languageLevel project.sourceCompatibility

        ipr {
            withXml { provider ->
                def node = provider.asNode()
                node.component.find { it.'@name' == 'VcsDirectoryMappings' }?.mapping[0].'@vcs' = 'Git'
            }
        }
    }
}

I no longer dread upgrading to a new Gradle version, nor fret to update lots of small scripts. I'm confident most changes can be pushed to the parent POM and parent build plugins so that changes to consuming projects are left to just upgrading versions most of the times.

TL;DR

What's described here is a technique for leveraging Gradle plugins. This technique has been available since the early days however not that many took advantage of it. Whether you use the Kordamp Gradle Plugin suite or not it's not really important, what's important is to keep cruft from attaching itself to your builds and plugins are the current way to make it happen. I leave you with a paraphrased quote from Ken Sipe, former CTO of Gradleware:

Your build files should describe how your build deviates from the [plugin] conventions, nothing more.

Ken was right all along.

Keep on coding!

Image by Edeni Mendes da Rocha Teka from Pixabay

Liked it? Take a second to support aalmiray on Patreon!
Become a patron at Patreon!

Trackbacks/Pingbacks

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

ˆ Back To Top