This chapter covers
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.
We’ll also look at Gradle’s build lifecycle to get a good understanding of how a build is configured and executed. Your build script can respond to notifications as the build progresses through the lifecycle phases. In the last part of this chapter, we’ll show how to write lifecycle hooks as closure and listener implementations.
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.
In Gradle’s terminology a project represents a component you’re trying to build (for example, a JAR file), or a goal you’re trying to achieve, like deploying an application. If you’re coming from Maven, this concept should sound pretty familiar. Gradle’s equivalent to Maven’s pom.xml is the build.gradle file. Each Gradle build script defines at least one project. When starting the build process, Gradle instantiates the class org.gradle.api.Project based on your configuration in build.gradle and makes it implicitly available through the project variable. Figure 4.2 shows the API interface and its most important methods:
A project can create new tasks, add dependencies and configurations, and apply plugins and other build scripts. Many of its properties, like name and description, are accessible via getter and setter methods. So why are we talking about Gradle’s API early on? You’ll find that after getting to know Gradle’s basics, you’ll want to go further and apply the concepts to your realworld projects. The API is key to getting the most out of Gradle.
The Project instance gives you programmatic access to all Gradle features in your build, like task creation and dependency management. You’ll use many of these features throughout the book by invoking their corresponding API methods. Keep in mind that you’re not required to use the project variable when accessing properties and methods of your project—it’s assumed you mean the Project instance. The following code snippet illustrates valid method invocations on the Project instance:
You already created some simple tasks in Chapter 2. Even though the use cases I presented were trivial, you got to know some important capabilities of a task: task actions and task dependencies. An action defines an atomic unit of work that’s executed when the task is run. This can be as simple as printing out text like “Hello world!” or as complex as compiling Java source code, as seen in Chapter 2. Many times a task requires another task to run first. This is especially true if the task depends on the produced output of another task as input to complete its own actions. For example, you’ve seen that you need to compile Java sources first before they can be packaged into a JAR file. Let’s look at Gradle’s API representation of a task, the interface org.gradle.api.Task, as shown in figure 4.3.
The Task interface provides even more methods than are shown in the figure. You’ll use them one by one as you apply them to concrete examples throughout the book.
Each instance of Project and Task provides properties that are accessible through getter and setter methods. A property could be a task’s description or the project’s version. Later in this chapter, you’ll read and modify these values in the context of a practical example. Often, you’ll want to define your own properties. For example, you may want to declare a variable that references a file that’s used multiple times within the same build script. Gradle allows defining user-defined variables through extra properties.
Many of Gradle’s domain model classes provide support for ad-hoc properties. Internally, these properties are stored as key-value pairs in a map. To add properties, you’re required to use the ext namespace. Let’s look at a concrete example. The following code snippet demonstrates that a property can be added, read, and modified in many different ways:
Properties can be directly injected into your project by declaring them in a properties file named gradle.properties under the directory
Extra properties and Gradle properties are the mechanisms you’ll probably use the most to declare custom variables and their values. Gradle offers many other ways to provide properties to your build, such as
I won’t show you concrete examples for these alternative ways of declaring properties, but you can use them if needed. The online Gradle user guide provides excellent usage examples if you want to go further. For the rest of this chapter, you’ll make extensive use of tasks and Gradle’s build lifecycle.
Working with tasks
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.
Managing the project version
To demonstrate properties and methods of the class DefaultTask in action, I’m going to explain them in the context of the To Do application from chapter 3. Now that you have the general build infrastructure in place, features can easily be added. Often, feature sets are grouped into releases. To identify each release, a unique version number is added to the deliverable.
Many enterprises or open source projects have their own versioning strategy. Think back to some of the projects you’ve worked on. Usually, you assign a specific version numbering scheme (for example, a major and minor version number separated by a dot, like 1.2). You may also encounter a project version that appends a SNAPSHOT designator to indicate that the built project artifact is in the state of development. You’ve already assigned a version to your project in Chapter 3 by setting a string value to the project property version. Using a String data type works great for simple use cases, but what if you want to know the exact minor version of your project? You’ll have to parse the string value, search for the dot character, and filter out the substring that identifies the minor version. Wouldn’t it be easier to represent the version by an actual class?
You could easily use the class’s fields to set, retrieve, and modify specific portions of your numbering scheme. You can go even further. By externalizing the version information to persistent data storage, such as a file or database, you’ll avoid having to modify the build script itself to change the project version. Figure 4.4 illustrates the interaction among the build script, a properties file that holds the version information, and the data representation class. You’ll create and learn how to use all of these files in the upcoming sections.
Being able to control the versioning scheme programmatically will become a necessity the more you want to automate your project lifecycle. Here’s one example: your code has passed all functional tests and is ready to be shipped. The current version of your project is 1.2-SNAPSHOT. Before building the final WAR file, you’ll want to make it a release version 1.2 and automatically deploy it to the production server. Each of these steps can be modeled by creating a task: one for modifying the project version and one for deploying the WAR file. Let’s take your knowledge about tasks to the next level by implementing flexible version management in your project.
Declaring task actions
An action is the appropriate place within a task to put your build logic. The Task interface provides you with two relevant methods to declare a task action: doFirst(Closure) and doLast(Closure). When a task is executed, the action logic defined as closure parameter is executed in turn.
You’re going to start easy by adding a single task named printVersion. The task’s purpose is to print out the current project version. Define this logic as the last action of this task, as shown in the following code snippet:
The same result could be achieved as the first action of the task by using the doFirst method instead:
So far, you’ve only added a single action to the task printVersion, either as the first or last action. But you’re not limited to a single action per task. In fact, you can add as many actions as you need even after the task has been created. Internally, every task keeps a list of task actions. At runtime, they’re executed sequentially. Let’s look at a modified version of your example task:
As shown in the listing, an existing task can be manipulated by adding actions to them. This is especially useful if you want to execute custom logic for tasks that you didn’t write yourself. For example, you could add a doFirst action to the compileJava task of the Java plugin that checks if the project contains at least one Java source file.
Accessing DefaultTask properties
Next you’ll improve the way you output the version number. Gradle provides a logger implementation based on the logging library SLF4J. Apart from implementing the usual range of logging levels (DEBUG, ERROR, INFO, TRACE, WARN), it adds some extra levels. The logger instance can be directly accessed through one of the task’s methods. For now, you’re going to print the version number with the log level QUIET:
Even though setting a task’s description and grouping is optional, it’s always a good idea to assign values for all of your tasks. It’ll make it easier for the end user to identify the task’s function. Next, we’ll review the intricacies of defining dependencies between tasks.
Defining task dependencies
The method dependsOn allows for declaring a dependency on one or more tasks. You’ve seen that the Java plugin makes extensive use of this concept by creating task graphs to model full task lifecycles like the build task. The following listing shows different ways of applying task dependencies using the dependsOn method.
- Listing 4.1 Applying task dependencies
If you take a close look at the task execution order, you may be surprised by the outcome. The task printVersion declares a dependency on the tasks second and first. Wouldn’t you have expected that the task second would get executed before first? In Gradle, the task execution order is not deterministic.
TASK DEPENDENCY EXECUTION ORDER
It’s important to understand that Gradle doesn’t guarantee the order in which the dependencies of a task are executed. The method call dependsOn only defines that the dependent tasks need to be executed beforehand. Gradle’s philosophy is to declare what should be executed before a given task, not how it should be executed. This concept is especially hard to grasp if you’re coming from a build tool that defines its dependencies imperatively, like Ant does. In Gradle, the execution order is automatically determined by the input/output specification of a task, as you’ll see later in this chapter. This architectural design decision has many benefits. On the one hand, you don’t need to know the whole chain of task dependencies to make a change, which improves code maintainability and avoids potential breakage. On the other hand, because your build doesn’t have to be executed strictly sequentially, it’s been enabled for parallel task execution, which can significantly improve your build execution time.
In practice, you may find yourself in situations that require a certain resource to be cleaned up after a task that depends on it is executed. A typical use case for such a resource is a web container needed to run integration tests against a deployed application. Gradle’s answer to such a scenario is finalizer tasks, which are regular Gradle tasks scheduled to run even if the finalized task fails. The following code snippet demonstrates how to use a specific finalizer task using the Task method finalizedBy:
Chapter 7 covers the concept of finalizer tasks in more depth with the help of a realworld example. In the next section, you’ll write a Groovy class to allow for finergrained control of the versioning scheme.
Adding arbitrary code
It’s time to come back to my statement about Gradle’s ability to define general-purpose Groovy code within a build script. In practice, you can write classes and methods the way you’re used to in Groovy scripts or classes. In this section, you’ll create a class representation of the version. In Java, classes that follow the bean conventions are called plain-old Java objects (POJOs). By definition, they expose their fields through getter and setter methods. Over time it can become very tiresome to write these methods by hand. POGOs, Groovy’s equivalent to POJOs, only require you to declare properties without an access modifier. Their getter and setter methods are intrinsically added at the time of bytecode generation and therefore are available at runtime. In the next listing, you assign an instance of the POGO ProjectVersion. The actual values are set in the constructor.
- Listing 4.2 Representing the project version by a POGO
Understanding task configuration
Before you get started writing code, you’ll need to create a properties file named version.properties alongside the build script. For each of the version categories like major and minor, you’ll create an individual property. The following key–value pairs represent the initial version 0.1-SNAPSHOT:
Listing 4.3 declares a task named loadVersion to read the version classifiers from the properties file and assign the newly created instance of ProjectVersion to the project’s version field. At first sight, the task may look like any other task you defined before. But if you look closer, you’ll notice that you didn’t define an action or use the left shift operator. Gradle calls this a task configuration.
- Listing 4.3 Writing a task configuration
You may ask yourself why the task was invoked at all. Granted, you didn’t declare a dependency on it, nor did you invoke the task on the command line. Task configuration blocks are always executed before task actions. The key to fully understanding this behavior is the Gradle build lifecycle. Let’s take a closer look at each of the build phases.
GRADLE’S BUILD LIFECYCLE PHASES
Whenever you execute a Gradle build, three distinct lifecycle phases are run: initialization, configuration, and execution. Figure 4.5 visualizes the order in which the build phases are run and the code they execute.
During the initialization phase, Gradle creates a Project instance for your project. Your given build script only defines a single project. In the context of a multiproject build, this build phase becomes more important. Depending on which project you’re executing, Gradle figures out which of the project dependencies need to participate in the build. Note that none of your currently existing build script code is executed in this build phase. This will change in Chapter 6when you modularize the To Do application into a multiproject build.
The build phase next in line is the configuration phase. Internally, 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.
In the execution phase tasks are executed in the correct order. The execution order is determined by their dependencies. Tasks that are considered up to date are skipped. For example, if task B depends on task A, then the execution order would be A → B when you run gradle B on the command line.
As you can see, Gradle’s incremental build feature is tightly integrated in the lifecycle. In chapter 3 you saw that the Java plugin made heavy use of this feature. The task compileJava will only run if any of the Java source files are different from the last time the build was run. Ultimately, this feature can improve a build’s performance significantly. In the next section, I’ll show how to use the incremental build feature for your own tasks.
Declaring task inputs and outputs
Gradle determines if a task is up to date by comparing a snapshot of a task’s inputs and outputs between two builds, as shown in figure 4.6. A task is considered up to date if inputs and outputs haven’t changed since the last task execution. Therefore, the task only runs if the inputs and outputs are different; otherwise, it’s skipped.
An input can be a directory, one or more files, or an arbitrary property. A task’s output is defined through a directory or 1...n files. Inputs and outputs are defined as fields in class DefaultTask and have a direct class representation, as shown in figure 4.7:
Let’s see this feature in action. Imagine you want to create a task that prepares your project’s deliverable for a production release. To do so, you’ll want to change the project version from SNAPSHOT to release. The following listing defines a new task that assigns the Boolean value true to the version property release. The task also propagates the version change to the property file.
- Listing 4.4 Switching the project version to production-ready
The task makeReleaseVersion may be part of another lifecycle task that deploys the WAR file to a production server. You may be painfully aware of the fact that a deployment can go wrong. The network may have a glitch so that the server cannot be reached. After fixing the network issues, you’ll want to run the deployment task again. Because the task makeReleaseVersion is declared as a dependency to your deployment task, it’s automatically rerun. Wait, you already marked your project version as production-ready, right? Unfortunately, the Gradle task doesn’t know that. To make it aware of this, you’ll declare its inputs and outputs, as shown in the next listing.
- Listing 4.5 Adding incremental build support via inputs/outputs
Task inputs/outputs evaluation
Now, if you execute the task twice you’ll see that Gradle already knows that the project version is set to release and automatically skips the task execution:
If you don’t change the release property manually in the properties file, any subsequent run of the task makeReleaseVersion will be marked up to date. So far you’ve used Gradle’s DSL to create and modify tasks in the build script. Every task is backed by an actual task object that’s instantiated for you during Gradle’s configuration phase. In many cases, simple tasks get the job done. However, sometimes you may want to have full control over your task implementation. In the next section, you’ll rewrite the task makeReleaseVersion in the form of a custom task implementation.
Writing and using a custom task
The action logic within the task makeReleaseVersion is fairly simple. Code maintainability is clearly not an issue at the moment. However, when working on your projects you’ll notice that simple tasks can grow in size quickly the more logic you need to add to them. The need for structuring your code into classes and methods will arise. You should be able to apply the same coding practices as you’re used to in your regular production source code, right? Gradle doesn’t suggest a specific way of writing your tasks. You have full control over your build source code. The programming language you choose, be it Java, Groovy, or any other JVM-based language, and the location of your task is up to you.
Custom tasks consist of two components: the custom task class that encapsulates the behavior of your logic, also called the task type, and the actual task that provides the values for the properties exposed by the task class to configure the behavior. Gradle calls these tasks enhanced tasks.
Maintainability is only one of the advantages of writing a custom task class. Because you’re dealing with an actual class, any method is fully testable through unit tests. Testing your build code is out of the scope of this chapter. If you want to learn more, feel free to jump to Chapter 7. Another advantage of enhanced tasks over simple tasks is reusability. The properties exposed by a custom task can be set individually from the build script. With the benefits of enhanced tasks in mind, let’s discuss writing a custom task class.
WRITING THE CUSTOM TASK CLASS
As mentioned earlier in this chapter, Gradle creates an instance of type DefaultTask for every simple task in your build script. When creating a custom task, you do exactly that—create a class that extends DefaultTask. The following listing demonstrates how to express the logic from makeReleaseVersion as the custom task class ReleaseVersionTask written in Groovy.
- Listing 4.6 Custom task implementation
EXPRESSING INPUTS AND OUTPUTS THROUGH ANNOTATIONS
Task input and output annotations add semantic sugar to your implementation. Not only do they have the same effect as the method calls to TaskInputs and TaskOutputs, they also act as automatic documentation. At first glance, youknow exactly what data is expected as input and what output artifact is produced by the task. When exploring the Javadocs of this package, you’ll find that Gradle provides you with a wide range of annotations.
In your custom task class, you use the @Input annotation to declare the input property release and the annotation @OutputFile to define the output file. Applying input and output annotations to fields isn’t the only option. You can also annotate the getter methods for a field.
Task input validation
USING THE CUSTOM TASK
You implemented a custom task class by creating an action method and exposed its configurable properties through fields. But how do you actually use it? In your build script, you’ll need to create a task of type ReleaseVersionTask and set the inputs and outputs by assigning values to its properties, as shown in the next listing. Think of it as creating a new instance of a specific class and setting the values for its fields in the constructor.
- Listing 4.7 Task of type ReleaseVersionTask
APPLIED CUSTOM TASK REUSABILITY
Let’s assume you’d like to use the custom task in another project. In that project, the requirements are different. The version POGO exposes different fields to represent the versioning scheme, as shown in the next listing.
- Listing 4.8 Different version POGO implementation
- Listing 4.9 Setting individual property values for task makeReleaseVersion
Gradle’s built-in task types
Do you remember the last time a manual production deployment went wrong? I bet you still have a vivid picture in your mind: angry customers calling your support team, the boss knocking on your door asking about what went wrong, and your coworkers frantically trying to figure out the root cause of the stack trace being thrown when starting up the application. Forgetting a single step in a manual release process can prove fatal.
Let’s be professionals and take pride in automating every aspect of the build lifecycle. Being able to modify the project’s versioning scheme in an automated fashion is only the first step in modeling your release process. To be able to quickly recover from failed deployments, a good rollback strategy is essential. Having a backup of the latest stable application deliverable for redeployment can prove invaluable. You’ll use some of the task types shipped with Gradle to implement parts of this process for your To Do application.
Here’s what you’re going to do. Before deploying any code to production you want to create a distribution. It’ll act as a fallback deliverable for future failed deployments. A distribution is a ZIP file that consists of your web application archive, all source files, and the version property file. After creating the distribution, the file is copied to a backup server. The backup server could either be accessible over a mounted shared drive or you could transfer the file over FTP. Because I don’t want to make this example too complex to grasp, you’ll just copy it to the subdirectory build/backup. Figure 4.8 illustrates the order in which you want the tasks to be executed.
USING TASK TYPES
Gradle’s built-in task types are derived classes from DefaultTask. As such, they can be used from an enhanced task within the build script. Gradle provides a broad spectrum of task types, but for the purposes of this example you’ll use only two of them. The following listing shows the task types Zip and Copy in the context of releasing the production version of your software. You can find the complete task reference in the DSL guide.
- Listing 4.10 Using task types to back up a zipped release distribution
The task types you used offer far more configuration options than those shown in the example. Again, for a full list of available options, please refer to the DSL reference or the Javadocs.
TASK DEPENDENCY INFERENCE
You may have noticed in the listing that a task dependency between two tasks was explicitly declared through the dependsOn method. However, some of the tasks don’t model a direct dependency to other tasks (for example, createDistribution to war). How does Gradle know to execute the dependent task beforehand? By using the output of one task as input for another task, dependency is inferred. Consequently, the dependent task is run automatically. Let’s see the full task execution graph in action:
After running the build, you should find the generated ZIP file in the directory build/distributions, which is the default output directory for archive tasks. You can easily assign a different distribution output directory by setting the property destinationDir. The following directory tree shows the relevant artifacts generated by the build:
Task types have incremental build support built in. Running the tasks multiple times in a row will mark them as up-to-date if you don’t change any of the source files. Next, you’ll learn how to define a task on which the behavior depends on a flexible task name.
Sometimes you may find yourself in a situation where you write multiple tasks that do similar things. For example, let’s say you want to extend your version management functionality by two more tasks: one that increments the major version of the project and another to do the same work for the minor version classifier. Both tasks are also supposed to persist the changes to the version file. If you compare the doLast actions for both tasks in the following listing, you can tell that you basically duplicated code and applied minor changes to them.
- Listing 4.11 Declaring tasks for incrementing version classifiers
Having two separate tasks works just fine, but you can certainly improve on this implementation. In the end, you’re not interested in maintaining duplicated code.
TASK RULE-NAMING PATTERN
Gradle also introduces the concept of a task rule, 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. Together they form a dynamic task name. If you wanted to apply a task rule to the previous example, the naming pattern would look like this: increment<Classifier>Version. When executing the task rule on the command line, you’d specify the classifier placeholder in camel-case notation (for example, incrementMajorVersion or incrementMinorVersion).
Task rules in practice
DECLARING A TASK RULE
You just read about defining a naming pattern for a task rule, but how do you actually declare a task rule in your build script? To add a task rule to your project, you’ll first need to get the reference to TaskContainer. Once you have the reference, you can call the method addRule(String, Closure). The first parameter provides a description (for example, the task name pattern), and the second parameter declares the closure to execute to apply the rule. Unfortunately, there’s no direct way of creating a task rule through a method from Project as there is for simple tasks, as illustrated in figure 4.10.
With a basic understanding of how to add a task rule to your project, you can get started writing the actual closure implementation for it:
- Listing 4.12 Merging similar logic into a task rule
Task rules can’t be grouped individually as you can do with any other simple or enhanced task. A task rule, even if it’s declared by a plugin, will always show up under this group.
Building code in buildSrc directory
You’ve seen how quickly your build script code can grow. In this chapter you already created two Groovy classes within your build script: ProjectVersion and the custom task ReleaseVersionTask. These classes are perfect candidates to be moved to the buildSrc directory alongside your project. The buildSrc directory is an alternative location to put build code and a real enabler for good software development practices. You’ll be able to structure the code the way you’re used to in any other project and even write tests for it.
Gradle standardizes the layout for source files under the buildSrc directory. Java code needs to sit in the directory src/main/java, and Groovy code is expected to live under the directory src/main/groovy. Any code that’s found in these directories is automatically compiled and put into the classpath of your regular Gradle build script. The buildSrc directory is a great way to organize your code. Because you’re dealing with classes, you can also put them into a specific package. You’ll make them part of the package com.manning.gia. The following directory structure shows the Groovy classes in their new location:
Keep in mind that extracting the classes into their own source files requires some extra work. The difference between defining a class in the build script versus a separate source file is that you’ll need to import classes from the Gradle API. The following code snippet shows the package and import declaration for the custom task ReleaseVersionTask:
The buildSrc directory is treated as its own Gradle project indicated by the path :buildSrc. Because you didn’t write any unit tests, the compilation and execution tasks for tests are skipped. Chapter 7 is fully dedicated to writing tests for classes in buildSrc. An important lesson you learned is that action and configuration code is executed during different phases of the build lifecycle. The rest of this chapter will talk about how to write code that’s executed when specific lifecycle events are fired.
* 認識 Gradle - （5）Gradle Task 觀念導讀