Introduction to writing Gradle plugins

Introduction to Gradle plugins

Most Gradle builds use at least a few plugins to add extra functionality, so why not make use of this powerful mechanism by writing your own Gradle plugins? In this article, you’ll discover how to encapsulate your own specific Gradle build logic into a plugin, in a simple and reusable way.

Gradle plugins overview

So what exactly is a Gradle plugin? It’s an add-on for your build, which you can apply using the Gradle plugin DSL (domain specific language), like this:

plugins {
    id 'org.springframework.boot' version '2.2.4.RELEASE'
}

Info: plugins which are bundled with Gradle, such as the java plugin don’t require a version

When you apply a plugin it’s going to do one of two things for you (or both):

1) Expose one or more tasks to allow you to run some specific action

An example of this type of task is the Maven publish plugin, which exposes tasks such as publishToMavenLocal to push an artifact to your local Maven repository

Often these tasks will be automatically configured into the Gradle task graph through a dependsOn relationship. This means you won’t need to run them explicitly yourself.

2) Perform some behaviour automatically when the plugin is applied

This behaviour may include configuring certain elements of the build. For example, the Java plugin configures the main and test source sets.

Configuration

Additionally, many plugins allow you to configure them in specific ways.

For example, with the Java plugin you can configure tests to be included or excluded by tag, like this:

test {
    useJUnitPlatform {
        includeTags 'fast'
        excludeTags 'slow'
    }
}

We’re going to look at how to create similar types of behaviour in the following example plugin. You’ll then be able to apply the same concepts to your own project’s specific requirements.

A plugin example

To start understanding how to write plugins in Gradle, we’re going to build up an example plugin which will do a diff (or comparison) between the file size of two files. The functionality will include:

  • ability to configure the two files to be diffed e.g.
fileDiff {
    file1 = file('testFile1.txt')
    file2 = file('testFile2.txt')
}
  • exposing a task to do the diff itself e.g. ./gradlew fileDiff
  • writing the diff result to the console and also to a target file e.g. testFile1.txt was the largest file at 43776237 bytes.
  • making use of Gradle’s incremental builds by only rerunning the task when one of the two input files changes
  • an automated integration test for the plugin, as we always test our code right? 😉

Plugin design

So what classes do we need in a plugin, and how do they relate to our specific requirements for this example?

  • plugin class – this defines what happens when the plugin gets applied. This may be to create some tasks that can be executed in the build, or configure the build in some way. In the case of the file-diff plugin, we’ll add a task to do the diff
  • extension class – this is a data object, representing the configuration that can be set for the plugin in build.gradle. For the file-diff plugin it will contain the two input files whose size will be compared.
  • task class – this class defines an action to be performed. An instance of it is normally created and bound to a task name made available to the user to run in a Gradle command. For our plugin, we’ll add a task to do the diff, and it will be available to the user by running ./gradlew fileDiff.

Publishing Gradle plugins

When you write a plugin in Gradle, if you want to share it between multiple projects then the best option is to create the plugin in a separate repository. This way, you can publish it to a private or public Maven repository, and then apply it in whatever project you need:

Of course you can also publish to Maven local (on your personal development machine), which we’ll be using for this example.

1) A new project

We’re going to start off by creating a new project for the plugin, imaginatively called file-diff-plugin. Let’s initialise the project with Git and Gradle:

mkdir file-diff-plugin
cd file-diff-plugin
git init
gradle init

You’ll also want to add .gradle/, .idea/, and build/ to a .gitignore file, so they’re not committed.

Save your fingers: if you don’t want to type out the code but do want to try this example, grab the code now from this GitHub repository.

2) Initial build script

Our build.gradle file will initially contain the following:

plugins {
    id 'groovy'
    id 'java-gradle-plugin'
}

group = 'com.tomgregory'
version = '0.0.1-SNAPSHOT'

repositories {
    jcenter()
}

dependencies {
    testImplementation('org.spockframework:spock-core:1.3-groovy-2.5') {
        exclude module: 'groovy-all'
    }
}

This simple build includes:

  • Groovy plugin – the language we’ll be using to write our plugin (you can also use Java)
  • java-gradle-plugin – sets up various configurations you need to do plugin development (read docs)
  • Spock testing framework – a Groovy testing framework, making it very easy to write give-when-then style tests

3) Extension class

The extension class represents the configuration for the plugin. Create the directory structure src/main/groovy/com/tomgregory/plugins/filediff where we’ll be putting all our classes.

Let’s create FileDiffExtension.groovy which will contain the following code:

package com.tomgregory.plugins.filediff

import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property

import javax.inject.Inject

class FileDiffExtension {
    final Property<File> file1
    final Property<File> file2

    @Inject
    FileDiffExtension(ObjectFactory objects) {
        file1 = objects.property(File)
        file2 = objects.property(File)
    }
}

We have two final properties representing the input files. Notice these are of the Property type, which is a way in Gradle to initialise a plugin’s values lazily i.e. not rely on the values being set until we need to use them. This is useful in Gradle since plugins are normally applied before they are configured.

4) Task class

Let’s create the task class, FileDiffTask.groovy, which will perform the diff itself:

package com.tomgregory.plugins.filediff

import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction

class FileDiffTask extends DefaultTask {
    @InputFile
    Property<File> file1
    @InputFile
    Property<File> file2
    @OutputFile
    File resultFile = new File("${project.buildDir}/diff-result.txt")

    @TaskAction
    def diff() {
        String diffResult
        if (file1.get().size() == file2.get().size()) {
            diffResult = "Files have the same size at ${file1.get().size()} bytes."
        } else {
            File largestFile = file1.get().size() > file2.get().size() ? file1.get() : file2.get()
            diffResult = "${largestFile.toString()} was the largest file at ${largestFile.size()} bytes."
        }

        resultFile.write diffResult

        println "File written to $resultFile"
        println diffResult
    }
}

Note here we have:

  • two input file properties defined, as in FileDiffTask.groovy. The plugin will be responsible for mapping the properties from the extension to those on the task.
  • an @InputFile annotation on the two input file properties, which means that Gradle will watch for changes to these files, and only re-run the task if they do change
  • a method annotated with @TaskAction, signifying that this method is the one to run when the task is executed. It uses the two file inputs to work out which is the larger, and generated some text output. The result is then written to the resultFile and standard output.

5) Plugin class

The plugin class, FileDiffPlugin.groovy, brings together the previous two classes:

package com.tomgregory.plugins.filediff

import org.gradle.api.Plugin
import org.gradle.api.Project

class FileDiffPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.extensions.create('fileDiff', FileDiffExtension)

        project.tasks.create('fileDiff', FileDiffTask) {
            file1 = project.fileDiff.file1
            file2 = project.fileDiff.file2
        }
    }
}

Notice here that we’ve:

  • overriding the apply method. This method gets called when the plugin gets applied in the build.gradle file.
  • creating an extension called filediff. This means that in the build.gradle in which this plugin is applied, we’ll be able to define the configuration in a filediff configuration block.
  • creating a task of type FileDiffTask. Inside the task, we’re simply mapping the properties from the FileDiffExtension to the FileDiffTask.

6) Plugin properties file

The plugin properties file is used as a way to discover a plugin by name. It needs to be located in src/main/resources/META-INF/gradle-plugins and named com.tomgregory.file-diff.properties and contains just one line:

implementation-class=com.tomgregory.plugins.filediff.FileDiffPlugin
  • the file name corresponds to how you want to reference the plugin in the Gradle plugin DSL. For example, when we want to use this plugin we’ll do:
plugins {
    id 'com.tomgregory.file-diff'
}
  • the contents of the file should be a reference to the plugin class itself

7) Adding a plugin integration test

Gradle provides a very nice mechanism for writing an integration test for our Gradle plugin. It allows us to:

  • dynamically generate a build.gradle file which references our plugin
  • run a specific task against that build
  • verify that the task ran successfully

Create a file in src/test/groovy/com/tomgregory/plugins/filediff called FileDiffPluginFunctionalTest.groovy with the following contents:

package com.tomgregory.plugins.filediff

import org.gradle.testkit.runner.GradleRunner
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import spock.lang.Specification

import static org.gradle.testkit.runner.TaskOutcome.SUCCESS

class FileDiffPluginFunctionalTest extends Specification {
    @Rule
    TemporaryFolder testProjectDir = new TemporaryFolder()
    File buildFile

    def setup() {
        buildFile = testProjectDir.newFile('build.gradle')
        buildFile << """
            plugins {
                id 'com.tomgregory.file-diff'
            }
        """
    }

    def "can successfully diff 2 files"() {
        given:
        File testFile1 = testProjectDir.newFile('testFile1.txt')
        File testFile2 = testProjectDir.newFile('testFile2.txt')

        buildFile << """
            fileDiff {
                file1 = file('${testFile1.getName()}')
                file2 = file('${testFile2.getName()}')
            }
        """

        when:
        def result = GradleRunner.create()
                .withProjectDir(testProjectDir.root)
                .withArguments('fileDiff')
                .withPluginClasspath()
                .build()

        then:
        result.output.contains("Files have the same size")
        result.task(":fileDiff").outcome == SUCCESS
    }
}

And note here:

  • we’re using a JUnit rule to create a temporary folder for our build
  • in the setup (equivalent to JUnit’s @Before method) we’re creating a build.gradle file that applies our plugin
  • in the test’s given section we’re creating two temporary files. We also append to the build.gradle the configuration defining the two files.
  • in the test’s when section we’re using GradleRunner in a library called gradle-test-kit to run the fileDiff task
  • finally, in the then section of the test we’re asserting that the build output contains the expected text and that the task completed successfully

We can now run the test by executing./gradlew test and you’ll see that it passes! ✅

Test summary in build/reports/tests/test/index.html

8) Deploy to Maven local repository

To deploy to our ~/.m2/repository local Maven repository we’ll need to add an additional plugin to the build.gradle:

plugins {
    ....
    id 'maven-publish'
}

This will help get our jar file in the right Maven format, and provide the publishToMavenLocal task.

We also need to do some additional configuration to the java-gradle-plugin, so it can add relevant details required for us to be able to reference the plugin from another project.

gradlePlugin {
    plugins {
        fileDiff {
            id = 'com.tomgregory.file-diff'
            implementationClass = 'com.tomgregory.plugins.filediff.FileDiffPlugin'
        }
    }
}

Now just run ./gradlew publishToMavenLocal and you can see the jar has been deployed to the expected location, along with all the associated metadata:

If you’re looking closely, you may also notice an additional artifact gets added to our Maven local repository:

The file-diff artifact is simply a reference to the file-diff-plugin artifact. This means we can reference the plugin in the Gradle plugin DSL as simply com.tomgregory.file-diff, as we’ll see in the following section.

Using a published Gradle plugin in another project

Let’s use our brand new plugin in another project to compare two files!

If you saw the previous article about performing a Maven to Gradle migration, you’ll remember we had a requirement to compare the size of two generated jar files. We can now make use of our plugin here to make things even easier.

Clone this GitHub repository or use your own project if you like.

Update settings.xml

We need to update settings.xml to tell Gradle to look in the Maven local repository for plugins. Do this by adding this block of code to the very beginning of the file:

pluginManagement {
    repositories {
        mavenLocal()
        gradlePluginPortal()
    }
}

Now in build.gradle apply the plugin with:

plugins {
    ...
    id 'com.tomgregory.file-diff' version '0.0.1-SNAPSHOT'
}

And configure it with this code block:

fileDiff {
    file1 = file('build/libs/maven-to-gradle-0.0.1-SNAPSHOT.jar')
    file2 = file('target/maven-to-gradle-0.0.1-SNAPSHOT.jar')
}

Info: here we’re comparing a Gradle built jar file to one built with Maven, to make sure they’re similar.

Now run the task with ./gradlew fileDiff and you’ll see a diff-result.txt file gets created in the build directory:

C:\workspace\maven-to-gradle\target\maven-to-gradle-0.0.1-SNAPSHOT.jar was the largest file at 43776237 bytes.

Final thoughts

Writing simple plugins can be a surprisingly straightforward process. It’s not only be a great way to add new custom functionality to your build, but also to clean up existing build files into simple reusable components.

If you liked this article, be sure to check out other articles here on my blog on the subject of build automation.

Resources

GITHUB
Clone this GitHub repository for access to all the code in this article

DOCS
See these great Gradle docs for more info on plugins

VIDEO
If you prefer to learn in video format, check out this accompanying video to this post on the Tom Gregory Tech YouTube channel.

Introduction to writing Gradle plugins

3 thoughts on “Introduction to writing Gradle plugins

  1. I created my own custom plugin, which just writes a sentence into an output file, including an input parameter value. I used your tutorial as a reference, but I found a few issues.

    1. My plugin was not being found in the test, because I was creating the build.gradle file indicating the plugin as `apply plugin: ‘com.cristianlm.test-plugin’`. After I changed it to `plugins { id: ‘com.cristianlm.test-plugin’} it started finding it.
    2. The GradleRunner builder was throwing NullPointerException, because of the way I declared the input variable in the task class. At the end, it worked when I initialized it in the task class constructor like so `testProperty = getProject().getObjects().property(String.class);`.
    3. I couldn’t get the output file content via `result.output`, that only gives me gradle standard output. In order to get the content of the file I did `String output = new File(testProjectDir.root.toString() + “/build/test-plugin-output.txt”).text`. I don’t know if it’s the best way, but at least the test passes now 🙂

    My plugin: https://github.com/zeitgeist2018/gradle-plugin/blob/master/src/test/groovy/com/cristianlm/gradle_plugin/TestPluginTest.groovy#L40

    1. Hi Cristian. Thanks for the comment. Nice work on creating a plugin, but I can’t access the URL you’ve provided (getting a 404). Is your repo public? If you make it public I can take a look to see what the differences are in your repo. I’ve double checked the file-diff-plugin in GitHub and it’s in working order.

Leave a Reply

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

Scroll to top

To keep up to date with all things to do with scaling developer productivity, subscribe to my monthly newsletter!