Understanding Gradle
The daily struggle of managing, modifying and extending build configuration of complex software systems can lead to the necessity of writing complex command line scripts or managing huge configuration files.
Apart from constructing, the software engineering process must take into consideration the third party dependency injection and phases like testing, packaging and deployment.
Here comes Gradle
Note:
If you are familiar with gradle basics, you can jump straight to Build lifecycle and phases section.
The following article was written basing on 6.0.1 version of Gradle, previous versions have small differences, for example: the oldcompile
dependency configuration is replaced withimplementation
.
Gradle is a software build automation system based on similar concepts seen in Apache Maven and Apache Ant. It’s fully open-source, released under Apache License 2.0. Gradle can be used to build projects written in many programming languages such as C++
, Groovy
, Java
, Kotlin
, Scala
, Python
, Swift
.
Because of it ease of use and huge ability to manage, extend and modify the whole build process, it was adopted in early stages as the main build tool in the Android Studio. Another reason why Gradle is used as build tool for Android applications is that Gradle takes the best of other build automation tools, containing abilities and features like:
- convention over configuration
- flexibility and extensibility
- multi-module project support
- caching and incremental builds
- and of course the dependency injection
Key differences with Maven
The Apache Maven uses the POM (Project Object Model) XML-based file which describes the project in details. When starting a maven task it looks for the pom.xml
file in the current working directory. The file pom.xml
may grow to extensive sizes, because of the requirement of sustaining the XML rules.
Maven features project inheritance with its parent
tag, which merges the POM file of project defined in that tag to the current one.
Gradle configures the project using the build.gradle
which uses its own DSL based on Groovy language (in the few last versions, Gradle allows to write build scripts in Kotlin DSL). The use of Kotlin or Groovy DSLs allows us to use the full capabilities of those languages and can lead to much shorter build files, which makes them easier to manage.
Maven allows us to create a project as a bill of materials, which can be later imported into other projects.
A BOM defines the versions of all the artifacts that will be created in some library. Using a BOM in an app ensures proper versions of its artifacts and therefore allows us to omit the version when importing the artifact from that BOM.
Gradle doesn’t stay behind in that case, because it natively supports creating and importing BOM projects.
Importing a BOM project in Gradle has the same advantages as in Maven. In addition, Gradle has plugins, which allows for Maven-like dependency management and exclusions.
Some other differences:
- Because the
build.gradle
is written in an actual programming language working under JVM it allows the use of any class or method from Java standard API. - As mentioned above, flexibility and extensibility allows us to create highly customized builds, which in some cases could be impossible to do with Maven.The Gradle task execution order is based on a graph of task dependencies, and the task API enables us to define if one task is dependent on another, which allows us to customize the execution order, where Maven uses the fixed and linear model of phases to execute its goals.
- Performance improvement achieved by: incremental execution, build cache and Gradle Daemon.
- Incremental execution – keeps track of changes to process only the files that have changes.
- Caching – for executed builds and task as well as downloaded dependencies.
- Gradle Daemon – a process which keeps the build information in the memory making subsequent builds much faster.
- Gradle, by default, is able to embed itself in the project as „Gradle Wrapper”, which reduces the need of installing the Gradle while maintaining the ability to build the project using it.
You can read more on the differences here or here.
Installation
The only prerequisite of Gradle is to have the JDK or JRE of version 8 or newer installed and added to the PATH
system variable, so that later it can be used simply as a command over the terminal.
Gradle can be installed using many package managers, for example using „Homebrew” or „SDKMAN”. The version of Gradle distributed by other package managers is not controlled by a company behind Gradle development.
Manual installation requires us to download and unpack the Gradle archive and, as a last step, to add the Gradle’s \bin
folder the the environment PATH
variable.
To test if it works fine, run the gradle -v
in the command line, it will display the currently configured Gradle version.
Some modern IDEs, like IntelliJ IDEA and the mentioned Android studio, come with embedded Gradle installation, which reduces the need of installing it for simpler projects.
More about Gradle installation can be found here.
Sample usage and starters
There are several ways to create a Gradle project, some of them are:
- Using Gradle through CLIWhen you have Gradles
\bin
folder in thePATH
environment variable, it’s possible to initialize a starter project using thegradle
command. Create a new directory for your project, and while having that directory as a current working one, simply rungradle init
. After the start ofinit
task, you’ll be asked for some information regarding the project you are creating, to allow Gradle to configure basic requirements for the project. Theinit
task asks about the type of the project (e.g. library, application or plugin), main implementation language,build.gradle
language and in some cases the test framework. - Some IDEs which support Gradle allow us to make starter projects using their GUI. For example the Intellij IDEA enables us to create a Gradle managed project with multiple language support.
- The Spring Initializr and some other application generators like JHipster also feature the possibility to use Gradle. The created
build.gradle
file using one of the above-mentioned generators will already contain the required plugins, dependencies and configurations.
Using any of these methods to create a project will automatically add the „Gradle Wrapper”.
The wrapper creates two command line scripts gradlew
and gradlew.bat
which can be used to invoke project tasks. The gradle binary and properties files will be placed in the gradle\wrapper
directory under the project root folder.
The project roots
The root folder of initial Gradle based projects will always contain two files. One is build.gradle
which contains build and tasks configuration. The second is settings.gradle
, it has basic project properties and allows us to include subprojects or register lifecycle handlers.
While building the multi-module project, the only one settings.gradle
is invoked in each build, and is read before any build.gradle
file.
An example of Groovy based build.gradle
file created for Java application with gradle init
:
plugins {
// Apply the java plugin to add support for Java
id 'java'
// Apply the application plugin to add support for building a CLI application.
id 'application'
}
repositories {
jcenter()
mavenCentral() //added manually
maven { url "https://oss.sonatype.org/content/repositories/snapshots" } //added manually
}
dependencies {
implementation 'com.google.guava:guava:28.0-jre'
testImplementation 'junit:junit:4.12'
}
application {
mainClassName = 'pl.jlabs.example.App'
}
You can read more about build script basics here.
Built-in and creating tasks
Similarly to Maven, Gradle provides a basic set of tasks, including: clean
, build
, assemble
, dependencies
, javadoc
. Executing the gradlew -q tasks
command outputs in all runnable tasks of root project with their description. The -q
option skips some internal Gradle messages, logging the errors only. Run the gradlew -h
to see all available options.
Creating a new task is simply writing it in the build file, for example:
task hello {
group = 'my-tasks' //self created group
description = 'Printing some output' //this is shown when running "gradle tasks"
doLast {
println 'Hello world!'
}
}
To execute that task simply run gradlew hello
.
By default gradle divides the tasks into few subgroups. New tasks can be added to an existing group or created in a new one like in the example above. The hello
task uses the doLast
method which marks the given action to be executed as the last one when running that task. It’s related to the build lifecycle, which is covered in the next section.
A few other samples:
task killNodeProcess(type:Exec) {
group = 'application'
description = 'kills node.exe'
commandLine 'cmd', '/c', 'taskkill /F /IM node.exe'
}
task copyLicense {
outputs.file new File("$buildDir/LICENSE.txt")
doLast {
copy { from "LICENSE.txt" into "$buildDir" }
}
}
You can read more about writing custom tasks here and even more here.
Managing dependencies
The external repositories in which Gradle will search for the dependencies can be put inside the repositories {}
block. Third party or module dependencies can be added in dependencies {}
block with <configuration> '<group>:<module>:<version>'
, for example:
dependencies {
implementation project(':module-name') //a submodule dependency, needs to be included in settings.gradle file
implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.3.5'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.integration:spring-integration-test'
}
The <configuration>
defines the scope of dependency, the standard configurations are:
- compileOnly – for dependencies that are necessary to compile your production code but shouldn’t be part of the runtime classpath
- implementation (supersedes compile) – used for compilation and runtime
- runtimeOnly (supersedes runtime) – only used at runtime, not for compilation
- testCompileOnly – same as compileOnly except it’s for the tests
- testImplementation – test equivalent of implementation
- testRuntimeOnly – test equivalent of runtimeOnly
- annotationProcessor – puts the dependency on the annotation processor path
Build lifecycle and phases
Running any of the Gradle task is divided into three phases, which are invoked in a given order of the root project:
- Initialization – executes the
settings.gradle
, which contains the information on included projects. The build file of the root project is automatically included. - Configuration – creates project objects (containing tasks) and executes build file for each included project.
- Execution – determine the subset of the tasks to be executed basing on a current working directory and task names passed as arguments to the gradle command.
The Initialization phase happens only within settings.gradle
, so it’s pretty straightforward. To understand the other phases let’s put this in the end of a build.gradle
file:
println 'Appears in configuration phase'
task hello {
println '1. Executed only in configuration phase'
doLast {
println 'Executed as last during execution phase of hello task'
}
doFirst {
println 'Executed as first during execution phase of hello task'
}
println '2. Executed only in configuration phase'
}
hello.configure {
doFirst {
println 'First printed line in execution of hello task'
}
doLast {
println 'Latest printed line in execution of hello task'
}
}
println 'Last line in configuration phase' //last if this is last println call in build script
Running gradlew hello
will result in:
> Configure project :
Appears in configuration phase
1. Executed only in configuration phase
2. Executed only in configuration phase - order matters
Last line in configuration phase - order matters
> Task :hello
First printed line in execution of hello task
Executed as first during execution phase of hello task
Executed as last during execution phase of hello task
Latest printed line in execution of hello task
The output shows that while being in the Configuration phase, the build file is executed from top to bottom. This causes some restrictions on the first blocks when writing the build script, for example the plugins {}
block is required to be one of the first blocks in the build script. Only the buildscript {}
and other plugins {}
blocks are allowed before it. Placing any other statement before it will result in build fail.
The doFirst
and doLast
determines which instructions are going to be executed as first and last.
Existing tasks can be extended using someTask.configure
similarly as in the example above (the task must be already defined to use the .configure
).
Any further task configuration adds the action from doFirst
as head and action from doLast
to the tail of instruction list.
Making one task dependent on another can be done in two ways, see the following example:
task dependent {
dependsOn(hello) // <1> adding the dependency in task definition
doLast {
println 'The hello task will be executed before this'
}
}
dependent.dependsOn(hello) // <2> adding the dependency later in build script
Using someTask.dependsOn(anotherTask)
enables us to add task dependencies to default tasks allowing to modify the standard build tasks execution order, for example:
compileJava.dependsOn(dependent) //does some work before compiling sources
It’s also possible to execute a task when finishing another with finalizedBy
:
task finalizedByOther {
doLast { println 'Last action in finalizedByOther' }
finalizedBy(hello)
}
Running this task will execute the 'hello’ task when it is finished.
All of those can be used to invoke tasks from different modules, for example:
finalizedBy(':module-name:bootRun')
More about build lifecycle can be found here.
Multi-module builds
The top level project in Gradle (containing settings script) is called rootProject. To make a subproject, it is necessary to create a folder (its name will be the name of the submodule) and include it in the root. Let’s say there are a few submodules, so the settings.gradle
would look similarly to:
rootProject.name = 'the-name-of-root-project'
include 'module-1'
include 'module-2', 'module-3'
Running the gradlew -q projects
shows the project tree:
Root project 'the-name-of-root-project'
+--- Project ':module-1'
+--- Project ':module-2'
+--- Project ':module-3'
To reduce the size of module build scripts, Gradle allows adding basic configuration for all of the subprojects using
the root project build.gradle
script with allprojects {}
and subprojects {}
blocks. For example:
subprojects {
apply plugin: 'java'
group = 'pl.jlabs'
version = '1.0'
sourceCompatibility = System.getProperty("java.version")
targetCompatibility = System.getProperty("java.version")
repositories {
jcenter()
mavenCentral()
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.10'
annotationProcessor 'org.projectlombok:lombok:1.18.10'
testImplementation "junit:junit:4.12"
}
task hi {
doLast { task -> println "Hi from $task.project.name" }
}
}
Running the hi
task from the root project will execute the hi
task for each subproject.
Using the allprojects
blocks also configures the root project. Moving the hi
task to the allprojects
block will also output the greeting from the root project.
If your root project contains some source code, then the java plugin can be applied with apply plugin: 'java'
under the allprojects
block or on top of the root build script:
plugins {
id 'java'
}
Of course, besides the subprojects configuration made through the root build script, the module build scripts can contain their own build script. For example, the build.gradle
of module-1
can contain:
group = 'pl.jlabs.module1' //specific group of this module
version = '0.0.1' //specific version of this module
dependencies {
implementation project(":module-2") //adds the dependency of another subproject/module
implementation 'org.modelmapper:modelmapper:2.3.5'
}
Therefore the module-1
will have both its own and a root project configuration (when configuring the same thing, the module config overrides the root one).
If your root/master project is in the same directory structure level you may need to use the includeFlat
instead of include
, if so, take a look at this example.
More on multi-project builds can be found here.
Tips and others
- Using third-party plugins
To use third-party libraries (e.g. those not listed in the gradle plugin repository), the buildscript{}
block needs to be added as the first block in the project build script, for example:
buildscript {
repositories { //the dependencies will be searched in those repositories
mavenLocal()
jcenter()
maven { url 'http://dl.bintray.com/sleroy/maven' }
}
dependencies { //all the dependencies here are plugins
classpath 'com.metrixware:gradle-doc-plugin:0.1.4'
}
}
And later to use the plugin it needs to be applied in the build script like apply plugin: 'pandoc'
.
- Maintaining the build scripts briefness
Some parts of build scripts can be moved to separate files, to maintain their readability.
For example the whole dependencies
block can be moved to a new dependencies.gradle
file, and later applied to the build script with:
apply from: "dependencies.gradle"
The same solution can be used with all self created tasks. One important thing is to apply the file with tasks, before creating the task dependency with any task defined in that file.
- Migrating from Maven
Gradle tries to make a simple way of migrating from Maven, by simply running the gradle init
in the directory where pom.xml
is located. When running it, Gradle parses the existing POM files and tries to create equivalent build scripts. However, because of huge differences between those two tools, Gradle might not be able to create a fully corresponding build script, so some manual configuration could be needed.
For example, both have plugins with similar abilities, but under different names.
More info on migrating is here.
- User created properties and variables
Adding extra properties or declaring local variables in the project can be done through:
ext {
lombokVersion = "1.18.10"
emailNotification = "build@master.org"
}
def myVar = "value"
//and used later like:
println lombokVersion + myVar
//or when declaring the dependency
compileOnly "org.projectlombok:lombok:${lombokVersion}" //notice double quote string
- Using secured repositories
The connection to the secured repository can be done similarly to:
repositories {
//other repos
maven {
credentials {
username = "${repoUser}"
password = "${repoPass}"
}
url "http://my.private.artifactory/url/goes/here"
}
//other repos
}
To avoid putting your repository login/password to the code, create gradle.properties
file under the .gradle
directory in your system user folder (e.g. C:\Users\me\.gradle\
for Windows) and add the required properties, for example:
repoUser=myLogin
repoPass=myPass
For using specific authentication methods take a look here.
Final words
Gradle seems to fit projects in all ranges, from simple to the most complex ones. The brief, straightforward statements makes it easy to understand. Comparing to the Mavens POM, the Gradles build script is a few times smaller still covering equivalent configuration. The ease of creating its own tasks and the ability to use Java standard API classes and methods gives it a huge advantage. For example copying files by gradle task can be written in a few lines, without the need of using additional plugins. Configuring its own task dependencies and modifying existing ones is able to cover the most complex build requirements.
The ability of dividing the build scripts to separate files allows easy maintenance and the readability of build scripts. The initial release of Gradle was made in 2007, so now it’s a fully mature project. And its community doesn’t sleep, making lots of useful plugins.
Thanks to those, currently, Gradle seems to be the number one build-automation system.
A whole tutorial and lots of useful information can be found in extensive Gradle user guide.
A sample project containing most of the Gradle features is covered in this article: multi-module-project.