Configure Global, Customise per Task

Summary

Use of extensions is a common pattern for global configuration. In cases where multiple tasks rely on such global configuration, it is sometimes required to customise specific tasks not to use the global configuration. This leads to additional properties on a task with additional testing and complexity.

Solution

Create an extension that can be applied to both the project and tasks. Make the tasks in your plugin that would previously have relied only on a project extension to be task-extension aware. Build the extension in such a way that task extensions are aware of the global project extension. If the properties of the task extension is not explicitly configured by the build script author, the task extension will retrieve the value from the project extension.

Examples

Consider for a moment that you are writing an extension for the GNU Make tool. In this extension you want to set the number of concurrent jobs and as well as the flags that should always be passed to an invocation of the tool. Conceptually you may model this in your extension as follows.

GnuMakeExtension.groovy
 1 @CompileStatic
 2 class GnuMakeExtension {
 3     void setNumJobs(int numJobs) {
 4         this.numJobs = numJobs
 5     }
 6 
 7     Integer getNumJobs() {
 8         this.numJobs
 9     }
10 
11     void flags(final Map<String,String> extraFlags) {
12         flags.putAll(extraFlags)
13     }
14 
15     Map<String,String> getFlags() {
16         this.flags
17     }
18 
19     private Integer numJobs
20     private Map<String,String> flags = [:]
21 }

The first step is to add the constructors for both task and project.

GnuMakeExtension.groovy
 1 final static String NAME = 'gnumake' 
 2 
 3 GnuMakeExtension(Project project) {
 4     this.project = project
 5 }
 6 
 7 GnuMakeExtension(Task task) {
 8     this.task = task
 9 }
10 
11 private final Project project 
12 private final Task task
  1. By convention add the name that the extensions will be known by.
  2. Both a Project and a Task instance is kept in the extension. One of them will always be null. Also ntoe the usage of final as these will be one-shot assignments during instantiation of the extension.

The second step is to modify in the internal fields so that they can be recognised as being uninitialised. The most common case is to use null, but depending on your context this might be different. For containers you might want to simply leave them as empty. Now modify your project constructor to set some default values and leave them uninitialised in the task form of the extension.

GnuMakeExtension.groovy
 1 GnuMakeExtension(Project project) {
 2     this.project = project
 3     this.numJobs = 4 
 4 }
 5 
 6 GnuMakeExtension(Task task) {
 7     this.task = task
 8 }
 9 
10 private Integer numJobs
11 private final Map<String,Object> flags = [:]
  1. Assume for the purpose of this example, that in your plugin you want four jobs to always be run concurrently.

The final step is to retrieve the values in an intelligent manner. The logic is always to look in the task extension first and then in the project extension. However, this latter logic should only occur when an extension is attached to a task. All other entities in Gradle should not be aware of this behaviour. Start by adding a helper function that will always return the project extension

GnuMakeExtension.groovy
1 private GnuMakeExtension getProjectExtension() {
2     project ? this : (GnuMakeExtension)(task.project.extensions.getByName(NAME)) 
3 }

Now continue to modify the getters in the extension to check first whether the latter is attached to a project or a task and modify behaviour accordingly:

GnuMakeExtension.groovy
 1 Integer getNumJobs() {
 2     if(project) {  
 3         return this.numJobs
 4     } else {
 5         this.numJobs ?: getProjectExtension().getNumJobs() 
 6     }
 7 }
 8 
 9 Map<String,Object> getFlags() {
10     if(project) {
11         this.flags
12     } else {
13         this.flags.isEmpty() ? getProjectExtension().getFlags() : this.flags 
14     }
15 }
  1. If this is a project extension, return the current value of numJobs.
  2. If this is a task extension, check whether it has been set. If so return the configured value, otherwise defer to the project extension.
  3. In a similar fashion an empty collection can be used to defer to a project extension. The context of your own plugin will determine whether an empty collection is a feasible approach, but this is shown as one possible option.

Having coded your extension it is now time to add it your plugin code and to your plugin’s task types. Create the global extension as per usual when the plugin is applied.

GnuMakePlugin.groovy
1 void apply(final Project project) {
2 
3     project.extensions.create(
4         GnuMakeExtension.NAME,
5         GnuMakeExtension,
6         project
7     )
8 }

The task extension is added when the task is instantiated. Assuming you have created a task type called GnuMakeTask, just add one line to the constructor.

GnuMakeTask.groovy
1 GnuMakeTask() {
2     gnumake = extensions.create(GnuMakeExtension.NAME,GnuMakeExtension,this) 
3 
4 }
5 
6 private final GnuMakeExtension gnumake
  1. It is also good practice to store the task extension reference locally so that your code does not have to do a lookup via the extension container everytime.

Accessing the values in the extension simply becomes a case of referring to the local reference. For instance, you may want to add a method that returns the correct command-line parameter for concurrent jobs, This can be done as follows.

GnuMakeTask.groovy
1 String getNumJobsParameter() {
2     "-j${this.gnumake.getNumJobs()}" 
3 }
  1. The logic previously coded will ensure that task extension will perform a fallback to the project extension if nothing has been configured.

At this point all that remains is for a build script author to apply your plugin and make use of. Assuming that the build script creates two GnuMakeTask instances called makeProjectA and makeProjectB, configuration becomes readable and comprehensible.

build.gradle
 1 gnumake {
 2     numJobs = 10 
 3 }
 4 
 5 makeProjectA {
 6     gnumake {
 7         defaultFlags BUILDNUM : '1234' 
 8     }
 9 }
10 
11 makeProjectB {
12     gnumake {
13         numJobs = 1 
14     }
15 }
  1. Configure setting globally
  2. makeProjectA uses its own set of flags, but use the numJobs from the project extension.
  3. makeProjectB will use its own setting for numJobs but will use the global set of flags.

A number of plugins in the field already make use of this recipe. If you want to study their usage and implementations have a look at Node.js + NPM plugins and well as the Packer plugin.

Gradle API Updates

The Gradle team has made advances in moving conventions to a public API. This approach are in many cases still more flexible and useful than conventions.

Caveats

  • The above example simplifies the construction of task extensions and makes the assumption that it will always be added with the same name. If you are concerned about potential mis-use, then you should add a second parameter to the task constructor which takes the name of the project extension.
  • When values in the task extension is modified, the task will not be out of date. If this behaviour is important to you, implement a task input property which can monitor changes in the task extension.

Grolifant

AbstractCombinedProjectTaskExtension is a base class inf Grolifant that can be used to simplify building of this recipe.