Plugins Hudson – Episode 3 : Des formulaires et des données


Suite de ma série de billets consacrés au développement de plugins Hudson. Dans cet épisode qui se situe dans le prolongement de l’implémentation de son premier plugin, je vais principalement traiter de  la gestion des données, depuis leur saisie par un utilisateur à leur persistance. Nous verrons également comment tirer partie du framework javascript en place dans Hudson.

Aucune base de données

C’est généralement l’une des choses qui choque le plus les utilisateurs d’Hudson (moi, en tous les cas, ça m’avait beaucoup étonné !) : le fait que ce dernier ne persiste pas ses données en base ! Et clairement, ce n’est pas demain qu’une base de données apparaîtra dans Hudson (pour la gestion des données du noyau tout du moins) !

La principale raison que je vois à cela est dû à la fonctionnalité d’exécution des jobs distribués. En effet, si on jette un oeil au blog de Stephen Connolly (en anglais), on s’aperçoit que lors de la distribution d’un job sur un slave, les données sont sérializées/déserializées pour être transportées et exécutées sur le slave (puis rapatriées sur le master). Il s’agirait donc d’une sorte de « verrou » posé sur les données du job au moment de l’exécution de celui-ci, un même job ne pouvant être exécuté deux fois en parallèle.

Mais où sont donc stockées les données ? Tout utilisateur de hudson connaît bien le $HUDSON_HOME dans lequel se trouvent l’ensemble des données  persistées d’hudson. On pourrait classer ces données en plusieurs familles :

  • Le paramétrage de l’instance hudson : config.xml et autres fichiers présents à la racine comme queue.xml, nodeMonitors.xml…
  • Les utilisateurs : users/<email>/config.xml
  • Le paramétrage des jobs : jobs/<nom job>/config.xml, éventuellement jobs/<nom job>/modules/<nom module>/config.xml (pour des builds maven multi module)
  • L’historique des différents builds : jobs/<nom job>/builds/<timestamp>/build.xml, éventuellement jobs/<nom job>/modules/<nom module>/builds/<timestamp>/build.xml (pour des builds maven multi module)
  • Le paramétrage des plugins : fichiers à la racine du $HUDSON_HOME, comme par exemple global-build-stats.xml

Il est à noter que les fichiers de type config.xml peuvent intégrer des sections alimentées par des plugins.
Par exemple lorsqu’on souhaite persister des données de paramétrage d’un build induites par un plugin (ex: paramétrage de plugins de reporting, d’une release etc…).

Exemple de présence de sections dédiées aux plugins dans le fichier config.xml (lignes 9-11) :

<?xml version='1.0' encoding='UTF-8'?>
<maven2-moduleset>
  <actions/>
  <description></description>
  <keepDependencies>false</keepDependencies>
  <properties/>
  <scm class="hudson.scm.SubversionSCM">
    <locations>
      <hudson.scm.SubversionSCM_-ModuleLocation>
        <remote>https://svn.dev.java.net/svn/hudson/trunk/hudson/plugins/global-build-stats</remote>
      </hudson.scm.SubversionSCM_-ModuleLocation>
    </locations>
    <useUpdate>true</useUpdate>
    <excludedRegions></excludedRegions>
    <excludedUsers></excludedUsers>
    <excludedRevprop></excludedRevprop>
  </scm>
  <canRoam>true</canRoam>
  <disabled>false</disabled>
  <triggers class="vector"/>
  <concurrentBuild>false</concurrentBuild>
  <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
  <rootModule>
    <groupId>org.jvnet.hudson.plugins</groupId>
    <artifactId>global-build-stats</artifactId>
  </rootModule>
  <aggregatorStyleBuild>true</aggregatorStyleBuild>
  <incrementalBuild>false</incrementalBuild>
  <usePrivateRepository>false</usePrivateRepository>
  <ignoreUpstremChanges>false</ignoreUpstremChanges>
  <reporters/>
  <publishers/>
  <buildWrappers/>
</maven2-moduleset>

Il est nécessaire d’être conscient des implications du stockage fichier des information :

  • L’empreinte mémoire requise pour faire tourner Hudson va en grandissant au fur et à mesure qu’on installe des plugins ou qu’on lance des builds en parallèle sur la même machine.
    Cela reste raisonnable : par exemple, lors de l’exécution d’un build, il me semble que seules les informations de type config.xml (présents dans le job et les modules du job) sont chargées, alors que les nombreux build.xml (présents dans le builds et les modules/*/builds) ne le sont pas (cela reste donc raisonnable).
  • L’aggrégation de données est potentiellement gourmande en traitements lorsqu’on ne travaille pas sur des données pré-calculées.
    Un exemple simple : pour calculer une moyenne sur la stabilité du build (0 points pour un build error, 1 point pour un build failed et 2 points pour un build success), si les données n’étaient pas agglomérées au fur et a mesure, l’algorithme serait obligé de charger en mémoire successivement tous les résultats de build passés pour en déduire leur résultat (là où une simple requête SQL nous calculerait la moyenne) : on a là un algorithme de plus en plus gourmand dans le temps au fur et à mesure que le nombre de builds augmente.
    Le paliatif à ce problème est clairement d’utiliser les différents points d’extension d’hudson pour analyser les choses au fur et à mesure.
  • Comment sont gérées les transactions sur ces données persistées sous forme de fichier ?
    Sur le global-build-stats plugin, j’ai par exemple eu, à certains moment, des ConcurrentModificationException dûes à l’écriture en parallèle du même fichier : pour les résoudre, il m’a fallu synchroniser les endroits où les informations persistées étaient modifiées puis sauvegardées.

Persister les données d’un plugin

Hudson se base sur la librairie XStream pour persister ses données. Il s’agit d’une librairie qui permet de sérialiser n’importe quel objet java (ainsi que l’ensemble de ses dépendances) sous forme de fichier XML, en utilisant la réflexion (pour rappel : l’attribut est sérializé, même si aucun getter/setter n’est présent ; seul l’utilisation du modifier transient permet d’éviter cette serialization).

L’API XStream est cachée par Hudson via le fait que la plupart des objets d’Hudson (Plugin, Job, Build etc…) possèdent une méthode save() qui leur permet de se sérializer via XStream.
Il est toutefois important de bien garder à l’esprit la notion de transaction lorsqu’on utilise XStream. En effet, les moments où les données persistées sont modifiées (update) et sauvegardées (commit de l’update précédent) doivent être effectués de manière atomiques et thread safe (utilisation d’un bloc synchronized).

Typiquement, pour pouvoir enregistrer le résultat de chaque build dans les données persistées de mon plugin, je n’ai eu qu’à faire :

public class GlobalBuildStatsPlugin extends Plugin {

    private List<JobBuildResult> jobBuildResults = new ArrayList<JobBuildResult>();
    private List<BuildStatConfiguration> buildStatConfigs = new ArrayList<BuildStatConfiguration>();

    // ...

    @Override public void postInitialize() throws Exception {
    	super.postInitialize();

    	// Reload plugin informations
    	this.load();
    }

    @Extension
    public static class GlobalBuildStatsRunListener extends RunListener<AbstractBuild> {
    	public GlobalBuildStatsRunListener() {
    		super(AbstractBuild.class);
    	}

    	// Handler appelé à chaque fin d'exécution d'un job
    	@Override public void onCompleted(AbstractBuild r, TaskListener listener) {
    		super.onCompleted(r, listener);

    		// On récupère l'instance de notre plugin
    		GlobalBuildStatsPlugin plugin = Hudson.getInstance().getPlugin(GlobalBuildStatsPlugin.class);
    		// Attention à bien entourer vos modifications partagées via un bloc synchronized
    		synchronized (plugin) {
    			GlobalBuildStatsPlugin.addBuild(plugin.jobBuildResults, r);

    			try {
    				plugin.save();
    			} catch (IOException e) { /* On log notre erreur ... */ }
    		}
    	}
    }

    // ...
}

La principale interrogation qu’on pourrait avoir ici est : « Est-ce que le bloc synchronized suffit dans un environnement clusterisé comme celui d’Hudson ? ».
En effet, même si Hudson garantit que chaque plugin est instancié une et une seule fois (pattern Singleton), une JVM différente est présente sur chaque node Hudson : on aurait alors un singleton différent par node !
Et bien la réponse à cette question est « oui » puisque la méthode onCompleted() ci-dessus est appelée sur le master une fois que le job s’est terminé (même si exécuté sur un slave).

Attention toutefois à plusieurs problématiques liées à la sérialization XStream :

  • L’aspect thread safe (nous l’avons vu précédemment)
  • Mettre les champs calculés de notre plugin en transient (de manière à ne pas sérializer inutilement des données !)
  • Gérer l’historisation de votre modèle de données (au fur et à mesure que votre plugin évolue, vos fichiers XML risquent de ne plus être rétro-compatibles). Une entrée du wiki traite de ce problème.
  • Vous aurez beaucoup de mal à utiliser XStream pour parser un fichier XML existant (approche XML => Objet). En revanche, il existe ce qu’on appelle des « converters » qui permettent de convertir un noeud donné en un objet (permet de structurer un minimum les données de votre classe sérialisée), une entrée du wiki traite d’ailleurs (rapidement) de cette problématique.
  • La volumétrie des données : les 2 inconvénients majeurs de la sérialization par rapport à un enregistrement en base de données sont :
    • La taille des données persistées (un fichier XML est très verbeux ! 1,5Mo pour 8600 résultats de builds sur le global-build-stats plugin)
    • La taille des données en mémoire : lorsque les données atteignent une taille critique, mettre en place un découpage en plusieurs entités/fichiers différents avec un chargement de type lazy loading (afin d’éviter un chargement de grappe volumineuse en mémoire)

La saisie de données par l’utilisateur

Le wiki d’Hudson traite des différents cas de formulaires (ici et ici), notamment :

  • Comment gérer des données saisies au niveau de la configuration d’un job (/jobs/<nom job>/config.xml) ou du paramétrage général d’Hudson (/config.xml) : ces données sont généralement regroupées sous la forme de ce qu’on appelle un Descriptor dans Hudson
  • Comment gérer des données non forcément liées à la configuration existante : il s’agit des objets dits « sans Descriptor »

Pour le cas précis du global-build-stats, il était clair que les données n’allaient pas être liées à un job.
Je me suis donc posé la question « est-ce que je présente mon formulaire coté configuration globale d’Hudson (/config.xml) ou dans une page complètement à part de cette dernière ? ».
J’avais en tête, dès le début, de présenter la liste des graphiques de manière centralisée, avec la possibilité de faire du CRUD dessus. Intégrer cela à la page de configuration générale d’Hudson -déjà bien chargée- n’aurait pas été pertinent. C’est pourquoi j’ai décidé de créer ma propre page dédiée au plugin (et intégrée au panel d’Administration, cf l’épisode 1) : <plugin FQN>/index.jelly

Attention toutefois : ce choix n’a pas été sans conséquences pour moi car créer un formulaire « dissocié » des différents config.xml existants a eu un certain nombre d’inconvénients :

  • La plupart des pages du wiki (notamment celle-ci) préconisent d’agencer vos pages en utilisant les tags <l:layout> et <l:main-panel>. Ceci fonctionne bien !
    En revanche, en définissant soi-même le layout de la page, les tags de type <f:entry>, <f:textbox> (et autres taglibs jelly servant à l’affichage de champs de formulaires) ont un rendu complètement différent (=moche) de lorsqu’on les appelle à l’intérieur d’un **/config.xml.
    Le workaround simple est d’utiliser le « bon vieux HTML » en regardant les tags & classes générées par un <f:textbox> (par exemple) et de les mettre en place pour notre champ.
    Cela donne, par exemple :

        <form name="createBuildStat_${buildStatId}" action="${formAction}" method="post" class="globalBuildStatsForm"
        	  id="createBuildStat_${buildStatId}" onsubmit="return !isDivErrorPresentInForm(this);">
          Title : <input type="text" name="title"
          				 value="${currentBuildStatConfig.buildStatTitle}"
          				 checkUrl="'${rootURL}/plugin/global-build-stats/checkTitle?value='+escape(field.value)"
          				 onblur="validateField(this);" onchange="validateField(this);"
          		  /><span class="validation-error-area" style="display:inline-block;"></span><br/>
          <!-- ... -->
        </form>
    

    Nous reviendrons plus tard sur l’attribut checkUrl de la balise <input> qui n’existe pas en HTML « normal ». Nous verrons également à quoi sert le <span class= »validation-error-area »> même si son nom en dit déjà long …

  • L’organisation des <table> n’étant pas du tout la même que sur les **/config.xml, j’ai eu un certain nombre de problèmes avec les CSS (là encore, j’ai dû réinventer la roue pour parvenir à quelque chose de pas trop moche).
  • J’ai eu un certain nombre de comportements Javascript assez bizarres sur des fonctionnalités techniques qui fonctionnent « out of the box » lorsqu’on utilise les taglibs citées précédemment dans des config.xml.
    • Notamment au niveau de l’aide en ligne associée à chaque champ (fichier help-<nom du champ>.jelly) : cette fonctionnalité ne marche pas du tout au moment où j’écris ces lignes.
    • Egalement au niveau de l’affichage des messages d’erreur liés à la validation du formulaire (j’ai dû ré-écrire et surcharger certaines fonctions Javascript d’Hudson pour pouvoir parvenir à mes fins !)

La validation de formulaires

Le framework Stapler permet de faire de la validation AJAX de formulaires.
Pour cela, le framework Javascript d’Hudson référence un certain nombre de comportements sur des balises/classes HTML couramment utilisées dans Hudson.
Ces comportements sont définis au niveau du hudson-behavior.js dont voici un extrait :

var hudsonRules = {
    "BODY" : function() {
        tooltip = new YAHOO.widget.Tooltip("tt", {context:[], zindex:999});
    },

    // ...

    "TABLE.progress-bar" : function(e) {// sortable table
        e.onclick = function() {
            var href = this.getAttribute("href");
            if(href!=null)      window.location = href;
        }
        e = null; // avoid memory leak
    },

    // ...

// form fields that are validated via AJAX call to the server
// elements with this class should have two attributes 'checkUrl' that evaluates to the server URL.
    "INPUT.validated" : registerValidator,
    "SELECT.validated" : registerValidator,
    "TEXTAREA.validated" : registerValidator,

// validate required form values
    "INPUT.required" : function(e) { registerRegexpValidator(e,/./,"Field is required"); },

// validate form values to be a number
    "INPUT.number" : function(e) { registerRegexpValidator(e,/^(\d+|)$/,"Not a number"); },
    "INPUT.positive-number" : function(e) {
        registerRegexpValidator(e,/^(\d*[1-9]\d*|)$/,"Not a positive number");
    },

    // ...

    // structured form submission
    "FORM" : function(form) {
        crumb.appendToForm(form);
        if(Element.hasClassName("no-json"))
            return;
        // add the hidden 'json' input field, which receives the form structure in JSON
        var div = document.createElement("div");
        div.innerHTML = "<input type=hidden name=json value=init>";
        form.appendChild(div);

        form.onsubmit = function() { buildFormTree(this); };
        form = null; // memory leak prevention
    },

    // ...
};

Behaviour.register(hudsonRules);

// ...

// Behavior rules
//========================================================
// using tag names in CSS selector makes the processing faster
function registerValidator(e) {
    e.targetElement = findFollowingTR(e, "validation-error-area").firstChild.nextSibling;
    e.targetUrl = function() {
        return eval(this.getAttribute("checkUrl"));
    };
    var method = e.getAttribute("checkMethod");
    if (!method) method = "get";

    FormChecker.delayedCheck(e.targetUrl(), method, e.targetElement);

    var checker = function() {
        var target = this.targetElement;
        FormChecker.sendRequest(this.targetUrl(), {
            method : method,
            onComplete : function(x) {
                target.innerHTML = x.responseText;
            }
        });
    }
    var oldOnchange = e.onchange;
    if(typeof oldOnchange=="function") {
        e.onchange = function() { checker.call(this); oldOnchange.call(this); }
    } else
        e.onchange = checker;
    e.onblur = checker;

    e = null; // avoid memory leak
}

Comme vous pouvez le voir ci-dessus, le framework Javascript d’Hudson cherche à faire un appel Ajax sur chaque champ possédant la classe « validated », en appelant l’url donnée par l’attribut checkUrl du champ cible.

En somme, pour le code HTML suivant :

<f:entry title="${%Chart Width * Height}">
  <f:textbox field="buildStatWidth" checkUrl="'${rootURL}/plugin/global-build-stats/checkBuildStatWidth?value='+escape(field.value)" />
</f:entry>

Il est nécessaire de faire une méthode « doCheckBuildStatWidth » dans notre plugin, dont l’objectif est de valider le champ cible :

public class GlobalBuildStatsPlugin extends Plugin {
    // ...
    public FormValidation doCheckBuildStatWidth(@QueryParameter String value){
    	if(!isFilled(value)){ return FormValidation.error("Build stats width is mandatory"); }
    	else if(!isInt(value)){ return FormValidation.error("Build stats width should be an integer"); }
    	else { return FormValidation.ok(); }
    }
    // ...
}

Un certain nombre de points notables :

  • Si vous jetez un oeil à la fonction javascript registerValidator(), vous pourrez voir qu’il est codé en dur que le message d’erreur éventuel renvoyé via Ajax est affiché dans le <tr> qui suit le champ (dommage : dans mon cas, je n’avais aucun <tr> => c’est typiquement le type de fonction javascript que j’ai dû surcharger pour avoir un fonctionnement non erratique de la validation de formulaire sur une page « non-config.xml-like »)
  • La validation n’est que purement client, il faudra bien veiller à faire appel aux même méthodes de validation coté serveur
    (pour l’heure, je n’ai pas trouvé comment faire reposer cette validation sur des conventions de nommage de manière à limiter le code inutile)
  • Si, à l’intérieur d’une méthode doXXX, vous souhaitez rediriger vers un script Jelly de votre choix, vous pouvez utiliser la méthode StaplerRequest.getView() de la manière suivante :
        public void doXXX(StaplerRequest req, StaplerResponse res) throws ServletException, IOException {
        	req.getView(this, "buildHistory/index.jelly").forward(req, res);
        }
    
  • Coté serveur, il existe une méthode StaplerResponse.forwardToPreviousPage(StaplerRequest) qui permet de retourner sur le script Jelly d’où l’on vient suite aux traitement effectués.
    • Attention : contrairement aux apparences, cette méthode n’effectue pas un forward mais un redirect ! (j’ai passé quelques heures de debug dans le source Hudson avant de m’en apercevoir !)
  • Une méthode très utile : la méthode StaplerRequest.bindParameters(*) qui permet, coté serveur, d’instancier un databean, via l’introspection, à partir des données présentes dans la request

Conclusion

Suite à cet article, vous devriez maintenant être capable de faire des formulaires et de persister des données saisies par vos utilisateurs.

Nous avons pu voir que le stockage sous la forme fichier (et non dans une base de données) apporte un certain nombre de contraintes … en revanche, cela facilite, je trouve, les développements dans un environnement clusterisé (pas besoin de se poser de questions quand à la réplication des données sur les différents noeuds : c’est le noyaux d’Hudson qui s’en chargera pour vous !).

Nous avons également pu jeter rapidement un oeil au framework Javascript d’Hudson, principalement basé sur du JQuery et du développement évènementiel. Cela m’a changé de mon quotidien où je dois systématiquement réinventer la roue en javascript (interdiction d’utiliser des librairies tierces, même javascript !). Cependant, c’est assez frustrant de voir le nombre de fonctions existantes dans lesquelles des choses sont cablées en dur (cf le coup des <tr> pour les messages d’erreur !).
A ce sujet, en développant le global-build-stats plugin, j’ai eu l’occasion de soumettre quelques patchs sur des fonctions javascript. Ces derniers ont été intégrés relativement rapidement (environ 1 mois me semble-t-il … ce qui n’est pas énorme je trouve !).
Après, se pose une question intéressante : admettons que ces fixes aient été intégrés dans la version 1.360 et qu’en attendant, j’ai trouvé des workarounds pour contourner le problème. Dois-je m’aligner sur cette « bleeding edge version » 1.360 (et du coup, tous les utilisateur utilisant une version inférieure ne pourront utiliser mon plugin) ou garder un pointeur sur ma « bonne vieille version non patchée » ? Dilemne intéressant !

Dans un prochain billet -le dernier de la série à priori- nous traiterons justement de la gestion des versions Hudson compatibles avec votre plugin (ainsi que de la compatibilité entre plugins), de la sécurité, de l’internationalisation et enfin de la première release de votre plugin.

Références du billet

Episode 1 : https://fcamblor.wordpress.com/2010/04/04/plugins-hudson-etape-1-la-creation-du-plugin/
Episode 2 : https://fcamblor.wordpress.com/2010/04/10/plugins-hudson-episode-2-implementer-son-premier-plugin/
Les jobs distribués (blog de Stephen Connoly) : http://javaadventure.blogspot.com/2008/01/writing-hudson-plug-in-part-2.html
Repository SVN du global-build-stats Hudson plugin : https://svn.dev.java.net/svn/hudson/trunk/hudson/plugins/global-build-stats
Site de XStream : http://xstream.codehaus.org/
Gestion de la compatibilité ascendante de vos plugins : http://wiki.hudson-ci.org/display/HUDSON/Hint+on+retaining+backward+compatibility
Quelques tips pour XStream (l’utilisation de converters notamment) : http://wiki.hudson-ci.org/display/HUDSON/XStream+Tips
Soumettre des formulaires structurés dans Hudson : http://wiki.hudson-ci.org/display/HUDSON/Structured+Form+Submission
Utiliser les tags Jelly pour vos formulaires Hudson : http://wiki.hudson-ci.org/display/HUDSON/Basic+guide+to+Jelly+usage+in+Hudson
Exemples de formulaires Hudson : http://wiki.hudson-ci.org/display/HUDSON/Jelly+form+controls
Quelques bases sur les tags Jelly : http://wiki.hudson-ci.org/display/HUDSON/Understanding+Jelly+Tags
Le projet Stapler : https://stapler.dev.java.net/what-is.html
Le projet JQuery : http://jquery.com/

Publicités

2 Réponses to “Plugins Hudson – Episode 3 : Des formulaires et des données”

  1. Fabien Says:

    Bon article..
    Pourquoi les JS externes seraient interdits?

    • Frédéric Camblor Says:

      Ils ne sont pas interdits dans Hudson .. c’est dans ma « vie pro » qu’ils le sont ..

      Principalement pour des raisons de licences je pense … mon client souhaite maîtriser l’ensemble du code sur lequel reposent ses applications (c’est une philosophie comme une autre … le principe NIH à l’envers en somme :-))


Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s

%d blogueurs aiment cette page :