Chapter 3. Build Hooks

By itself, Gradle is a deeply customizable toolkit for creating custom build software. It comes with out-of-the-box conventions that are useful for highly standardized builds, and it exposes a rich set of APIs for introducing novel functionality into a non-commodity build as we saw in Chapter 2. But your ability to customize a Gradle build doesn’t end with plug-ins.

Gradle offers you the ability to modify the execution of that program by hooking a variety of events that occur during the configuration and execution of your build. These hooks are simply blocks of Groovy code that run when tasks are added, when projects are created, and at other times during Gradle’s internal configuration sequence. Also, following the patterns implicit in Groovy’s own metaprogramming APIs, Gradle gives you the ability to create tasks dynamically based on its powerful rules feature. This chapter presents hooks and rules as a means of managing build complexity and decorating the functionality of builds whose source we do not always directly control.

The Gradle Lifecycle: A Review

Many readers of this book will be familiar with the Gradle build lifecycle, but a thorough understanding of build hooks requires that we revisit it briefly. Every Gradle build proceeds through three lifecycle phases in precisely the same order. These phases are initialization, configuration, and execution.

During the initialization phase, Gradle starts up and locates the build files it must process. Crucial during this phase is the determination of whether the build is single-project or multi-project. If it is a single project build, Gradle identifies a single build file to pass to the next phase. If it is a multi-project build, Gradle locates potentially many build files for processing in the next phase.

That next phase is configuration. During configuration, Gradle executes each build file as a Groovy script. The effect of configuration is not the actual execution of build actions—that comes next—but rather the creation of a directed acyclic graph (DAG) of task objects.[12] It is during the construction of this graph that many of the hook methods run.

The final phase is execution. During this phase, Gradle identifies the tasks in the task DAG that must be executed, and executes them in dependency order.[13] All build activities (e.g., compiling code, copying files, uploading artifacts, etc.) occur during execution. Some build hooks are evaluated during execution.

Advising the Build Graph

In the early 2000s, the paradigm of aspect-oriented programming (AOP) became a marginally popular way to manage the complexity of enterprise Java software. AOP acknowledged that programs often contained individual units of functionality—in the case of an object-oriented language like Java, these are methods—which need to be enhanced with code whose purpose is not directly related to the unit in question. For example, a method that processes a web request might need to make security assertions before it runs, or it might have to emit logging after it runs. A method that writes to the database might have to have a database context set up first and a transaction committed after.

AOP developed its own vocabulary and a small number of Java frameworks to implement it. Gradle build hooks are similar enough to AOP that some of this vocabulary is worth retaining. The code that ran before and after the original method was called advice. The advice code was often described as being orthogonal to the original code, meaning that the purposes of the two blocks of code weren’t correlated in any direct way, even though the execution sequence was. The kinds of code with which one would advise application methods were called cross-cutting concerns. The most common tutorial examples were logging and database transaction management.

In our discussion of Gradle build hooks, we’ll retain the “advice” terminology, and generally focus our examples on the sorts of orthogonal, cross-cutting concerns that have populated AOP texts for the past decade.

Advising Project Evaluation

The goal of an individual Gradle build file is to set up a Project with all of the settings and tasks it needs to give Gradle useful work to do during the build. The setup takes place during the configuration phase, and the build itself takes place during the execution phase. The project.beforeEvaluate() and project.afterEvaluate() methods are hooks to execute blocks of code before and after configuration is run on a particular project.

Example 3-1 is a trivial build that hooks the afterEvaluate event. After the build has finished evaluating, the hook checks for the presence of a grammars directory. If the directory exists, the hook creates a task called testGrammars, ostensibly to perform grammar tests on the contents of the directory. Try running the build with and without a grammars directory present. Note the output of gradle tasks in each case.

Example 3-1. Hooking project after-evaluation

afterEvaluate {
  if (file('grammars').isDirectory()) {
    println "'grammars' directory found"

    task testGrammars << {
      println "Running grammar tests"
    }
  } else {
    println "'grammars' directory not found"
  }
}

task helloWorld {
  doLast {
    println "hello, world"
  }
}

The beforeEvaluate() hook is not particularly useful in this case, since there is no way in a single build file to advise the build to do something before it is evaluated.[14] Before-hooks are only useful in the case of a multiproject build. Example 3-2 has three builds, the parent project and two subprojects. The grammar check is only performed on the subprojects—now as a before-hook—by way of the allprojects method. The configuration inside the allprojects closure is applied to all projects, and therefore has a chance to affect the subprojects, since they have not yet been evaluated at the time the closure is run. If you run the example with and without the grammar directory in all three project directories, you’ll find that the parent project never gains the testGrammars task, since the beforeEvaluate hook can’t run on the project in which it is read.

Example 3-2. Hooking project before-evaluation

allprojects {
  beforeEvaluate {
    if(project.file('grammars').isDirectory()) {
      println "'grammars' found in ${project.name}"

      task testGrammars << {
        println "Running grammar tests in ${project.name}"
      }
    } else {
      println "'grammars' not found in ${project.name}"
    }
  }
}

task helloWorld {
  doLast {
    println "the parent says hello"
  }
}

Note that the check for the file has become project.file() instead of merely file() as in the previous example. This causes Gradle to look explicitly in the individual subproject’s directory for the grammars directory, rather than only at the root project level.

Global Project Loading and Evaluation Hooks

The preceding examples show beforeEvaluate() and afterEvaluate() being used globally, to apply to all projects at once using the allprojects closure. The methods can also be applied to individual project objects just as easily, if a particular subproject needs before-evaluate or after-evaluate processing applied more surgically. However, you might find that you often have a block of processing to apply in a global sense after all projects are evaluated or, if you want to get the hook in earlier in the lifecycle than that, after all projects are loaded. This is what gradle.projectsLoaded() and gradle.projectsEvaluated() are for.

The gradle.projectsLoaded() method takes a closure that runs after all projects are loaded. This event fires at the end of the initialization phase, before any project evaluation has begun. Of course, not much of the Gradle object model is available for examination or configuration at this point in the lifecycle, but useful work is still possible. The gradle object is passed to the closure as a parameter (named g in Example 3-3), and can be operated upon as usual. This example shows the buildscript block being configured with classpath dependencies to support the loading of custom plug-ins in other builds within the project. This buildscript config—often an annoying bit of noise in small builds—is now abstracted away through the work of the projectsLoaded() hook.

Example 3-3. Hooking the end of the initialization phase in the settings.gradle file

gradle.projectsLoaded { g ->
  g.rootProject.buildscript {
    repositories {
      mavenCentral()
    }
    dependencies {
      classpath 'com.augusttechgroup:gradle-liquibase-plugin:0.7'
      classpath 'com.h2database:h2:1.3.160'
    }
  }
}

The projectsEvaluated() method similarly takes a closure that runs after all projects have been evaluated.[15] This event fires at the end of the configuration phase, before any build execution has begun. The entire project graph is available for inspection and manipulation at this point, and the caller can be assured that no build activity has taken place at the time the hook runs.

Build Finished

You may want to know when a build is finished, and whether it ran to completion or encountered an error. Certainly your build’s command-line user interface or continuous integration framework is going to give you this feedback, but we can imagine other ways we might want to capture this status and act on it.

If the build fails, it fails because some part of the build threw an exception. A build that runs to completion without throwing an exception is considered successful. This status is reflected in the buildFinished() closure through the BuildResult instance passed to it (Example 3-4).

Example 3-4. Hooking the build finished event

gradle.buildFinished { buildResult ->
  println "BUILD FINISHED"
  println "build failure - " + buildResult.failure
}

task succeed {
  doLast {
    println "hello, world"
  }
}

task fail {
  doLast {
    throw new Exception("Build failed")
  }
}

Running gradle succeed at the command line produces the output sequence in Example 3-5.

Example 3-5. The output of the succeed task

$ gradle succeed

:succeed
hello, world

BUILD SUCCESSFUL

Total time: 1.374 secs
BUILD FINISHED
build failure - null

Note the last two lines of the output. After Gradle reports the build has finished with Total time: 1.374 secs, our buildFinished() hook runs, reporting that that build has finished, and printing the value of buildResult.failure, which is null.

Running gradle fail at the command line captures an exception (Example 3-6). Note that the BUILD FINISHED line is still emitted, even though the build failed. Our hook runs in both cases, but only has a non-null gradle.failure property when there is a failure to report. This object is an internal Gradle API type that wraps the root-cause exception. We can inspect it to determine the cause of the failure, display it in a automated build console, or simply log it.

Example 3-6. The output of the succeed task

$ gradle fail

:fail FAILED

FAILURE: Build failed with an exception.

* Where:
Build file '/Users/tlberglund/Documents/Writing/Gradle/oreilly-gradle-book-examples/hooks-lab/build-finished/build.gradle' line: 14

* What went wrong:
Execution failed for task ':fail'.
> java.lang.Exception: Build failed

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or
--debug option to get more log output.

BUILD FAILED

Total time: 1.453 secs
BUILD FINISHED
build failure - org.gradle.api.internal.LocationAwareException: Build file '/Users/tlberglund/Documents/Writing/Gradle/oreilly-gradle-book-examples/hooks-lab/build-finished/build.gradle' line: 14
Execution failed for task ':fail'.

Rules

Typically, a task is a specific activity with a specific name. For example, the build task in a Java project compiles and tests all of the code. It always does that same thing, and it is always called by the same name. It is like a particular method written into a particular class: specific and identifiable.

However, what if your task is less predictable? Cases commonly arise in which you have a certain kind of build activity to carry out that follows a general template but needs to vary with circumstances. For example, you might want a family of tasks that do the same kind of deployment, but to different hosts, or the same kind of binary repository deployment, but with different archives.

The impulse is to want to pass arguments to tasks, which isn’t possible. It’s easy enough to parameterize imperative task activity with a System property, an environment variable, or even a variable in the build script itself, but those solutions also founder on complexity considerations. They introduce extra coupling of the build to states outside the build script, and their activity is not visible at the level of the task graph—the fundamental data structure that defines a Gradle build and provides opportunities for hooking and tooling integration. In Gradle, wherever possible, we want to expose discrete and specifically named tasks to the build user. An effective way to solve the problem of dynamic task behavior within this constraint is through the use of rules.

Any time the build user references (i.e., executes or tries to configure) a task that doesn’t already exist, Gradle consults the project’s task rules. Task rules are able to respond to the request for a nonexistent task in any way they wish, but it is very common for a rule to create a task on demand based on the name of the requested task. This is useful for creating tasks dynamically based on other build configuration. If you’re familiar with the methodMissing and propertyMissing facilities of Groovy or Ruby’s method_missing, you’ve already got the idea.

The Gradle Java plug-in uses rules to provide tasks for targeted building, binary repository uploading, and cleaning. To see how rules work, let’s take a look at the simplest of these three rules: the clean rule.

There is a general-form clean task in every Java build, which simply deletes the build directory. However, we might want to clean only the result of one particular task that contributes to the build. In a Java project, both the compileJava task and the processResources task create content in the build directory: the compile task by putting class files by default under build/classes/main, and the resources task by putting them by default under build/resources/main. We can see this after compiling this example project. Here we see the build directory after running the build task:

build
├── classes
│   └── main
│       └── org
│           └── gradle
│               └── poetry
│                   └── PoetryEmitter.class
├── libs
│   └── java-build-with-resources.jar
└── resources
    └── main
        ├── chesterton.txt
        ├── henley.txt
        ├── shakespeare.txt
        └── shelley.txt

Java classes have been compiled, static resources have been copied, and a JAR has been created. Simply running clean at this point will delete the entire build directory, wiping out all three of those resources indiscriminately. However, running cleanJar will delete only the JAR file, and running cleanResources will surgically remove only the build/resources directory.

Here’s a partial listing of the build directory after running cleanResources: 

build
├── classes
│   └── main
│       └── org
│           └── gradle
│               └── poetry
│                   └── PoetryEmitter.class
└── libs
    └── java-build-with-resources.jar

This simple example may not show particularly advanced behavior—judicious use of the rm command would do the same thing—but it does illustrate the power of task rules. The possibilities are compelling indeed.

Creating a Rule

Let’s create a rule to ping an arbitrary server and store the results of the ping. We’ll log them in the build directory and store them in the extended properties of the task itself, so other tasks in the build can optimize their activity based on the availability of the pinged server.

Pinging a server is a one-line operation in Java (and by extension, in Groovy), but we don’t want to write a specific ping task for every server we might need to contact. Moreover, it isn’t long before we take a one-line operation and start adding handling for various error cases and output formats. This is a very common story for small chunks of code that start off as simple operations, but grow in complexity over time. To keep the complexity manageable, it would be good to have that code in one place, not spread across multiple tasks containing their own customized configuration, as in Example 3-7.

Example 3-7. A rule for checking the HTTP-reachability of an arbitrary server

ext {
  pingLogDir = "${buildDir}/reachable"
}

tasks.addRule('Rule Usage: ping<Hostname>') { String taskName ->
  if(taskName.startsWith('ping')) {
    task(taskName) {
      ext.hostname = taskName - 'ping'
      doLast {
        def url = new URL("http://${ext.hostname}")
        def logString
        try {
          def pageContent = url.text
          // Do some regex matching on pageContent
          logString = "${new Date()}\t${ext.hostname}\tUP\n"
          ext.up = true
        } catch(UnknownHostException e) {
          logString = "${new Date()}\t${ext.hostname}\tUNKNOWN HOST\n"
          ext.up = false
        } catch(ConnectException e) {
          logString = "${new Date()}\t${ext.hostname}\tDOWN\n"
          ext.up = false
        }
        file(pingLogDir).mkdirs()
        file("${pingLogDir}/ping.log") << logString
      }
    }
  }
}

Presumably, using HTTP for reachability would also be accompanied by some parsing of the message body that was returned. This could easily be added to the script in an imperative way through Groovy’s regular expression facilities; however, this class is already getting a little bit long. We might want to look at a better solution for managing all that imperative code.

Dealing with Imperative Rule Code

Our current rule definition relies on 18 lines of doLast code, which is probably too much imperative code for a well-factored, maintainable build. We can’t test it, and as of this writing, most tooling does not support Groovy script development inside a Gradle build as well as it supports coding in conventional Groovy classes. The solution to this problem is the Rule interface.

You may recall that every Gradle build has an embedded Groovy project in it in the buildSrc directory. If this project exists, it is built prior to running the main build, and all of the classes it generates are made available to the build script classpath. It’s a convenient staging ground to begin creating a Rule class and some tests for it.

The basic skeleton of a Rule class is shown in Example 3-8. The interface specifies two methods: a getter for the description, which is used by the tasks task to document whatever rules are present in the build, and the apply() method, which will eventually create our new task. This class goes in the buildSrc/src/main/groovy/org/gradle/examples/rules directory of our build.

Example 3-8. The minimal Rule class

import org.gradle.api.Rule

class HttpPingRule implements Rule {

  String getDescription() {
    'Rule Usage: ...'
  }

  void apply(String taskName) {
  }
}

And now our build file becomes radically simpler. All of the imperative code has been removed from the build and placed in a source file where it belongs, so our build looks like Example 3-9 now.

Example 3-9. The build file is much smaller with a class-based rule definition

import org.gradle.examples.rules.HttpPingRule

tasks.addRule(new HttpPingRule(project))

Running gradle tasks will now reveal the task rule definition after the statically defined tasks are listed.

Fleshing the rule definition out a bit, we might want to add code in the apply() method to test the name of the task being referenced. (Remember, the point of a task rule is to capture references to tasks that don’t exist, and create them if their name matches some convention.) Adding a bit more functionality to apply(), we have what we see in Example 3-10.

Example 3-10. A Rule class that creates a task in the apply() method

import org.gradle.api.Rule

class HttpPingRule implements Rule {

  def project

  HttpPingRule(project) {
    this.project = project
  }

  String getDescription() {
    'Rule Usage: ping<Hostname>'
  }

  void apply(String taskName) {
    if (taskName.startsWith('ping')) {
      project.task(taskName) {
        ext.hostname = taskName - 'ping'
        doLast {
          println "PING ${ext.hostname}"
        }
      }
    }
  }
}

This rule definition can be further expanded with actual network reachability code in place of the println() call, with that functionality comfortably factored into methods as we see fit. But more importantly, since this rule is now a class, we can test it.

If we place a JUnit or TestNG test in buildSrc/src/test/groovy/org/gradle/examples/rules/HttpPingTests.groovy, the default Gradle unit test support will run the test prior to running our build. Since the topic of unit testing is already covered in Building and Testing with Gradle —and the buildSrc project works exactly like a normal project in this regard—I will invite you to explore the full example code at your leisure.

Generalizing Rules Beyond Tasks

The rules we’ve programmed so far have all been task rules, or rules that create new tasks on demand. As it turns out, Gradle’s rule API doesn’t exist specifically at the task level, but rather at the level of the named domain object collection. That is, any named collection of things in Gradle—SourceSets, configurations, custom domain objects—can be created by rules.

Conclusion

When you have full control over the build source and are writing your own plug-in, often the easiest way to introduce new functionality is simply to code it directly into your custom plug-in. However, at times when build source is not available to you or is not convenient to modify, build hooks and rules provide powerful mechanisms for bringing concepts from aspect-oriented programming and metaprogramming into your build development.



[12] If it is a multi-project build, there is also a DAG of project objects, one for each project.

[13] Dependency order means that if task A depends on task B, task B will run first.

[14] By the time the hook itself is evaluated, it’s too late; evaluation has already taken place.

[15] Recall the discussion of the Gradle lifecycle.

Get Gradle Beyond the Basics now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.