Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

Idiomatic Gradle Plugin Writing

793 views

Published on

Recipes for writing plugins for Gradle. From the basics to some advanced concepts. As presented at Groovy-Grails Exchange 2015 in London

Published in: Software
  • Be the first to comment

Idiomatic Gradle Plugin Writing

  1. 1. #GGX Groovy Grails Exchange 2015 IDIOMATIC GRADLE PLUGIN WRITING Schalk W. Cronjé
  2. 2. ABOUT ME Email: Twitter / Ello : @ysb33r ysb33r@gmail.com Gradle plugins authored/contributed to: VFS, Asciidoctor, JRuby family (base, jar, war etc.), GnuMake, Doxygen
  3. 3. ABOUT THIS PRESENTATION Written in Asciidoctor (1.5.3.2) Styled by asciidoctor-revealjs extension Built using: Gradle gradle-asciidoctor-plugin gradle-vfs-plugin
  4. 4. THE PROBLEM There is no consistency in the way plugin authors craft extensions to the Gradle DSL today
  5. 5. QUALITY ATTRIBUTES OF DSL Readability Consistency Flexibility Expressiveness
  6. 6. FOR BEST COMPATIBILITY Support same JDK range as Gradle Gradle 1.x - mininum JDK5 Gradle 2.x - minimum JDK6 Build against Gradle 2.0 Only use later versions if specific new functionality is required. Suggested baseline at Gradle 2.6
  7. 7. FOR BEST COMPATIBILITY // build.gradle targetCompatibility = 1.6 sourceCompatibility = 1.6 project.tasks.withType(JavaCompile) { task -> task.sourceCompatibility = project.sourceCompatibility task.targetCompatibility = project.targetCompatibility } project.tasks.withType(GroovyCompile) { task -> task.sourceCompatibility = project.sourceCompatibility task.targetCompatibility = project.targetCompatibility } // gradle/wrapper/gradle-wrapper.properties distributionUrl=https://..../distributions/gradle-2.0-all.zip
  8. 8. NOMENCLATURE Property: A public data member (A Groovy property) Method: A standard Java/Groovy method Attribute: A value, set or accessed via the Gradle DSL. Can result in a public method call or property access. User: Person authoring or executing a Gradle build script @Input String aProperty = 'stdValue' @Input void aValue(String s) { ... } myTask { aProperty = 'newValue' aValue 'newValue' }
  9. 9. PREFER METHODS OVER PROPERTIES ( IOW To assign or not to assign ) Methods provide more flexibility Tend to provide better readability Assignment is better suited towards One-shot attribute setting Overriding default attributes Non-lazy evaluation
  10. 10. HOW NOT 2 : COLLECTION OF FILES Typical implementation …​ class MyTask extends DefaultTask { @InputFiles List<File> mySources } leads to ugly DSL task myTask( type: MyTask ) { myTask = [ file('foo/bar.txt'), new File( 'bar/foo.txt') ] }
  11. 11. COLLECTION OF FILES myTask { mySources file( 'path/foobar' ) mySources new File( 'path2/foobar' ) mySources 'file3', 'file4' mySources { "lazy evaluate file name later on" } } Allow ability to: Use strings and other objects convertible to File Append lists Evaluate as late as possible Reset default values
  12. 12. COLLECTION OF FILES Ignore Groovy shortcut; use three methods class MyTask extends DefaultTask { @InputFiles FileCollection getDocuments() { project.files(this.documents) // magic API method } void setDocuments(Object... docs) { this.documents.clear() this.documents.addAll(docs as List) } void documents(Object... docs) { this.documents.addAll(docs as List) } private List<Object> documents = [] }
  13. 13. STYLE : TASKS Provide a default instantiation of your new task class Keep in mind that user would want to create additional tasks of same type Make it easy for them!!
  14. 14. KNOW YOUR ANNOTATIONS @Input @InputFile @InputFiles @InputDirectory @OutputFile @OutputFiles @OutputDirectory @OutputDirectories @Optional
  15. 15. COLLECTION OF STRINGS import org.gradle.util.CollectionUtils Ignore Groovy shortcut; use three methods @Input List<String> getScriptArgs() { // stringize() is your next magic API method CollectionUtils.stringize(this.scriptArgs) } void setScriptArgs(Object... args) { this.scriptArgs.clear() this.scriptArgs.addAll(args as List) } void scriptArgs(Object... args) { this.scriptArgs.addAll(args as List) } private List<Object> scriptArgs = []
  16. 16. HOW NOT 2 : MAPS Typical implementation …​ class MyTask extends DefaultTask { @Input Map myOptions } leads to ugly DSL task myTask( type: MyTask ) { myOptions = [ prop1 : 'foo/bar.txt', prop2 : 'bar/foo.txt' ] }
  17. 17. MAPS task myTask( type: MyTask ) { myOptions prop1 : 'foo/bar.txt', prop2 : 'bar/foo.txt' myOptions prop3 : 'add/another' // Explicit reset myOptions = [:] }
  18. 18. MAPS @Input Map getMyOptions() { this.attrs } void setMyOptions(Map m) { this.attrs=m } void myOptions(Map m) { this.attrs+=m } private Map attrs = [:]
  19. 19. USER OVERRIDE LIBRARY VERSION Ship with prefered (and tested) version of dependent library set as default Allow user flexibility to try a different version of such library Dynamically load library when needed Still use power of Gradle’s dependency resolution
  20. 20. USER OVERRIDE LIBRARY VERSION Example DSL from Asciidoctor asciidoctorj { version = '1.6.0-SNAPSHOT' } Example DSL from JRuby Base jruby { execVersion = '1.7.12' }
  21. 21. USER OVERRIDE LIBRARY VERSION 1. Create Extension 2. Add extension object in plugin apply 3. Create custom classloader
  22. 22. USER OVERRIDE LIBRARY VERSION Step 1: Create project extension class MyExtension { // Set the default dependent library version String version = '1.5.0' MyExtension(Project proj) { project= proj } @PackageScope Project project }
  23. 23. USER OVERRIDE LIBRARY VERSION Step 2: Add extension object in plugin apply class MyPlugin implements Plugin<Project> { void apply(Project project) { // Create the extension & configuration project.extensions.create('asciidoctorj',MyExtension,project) project.configuration.maybeCreate( 'int_asciidoctorj' ) // Add dependency at the end of configuration phase project.afterEvaluate { project.dependencies { int_asciidoctorj "org.asciidoctor:asciidoctorj" + "${project.asciidoctorj.version}" } } } }
  24. 24. USER OVERRIDE LIBRARY VERSION (2.5+) Step 2: Add extension object Gradle 2.5+ class MyPlugin implements Plugin<Project> { void apply(Project project) { // Create the extension & configuration project.extensions.create('asciidoctorj',MyExtension,project) def conf = configurations.maybeCreate( 'int_asciidoctorj' ) conf.defaultDependencies { deps -> deps.add( project.dependencies.create( "org.asciidoctor:asciidoctorj:${asciidoctorj.version}") ) } } }
  25. 25. USER OVERRIDE LIBRARY VERSION Step 3: Custom classloader (usually loaded from task action) // Get all of the files in the `asciidoctorj` configuration def urls = project.configurations.int_asciidoctorj.files.collect { it.toURI().toURL() } // Create the classloader for all those files def classLoader = new URLClassLoader(urls as URL[], Thread.currentThread().contextClassLoader) // Load one or more classes as required def instance = classLoader.loadClass( 'org.asciidoctor.Asciidoctor$Factory')
  26. 26. NEED 2 KNOW : 'AFTEREVALUATE' afterEvaluate adds to a list of closures to be executed at end of configuration phase Execution order is FIFO Plugin author has no control over the order
  27. 27. STYLE : PROJECT EXTENSIONS Treat project extensions as you would for any kind of global configuration. With care! Do not make the extension configuration block a task configuration. Task instantiation may read defaults from extension. Do not force extension values onto tasks
  28. 28. NEED 2 KNOW : PLUGINS Plugin author has no control over order in which plugins will be applied Handle both cases of related plugin applied before or after yours
  29. 29. EXTEND EXISTING TASK Task type extension by inheritance is not always best solution Adding behaviour to existing task type better in certain contexts Example: jruby-jar-plugin wants to semantically describe bootstrap files rather than force user to use standard Copy syntax
  30. 30. EXTEND EXISTING TASK jruby-jar-plugin without extension jrubyJavaBootstrap { // User gets exposed (unnecessarily) to the underlying task type // Has to craft too much glue code from( { // @#$$!!-ugly code goes here } ) } jruby-jar-plugin with extension jrubyJavaBootstrap { // Expressing intent & context. jruby { initScript = 'bin/asciidoctor' } }
  31. 31. EXTEND EXISTING TASK 1. Create extension class 2. Add extension to task 3. Link extension attributes to task attributes (for caching)
  32. 32. EXTEND EXISTING TASK Create extension class class MyExtension { String initScript MyExtension( Task t ) { // TODO: Add Gradle caching support // (See later slide) } }
  33. 33. EXTEND EXISTING TASK Add extension class to task class MyPlugin implements Plugin<Project> { void apply(Project project) { Task stubTask = project.tasks.create ( name : 'jrubyJavaBootstrap', type : Copy ) stubTask.extensions.create( 'jruby', MyExtension, stubTask ) }
  34. 34. EXTEND EXISTING TASK Add Gradle caching support class MyExtension { String initScript MyExtension( Task t ) { // Tell the task the initScript is also a property t.inputs.property 'jrubyInitScript' , { -> this.initScript } } }
  35. 35. NEED 2 KNOW : TASK EXTENSIONS Good way extend existing tasks in composable way Attributes on extensions are not cached Changes will not cause a rebuild of the task Do the extra work to cache and provide the user with a better experience.
  36. 36. HONOUR OFFLINE gradle --offline The build should operate without accessing network resources.
  37. 37. HONOUR OFFLINE Unset the enabled property, if build is offline task VfsCopy extends DefaultTask { VfsCopy() { enabled = !project.gradle.startParameter.isOffline() } }
  38. 38. ADD GENERATED JVM SOURCE SETS May need to generate code from template and add to current sourceset(s) Example: Older versions of jruby-jar-plugin added a custom class file to JAR Useful for separation of concerns in certain generative programming environments
  39. 39. ADD GENERATED JVM SOURCE SETS 1. Create generator task using Copy task as transformer 2. Configure generator task 3. Update SourceSet 4. Add dependency between generation and compilation
  40. 40. ADD GENERATED JVM SOURCE SETS Step1 : Add generator task class MyPlugin implements Plugin<Project> { void apply(Project project) { Task stubTask = project.tasks.create ( name : 'myGenerator', type : Copy ) configureGenerator(stubTask) addGeneratedToSource(project) addTaskDependencies(project) } void configureGenerator(Task t) { /* TODO: <-- See next slides */ } void addGeneratedToSource(Project p) { /* TODO: <-- See next slides */ } void addTaskDependencies(Project p) { /* TODO: <-- See next slides */ } } This example uses Java, but can apply to any kind of sourceset that Gradle supports
  41. 41. ADD GENERATED JVM SOURCE SETS Step 2 : Configure generator task /* DONE: <-- See previous slide for apply() */ void configureGenerator(Task stubTask) { project.configure(stubTask) { group "Add to correct group" description 'Generates a JRuby Java bootstrap class' from('src/template/java') { include '*.java.template' } into new File(project.buildDir,'generated/java') rename '(.+).java.template','$1.java' filter { String line -> /* Do something in here to transform the code */ } } }
  42. 42. ADD GENERATED JVM SOURCE SETS Step 3 : Add generated code to SourceSet /* DONE: <-- See earlier slide for apply() */ void addGeneratedToSource(Project project) { project.sourceSets.matching { it.name == "main" } .all { it.java.srcDir new File(project.buildDir,'generated/java') } }
  43. 43. ADD GENERATED JVM SOURCE SETS Step 4 : Add task dependencies /* DONE: <-- See earlier slide for apply() */ void addTaskDependencies(Project project) { try { Task t = project.tasks.getByName('compileJava') if( t instanceof JavaCompile) { t.dependsOn 'myGenerator' } } catch(UnknownTaskException) { project.tasks.whenTaskAdded { Task t -> if (t.name == 'compileJava' && t instanceof JavaCompile) { t.dependsOn 'myGenerator' } } } }
  44. 44. TRICK : SAFE FILENAMES Ability to create safe filenames on all platforms from input data Example: Asciidoctor output directories based upon backend names // WARNING: Using a very useful internal API import org.gradle.internal.FileUtils File outputBackendDir(final File outputDir, final String backend) { // FileUtils.toSafeFileName is your magic method new File(outputDir, FileUtils.toSafeFileName(backend)) }
  45. 45. TRICK : SELF-REFERENCING PLUGIN New plugin depends on functionality in the plugin Apply plugin direct in build.gradle apply plugin: new GroovyScriptEngine( ['src/main/groovy','src/main/resources']. collect{ file(it).absolutePath } .toArray(new String[2]), project.class.classLoader ).loadScriptByName('book/SelfReferencingPlugin.groovy')
  46. 46. COMPATIBILITY TESTING How can a plugin author test a plugin against multiple Gradle versions?
  47. 47. COMPATIBILITY TESTING Gradle 2.7 added TestKit Gradle 2.9 added multi-distribution testing TestKit still falls short in ease-of-use (Hopefully to be corrected over future releases) What to do for Gradle 2.0 - 2.8?
  48. 48. COMPATIBILITY TESTING GradleTest plugin to the rescue buildscript { dependencies { classpath "org.ysb33r.gradle:gradletest:0.5.4" } } apply plugin : 'org.ysb33r.gradletest' http://bit.ly/1LfUUU4
  49. 49. COMPATIBILITY TESTING Create src/gradleTest/NameOfTest folder. Add build.gradle Add task runGradleTest Add project structure
  50. 50. COMPATIBILITY TESTING Add versions to main build.gradle gradleTest { versions '2.0', '2.2', '2.4', '2.5', '2.9' } Run it! ./gradlew gradleTest
  51. 51. THANK YOU Keep your DSL extensions beautiful Don’t spring surprising behaviour on the user Email: Twitter / Ello : @ysb33r #idiomaticgradle ysb33r@gmail.com http://bit.ly/1iJmdiP

×