Build Blocks
There are 3 basic building blocks for a Gradle build:
- projects
- tasks
- properties
Each build has at least one project, each project contains one or more tasks. Projects and tasks expose the properties that can be used to control the build.
Gradle applies the principles of domain-driven design (DDD) to model its own domain-building software. As a consequence, projects and tasks have a direct class representation in Gradle’s API.
Projects
A project represents a component you are trying to build (such as a jar), or a goal you are trying to achieve(for example, deploy an app).
There is a build.gradle file, each build script defines at least one project.
There is a class org.gradle.api.Project, when starting the build, Gradle will instantiate it based on the build.gradle file.
A project can create new tasks, add dependencies and configurations, and apply plugins and other build scripts.
In a multi-project application, each part can be represented as a Gradle project and have its own build.gradle script file.
Tasks
task actions: an action defines an atomic unit of work that's executed when the task is run.
task dependencies: sometimes a task needs another task to run first.
Properties
Project and Task provide some properties through getter and setter methods.
You can also define some extra properties to be used by using ext namespace.
project.ext.myprop = 'myValue'
You can also define extra properties by gradle.properties file.
Example -- Manage the Project Version
Add Actions to Existing Tasks
build.gradle
version = '0.1-SNAPSHOT'
task printVersion {
doFirst {
println "Before reading the project version"
}
doLast {
println "Version: $version"
}
}
printVersion.doFirst { println "First action" }
printVersion << { println "Last action" }
result
> gradle build
First action
Before reading the project version
Version: 0.1-SNAPSHOT
Last action
Add Group and Description Properties
You can change printVersion to below code
task printVersion(group: 'versioning', description: 'Prints project version.') << {
logger.quiet "Version: $version"
}
or
task printVersion {
group = 'versioning'
description = 'Prints project version.'
doLast {
logger.quiet "Version: $version"
}
}
The first one inits the properties when creating a task, the second inits them by the setter.
When running gradle tasks
gradle tasks
:tasks
...
Versioning tasks
printVersion - Prints project version.
...
Create POGO(Plain-old Groovy Objects) and Add a Configuration Block for Task
version.properties
major=0
minor=1
release=false
build.gradle
ext.versionFile = file('version.properties')
task loadVersion {
version = readVersion()
}
class ProjectVersion {
Integer major
Integer minor
Boolean release
ProjectVersion(Integer major, Integer minor) {
this.major = major
this.minor = minor
this.release = Boolean.FALSE
}
ProjectVersion(Integer major, Integer minor, Boolean release) {
this(major, minor)
this.release = release
}
@Override
String toString() {
"$major.$minor${release? '' : '-SNAPSHOT'}"
}
}
task makeReleaseVersion(group: 'versioning', description: 'Makes project a release version') {
doLast {
version.release = true
ant.propertyfile(file: versionFile) {
entry(key: 'release', type: 'string', operation: '=', value: 'true')
}
}
}
ProjectVersion readVersion() {
logger.quiet 'Reading the version file'
Properties versionProps = new Properties()
versionFile.withInputStream{
stream -> versionProps.load(stream)
}
new ProjectVersion(versionProps.major.toInteger(), versionProps.minor.toInteger(), versionProps.release.toBoolean())
}
task printVersion << {
logger.quiet "Version: $version"
}
You may find that the task loadVersion is a little different from the tasks before.
task loadVersion {
version = readVersion()
}
There is no action defined in this task, nor the left shift operator.
This task is called a task configuration
Task configuration blocks are always executed before task actions. We can take a look at Gradle's build lifecycle.
Whenever you execute a build, three distinct phases are run: initialization, configuration, execution.
During the initialization phase, Gradle creates a Project instance for your project. In the context of multi-project build, this phase is important. Depending on which project you are executing, Gradle figures out which dependencies need to be added during the build.
During the configuration phase, Gradle constructs a model representation of the tasks that will take part in the build. The incremental build feature determines if any of the tasks in the model are required to be run. This phase is perfect for setting up the configuration that’s required for your project or specific tasks. (Even if you just run gradle tasks, any configuration code will run.)
During the execution phase, the tasks are executed in correct order.
If you run
$ gradle printVersion
The output would be
Reading the version file.
:printVersion
Version: 0.1-SNAPSHOT
If you run
$ gradle makeReleaseVersion
The script will use ant to change the property file, which turns the release to be 'true'.
And then
$ gradle printVersion
The output would be
Reading the version file.
:printVersion
Version: 0.1
Writing and Using Custom Tasks
Custom tasks consist of two components:
- the custom task class that encapsulates the behavior of your logic
- the actual task that provides the value for the properties exposed by the task class to configure the behavior
The Custom Task Class
class ReleaseVersionTask extends DefaultTask {
@Input Boolean release
@OutputFile File destFile
ReleaseVersionTask() {
group = 'versioning'
description = 'make a project release version'
}
@TaskAction // Annotaion declares a method to be executed
void start() {
project.version.release = true
ant.propertyFile(file: destFile) {
entry(key: 'release', type: 'string', operation: '=', value: 'true')
}
}
}
The Actual Task
task makeReleaseVersion(type: ReleaseVersionTask) {
release = version.release
destFile = versionFile
}
And another advantage of using custom task class is that it can be reused in another task.
Using Built-in Task Types
Suppose there are some dependencies in releasing a project:
makeReleaseVersion->war->createDistribution->backupReleaseVersion->release
There are two built-in task types which derived from DefaultTask: Zip and Copy
class ReleaseVersionTask extends DefaultTask {
@Input Boolean release
@OutputFile File destFile
ReleaseVersionTask() {
group = 'versioning'
description = 'Makes project a release version'
}
@TaskAction
void start() {
project.version.release = true
ant.propertyfile(file: destFile) {
entry(key: 'release', type: 'string', operation: '=', value: 'true')
}
}
}
task makeReleaseVersion(type: ReleaseVersionTask) {
release = version.release
destFile = versionFile
}
task createDistribution(type: Zip, dependsOn: makeReleaseVersion) {
from war.outputs.files
from(sourceSets*.allSource) {
into 'src'
}
from(rootDir) {
include versionFile.name
}
}
task backupReleaseDistribution(type: Copy) {
from createDistribution.outputs.files
into "$buildDir/backup"
}
task release(dependsOn: backupReleaseDistribution) <<{
logger.quiet 'Releasing the project...'
}
As you can see, you can declare a dependency between two tasks by using "dependsOn" explicitly (task createDistribution). Furthermore, you can use an output for an input for another task to infer the dependency(task backupReleaseDistribution).
The following directory tree shows the relevant artifacts generated by the build:
Task Rules
Let's see two similar tasks
task incrementMajorVersion( group: 'versioning', description: 'Increments project major version.') << {
String currentVersion = version.toString()
++version.major
String newVersion = version.toString()
logger.info "Incrementing major project version: $currentVersion -> $newVersion"
ant.propertyfile(file: versionFile) {
entry(key: 'major', type: 'int', operation: '+', value: 1)
}
}
task incrementMinorVersion(group: 'versioning', description: 'Increments project minor version.') << {
String currentVersion = version.toString()
++version.minor
String newVersion = version.toString()
logger.info "Incrementing major project version: $currentVersion -> $newVersion"
ant.propertyfile(file: versionFile) {
entry(key: 'minor', type: 'int', operation: '+', value: 1)
}
}
They are very similar, thus we can use task rules to replace these two tasks, which executes specific logic based on a task name pattern.
The pattern consists of two parts: the static portion of the task name and a placeholder.
tasks.addRule("Pattern: incrementVersion - increments the project version classifier") { String taskName ->
if (taskName.startsWith('increment') && taskName.endsWith('Version')) {
task(taskName) << {
String classifier = (taskName - 'increment' - 'Version').toLowerCase()
String currentVersion = version.toString()
switch(classifier) {
case 'major': ++version.major
break
case 'minor': ++version.minor
break
default: throw new GradleException('Invalid version type')
}
String newVersion = version.toString()
logger.info "Increment $classifier project version: $currentVersion - > $newVersion"
ant.propertyfile(file: versionFile) {
entry(key: classifier, type: 'int', operation: '+', value: '1')
}
}
}
}
Hooking into the Build Lifecycle
As a build script developer, you’re not limited to writing task actions or configuration logic, which are evaluated during a distinct build phase. Sometimes you’ll want to execute code when a specific lifecycle event occurs.
There are two ways to write a callback to build lifecycle events:
- within a closure
- with an implementation of a listener interface provided by the Gradle API.
Here is an example of build lifecycle hooks
Hook with a closure
gradle.taskGraph.whenReady { TaskExecutionGraph taskGraph ->
if (taskGraph.hasTask(release)) {
if (!version.release) {
version.release = true
ant.propertyfile(file: versionFile) {
entry(key:'release', operation: '=', type: 'string', value: 'true')
}
}
}
}
Hook with a listener
It can be done in two steps:
- Implement the specific Listener interface
- Register the listener implementation with the build
In this example, the listener interface to be implemented is TaskExecutionGraphListener
class ReleaseVersionListener implements TaskExecutionGraphListener {
final static String releaseTaskPath = ':release'
@override
void graphPopulated(TaskExecutionGraph taskGraph) {
if (taskGraph.hasTask(releaseTaskPath)) {
List tasks = taskGraph.allTasks
Task releaseTask = tasks.find {it.path == releaseTaskPath}
Project project = releaseTask.project
if (!project.version.release) {
project.version.release = true
project.ant.propertyfile(file: project.versionFile) {
entry(key:'release', type: 'string', operation: '=', value: 'true')
}
}
}
}
}
And you need to register the implemented listener
gradle.taskGraph.addTaskExecutionGraphListener(new ReleaseVersionListener())
Summary
Every gradle build scripts consist of two components: one or more projects and tasks.
At runtime, Gradle creates a model from the build definition, stores it in a memory, and make it accessible for you to access through methods.
As an example, we implement build logic to control the release version of the project, which stored in an external property file. We can add simple tasks to the build script, and define build logic in tasks actions. Every task is derived from the org.gradle.api.DefaultTask.
Then, we learned the build lifecycle and the execution order of its phases. There are task actions and task configurations in Gradle. Task actions are executed in the execution phase, while task configurations are executed in the configuration phase. Any other code defined outside of a task action is considered a configuration and therefore executed beforehand during the configuration phase.
Then we learned to structure the build scripts. Such as writing our own custom tasks to hold the build logic, moving the compilable code into buildsrc folder.
Finally, you can register build life-cycle hooks that execute code whenever the targeted event is fired.