Task Dependencies: Gradle

Steven J Zeil

Last modified: Mar 29, 2024
Contents:

Abstract

Gradle is a build manager based upon an Ant-like task dependency graph expressed in a more human-friendly notation, with a Maven-like ability to express standard project layouts and build conventions.

In this lesson we look at how Gradle combines some of the better features of Ant and Maven, while providing a more convenient notation than either.

We will look at how Gradle could be applied to some of our sample projects.


1 Gradle Overview

1.1 What to keep and leave from Ant

Keep:

Leave


“XML was an easy choice for a build tool a decade ago, when it was a new technology, developers were enthusiastic about it, and no one yet knew the pain of reading it in large quantities. It seemed to be human-readable, and it was very easy to write code to parse it. However, a decade of experience has shown that large and complex XML files are only easy for machines to read, not for humans. Also, XML’s strictly hierarchical structure limits the expressiveness of the format. It’s easy to show nesting relationships in XML, but it’s hard to express program flow and data access the way most common programming language idioms express them. Ultimately, XML is the wrong format for a build file.”

Tim Berglund, Learning and Testing with Gradle

1.2 What to keep and leave from Maven

Keep:

Leave

1.3 What does Gradle offer?

Goals scripting make ant maven gradle
easy to use? Y Y Y Y Y
easy to set up for a given project? N N N Y 1 Y
efficient: avoid redundant/unnecessary actions N Y ? Y ?
efficient: detect and abort bad builds in progress ? Y ? Y Y
incremental: allow focused/partial builds ? Y Y ? Y
flexible: allow for a variety of build actions N Y Y N Y
flexible: builds on/for a variety of platforms N N Y Y Y
configurable: permit the management of multiple artifact configurations ? ? Y Y Y

1: But can be very hard to modify.

1.4 The Gradle Wrapper

Suppose that you are building your project with Gradle.

If you set up your project with the Gradle Wrapper, you get a simple script named gradlew.

This works nicely for projects that distribute their source code via any of the version control systems that we will be discussing later.

2 gradle by example

A gradle project is controlled by two build files

I’ll work through a progressive series of builds for this project.

2.1 Simple Java Build

Using the Java plugin, a basic build without unit tests is easy:

settings.gradle

// A simple Java project

This file needs to exist, but can be empty.

build.gradle

plugins {
   id 'java'  ➀
}

repositories { ➁
    mavenCentral()
}

This is all you need to compile Java code stores in src/main/java.

2.2 Java Build with Third-Party Libraries

plugins {
   id 'java'
}

java {
    sourceCompatibility = JavaVersion.toVersion(17)
    targetCompatibility = JavaVersion.toVersion(17)
}

repositories {                ➁
    mavenCentral()  
}

dependencies {                ➂
    implementation 'com.opencsv:opencsv:5.1'
    implementation 'commons-io:commons-io:2.6'
}


Gradle will fetch the requested libraries and any other libraries that they depend upon, stashing them away in a cache (usually within ~/.gradle/).

More importantly, gradle will then appropriately modify the CLASSPATH of any Java compilation commands it rules to include those libraries, making those libraries available to the compilation. Because those command modifications can be both large and complicated, it’s really nice to have it all taken care of for us.

2.3 Java Applications

If you are building a Java application (as opposed to a library), a program that is designed to be run directly, you can modify yur build with the application plugin.

settings.gradle

// A simple Java application

This file needs to exist, but can be empty.

build.gradle

plugins {
   id 'java'
   id 'application'  ➀
}

repositories {
    mavenCentral()
}

application { // Allows  gradle run --args="command line arguments"
    mainClass = 'packages.MainClass'  ➁
}

The advantage of adding this plugin is that it allows us to run the main program from within gradle.

./gradlew run

That can be very convenient when combined with the previous example, where we ask gradle to fetch 3rd party libraries. Running a program with 3rd party libraries would normally require complicated changes to the Java CLASSPATH, but this plugin takes care of that for us.

If your Java program expects to take command-line parameters, you can pass those into gradle via the --args parameter. For example, the equivalent of the java command:

 java -cp complicated-class-path packages.MainClass path-to-input-file -n 42

would be

./gradlew run --args="path-to-input-file -n 42"

2.4 Java Build with Unit Tests

If you have unit tests, we need to add a little more info.

build.gradle

plugins {
   id 'java'
}

java {
    sourceCompatibility = JavaVersion.toVersion(17)
    targetCompatibility = JavaVersion.toVersion(17)
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.7.0' ➀
    testImplementation 'org.hamcrest:hamcrest-library:2.2'
}

test {  ➁
    ignoreFailures = true ➂
    useJUnitPlatform()
}

The two sections at the end establish that

2.5 C++ Multi-project Build

 

A multi-project build in gradle is kept in a directory tree.

2.5.1 The master project

Any multi-project build in Gradle uses the settings.gradle file (usually in the common root containing the subproject directories) to identify the subprojects:

rootProject.name = 'manhattan'

include "application", "geomlib"

In this case, we have two subprojects. One provides the executable, the other a library of ADTs, with unit tests, constituting the bulk of the code design.

There is no need for build.gradle file at this level, although it would be legal to provide one if you have project-level steps to be carried out.

2.5.2 The geomlib subproject

Of the two subprojects, the geomlib subproject is probably most interesting because it does the most. It compiles code into a library, then compiles and runs unit tests on that library code. By contrast, the application subproject only compiles code.

Here is the build.gradle file for the lib subproject:

build.gradle.lib.listing
plugins {
    id 'cpp-library'
	id 'cpp-unit-test'
}


unitTest {
    binaries.whenElementKnown(CppTestExecutable) { binary ->
        if (binary.targetMachine.operatingSystemFamily.linux) {
            binary.linkTask.get().linkerArgs.add('-pthread')
        }
    }
}

2.5.3 The application subproject

The application subproject is simpler, because it only has one job to do – create an executable. This project depends on the geomlib subproject. The library must have been constructed before the code in the application subproject can be compiled.

Here it is:

build.gradle.exe.listing
plugins {
		id 'cpp-application'
}

dependencies {
			implementation  project(':geomlib')
}

You can find this entire project, with the Gradle files, here.

3 Customizing Tasks

The gradle build files are Groovy scripts, with Java-like syntax. Many of the more common Java API packages are imported automatically.

task upper {
    doLast {
       String myName = "Steven Zeil";
       System.out.println(myName);
       System.out.println(myName.toUpperCase());
    }
}

If this is placed in build.gradle, then we can run it:

$ gradle upper
:upper
Steven Zeil
STEVEN ZEIL

BUILD SUCCESSFUL

Total time: 1.747 secs
$

Actually, however, that code is not very Groovy-ish. Groovy offers lots of shortcuts. Semicolons at the ends of statement are optional. Parentheses around function parameter lists are optional. Some commonly used objects like System.out can be used explicitly:

task upper {
    doLast {
       String myName = "Steven Zeil"
       println myName;
       println myName.toUpperCase()
    }
}

3.1 Gradle Tasks

The basic elements of a Gradle build file are tasks.

3.2 Anatomy of a Task

3.2.1 The phases of a Gradle run

Before looking at the components of a task, we need to understand a bit about how Gradle works.

 

A Gradle run occurs in four specific phases.

  1. Initialization takes care of things that affect how Gradle itself will run. The most visible activity during this phase is loading Gradle plugins that add new behaviors.

  2. Configuration involves collecting the build’s tasks, setting the properties of those tasks, and then deciding which tasks need to be executed and the order in which that will be done.

  3. Execution is when the tasks that need to be executed get run.

  4. Finalization covers any needed cleanup before the Gradle run ends.

Software developers will be mainly concerned with the middle two phases. For example, if we need to compile some Java source code for a project, then we would want to configure that task by indicating the source code directory (or directories) and the destination for the generated code. With that information, Gradle can look to see if the .class files already exist from a prior run and whether the source code has been modified since then. Gradle can then decide whether or not the compilation task actually needs to be run this time. This decision is remembered during the execution phase when the task will or will not be run, accordingly.

3.2.2 Declaring tasks

A Gradle task can be declared as easily as:

task myTask
 

Some tasks may need parameters:

task copyResources (type: Copy)

3.2.3 Configuring tasks

You can add code to be run at configuration time by putting it in { } brackets just after the task name:

task copyResources (type: Copy) {
    description = 'Copy resources into a directory from which they will be added to the Jar' ➀
    group = 'setup'                                                                          ➁
    from(file('src/main/resources'))                                                         ➂
    into(file('target/classes'))
}

You can also do most of these configurations later:

task copyResources (type: Copy)  // declares the task
    ⋮ 
copyResources {  // configures the task
    description = 'Copy resources into a directory from which they will be added to the Jar'  ➀
    group = 'setup'                                                                           ➁
    from(file('src/main/resources'))                                                          ➂
    into(file('target/classes'))
}

That last block introduces another Groovy-ism. Some programming languages (not Java or C++) provide a with statement that allows you to temporarily open up a class or structured type.

struct LinkedListNode {
    int data;
    LinkedListNode* next;
};
LinkedListNode node;

with node do {
    data = 42;
    next = null;
}

The code in the with block above is understood to be equivalent to

{
    node.data = 42;
    node.next = null;
}

Groovy’s version of this omits the keywords, leaving us with just the name of the object followed by code inside { }.

copyResources {  // configures the task
    description = 'Copy resources into a directory from which they will be added to the Jar'  ➀
    group = 'setup'                                                                           ➁
    from(file('src/main/resources'))                                                          ➂
    into(file('target/classes'))
}

You’ve actually seen this construct used in the earlier case studies. For example,

test {
    ignoreFailures = true
    useJUnitPlatform()
}

could also have been written

test.ignoreFailures = true
test.useJUnitPlatform()

But the Gradle community heavily favors the use of the structured “with” blocks.

3.2.4 Executable Behavior

3.2.5 Adding Executable Behavior

3.3 Task Dependencies

 

task setupFormat

task setupGraphics

task setupSourceCode


task generatePDFs (dependsOn: 'setup') ➀
generatePDFs.doLast {  
    println 'in task generatePDFs'
}

task setup (dependsOn: ['setupFormat',    ➁
                        'setupGraphics',
                        'setupSourceCode'])
setup.doLast {
   println 'in task setup'
}

task deploy (dependsOn: generatePDFs)
deploy.doLast {
    println 'in task deploy'
}
 

Running this gives:

$ gradle deploy
:setupFormat UP-TO-DATE
:setupGraphics UP-TO-DATE
:setupSourceCode UP-TO-DATE
:setup
in task setup
:generatePDFs
in task generatePDFs
:deploy
in task deploy

The task class in Gradle also supports a dependsOn function that can be used to later augment the dependencies of an existing class.

For example, we could rewrite the generatePDFs task above as

task generatePDFs {
    generatePDFs.doLast {
        println 'in task generatePDFs'
    }
}

and later, after both tasks generatePDFs and setup have both been declared, said

generatePDFs.dependsOn setup

Or, if you prefer a slightly more Java-ish style:

generatePDFs.dependsOn(setup);

Or you could put the code into a “with” block:

generatePDFs {
    dependsOn setup
}

3.3.1 Task inputs and outputs

Gradle will attempt to determine whether tasks actually need to be run by examining their inputs and outputs, checking to see if the outputs exist already and are more up-to-date than the inputs.

“Built-in” task types like Copy will tell Gradle about their inputs and outputs based upon the other parameters (from and into). When we write our own tasks, it’s advisable to declare the task input and outputs explicitly. If we don’t Gradle will have to run the task on every build where the dependsOn relations suggest that it is relevant.

For example, suppose that I have a Java project that includes a program in class CS350.GenerateTable that converts .csv files into HTML tables, and that I want to use it, as part of my build, to create a table from src/main/data/table1.csv. The basic task will look like this:

task generateTable1 (dependsOn: 'jar')  { ➀
    doLast {
        exec {                            ➁
            commandLine = ['java', '-cp', 'build/libs/myProject.jar', 'CS350.GenerateTable', 
            'src/main/data/table1.csv', 'build/data/table1.html']
        }
    }
}

Now, this task would work, but if we had other tasks that dpeend upon it, Gradle would run generateTable1 every time, because it can’t possibly figure out from our command line what are the inputs and what are the outputs.

We can supply that information as part of the task configuration:

task generateTable1 (dependsOn: 'jar')  {
    inputs.file('build/libs/myProject.jar')     ➀
    inputs.file('src/main/data/table1.csv')
    outputs.file('build/data/table1.html')
    doLast {
        exec {
            commandLine = ['java', '-cp', 'build/libs/myProject.jar', 'CS350.GenerateTable', 
            'src/main/data/table1.csv', 'build/data/table1.html']
        }
    }
}

3.3.2 Appending to Tasks

doLast (and doFirst) add actions to a task.

If we add the following to the previous script:

setup.doLast {
    println 'still in task setup'
}
$ gradle deploy
:setupFormat UP-TO-DATE
:setupGraphics UP-TO-DATE
:setupSourceCode UP-TO-DATE
:setup
in task setup
still in task setup
:generatePDFs
in task generatePDFs
:deploy
in task deploy

3.4 Tasks

At its heart, a build file is a collection of tasks and declarations.

3.4.1 Using Java within tasks

task playWithFiles {
    doLast {
        def files = file('src/main/data').listFiles().sort()    ➀
        files.each { File file ->                               ➁
            if (file.isFile()) {                                ➂
                def size = file.length()                        ➃
                println "** $file.name has length " + size      ➄
            }
        }
    }
}

3.5 Running Java Programs

Earlier, we saw how to run a project’s main class via the gradlew run… target.

You may want to run other Java programs from your build, or to run the same program with different parameters. This can be accomplished via a gradle JavaExec task, which lets you run a Java program with the CLASSPATH preset to include your compiled code and all of your downloaded libraries.

For example, suppose that we are building a program that has a main function in class edu.odu.cs.cs350.Example, and that we want to give it test data files in src/test/data/file1.dat and src/test/data/file2.dat as command-line parameters.

task runExample(type: JavaExec, dependsOn: 'jar') {            ➀
    classpath = sourceSets.main.runtimeClasspath               ➁
    mainClass = 'edu.odu.cs.cs350.Example'                     ➂
    args 'src/test/data/file1.dat', 'src/test/data/file2.dat'  ➃
}

This would enable us to run our program with the gradle command

./gradlew runExample

If you have a self-contained (a.k.a “fat”) jar, you can run that jar directly without specifying the class name:

task runFatJar(type: JavaExec, dependsOn: 'jar') {            
    classpath = files('build/libs/project.jar')
    args 'src/test/data/file1.dat', 'src/test/data/file2.dat'
}

It’s also possible to use these tasks to run the program in debug mode.