Task Dependencies: Gradle
Steven J Zeil
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
-
Gradle devised by GradleWare, founded by Hans Dockter, released in 2012
-
Has become the standard build tool for Android
-
Tries to strike a middle ground between Ant and Maven
1.1 What to keep and leave from Ant
Keep:
- Portability
- Build commands are described in a platform-independent manner.
- Flexibility
- Almost any sequence of processing steps can be described.
- Allows for unconventional build sequences
- Allows for unconventional build targets
Leave
- XML as a build language.
“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
- Inability to express simple control flow
- e.g., loop through all files in a directory and do task
1.2 What to keep and leave from Maven
Keep:
-
Dependency management
- Gradle will work with Maven & Ivy repositories.
- Early versions of Gradle actually used Ivy, though eventually it gained its own dependency manager.
- Gradle will work with Maven & Ivy repositories.
-
Archetypes
- If your project has nothing unusual about its build, you can sit back and use the defaults.
Leave
-
XML as a build language.
-
Inflexibility
- If your project has anything unusual about its build, changing the defaults in Maven is frustrating.
-
Inability to express simple control flow
1.3 What does Gradle offer?
- Build files are written in Groovy
- Groovy is a scripting language that runs in a Java JVM
- Syntax is Java-based
- Interfacing with Java code is easy
- Gradle adds build-specific functions as a Groovy library
- Groovy is a scripting language that runs in a Java JVM
- Gradle is slowly moving away from Groovy towards a similar language named Kotlin.
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.
- Other people may try to check out a copy of your code, including your Gradle build file.
- But maybe they won’t have Gradle on their machine.
If you set up your project with the Gradle Wrapper, you get a simple script named gradlew
.
- You invoke your build via ‘gradlew’ instead of ‘gradle’
gradlew
checks to see if the system on which it is running has Gradle installed.- If not, it downloads a copy and installs it under
$USER_HOME/.gradle
- If not, it downloads a copy and installs it under
- It then invokes
gradle
(old installation or new), passing any parameters you supplied to thegradlew
command.
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
settings.gradle
in the project root directory gives project-wide info- name and version of the project
- names of any subproject directories
build.gradle
, in each subproject directory, desctibes the tasks for that subproject
I’ll work through a progressive series of builds for this project.
- In each of the following examples, the command
gradle build
or, if you are using the gradlew wrapper (and you should)
./gradlew build
(possibly modified with the appropriate path to your copy of the wrapper), will compile the production code, compile the unit test code, and will run the unit tests and prepare an HTML report.
- There are other targets that you can use, but
build
is the most common. To see the list of possible targets (tasks), give the command./gradlew tasks --all
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 loads and runs the Java plugin.
-
As long as our source code is arranged in the Apache/Android directory style, it will be handled correctly.
-
-
➁ The
repositories
section loads frommavenCentral
(the default). Even this could probably be omitted in this build.
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'
}
-
➀ Most open-source libraries in public repositories are compiled with Java 17.
-
➁ The
repositories
area identifies the locations from which third party libraries will be downloaded. -
➂ The
dependencies
list identifies the libraries that we want to use. We’ll look at this in more detail in a later lesson.
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' ➁
}
- ➀ This adds the application plugin.
- ➁ Here we configure the
application
by specifying which class in our code contains themain(...)
function that is used to launch the program.
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
- ➀ When compiling our unit tests, we need to have the JUnit and Hamcrest matcher libraries available.
- ➁ The code in the
src/test
directory contains our JUnit tests. - ➂ Don’t stop the build just because we fail some tests.
2.5 C++ Multi-project Build
A multi-project build in gradle
is kept in a directory tree.
-
The top-most directory is the master project.
-
It contains the
settings.gradle
file.
-
-
Each subproject directory contains its own
build.gradle
file.
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:
plugins {
id 'cpp-library'
id 'cpp-unit-test'
}
unitTest {
binaries.whenElementKnown(CppTestExecutable) { binary ->
if (binary.targetMachine.operatingSystemFamily.linux) {
binary.linkTask.get().linkerArgs.add('-pthread')
}
}
}
-
This listing starts off with two plugins, one for C++ compilation (into a reusable library) and the other for unit testing.
-
The unit test plugin can work with a variety of frameworks. Eventually, you should be able to load these frameworks as dependencies, much as you do in Java projects.
In this case, I am using CppUnitLite because it can be easily dropped into the directory with the unit tests.
-
Like most unit test frameworks, this one runs the tests in a separate thread (process). The
unittest
configuration here adds the requiredpthread
library when compiling under Linux.
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:
plugins {
id 'cpp-application'
}
dependencies {
implementation project(':geomlib')
}
- Very simple: we invoke the
cpp-application
plugin and - Indicate that this depends on the
:geomlib
subprojectThis dependency guarantees that the
geomlib
library will be constructed before this application is built and that it will be automatically included into the application compilation and link steps.
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.
-
Gradle tasks can correspond Ant targets.
A Gradle task can perform multiple actions.
task upper { doLast { String myName = "Steven Zeil"; println myName; println myName.toUpperCase(); } }
-
Gradle tasks can correspond individual Ant tasks.
A Gradle task can perform multiple actions.
task copyResources (type: Copy) { from(file('src/main/resources')) into(file('target/classes')) }
In Ant, we would have used a
<copy>
task within a larger Ant target for this purpose.
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.
-
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.
-
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.
-
Execution is when the tasks that need to be executed get run.
-
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'))
}
- ➀ The
description
supplies a documention string that will be displayed when someone runsgradle tasks
to get a list of available tasks for ap roject. - ➁ The
group
is also documentation. Tasks in the same group are listed together. - ➂ The
from
andinto
parameters are the actual parameters for theCopy
task type.
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
- Task types often have pre-defined behaviors.
-
For example, the
Copy
type copies files at execution timetask copyResources (type: Copy) { from(file('src/main/resources')) into(file('target/classes')) }
The
from
andinto
calls are performed at configuration time.The
Copy
type actually copies the files at execution time.gradle
will check to see, at configuration time, if the files already exist at the destination and appear to be no olderthan the ones at the source. If so, thecopyResources
task will be skipped at execution time.
-
3.2.5 Adding Executable Behavior
- You can add code to be run at execution time by attaching it, within
{ }
, using thedoLast
operation.task copyResources (type: Copy) { from(file('src/main/resources')) into(file('target/classes')) doLast { println 'Copy has been done.' } }
or you can use that operation to add the code to an already-declared task object.
task copyResources (type: Copy) { from(file('src/main/resources')) into(file('target/classes')) } ⋮ copyResources.doLast { println 'Copy has been done.' }
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'
}
-
➀ : This line shows a dependency. Note that the task on which we are depending has not been declared yet.
The idea of dependencies is fundamental to Gradle. A task will not be executed until all of the tasks that it depends on have successfully executed.
-
➁ : The
[a, b, ...]
notation introduces a Groovy array value.The
dependsOn
parameter expects an array. But note that we did not use an array at ➀. Like most scripting languages, Groovy has lots of little shortcuts designed to make like easier for programmers. At ➀, we got away due to a shortcut that allows a single element to be passed as an array of length 1.
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']
}
}
}
- ➀ We make this new task depend on
jar
so that the Java source code will have been compiled and packaged into a jar file before we try to execute it. - ➁
exec
is a Gradle function that executes commands as if typed at the command line of a shell. In this case, we are running our compiled Java program.Besides the
commandLine
parameter,exec
can also be supplied aworkingDir
parmaeter for the directory from which to run the command-line, but that defaults to ‘.’.
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']
}
}
}
- ➀ The
inputs
andoutputs
specifications cna be repeated as necessary to describe all inputs.Common variants on the
.file
calls shown here are.dir(path)
to indicate that the entire contents of a directory are inputs/outputs..property(name)
, for inputs only, to indicate that a named property (e.g.,commandLine
above) is actually an input so that the task should be rerun when the value of that property changes..upToDateWhen({expression})
, for outputs only, allows you to specify a condition (boolean expression) to indicate when the task is already up-to-date.
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.
-
A task is a Groovy function. It has a name, a body, and, optionally,
- a list of dependencies
- a condition
- a human-readable description
-
The body of a task can contain multiple declarations and commands.
-
Gradle tasks are equivalent to Ant “targets”.
- Ant tasks are handled as function calls in the body of a Gradle script.
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 ➄
}
}
}
}
-
➀ There’s several things going on here. Let’s take it piece by piece.
-
def
declares a variable. In this case, the variable is namedfiles
.Like most scripting languages, Groovy (and therefore Gradle) is loosely (dynamically) typed, so we generally do not bother declaring variables by giving their type, though that’s certainly possible.
- Of course, that means that we have to pay close attention to the return values of our functions calls and operator uses if we, as programmers, want to know what kind of data will be stored in a variable.
-
file(...)
returns a value of typeFile
. In fact, this is a good old fashioned Javajava.io.File
, so we can look to the Java documentation to see what can be done with it. -
One of the things we can do with it is to call
listFiles()
, which treats theFile
on the left of the ‘.’ as a directory and produces an array ofFile
representing all the files in that directory. -
And, knowing that
listFiles()
produces and array of files, it’s pretty obvious what.sort()
would do. -
We conclude that the variable
files
will hold a sorted list of all the files in directorysrc/main/data
.
-
-
➁ The
.each
function is a Groovy function on arrays that allows us to iterate through the elements of the array, one at a time. On each iteration, we will store the current array element in the variablefile
which we have declared, for clarity, as being of typeFile
. -
➂ Another thing we can do with
File
s is to check and see if they are “regular” files as opposed to being directories, links, or other special cases.Again, this is not a special Gradle function, but is part of the normal Java behavior of a
File
class. -
➃ The call to
file.length()
is just an ordinary Java function call. -
➄ Several interesting things happen here.
-
The ‘$’ inside the quoted string allows us to request the replacement of a simple expression by its value. So we won’t actually print “file.name”. Instead the expressions
file.name
will be evaluated and the resulting value inserted into the string to be printed.This “$” string substitution has been a common shortcut in scripting languages for decades.
-
Now, Java
File
s do not have a public data member called “name”, so the expressionfile.name
would fail to compile in Java.Java
File
s do, however, have a public function membergetName()
. And here we run into another one of those shortcuts that Groovy, as a scripting language, introduces. In Groovy, the notationx.data
is considered a shorthand forx.getData()
when we are trying to fetch data and forx.setData(..)
when we are trying to store data. So the Groovy statementx.data = y.member;
is actually considered shorthand for
x.setData(y.getMember());
-
The
+
operator is the conventional JavaString
concatenation operator.
-
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' ➃
}
- ➀ We create a new task named
runExample
.- It is a
JavaExec
task, meaning that it will run a Java program. - And we make it depend on
jar
, which guarantees that the code insrc/main/java
will have been compiled before this task can launch.
- It is a
- ➁ Here we specify what CLASSPATH should be used – we are here using the CLASSPATH used to compile our main source code, so it will include any libraries that gradle has fetched for us.
- ➂ Here we name the class containing the
main
function that we would like to launch. - ➃ And finally, we give the command-line arguments for the execution.
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.