Chapter 4. Build script essentials
This chapter covers
- Gradle’s building blocks and their API representation
- Declaring new tasks and manipulating existing tasks
- Advanced task techniques
- Implementing and using task types
- Hooking into the build lifecycle
In chapter 3, you implemented a full-fledged Java web application from the ground up and built it with the help of Gradle’s core plugins. You learned that the default conventions introduced by those plugins are customizable and can easily adapt to nonstandard build requirements. Preconfigured tasks function as key components of a plugin by adding executable build logic to your project.
In this chapter, we’ll explore the basic building blocks of a Gradle build, namely projects and tasks, and how they map to the classes in the Gradle API. Properties are exposed by methods of these classes and help to control the build. You’ll also learn how to control the build’s behavior through properties, as well as the benefits of structuring your build logic.
At the core of this chapter, you’ll experience the nitty-gritty details of working with tasks by implementing a consistent example. Step by step, you’ll build your knowledge from declaring simple tasks to writing custom task classes. Along the way, we’ll touch on topics like accessing task properties, defining explicit and implicit task dependencies, adding incremental build support, and using Gradle’s built-in task types.
Every Gradle build consists of three basic building blocks: projects, tasks, and properties. Each build contains at least one project, which in turn contains one or more tasks. Projects and tasks expose properties that can be used to control the build. Figure 4.1 illustrates the dependencies among Gradle’s core components.
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. Let’s take a closer look at each component and its API counterpart.
By default, every newly created task is of type org.gradle.api.DefaultTask, the standard implementation of org.gradle.api.Task. All fields in class DefaultTask are marked private. This means that they can only be accessed through their public getter and setter methods. Thankfully, Groovy provides you with some syntactic sugar, which allows you to use fields by their name. Under the hood, Groovy calls the method for you. In this section, we’ll explore the most important features of a task by example.
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. A lifecycle event can occur before, during, or after a specific build phase. An example of a lifecycle event that happens after the execution phase would be the completion of a build.
At configuration time, Gradle determines the order of tasks that need to be run during the execution phase. As noted in chapter 1, the internal structure that represents these task dependencies is modeled as a directed acyclic graph (DAG). Each task in the graph is called a node, and each node is connected by directed edges. You’ve most likely created these connections between nodes by declaring a dependsOn relationship for a task or by leveraging the implicit task dependency interference mechanism. It’s important to note that DAGs never contain a cycle. In other words, a task that has been executed before will never be executed again. Figure 4.12 demonstrates the DAG representation of the release process modeled earlier.
Understanding the build lifecycle and the execution order of its phases is crucial to beginners. Gradle makes a clear distinction between task actions and task configurations. Task actions, defined through the closures doFirst and doLast or its shortcut notation <<, are run during the execution phase. Any other code defined outside of a task action is considered a configuration and therefore executed beforehand during the configuration phase.
Next, we turned our attention to implementing nonfunctional requirements: build execution performance, code maintainability, and reusability. You added incremental build support to one of your existing task implementations by declaring its input and output data. If the data doesn’t change between the initial and subsequent builds task, execution is skipped. Implementing incremental build support is easy and cheap. If done right, it can significantly improve the execution time of your build. Complex build logic is best structured in custom task classes, which give you all the benefits of object-oriented programming. You practiced writing a custom task class by transferring the existing logic into an implementation of DefaultTask. You also cleaned up your build script by moving compilable code under the buildSrc directory. Gradle comes with a whole range of reusable task types like Zip and Copy. You incorporated both types by modeling a chain of task dependencies for releasing your project.