SBT Partie 3 : Étendre les fonctions

Ceci est la troisième et dernière partie d’une série d’articles consacrée à SBT. Après avoir vu les principes de fonctionnement et comment décrire un projet multiple, nous allons voir ic comment étendre les fonctions de SBT :

  • en ajoutant des tâches,
  • en ajoutant des plugins,
  • en créant nous-même un plugin.

Dans les exemples illustrant cet article, nous continuerons à utiliser la structure de projets présentée dans le second article :

  • blog-ippon-multiple-root- build.sbt
  • blog-ippon-multiple-data
  • blog-ippon-multiple-service
  • blog-ippon-multiple-web
  • project- build.properties
  • target

Ajouter des tâches

Définition

Lorsque je dis que nous allons ajouter des tâches, c’est en fait un abus de langage. Comme vu dans le premier article, un build SBT est une série de Key. Nous allons donc en réalité ajouter des Key des trois types (Setting, Task et Input).

// Definition des Key

// Définition d'un Setting
val sampleSetting = settingKey[String]("Configure something")

// Définition d'un Task
val sampleTask = taskKey[Unit]("Print character's number of project name") 

// Définition d'un  Input
val sampleInput = inputKey[Unit]("Display number of parameter") 

// Implémentation sur le projet Root
lazy val blogIpponMultipleRoot = (project in file(".")).
  settings(
    name := "blog-ippon-multiple-root", // Implémentation d'un Setting standard de SBT
    sampleSetting := "A configuration", // Implémentation du Setting ajouté
    sampleTask := {  // Implémentation du Task ajouté
      println(s"The number of character is : ${name.value.size}") // Le code appelle un autre Setting
    },
    sampleInput := { // Implémentation du Input ajouté
      val args = Parsers.spaceDelimited("<arg>").parsed // Récupère les paramètres du Input
      println(s"The number of argument is : ${args.size}") 
    }
  )

Réutilisation d’une tâche

Il arrive que nous souhaitions avoir la même tâche sur le projet ou les sous-projets. Pour cela, il suffit d’utiliser une variable Scala. Dans l’exemple ci-dessous, la tâche sera appliquée au projet root et au sous-projet service.

val sampleTask = taskKey[Unit]("Print character's number of project name")

val sampleTaskImplementation = sampleTask := {
  println(s"The number of character is : ${name.value..size}")
}

lazy val blogIpponMultipleRoot = (project in file(".")).
  settings(
    name := "blog-ippon-multiple-root",
    sampleTaskImplementation
  )

lazy val blogIpponMultipleService = (project in file("blog-ippon-multiple-service")).
  settings(
    name := "blog-ippon-multiple-service",
    sampleTaskImplementation
  )

Insertion d’une tâche

Il peut être intéressant d’ajouter l’exécution d’une tâche dans build complet, par exemple, si nous souhaitons générer du code avant la compilation. Pour ce faire, nous allons modifier la tâche compile pour y insérer l’appel à notre tâche.

val sampleTask = taskKey[Unit]("Print character's number of project name")

val sampleTaskImplementation = sampleTask := {
  println(s"The number of character is : ${name.value..size}")
}

lazy val blogIpponMultipleService = (project in file("blog-ippon-multiple-service")).
  settings(
    name := "blog-ippon-multiple-service",
    sampleTaskImplementation, 
    compile in Compile := {  // la Task compile est différente en fonction de la configuration, il faut préciser celle-ci
      sampleTask.value // Appel à notre tâche
      (compile in Compile).value // Appel à la tâche d'origine pour que notre code compile
    }
  )

Utilisation d’une librairie externe

Supposons que nous ayons développé une librairie permettant de générer un changelog git de ce type conventional changelog (https://github.com/conventional-changelog/conventional-changelog)

Cette librairie aurait pour interface ceci :

object ConventionalChangelogGenerator {
def generate(localGitRepository: String, localGenerateDirectory: String, format: LogGeneratorFormat) = Unit
}

Le fonctionnement est simple, il s’agit d’un object contenant une fonction generate prenant en paramètre :

  • localGitRepository : le chemin vers le répertoire .git
  • localGenerateDirectory : le chemin où générer le fichier de sortie
  • LogGeneratorFormat : le format du fichier de sortie qui peut être HTML ou Markdown

Pour pouvoir utiliser la librairie, il suffit de la déclarer dans le fichier project/build.sbt :

libraryDependencies += "fr.blogippon" %% "sconventionalchangelog-core" % "1.0"

Nous pouvons alors importer l’objet dans le fichier build.sbt du projet et l’utiliser dans une tâche :

import fr.blogippon.sconventionalchangelog.ConventionalChangelogGenerator
import fr.blogippon.sconventionalchangelog.generator.LogGeneratorFormat
…

val generateChangeLog = taskKey[Unit]("Generate changelog base on .git directory")

lazy val blogIpponMultipleRoot = (project in file(".")).
  settings(
    name := "blog-ippon-multiple-root",
    generateChangeLog := {      
      val gitRepositoryDirectory = baseDirectory.value.getAbsolutePath+"/.git"
      val targetDirectory = target.value.getAbsolutePath
      val format = LogGeneratorFormat.Markdown
      ConventionalChangelogGenerator.generate(gitRepositoryDirectory, targetDirectory, format)
    }
  )

Utiliser un Plugin

Une autre manière d’étendre les fonctionnalités de SBT est d’utiliser l’un des nombreux plugins disponibles. Pour faire ceci, rien de plus simple, il suffit de déclarer le plugin dans un fichier .sbt se trouvant dans le répertoire project*.

Pour déclarer le plugin sbt-assembly, il suffit d’ajouter ceci :

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.3")

Pour la gestion des plugins, il y a deux conventions :

  • Déclarer tous les plugins dans un fichier project/plugins.sbt
  • Créer un fichier .sbt par plugin. Par exemple, pour le plugin assembly nous utilisons project/assembly.sbt

La plupart des plugins SBT sont maintenant des AutoPlugin, c’est-à-dire qu’aucune opération n’est nécessaire pour les activer. Dans certains cas, des plugins nécessitent qu’on les activent. Ceci se fait de la manière suivante :

lazy val blogIpponMultipleRoot = (project in file(".")).
  enablePlugins(AssemblyPlugin).
  settings(
    name := "blog-ippon-multiple-root"
  )

Nous pouvons aussi désactiver un plugin :

lazy val blogIpponMultipleRoot = (project in file(".")).
  disablePlugins(AssemblyPlugin).
  settings(
    name := "blog-ippon-multiple-root"
  )

Remarque : Le plugin assembly permet de créer un gros JAR contenant l’ensemble du code ainsi que ses dépendances. Dans le cas de notre projet, si nous l’appliquons sur le root, alors le plugin générera un JAR vide. Pour récupérer notre application complète, il faut lancer l’assemblage sur le projet Web.

Créer un plugin

Dans la version 0.13.5 de SBT la fonctionnalité auto plugin a été introduite et permet de créer facilement des Keys aux projets au travers d’un plugin.

L’exemple ci-dessous, montre comme nous pourrions créer un plugin reprenant les fonctionnalités du ConventionalChangelog

Tout d’abord, nous allons créer un projet SBT standard et d’indiquer via le Setting sbtPlugin qu’il s’agit d’un plugin.

organization := "fr.blogippon"

name := "sbt-sconventionalchangelog-plugin"  // Par convention un plugin SBT doit
                                             // débuter par sbt-
version := "1.0"

sbtPlugin := true                           // Indique qu'il s'agit d'un plugin

Le point d’entrée d’un plugin est un object qui étend AutoPlugin

package fr.blogippon.sbtsconventionalchangelogplugin

import fr.blogippon.sbtsconventionalchangelogplugin.LogGeneratorFormat
import sbt._

// Import toutes les Keys standards comme name, target ...
import sbt.Keys._

object ConventionalChangelogGeneratorPlugin extends AutoPlugin {

  // Indique que les Key seront intégrées automatiquement, sinon il est   
  // nécessaire la fonction .enablePlugins sur le projet
  override def trigger = allRequirements

  // Nous définissons notre tâche comme dans le build.sbt
  val generateChangeLog = taskKey[Unit]("Generate changelog base on .git directory")

  // Nous ajoutons la tâche 
  override lazy val projectSettings = Seq(
    generateChangeLog := {
      val gitRepositoryDirectory = baseDirectory.value.getAbsolutePath + "/.git"
      val targetDirectory = target.value.getAbsolutePath
      val format = LogGeneratorFormat.Markdown
      ConventionalChangelogGenerator.generate(gitRepositoryDirectory, targetDirectory, format)
    }
  )
}

Dans l’exemple ci-dessus, la tâche a été ajoutée au niveau projet, nous pouvons ajouter des tâches :

  • Dans globalSettings pour que la tâche soit ajoutée une seule fois, pour par exemple des valeurs par défaut
  • Dans buildSettings pour que la tâche soit évaluée une seule fois par scope dans le build quelque soit le nombre de projets
  • Dans projectSettings pour que la tâche soit définie sur chaque projet

Il est possible de rendre des Key accessibles dans le build.sbt directement sans avoir à passer par un import en définissant un objet autoImport comme ci-dessous

package fr.blogippon.sbtsconventionalchangelogplugin

import fr.blogippon.sbtsconventionalchangelogplugin.LogGeneratorFormat
import sbt._

import sbt.Keys._
import sbt._

object ConventionalChangelogGeneratorPlugin extends AutoPlugin {

  override def trigger = allRequirements

  // Ce qui est défini ici sera accessible directement dans le build.sbt
  object autoImport {
    val html = LogGeneratorFormat.Html
    val markdown = LogGeneratorFormat.Markdown
    val changelogFormat = settingKey[LogGeneratorFormat]("Changelog file format (HTML or Markdown)")
  }

  import autoImport._

  val generateChangeLog = taskKey[Unit]("Generate changelog base on .git directory")

  override lazy val globalSettings = Seq(
    changelogFormat := {
      LogGeneratorFormat.Markdown
    }
  )

  override lazy val projectSettings = Seq(
    generateChangeLog := {
      val gitRepositoryDirectory = baseDirectory.value.getAbsolutePath + "/.git"
      val targetDirectory = target.value.getAbsolutePath
      val format = changelogFormat.value
      ConventionalChangelogGenerator.generate(gitRepositoryDirectory, targetDirectory, format)
    }
  )

}

Pour utiliser notre plugin, nous devons :

  • Le rendre accessible à notre projet :- En le déployant sur un repository central
  • En exécutant la commande sbt publishLocal pour l’ajouter à notre repository local
  • Déclarant le plugin dans project/plugins.sbt

addSbtPlugin("fr.blogippon" % "sbt-sconventionalchangelog-plugin" % "1.0")

  • Nous pouvons configurer le format dans notre build.sbt
// Aucun import en relation avec le plugin. Tout est activé automatiqument

lazy val blogIpponMultipleRoot = (project in file(".")).
  settings(
    name := "blog-ippon-multiple-root", 
    changelogFormat := html             // on peut surcharger le format 
  )
  • Pour lancer la tâche sbt generateChangeLog

Conclusion

Nous avons vu qu’il est simple d’étendre les fonctionnalités de SBT, soit directement dans notre projet, soit via un plugin.

Pour ma part, ce que j’apprécie dans le fonctionnement de SBT est la possibilité d’étendre ses fonctionnalités dans le répertoire project à l’extérieur du fichier build.sbt. Les développeurs travaillant sur le projet pourront comprendre le build.sbt car il reste simple, l’ensemble de la complexité étant caché dans le project.

Dernier point intéressant, le passage d’un build complexe en plugin est extrêmement simple, au final il s’agit de déplacer du code Scala.