domingo, 20 de diciembre de 2015

Desarrollando un Plugin Básico de Grade

Introducción

En un post anterior se mostró cómo es posible tener diferentes tipos de construcción para un mismo proyecto Gradle, dependiendo del ambiente de despliegue, modificando el script de construcción (build.gradle). Sin embargo tener que repetir dicho código en cada proyecto es poco práctico y de hecho una práctica poco recomendada en el desarrollo de software (Duplicate Code).

Adicionalmente se tenía un inconveniente para aquellos que usan un IDE al intentar ejecutar la aplicación, ya que las propiedades que se encuentran en el classpath del proyecto en el IDE (carpeta de recursos/resources) tienen como valores los tokens, razón por la cual se requería primero construir y ejecutar mediante Gradle y luego iniciar un proceso de depuración remota (remote debug).

Es por ello que en este post se mostrará como desarrollar un plugin propio de Gradle, lo cual es una de las grandes ventajas de esta herramienta ya que no solamente se cuenta con los plugins oficiales, sino que también se pueden desarrollar los propios en Groovy, Java o Scala e inclusive incluirlos en el portal oficial de plugins de Gradle.

También se aprovechará la oportunidad para solucionar el inconveniente mencionado para quienes usan un IDE que no ejecuta las aplicaciones teniendo en cuenta las tareas de Gradle.

Básicamente lo que hará este plugin es tomar los archivos ".properties" de la carpeta de recursos del proyecto y comparar sus llaves con las que se encuentren en los archivos con el mismo nombre en la carpeta correspondiente al ambiente de despliegue a usar (y ubicación de sub-carpetas si las hay) y con las propiedades del sistema indicadas, reemplazando los valores de las llaves que coincidan, sin usar tokens (texto entre símbolos "@"). Los archivos de otro tipo serán copiados y sólo si estos usan tokens, entonces sus valores serán reemplazados usando las propiedades del sistema.

Esto permitirá que los archivos de la carpeta de recursos se puedan tener directamente los valores por defecto a usar en el proyecto (por ejemplo los del ambiente de desarrollo local) y reemplazarlos sólo al momento de generar el archivo ejecutable del proyecto para el ambiente externo (pruebas o producción), así los IDE podrán ejecutar el proyecto sin depender de Gradle.

Dado que este plugin estará basado en código que previamente se incluyó en el script de construcción, el cual usa un DSL basado en Groovy se usará dicho lenguaje.


Configuración Gradle

gradle.properties

version=0.0.1-SNAPSHOT
group=com.blogspot.nombre_temp.gradle

build.gradle

buildscript {
    repositories {
        maven {
            url 'https://plugins.gradle.org/m2/'
        }
    }
    dependencies {
        classpath 'com.gradle.publish:plugin-publish-plugin:0.9.2'
        classpath 'net.researchgate:gradle-release:2.3.4'
    }
}

apply plugin: 'groovy'
apply plugin: 'maven'
apply plugin: 'eclipse'
apply plugin: 'net.researchgate.release'
apply plugin: 'com.gradle.plugin-publish'

task wrapper(type: Wrapper) {
    gradleVersion = '2.9'
}

repositories {
    jcenter()
}

dependencies {
    compile gradleApi()
    compile localGroovy()
}

test {
    testLogging.showStandardStreams = true
    testLogging.exceptionFormat = 'full'
}

afterReleaseBuild.dependsOn publishPlugins

pluginBundle {
    website = 'https://github.com/guillermo-varela/gradle-replace-properties-resources-plugin'
    vcsUrl = 'https://github.com/guillermo-varela/gradle-replace-properties-resources-plugin'

    plugins {
        replacePropertiesResources {
            id = 'com.blogspot.nombre-temp.replace.properties.resources'
            displayName = 'Gradle Replace Properties Resources'
            description = 'Adds the propertyfile task from Ant to the processResources Gradle task'
            tags = ['propertyfile', 'processResources', 'environment', 'deployment environment', 'properties']
        }
    }
}

Líneas 1-11: Se incluye el repositorio oficial de plugins de Gradle y se agregan las dependencias de los plugins "Publishing Plugin" y "Gradle Release", los cuales se usarán en conjunto no sólo para controlar el versionamiento del proyecto, sino también para que cada versión sea publicada en el portal de plugins de Gradle.

Líneas 27-30: Se agregan las dependencias necesarias para el desarrollo del plugin; "localGroovy" sólo se requiere si el lenguaje a usar es Groovy.

Línea 37: Es la instrucción que permite publicar el plugin (tarea publishPlugins) luego de versionar el proyecto, integrando así los plugins "Publishing Plugin" y "Gradle Release".

Línea 39-51: Configuración del plugin "Publishing Plugin":
  • website: Sitio web de la compañía o persona que desarrolló el plugin, o página oficial del plugin.
  • vcsUrl: URL en la que puede encontrarse el versionamiento del código del plugin.
  • plugins: Es posible tener varios plugins dentro de un mismo proyecto y en esta sección se define cada uno. En este caso se tiene sólo un plugin.
  • replaceTokensResources: Identificador del bloque en la sección plugins. Este nombre sólo se usa dentro del script de construcción, no es necesario que corresponda con el identificador del plugin usado en los proyectos que lo apliquen.
  • id: Este valor sí debe corresponder con el identificador del plugin, el texto usado al aplicarlo (apply) en otros proyectos.
  • displayNamedescriptiontags: Son elementos para incluir datos informativos y facilitar que sea encontrado en las búsquedas dentro del portal de plugins.

Más información acerca de estas y otras opciones se pueden encontrar en: https://plugins.gradle.org/docs/publish-plugin

src/main/resources/META-INF/gradle-plugins/com.blogspot.nombre-temp.replace.properties.resources.properties

implementation-class=com.blogspot.nombre_temp.gradle.replace.properties.resources.ReplacePropertiesResourcesPlugin

Este archivo debe tener como nombre el identificador del plugin (el mismo valor de la propiedad "id") y aquí se indica el nombre completo de la clase que tiene el código del plugin. En caso de tener varios plugins, se tendrían varios archivos dentro de la carpeta "gradle-plugins", uno por cada plugin.

Código del Plugin

ReplacePropertiesResourcesPlugin.groovy

package com.blogspot.nombre_temp.gradle.replace.properties.resources

import org.apache.tools.ant.filters.ReplaceTokens
import org.gradle.api.InvalidUserDataException
import org.gradle.api.Plugin
import org.gradle.api.Project

/**
 * This Gradle plugin replaces the values in ".properties" files inside the project's resources with values from files with the same name inside a different
 * name after the "processResources" Gradle task, which is used during the build process for projects with code to be executed in the JVM (like Java and Groovy).
 *
 * For files other than ".properties" they will also be copied and filtered using using the ReplaceTokens feature from Ant.
 * Any text between "@" symbols (tokens) will be replaced with a value from a system property with the same key as the surrounded text.
 */
class ReplacePropertiesResourcesPlugin implements Plugin {
    @Override
    public void apply(Project project) {

        project.afterEvaluate { p ->
            def processResourcesTask = p.tasks.findByName('processResources')

            if (processResourcesTask == null) {
                println "processResources task not found. Make sure to apply the plugin that has it before (for example 'java' or 'groovy')."
            } else {
                processResourcesTask.inputs.properties System.properties
                def environment = System.properties.env

                if (environment != null) {
                    def configEnvironmentFolder = p.properties.configEnvironmentFolder ? p.properties.configEnvironmentFolder : 'config'
                    def environmentFolder = p.file("$configEnvironmentFolder/$environment")

                    if (!p.file(configEnvironmentFolder).exists()) {
                        throw new InvalidUserDataException("Configuration environment folder not found: $configEnvironmentFolder")
                    }
                    if (!environmentFolder.exists()) {
                        throw new InvalidUserDataException("Environment folder not found: $configEnvironmentFolder/$environment")
                    }

                    // Executed only if the configuration files or the system properties changed from previous execution
                    processResourcesTask.inputs.dir p.file("$configEnvironmentFolder/$environment")

                    processResourcesTask.doLast {
                        println "***********************************************************"
                        println "Using environment: $environment"
                        println "***********************************************************"

                        // Copy all resources files, except ".properties"
                        p.fileTree(dir: "$configEnvironmentFolder/$environment" , exclude: '**/*.properties').each { file ->
                            def fileRelativePath = environmentFolder.toURI().relativize( file.toURI() ).path

                            p.sourceSets.each { source ->
                                // Gets the corresponding file in the resources build folder
                                def ouputFile = p.file("$source.output.resourcesDir/$fileRelativePath")
                                if (ouputFile.exists()) {
                                    p.copy {
                                        into ouputFile.parent
                                        from(file) {
                                            filter(ReplaceTokens, tokens: System.properties)
                                        }
                                    }
                                }
                            }
                        }

                        p.fileTree(dir: "$configEnvironmentFolder/$environment" , include: '**/*.properties').each { file ->
                            def fileRelativePath = environmentFolder.toURI().relativize( file.toURI() ).path

                            p.sourceSets.each { source ->
                                // Gets the corresponding file in the resources build folder
                                def ouputFile = p.file("$source.output.resourcesDir/$fileRelativePath")
                                if (ouputFile.exists()) {
                                    def environmentProperties = new Properties()

                                    file.withInputStream {
                                        environmentProperties.load(it);
                                    }

                                    // Overwrites the values in the file with the ones given from command line arguments -Dkey
                                    System.properties.each { key, value ->
                                        if (environmentProperties.containsKey(key)) {
                                            environmentProperties.put(key, value)
                                        }
                                    }

                                    // Replaces all values on "ouputFile" with the ones contained in "environmentProperties"
                                    environmentProperties.each { propKey, propValue ->
                                        p.ant.propertyfile(file: ouputFile) {
                                            entry(key: propKey, type: 'string', operation: '=', value: propValue)
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

Línea 15: Se implementa la interfaz Plugin, la cual se requiere para el desarrollo de nuevos plugins.

Línea 17: Implementación del método "apply", el cual se ejecuta al aplicar el plugin a un proyecto, recibiendo precisamente como parámetro el proyecto en que se aplica.

Línea 19: Mediante un Closure se indica el código a ejecutar luego de que el script de construcción del proyecto ha sido evaluado y se han aplicado las configuraciones iniciales. El código de este plugin se deja dentro de este bloque para asegurar que al momento de buscar la tarea "processResources" el plugin que la adicione ("java", "groovy", o cualquier otra) ya se encuentre aplicado y la tarea haga parte del proyecto.

Línea 25: Adición de las propiedades del sistema a las entradas (inputs), para ejecutar la tarea "processResources" sólo cuando se cambien estos datos (adicional a los archivos de recursos) y así conservar la Construcción Incremental.

Línea 26: Se asigna el ambiente a usar indicado como parámetro (propiedad del sistema).

Línea 28: Si no se indicó un ambiente, no se continua con la ejecución de este plugin, usando entonces los valores de las propiedades que se encuentran directamente en la carpeta de recursos.

Línea 29: Con esta línea se permite cambiar el nombre de la carpeta en la cual se encontrarán las sub-carpetas de cada ambiente agregando la propiedad extra "configEnvironmentFolder", bien sea dentro del archivo "build.gradle" o en "gradle.properties" del proyecto en que se aplique el plugin.

Líneas 32-37: Se valida que las carpetas de configuración y de ambientes existan. Esta es una mejora con respecto al script del post anterior, ya que no se está dependiendo de una lista.

Línea 40: Adición de la carpeta del ambiente usado a las entradas (inputs), para ejecutar la tarea "processResources" sólo cuando se cambie su estructura o el contenido de sus archivos, adicional a las entradas previamente agregadas.

Línea 42: Se usa el bloque "doLast" de la tarea "processResources" para que el código sea ejecutado únicamente cuando se ejecute esta tarea (no en la fase de configuración) y después de que se copien los archivos de recursos (por defecto en "src/main/resources") a la carpeta de construcción (por defecto en "build/resources/main").

Línea 48: Iteración de los archivos que no son ".properties" ubicados en la carpeta del ambiente de despliegue a usar.

Línea 49: Se obtiene la ruta relativa del archivo iterado con respecto al proyecto. Esto con el fin de poder determinar si por ejemplo un archivo "src/main/resources/logback.xml" tiene su contra-parte en la carpeta del ambiente, por ejemplo "config/prod_1/logback.xml". No se valida sólo con el nombre del archivo para permitir tener sub-carpetas.

Línea 51: Iteración de las carpetas de código fuente (SourceSets) y recursos del proyecto. Por defecto se tienen "main" y "test" pero pueden añadirse más según se necesite en el proyecto.

Líneas 53-54: La instrucción "source.output.resourcesDir" obtiene la carpeta de salida (en la que se copian al compilar y construir el proyecto) de los recursos del SourceSet iterado. Por ejemplo, al iterar "main", la carpeta de salida de los recursos sería "build/resources/main". Luego se adiciona la ruta del archivo de propiedades iterado desde la línea 48 y se verifica si este existe la carpeta de salida.

Líneas 55-61: Se copia el archivo desde su carpeta origen (ambiente seleccionado) hacia la carpeta de salida de los recursos, reemplazando los tokens que dicho archivo pueda tener. Si el archivo no tiene tokens, se copia el archivo sin modificaciones.

Línea 65: Iteración de los archivos ".properties" que se encuentran en la carpeta del ambiente de despliegue a usar.

Línea 66: Se obtiene la ruta relativa del archivo iterado con respecto al proyecto.

Línea 68: Iteración de las carpetas de código fuente (SourceSets) y recursos del proyecto.

Líneas 74-76: Se cargan las propiedades que se encuentran en el archivo del ambiente usado.

Líneas 79-83: Las propiedades anteriormente cargadas se sobrescriben con las propiedades del sistema que tengan la misma llave.

Líneas 86-90: En aquel post anterior se estaba usando el filtro ReplaceTokens de Ant para reemplazar los tokens de los archivos de recursos procesados. En este plugin se usa en cambio se usa la tarea "PropertyFile" (también de Ant) para tomar las propiedades del archivo que fue copiado en la carpeta de salida y reemplazar sus valores con los indicados en el archivo correspondiente en la carpeta del ambiente o en las propiedades del sistema. De esta manera el archivo de propiedades original no se modifica y no requiere tokens en su contenido, permitiendo tener los valores del ambiente por defecto allí sin afectar la construcción para otros ambientes.


En general este código es muy similar al del post anterior con un par de mejoras.

Nota: Este plugin no tiene una clase para definir una tarea propia, ya que lo que se hace en este caso es modificar la tarea ya existente "processResources". Para más información sobre el desarrollo de tareas dentro de un plugin de Gradle puede consultarse la documentación oficial.

Pruebas Locales del Plugin

Antes de publicar el plugin, este puede aplicarse y probarse en un proyecto Gradle localmente. Para ello puede usarse la tarea "install" del plugin "maven" con lo cual se instala el JAR del proyecto en el repositorio local de Maven.


gradlew.bat install
:compileJava UP-TO-DATE
:compileGroovy
:processResources
:classes
:jar
:groovydoc
:publishPluginGroovyDocsJar
:publishPluginJar
:javadoc UP-TO-DATE
:publishPluginJavaDocsJar
:install

BUILD SUCCESSFUL

Una vez hecho esto puede incluirse en un proyecto Gradle. En este caso se usará una copia del proyecto que usaba el script original: https://github.com/guillermo-varela/gradle-replace-properties-resources-plugin-demo

Para usar el plugin desde el repositorio local se debe cambiar el archivo "build.gradle" de la siguiente manera:

buildscript {
    repositories {
        mavenLocal()
        maven {
            url 'https://plugins.gradle.org/m2/'
        }
    }
    dependencies {
       classpath 'net.researchgate:gradle-release:2.3.4'
       classpath 'com.blogspot.nombre-temp.replace.properties.resources:0.0.1-SNAPSHOT'
    }
}

plugins {
  id 'java'
  id 'application'
  id 'net.researchgate.release' version '2.3.4'
  id 'com.blogspot.nombre-temp.replace.properties.resources' version '0.0.1-SNAPSHOT'
}

sourceCompatibility = 1.8
targetCompatibility = 1.8
compileJava.options.encoding = 'UTF-8'

mainClassName = 'com.blogspot.nombre_temp.jetty.jersey.multi.project.example.ExampleStarter'

jar {
    manifest {
        attributes 'Implementation-Title': 'Jetty and Jersey Multi Environment Example', 'Implementation-Version': version
        attributes 'Main-Class': mainClassName
    }
}

task wrapper(type: Wrapper) {
    gradleVersion = '2.9'
}

repositories {
    jcenter()
}

dependencies {
    compile "org.eclipse.jetty:jetty-server:$jettyVersion"
    compile "org.eclipse.jetty:jetty-servlet:$jettyVersion"

    compile "org.glassfish.jersey.core:jersey-server:$jerseyVersion"
    compile "org.glassfish.jersey.containers:jersey-container-servlet:$jerseyVersion"
    compile "org.glassfish.jersey.media:jersey-media-json-jackson:$jerseyVersion"

    compile "commons-configuration:commons-configuration:1.10"
}

Las diferencias principales, con respecto a la versión original, están en las líneas 3, 10 y 17, en las cuales se adiciona el repositorio de Maven local como fuente de plugins, se agrega la dependencia al plugin que se está probando y se aplica.

También puede verse que esta versión del archivo es mucho más corta y fácil de leer al no tener que modificar la tarea "processResources", porque ello ya es realizado en el nuevo plugin.

Para probar rápidamente el funcionamiento del plugin puede ejecutarse simplemente la tarea "processResources" y los archivos de recursos procesados estarán en la carpeta "build/resources".


gradlew.bat processResources
:processResources
***********************************************************
Using environment: local
***********************************************************

BUILD SUCCESSFUL

build/resources/main/application.properties

app.instance.name=Jetty-Server
app.instance.number=1


gradlew.bat processResources -Denv=prod2
:processResources
***********************************************************
Using environment: prod2
***********************************************************

BUILD SUCCESSFUL

build/resources/main/application.properties

app.instance.name=Jetty-Server
app.instance.number=2


gradlew.bat processResources -Denv=prod2 -Dapp.instance.number=5
:processResources
***********************************************************
Using environment: prod2
***********************************************************

BUILD SUCCESSFUL

build/resources/main/application.properties

app.instance.name=Jetty-Server
app.instance.number=5

Publicación del Plugin

Para publicar el plugin en el portal oficial, luego de tener configurado "Publishing Plugin", se debe crear una cuenta y seguir las instrucciones en https://plugins.gradle.org/docs/submit para obtener un "API key" y "API secret".

Dado que se tienen integrados plugins "Publishing Plugin" y "Gradle Release", simplemente al ejecutar la tarea de Gradle "release", se versiona el proyecto en el repositorio de GitHub y se carga el plugin compilado al portal de plugins. Este plugin puede encontrarse en: https://plugins.gradle.org/plugin/com.blogspot.nombre-temp.replace.properties.resources

Nota: Al momento de escribir este post el plugin "Publishing Plugin" tiene un error ya reportado, en el que al usar un archivo "gradle.properties" en el proyecto no se toman los valores de "$USER_HOME/.gradle/gradle.properties". Para solucionar esto, sin incluir la llave y contraseña de Gradle en el archivo del proyecto, se puede ejecutar la tarea de Gradle con dichos valores:

gradlew.bat release -Dgradle.publish.key=xxxxxxx -Dgradle.publish.secret=yyyyyyy

Aplicando el Plugin

Una vez se tiene el plugin publicado en el portal de Gradle es posible modificar el archivo "build.gradle" del proyecto con el que se hizo la prueba local, para que use la versión publicada:

plugins {
  id 'java'
  id 'application'
  id 'net.researchgate.release' version '2.3.4'
  id 'com.blogspot.nombre-temp.replace.properties.resources' version '0.0.4'
}

sourceCompatibility = 1.8
targetCompatibility = 1.8
compileJava.options.encoding = 'UTF-8'

mainClassName = 'com.blogspot.nombre_temp.jetty.jersey.multi.project.example.ExampleStarter'

jar {
    manifest {
        attributes 'Implementation-Title': 'Jetty and Jersey Multi Environment Example', 'Implementation-Version': version
        attributes 'Main-Class': mainClassName
    }
}

task wrapper(type: Wrapper) {
    gradleVersion = '2.9'
}

repositories {
    jcenter()
}

dependencies {
    compile "org.eclipse.jetty:jetty-server:$jettyVersion"
    compile "org.eclipse.jetty:jetty-servlet:$jettyVersion"

    compile "org.glassfish.jersey.core:jersey-server:$jerseyVersion"
    compile "org.glassfish.jersey.containers:jersey-container-servlet:$jerseyVersion"
    compile "org.glassfish.jersey.media:jersey-media-json-jackson:$jerseyVersion"

    compile "commons-configuration:commons-configuration:1.10"
}

Las pruebas sobre el funcionamiento del plugin pueden ser las mismas que se hicieron localmente. El proyecto usando este plugin publicado puede descargarse en: https://github.com/guillermo-varela/gradle-replace-properties-resources-plugin-demo

Nota: En este caso el plugin desarrollado aparece con versión "0.0.4" debido a que se realizaron un par de mejoras a la primera versión publicada, pero el portal de Gradle no permite subir cambios sobre la misma versión del plugin sino que esta debe incrementarse.

Ejecutando la Aplicación desde un IDE

Como se comentó al inicio, este plugin permite que al ejecutar el proyecto desde un IDE (sin ejecutar las tareas de Gradle) puedan usarse los valores de un ambiente local de desarrollo, lo cual facilita la ejecución y depuración al no tener que configurar desde el IDE la ejecución de Gradle y la depuración remota.

Figura 1 - Aplicación ejecutada desde Eclipse sin ejecutar Gradle

Referencias

https://docs.gradle.org/current/userguide/custom_plugins.html
https://docs.gradle.org/current/userguide/custom_tasks.html
https://plugins.gradle.org/docs/publish-plugin
https://docs.gradle.org/current/userguide/build_lifecycle.html
https://docs.gradle.org/current/userguide/java_plugin.html
https://docs.gradle.org/current/javadoc/org/gradle/language/jvm/tasks/ProcessResources.html

Más sobre Gradle

http://nombre-temp.blogspot.com/2016/01/tutorial-gradle.html

No hay comentarios.:

Publicar un comentario