Understanding Gradle

Jacek Mazgaj

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 old compile dependency configuration is replaced with implementation.

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++GroovyJavaKotlinScalaPythonSwift.

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:

  1. convention over configuration
  2. flexibility and extensibility
  3. multi-module project support
  4. caching and incremental builds
  5. 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:
  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. Using Gradle through CLIWhen you have Gradles \bin folder in the PATH environment variable, it’s possible to initialize a starter project using the gradle command. Create a new directory for your project, and while having that directory as a current working one, simply run gradle init. After the start of init task, you’ll be asked for some information regarding the project you are creating, to allow Gradle to configure basic requirements for the project. The init 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.
  2. 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.
  3. 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: cleanbuildassembledependenciesjavadoc. 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:

  1. Initialization – executes the settings.gradle, which contains the information on included projects. The build file of the root project is automatically included.
  2. Configuration – creates project objects (containing tasks) and executes build file for each included project.
  3. 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.

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami