Publié

~9mn de lecture 🕑

jeu. 01 septembre 2016

← Liste des articles

Survoler les bugs avec Kinto

Read the English version

Voici un article rédigé par Eric Le Lay sur son utilisation de Kinto.

Introduction

Connaissez-vous jEditl'éditeur de texte des développeurs ? C'est un éditeur de texte multi-plateforme, écrit en java, disposant de nombreux plugins. Je participe à son développement depuis une dizaine d'années. Nous l'hébergeons sur Sourceforge ; le code principal est sous Subversion.

Nous sommes bénévoles et le maintenons sur notre temps libre. Pour ma part, je n'ai pas toujours accès à internet pendant les vacances. Ou via un câble Ethernet dans une pièce sombre et surchauffée. Tout ça pour dire que j'aime avoir un maximum de choses sous la main hors-ligne :

  • sources et doc de la plateforme java : pas de problème avec les packages openjdk7-doc et openjdk7-src ;
  • sources de jEdit et des plugins : avec git-svn j'ai le code et son historique ;
  • bugs et feature requests : oups!

Nous avons 1100 tickets ouverts et 7600 clos. Tout ça n'est pas disponible hors-ligne. Ça doit changer !

Comment obtenir une archive des tickets ?

Sourceforge permet aux admins de télécharger une archive du projet, dont les tickets. Actuellement un script les convertit en un paquet de fichiers HTML avec un peu de javascript pour la navigation. Le résultat (fichiers HTML pour un usage en-ligne et archive téléchargeable) est déployé via rsync sur jedit.org/trackers. Pour limiter le volume, seuls les tickets ouverts sont disponibles.

La génération de l'archive du projet par Sourceforge prend plusieurs minutes, c'est pourquoi je la lance au plus une fois par semaine. Pour les utilisateurs, ce n'est pas très pratique, car il faut qu'ils téléchargent une nouvelle archive pour avoir une vue à jour des tickets. Elle peut avoir une semaine de retard : peut mieux faire.

Objectifs

  • je voudrais permettre la mise à jour incrémentale des tickets ;
  • je voudrais enrichir la visualisation des tickets : possibilité de recherche améliorée ; peut-être permettre la modification hors-ligne ;
  • je voudrais essayer Kinto :-).

Mes premiers pas avec Kinto

J'ai entendu parler de Kinto par Rémy et Alexis. Comme avec couchdb/pouchdb, il est possible de synchroniser la base de données entre le serveur (Kinto) et le navigateur web (kinto.js). Kinto.js stocke les données dans IndexedDB donc il est possible de manipuler une base relativement volumineuse.

Chaque utilisateur peut créer ses propres collections (l'équivalent des tables SQL) Kinto. Il est même possible de gérer les permissions document par document (l'équivalent des lignes SQL).

Le tutoriel : ToDo list

J'ai commencé par le tutoriel. Il présente bien les opérations: créer des documents, les afficher, les modifier, synchroniser avec le serveur. Il s'appuie sur le serveur Kinto de test de mozilla : donc un obstacle de moins pour démarrer.

Des tickets au lieu des tâches

Je suis passé à un serveur kinto local pour mes tests : il est déployable très facilement via docker. Si vous envisagez d'importer des documents, utilisez tout de suite le backend PostgreSQL (aussi facile à mettre en place, avec docker-compose ; tout est expliqué). Le backend en RAM pose des problèmes lors d'import batch en parallèle (ce qui se produit si vous importez depuis un script node.js utilisant request).

Une fois les tickets importés, l'application web n'a plus qu'à les récupérer via la synchronisation kinto.js et les afficher.

Recherche locale avec kinto.js

La recherche locale avec kinto.js n'est pas aussi développée que la recherche sur le serveur : seule la conjonction de clé: [valeur1, valeur2, ...] est supportée. La recherche sur les champs id, _status et last_modified est accélérée par un index (code ici). Ensuite, tous les documents sont chargés et retournés sous forme de tableau puis filtrés selon les critères restants. Le filtrage selon des critères plus complexes se fait par l'application sur le résultat de list(). Dans les cas de petites collections c'est simple et de bon goût mais avec 8000 documents la recherche devient un peu lente.

Exemple de recherche complexe par mot clé (champ de texte) :

function filterByKeyword(keyword){
  return function(doc){
    return doc.summary.indexOf(keyword) >= 0;
  };
}

function search(){
  var keyword = document.getElementById("query");
  return tickets
    .list()
    .then(function(res){
      return res.data.filter(filterByKeyword(keyword));
    });
}

// on submit
search().then(render);

Architecture

J'héberge mon serveur kinto sur un serveur C1 chez scaleway. Il contient deux collections :

  • tickets, pour les tickets ouverts ou récemment fermés ;
  • oldTickets, pour les tickets fermés avant 2016.

J'utilise l'id des tickets chez Sourceforge plutôt qu'une id auto-générée. Je peux ainsi mettre à jour la base facilement à partir des infos de Sourceforge (pas besoin de rechercher le document chez kinto avant de le mettre à jour puisque je connais l'id).

Les clients sont des applications web.

Un script de mise à jour reporte les modifications de Sourceforge dans la base.

La machine étant un serveur ARM sous debian Jessie, quelques aménagements ont été nécessaires (détails sur le wiki kinto).

Client kinto.js

Mon objectif était d'abord d'en apprendre le maximum sur kinto.js. J'ai bien aimé le coté vanilla du tutoriel (pas de dépendance à n frameworks) et suis resté sur cette simplicité :

  • le CSS de bootstrap, les icônes de fontawesome
  • quelques utilitaires : moment, lodash,
  • datatables (après avoir fait ma propre gestion de table, mais voulant déplier certaines lignes de la table et ayant trouvé un exemple tout prêt).

Capture d'écran de l'application

État initial

Lorsque vous accédez pour la première fois à l'application, un panneau vous est présenté proposant de récupérer les tickets, dont éventuellement les vieux tickets.

Petite astuce pour déterminer si c'est la première fois :

tickets.db.getLastModified().then(function(res) {
    if (res) {
        hasDB();
    } else {
        noDB();
    }
});

Pour afficher le nombre de tickets sur le serveur avant de les récupérer, il faut descendre dans le package kinto-http.

Le code correspondant (bientôt simplifié) :

function fetchTotalRecords(api, bucket, name, etag) {
    var headers = {};
    headers["Authorization"] = "Basic " + btoa("token:tr0ub4d@ur");
    return api.client.execute({
        path: "/buckets/" + bucket + "/collections/" + name + "/records" + (etag ? ("?_since=" + etag) : ""),
        method: "HEAD",
        headers
    }, {
        raw: true
    }).then(function(res) {
        console.log("RES", res);
        return parseInt(res.headers.get("total-records"));
    });
}

tickets.db.getLastModified().then(function(lastMod) {
    var api = tickets.api.bucket(tickets.bucket).collection(tickets.name);
    return fetchTotalRecords(api, tickets.bucket, tickets.name, lastMod);
}).then(renderTicketCount);

Cette méthode fonctionne aussi pour déterminer s'il y a eu du nouveau sur le serveur (via l'ETag). Le serveur kinto ne répond pas instantanément. Il faudrait peut-être passer l'etag en If-None-Match en plus de since pour accélérer les choses.

Recherche

Pour la recherche en texte libre coté client, j'ai utilisé lunr. Il construit un index à la Lucene, et permet ensuite de rechercher dans cet index. L'index est sérialisé et enregistré comme un document d'une collection kinto.js cliente pour être réutilisé à la prochaine visite.

La recherche dans l'index retourne un tableau d'id de documents depuis lequel j'extrais les documents, avant de filtrer ce petit tableau avec les autres critères. Cela a accéléré les choses par rapport à la première version de l'application, dans laquelle je commençais toujours la recherche par un tickets.list(), qui retournait tous les documents.

Pour rechercher hello dans l'index en mémoire :

> index.search("hello")
   [{"ref":"52a4d8cb27184667e3400f42","score":0.019459983366287688},
    {"ref":"5690ff3f24b0d96f08df7d6a","score":0.013994677485991343},
    ...
   ]

Pour obtenir une promesse d'un objet { data: ['tableau', 'de', 'tickets', 'contenant', 'texteLibre'] }:

return Promise.all(index.search(texteLibre).map(function(hit) {
    return tickets.get(hit.ref);
})).then(function(allRes) {
    return {
        data: allRes.map(function(res) {
            return res.data;
        })
    };
});

Dans ce code, tous les documents sont récupérés d'IndexedDB en parallèle via leur Id (collection.get()). C'est rapide car IndexedDB est optimisé pour l'accès par clé, mais il serait peut⁻être plus efficace d'utiliser tickets.list({filters: {id: ['tableau', 'des', 'ids']}}) pour limiter le nombre de requêtes.

Affichage d'un ticket

L'application est conçue pour être multi-onglet, permettre la navigation dans l'historique et la création de marques pages. Pour cela, l'état est stocké dans l'url et les recherches entraînent des appels à pushState().

var state;

function encodeState(state) {
    return "#" + encodeURIComponent(JSON.stringify(state));
}

function applyState(state) {
    document.getElementById("search").value = state.filter;
    search();
}

function saveState(state) {
    window.history.pushState(state, "jEdit Trackers", encodeState(state));
}

function hashIsTicket() {
    return window.location.hash.match(/^#[a-z0-9-]+$/);
}

window.addEventListener("popstate", function(e) {
    // called on browser history navigation
    if (e.state) {
        state = e.state;
        applyState(state);
    } else {
        if (hashIsTicket()) {
            // show this ticket
        } else {
            // show search interface
        }
    }
});

On peut afficher un ticket dans un autre onglet en cliquant sur Ouvrir dans un résultat de recherche. Rien de bien compliqué : l'id est mise dans le hash de l'url et l'application interprète ce hash au chargement.

function restoreInitialState() {
    if (window.location.hash.match(/^#[a-z0-9-]+$/)) {
        showTicket(window.location.hash.substring(1));
    } else if (window.location.hash.startsWith("#%7B")) {
        try {
            state = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
        } catch (e) {
            console.log("invalid state in hash", window.location.hash);
            state = defaultState();
        }
        applyState(state);
    } else {
        state = defaultState();
        applyState(state);
    }
}

restoreInitialState(); // to restore state from bookmark or link at page load

Mises à jour

Lorsqu'un ticket est modifié coté Sourceforge il faut mettre à jour le document correspondant dans Kinto. Je le fais via un programme coté serveur.

updater.py

Il n'est pas possible d'obtenir les modifications de tickets Sourceforge en temps réel via les technologies web.

Quelques idées pour les obtenir :

  1. interroger périodiquement le flux RSS de chaque tracker (limité à 10 tickets donc on peut rater des mises à jour si le script ne s'exécute pas pendant un moment) ;
  2. interroger périodiquement l'API REST de chaque tracker, en demandant les tickets modifiés depuis la dernière fois (délai < période de polling) ;
  3. s'abonner à la mailing list jedit-devel et les récupérer avec un client pop3 (délai < 1 minute).

J'ai choisi la solution 2. Le script updater.py

  1. récupère la liste des trackers chez Sourceforge,
  2. récupère de kinto la date de dernière mise à jour (document dans une collection updater),
  3. interroge Sourceforge sur les tickets modifiés depuis cette date,
  4. pousse les mises à jour dans les collections tickets et oldTickets.

Les tickets sont déjà en JSON, avec un minimum de mapping à faire (récupérer les commentaires via une seconde requête à Sourceforge).

Notifications client

Kinto semble disposer d'un mécanisme de notification via websockets mais je ne l'ai pas exploré.

Les clients interrogent le serveur kinto pour les nouveaux tickets toutes les minutes. Lorsqu'il y en a, une notification proposant de synchroniser la base s'affiche en haut à droite de l'application.

Suite à la synchronisation, je mets à jour l'index lunr avec les documents modifiés/ajoutés/supprimés (index.add(document), index.remove(document)).

Les compteurs de tickets affichés sur les boutons (ratés, bugs ouverts) sont aussi recalculés à cette occasion et stockés dans une collection client. S'il n'y a pas de suppressions, je peux les mettre à jour incrémentalement, sinon, il faut re-parcourir la collection pour les calculer.

Conclusion

Ai-je bien fait d'essayer Kinto ?

Oui, certainement. C'est une bonne plateforme pour mon application. Ce n'est cependant pas une solution clé en main comme meteor. Il faut donc l'associer à d'autres librairies pour avoir une application complète. D'un autre coté, le déploiement est simple et le déroulement du programme est facile à suivre.

Les performances sont correctes (l'import batch des 8000 tickets fait tourner mon petit serveur pendant 5 minutes) et le système est ouvert.

Je me suis un peu compliqué la vie en séparant les tickets en deux collections, mais ne charger que les tickets récents allège vraiment l'application.

Amélioration prévues

J'ai passé 3 semaines à explorer Kinto et il reste encore des choses à tester. Je suis également loin d'une application terminée.

Quelques pistes :

Modifications des tickets Je voudrais qu'on puisse modifier les tickets depuis l'application, plutôt que de renvoyer vers Sourceforge. Les modifications pourraient être temporairement stockées dans kinto.js en attente de réseau.

La voie est libre depuis la correction d'un petit bug qui empêchait d'interroger l'API Sourceforge en CORS depuis la France, mais ça représente pas mal de code pour l'interface utilisateur. J'explorerai FormBuilder avant de coder ma propre solution. Sans aller jusque là, il pourrait être intéressant de permettre aux utilisateurs de tagger, mettre en favoris, suivre, certains tickets (stockage dans kinto).

Interface Utilisateur L'interface est largement améliorable. J'envisage également de restructurer le code en m'appuyant sur react, vu qu'il existe du boilerplate et kinto-admin comme source d'inspiration.

Pièces jointes Tous les tickets sont accessibles hors-ligne, mais pas les pièces-jointes (comme le log contenant la trace d'exécution permettant de comprendre le bug). Nous ne sommes donc pas encore à du 100% hors-ligne. J'aimerai distribuer ces pièces-jointes, peut-être avec le mécanisme d'Attachments de kinto. Les pièces-jointes des tickets récents font 10Mo, celles des anciens tickets 18Mo : ça semble donc faisable.

Recherche texte libre Je n'utilise lunr.js que de manière très basique : indexation du titre et de la description. Mais l'index est déjà très gros et lent à (dé)sérialiser depuis IndexedDB. J'hésite entre l'enrichir, permettant une recherche sur tous les champs ou définir mon langage de requête et jouer sur des index que je maintiendrai moi-même.

L'indexation pourrait se faire dans un WebWorker, pour ne pas bloquer l'application.

Performances Les tickets pourraient être indexés par numéro (ils ne sont pas uniques au sein d'un projet, mais au sein d'un tracker) pour accélérer ce type de recherche. De même, indexer par les autres critères de recherche prédéfinis accélèrerait les choses.

Je peux gérer des index manuels (un document par index, contenant un objet associant chaque valeur possible de la clé à un tableau d'id). Alors, rechercher tous les bugs ouverts reviendrait à

indexes.get("bug").then(resInd){
    var ind = resInd.data; // ind = { open : ["id1", "id2", ...], ...}
    return Promise.all((ind.open).map(tickets.get.bind(tickets)))
                  .then(function(multiRes){
                      return multiRes.map(function(res){ return res.data;})
                  });
});

Merci !

Je tiens à remercier l'équipe travaillant autour de Kinto pour leur gentillesse et leur ouverture : accessibles sur IRC (#kinto) et Slack, ils encouragent la création de tickets, de PR. De nombreuses sont même acceptées ;-)

Le code est disponible sur github: elelay/jedit-trackers-kinto. Il tourne sur trackers.elelay.fr. Vos retours sont les bienvenus via IRC (elelay) — Issues et PR, sait-on jamais...

Revenir au début