Search

Dark theme | Light theme

November 14, 2016

Gradle Goodness: Adding Task With Rule Based Model Configuration

Gradle has an incubating feature Rule based model configuration. This is a new way to configure Gradle projects where Gradle has more control of the configuration and the dependencies between configuration objects. This allows Gradle to resolve configuration values before they are used, because Gradle knows there is a dependency. With this new model we don't need any lazy evaluation "tricks" we had to use. For example there was an internal convention mapping mechanism for tasks to assign values to a task configuration after the task was already created. Also the project.afterEvalute is a mechanism to have late binding for task properties. With the new rule based model Gradle can do without these options, we can rely on Gradle resolving all dependent configuration values when we create a task.

In Gradle we already know about the "project space" where the Project object is the root of the object graph. For example repositories are part of the project space. Gradle can get some useful information from the project space, but it is mostly a graph of objects that Gradle only partially can reason about. Then we have the "model space". This is part of a project and we can use it in our build script with the model configuration block. The model space is separate from the project space and contains objects that are managed by Gradle. Gradle knows dependencies between the objects and how to create and change them. This helps Gradle to optimise build logic. To help Gradle we must define rules to work with objects in the model space. Each rule is like a recipe for Gradle on how to work with the model. Gradle can build a graph of models and know about dependencies between models. This way Gradle guarantees that model objects are completely configured before being used. For example if a rule needs a VersionFile model configuration object then Gradle makes sure that the VersionFile is created and all properties are set. So we don't need any lazy or late binding anymore, because the properties will be set (Gradle makes sure) when we want to use them. The rules are defined a class that extends RuleSource. Such a class is stateless and only contains methods to work with the model objects. Gradle has some specific annotations that can be used on methods to indicate what a method should do.

In our example we have a Gradle custom task VersionFileTask. The task has some properties which we want to set via the model space using a model configuration block. We want to add this task to the list of tasks in our project by using a apply plugin: statement.

Let's first look at the source of the custom Gradle task:

// File: buildSrc/src/main/groovy/mrhaki/gradle/VersionFileTask.groovy
package mrhaki.gradle

import org.gradle.api.DefaultTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction

/**
 * Simple task to save the value for the
 * {@link #version} property in a file.
 * The file is set with the {@link #outputFile}
 * property.
 */
class VersionFileTask extends DefaultTask {

    /**
     * Value for version to be saved.
     */
    @Input
    String version

    /**
     * Output file to store version value in.
     */
    @OutputFile
    File outputFile

    /**
     * Actual task actions to save the value
     * for {@link #version} in {@link #outputFile}.
     */
    @TaskAction
    void generateVersionFile() {
        outputFile.parentFile.mkdirs()
        outputFile.text = version
    }

}

Nothing special here. Now it is time to enter the model space of Gradle. First we create a object with properties that is used to configure our VersionFileTask task. Here we must use the annotation @Managed so Gradle knows this object will be managed in the object space:

// File: buildSrc/src/main/groovy/mrhaki/gradle/VersionFile.groovy
package mrhaki.gradle

import org.gradle.model.Managed

/**
 * Gradle is responsible for creating an implementation
 * for this interface. We use @Managed to let Gradle know.
 * We need to provide the get and set
 * methods following the Java Beans standards for properties.
 * 
 * In the model space Gradle provides an implementation and
 * knows how to create an instance of that implementation
 * and how to invoke the get and set methods to mutate the state.
 */
@Managed
interface VersionFile {
    String getVersion() 
    void setVersion(final String version) 

    File getOutputFile() 
    void setOutputFile(final File outputFile) 
}

Next we create a class with the rules for the model space VersionFileTaskRules. We can use this class like a plugin in our project using the statement apply plugin: mrhaki.gradle.VersionFileTaskRules. We need two rules to instruct Gradle about our model objects. First we need to make sure an instance of the managed VersionFile interface is created. We do this with the createVersionFile method. We need another method ( createVersionFileTask) to change the list of tasks (Gradle calls this mutate in the model space terminology) using an instance of VersionFile. Gradle knows about the connection between the two methods via the VersionFile object, so it makes sure VersionFile is created before the method createVersionFileTask is invoked:

// File: buildSrc/src/main/groovy/mrhaki/gradle/VersionFileTaskRules.groovy
package mrhaki.gradle

import org.gradle.api.Task
import org.gradle.model.Model
import org.gradle.model.ModelMap
import org.gradle.model.Mutate
import org.gradle.model.RuleSource

/**
 * Class contains several methods to tell Gradle
 * how to create a {@link VersionFile} instance
 * and how to mutate the list of tasks by creating
 * the {@link VersionFileTask} task.
 */
class VersionFileTaskRules extends RuleSource {

    /**
     * Method to tell Gradle that we need an instance
     * of {@link VersionFile} in the model space. The name of the method
     * is also used as in the model space to configure
     * the object. Another name can be used as an argument for the
     * {@code @Model} annotation.
     *
     * @param versionFile The type {@link VersionFile} has a {@code @Managed}
     *                    annotation, so Gradle can provide an implementation.
     */
    @Model
    void versionFile(final VersionFile versionFile) {}

    /**
     * Method to create the {@link VersionFileTask} task and add to list
     * of tasks. The first arguments is the type we want to mutate, the
     * other argument is an input argument used to mutate the list of tasks.
     * 
     * With the {@code versionFile} argument we can pass information to this method
     * that is needed to create the {@link VersionFileTask}. A user can use
     * the {@code model} configuration block in a build file to set values for 
     * the {@link VersionFile} instance.
     * 
     * Gradle will make sure the input argument is created and all properties
     * are set before it is used in this method. So no more {@link afterEvaluate}
     * or convention mappings are needed. Gradle makes sure all input arguments
     * are resolved before they are used.
     * 
     * @param tasks Tasks we want to add a new one to
     * @param versionFile Resolved instance used to configure new task
     */
    @Mutate
    void createVersionFileTask(final ModelMap<Task> tasks, final VersionFile versionFile) {
        tasks.create('generateVersionFile', VersionFileTask) { task ->
            task.version = versionFile.version
            task.outputFile = versionFile.outputFile
        }
    }
    
}

To use the rules we create a simple build.gradle file:

apply plugin: mrhaki.gradle.VersionFileTaskRules

To see the model space managed by Gradle we can invoke the model task. The output shows the current model of our project.

$ gradle model
...
------------------------------------------------------------
Root project
------------------------------------------------------------

+ tasks
      | Type:           org.gradle.model.ModelMap<org.gradle.api.Task>
      | Creator:        Project.<init>.tasks()
      | Rules:
         ⤷ VersionFileTaskRules#createVersionFileTask(ModelMap<Task>, VersionFile)
...
    + generateVersionFile
          | Type:       mrhaki.gradle.VersionFileTask
          | Value:      task ':generateVersionFile'
          | Creator:    VersionFileTaskRules#createVersionFileTask(ModelMap<Task>, VersionFile) > create(generateVersionFile)
          | Rules:
             ⤷ copyToTaskContainer
...
+ versionFile
      | Type:           mrhaki.gradle.VersionFile
      | Creator:        VersionFileTaskRules#createVersionFile(VersionFile)
    + outputFile
          | Type:       java.io.File
          | Value:      null
          | Creator:    VersionFileTaskRules#createVersionFile(VersionFile)
    + version
          | Type:       java.lang.String
          | Value:      null
          | Creator:    VersionFileTaskRules#createVersionFile(VersionFile)
...
$

We see the model type mrhaki.gradle.VersionFile is created with the method versionFile and it's properties outputFile and version. Also the model shows that the method generateVersionFile creates the task VersionFileTask.

We set values for the VersionFile properties in our build file:

apply plugin: mrhaki.gradle.VersionFileTaskRules

// Configure model space.
model {
    
    // Configure VersionFile instance created 
    // by method versionFile() from VersionFileTaskRules.
    versionFile {
    
        // Set value for version property of VersionFile.
        version = project.version

        // Set value for outputFile property of VersionFile.
        outputFile = project.file("${buildDir}/version.file")
    }   
}

version = '1.0.1.RELEASE'

We run the model task again and this time we see that the properties version and outputFile are set:

$ gradle model
...
------------------------------------------------------------
Root project
------------------------------------------------------------
...
+ versionFile
      | Type:           mrhaki.gradle.VersionFile
      | Creator:        VersionFileTaskRules#versionFile(VersionFile)
      | Rules:
         ⤷ versionFile { ... } @ build.gradle line 8, column 5
    + outputFile
          | Type:       java.io.File
          | Value:      /Users/mrhaki/Projects/mrhaki.com/blog/posts/samples/gradle/versionrule/build/version.file
          | Creator:    VersionFileTaskRules#versionFile(VersionFile)
    + version
          | Type:       java.lang.String
          | Value:      1.0.1.RELEASE
          | Creator:    VersionFileTaskRules#versionFile(VersionFile)
...
$

Finally we run the task generateVersionFile and check the result:

$ gradle generateVersionFile
:buildSrc:compileJava UP-TO-DATE
:buildSrc:compileGroovy UP-TO-DATE
:buildSrc:processResources UP-TO-DATE
:buildSrc:classes UP-TO-DATE
:buildSrc:jar UP-TO-DATE
:buildSrc:assemble UP-TO-DATE
:buildSrc:compileTestJava UP-TO-DATE
:buildSrc:compileTestGroovy UP-TO-DATE
:buildSrc:processTestResources UP-TO-DATE
:buildSrc:testClasses UP-TO-DATE
:buildSrc:test UP-TO-DATE
:buildSrc:check UP-TO-DATE
:buildSrc:build UP-TO-DATE
:generateVersionFile

BUILD SUCCESSFUL

Total time: 0.864 secs
$ more build/version.file
1.0.1.RELEASE
$

Please remember at the time of writing the Rule based model configuration is still incubating. In future versions things may change.

Written with Gradle 3.2