Exemple article

  • Améliorer ses requêtes Mongo avec Atlas et .explain()

    Améliorer ses requêtes Mongo avec Atlas et .explain()

    Quoi de plus frustrant qu’un site qui rame ? Rien. Si à chaque clic l’utilisateur·rice doit patienter en cherchant des formes dans les nuages pour faire passer le temps, pas sûr que grand monde reste sur votre site …

    Allez, on a (sûrement) du boulot !

    Identifier les problèmes avec Atlas

    MongoDB Atlas est un service de base de données dans le cloud, créé par la même entreprise que MongoDb. C’est un outil qui permet, entre autres, de monitorer l’état de la base de données.

    Metrics

    Les deux graphiques qui nous intéressent ici sont “Query Executor” et “Query Targeting”, que l’on trouvera dans l’onglet “Metrics”. Ils nous donneront une idée de la réponse à “y-a-t’il des choses à améliorer ?” mais ne nous donneront pas de précision sur quoi exactement. Cependant, ils sont un bon indicateur de l’état général de nos queries.

    Query Executor

    Sur ce graphique, on pourra voir :

    • en bleu le nombre moyen d’index scannés;
    • en vert le nombre moyen de documents scannés;

    données correspondantes à respectivement totalDocsExamined et totalKeysExamined que l’on retrouve en sortie d’un explain(). Plus la courbe verte est éloignée de la courbe bleue, moins les index sont utilisés dans les requêtes de l’application, ce qu’on cherche à éviter.

    “scanned” : The average rate per second over the selected sample period of index items scanned during queries and query-plan evaluation. This rate is driven by the same value as totalKeysExamined in the output of explain(). ”

    scanned objects” : The average rate per second over the selected sample period of documents scanned during queries and query-plan evaluation. This rate is driven by the same value as totalDocsExamined in the output of explain().

    Query Targeting

    Ce graphique représentant des ratios est peut-être plus intéressant à regarder pour déterminer si l’on a besoin de travailler sur nos queries. Un ratio de 1 indique que le nombre de documents ou d’index scannés est égal au nombre de documents renvoyés. On cherchera donc à se rapprocher de cette valeur, ce qui n’est pas toujours facile. Par exemple, on en est loin ici :

    “scanned / returned” : The ratio of the number of index items scanned to the number of documents returned by queries, since the previous data point for the selected sample period. A value of 1.0 means all documents returned exactly match query criteria for the sample period. A value of 100 means on average for the sample period, a query scans 100 documents to find one that’s returned.

    ”scanned objects / returned” : The ratio of the number of documents scanned to the number of documents returned by queries, since the previous data point for the selected sample period.

    Performance advisor

    Cet onglet peut proposer des créations ou suppressions d’index en fonction de nos queries. Même s’il est tentant de cliquer sans trop regarder, il vaut mieux réfléchir un peu avant. En effet, ajouter des indexes sur un peu tous les champs serait contre-productif : Mongo pourrait utiliser un index plutôt qu’un autre sans que ce soit forcément le bon choix. Autre potentiel problème : si les indexes créés sont triés et stockés dans la RAM, il faut s’assurer qu’on ne dépasse pas la place allouée !

    Ces propositions d’indexes se basent sur les requêtes faites sur l’app, et si elles ne sont pas écrites en pensant un minimum perf, les indexes proposés ici ne seront pas forcément pertinents.

    Profiler

    C’est sur cet onglet qu’on va enfin pouvoir mettre les mains dans le cambouis. Ce graphique identifie les requêtes “trop longues” selon Atlas.

    En cliquant sur un point du graphique, le détail de la requête incriminée apparait.

    Sur cette requête, le nombre documents examinés via l’index est de 3 209 alors qu’on ne renvoie au final que 5 documents !

    On trouvera également le détail de la requête, ce qui nous permettra d’identifier exactement dans quelle partie du code elle se trouve.

    {
    "type": "command",
    "command": {
    "aggregate": "myCollection",
    "pipeline": [

    ],

    },
    "planSummary": "IXSCAN { someField: 1 }",
    "keysExamined": 3209,
    "docsExamined": 3209,
    "hasSortStage": true,
    "cursorExhausted": true,
    "numYields": 6,
    "nreturned": 5,
    "durationMillis": 198,

    }

    Attention, toutes les requêtes à la base de données sont affichées ici ! De ce fait, si vous faites des requêtes un peu gourmandes à la mano dans votre terminal, elles apparaîtront aussi ici.

    Maintenant qu’on a identifié une requête qui pose problème, c’est cool, mais on fait quoi ?

    Améliorer ses requêtes

    Lire et comprendre un .explain()

    La doc Mongo est par ici et .

    TL;DR, on peut (essayer de) comprendre ce que fait Mongo avec notre requête, en faisant :

    db.getCollection('myCollection').find({ ... }).explain('executionStats');
    

    Attention, pour les aggregate, c’est dans l’autre sens ! Le .explain() vient avant le .aggregate(). Pour la lecture, c’est le même principe.

    db.getCollection('myCollection').explain('executionStats').aggregate([ ... ]);
    

    Pour le .find(), on aura un résultat du style :

    {
        "queryPlanner" : {
            "parsedQuery" : {...},
            "winningPlan" : { -- plan utilisé
                "stage" : "FETCH",
                "filter" : {...},
                "inputStage" : {
                    "stage" : "IXSCAN",
                    "keyPattern" : {
                        "myFieldWithIndex" : 1
                    },
                    "indexName" : "myIndexName",
                    "isMultiKey" : false,
                    "isUnique" : false,
                    ...
                }
            },
            "rejectedPlans" : [],
            ...
        },
        "executionStats" : {
            "executionSuccess" : true,
            "nReturned" : 32199, -- nombre de documents renvoyés
            "executionTimeMillis" : 160, -- temps d'exécution
            "totalKeysExamined" : 49805, -- nombre d'indexes examinés
            "totalDocsExamined" : 49805, -- nombre de documents examinés
            ...
        },
        ...
    }
    

    On peut déjà comparer le executionStats.nReturned et executionStats.totalDocsExamined. Si on a la même valeur, cela veut dire que tous les documents parcourus sont renvoyés en réponse.

    Est-ce qu’un index a été utilisé ? On peut voir ça à plusieurs endroits :

    • executionStats.totalKeysExamined qui nous donne le nombre de documents examinés via un index
    • winningPlan.inputStage.stage qui vaut IXSCAN (voir ci-dessous)
    • winningPlan.inputStage.indexName qui nous donne le nom de l’index utilisé

    Le champ stage peut prendre plusieurs valeurs :

    • COLLSCAN quand toute la collection est scannée (en général, on va essayer de ne pas avoir cette valeur dans le premier stage au moins)
    • IXSCAN quand on examine les documents via un index
    • FETCH quand on récupère des documents
    • GROUP quand on groupe des documents
    • SHARD_MERGE pour fusionner les résultats des shards
    • SHARDING_FILTER pour filtrer les documents orphelins des shards

    Pour diminuer le temps d’exécution, le but va être d’avoir le moins possible de COLLSCAN au profit des IXSCAN.

    Exemple

    Imaginons une collection transactions sans index et avec 106 041 documents de la forme suivante :

    {
        "_id" : "j9sdaW87Wv5gck443",
        "date" : ISODate("2022-09-24T00:00:00.000Z"),
        "id_user" : "1234",
        "id_bank_account" : "gTdxTA9ZvZrsrML6S",
        "description" : "Virement",
        "subdivisions" : [ 
            {
                "id" : "b44jxF599Tk8qkj32",
                "amount_in_cents" : 89400,
                "accounting_account" : "471000",
            }, 
            {
                "id" : "43BRMJHuXYNfxogdD",
                "amount_in_cents" : -89400,
                "accounting_account" : "512001",
            }
        ]
    }
    

    Prenons cette requête qui liste les transactions de l’utilisateur 1234 pour un compte comptable et une date :

    db.getCollection('transactions')
    	.find({ 
    			id_user: '1234',
    	    'subdivisions.accounting_account': '471000', 
          date: ISODate('2022-09-24 00:00:00.000Z') 
    	})
      .explain('executionStats')
    

    Et l’explication de son résultat :

    {
        "queryPlanner" : {
            ...
            "winningPlan" : {
                "stage" : "COLLSCAN", -- n'utilise pas d'index
                ...
            },
            "rejectedPlans" : [] -- n'a pas trouvé d'autre possibilité d'exécution
        },
        "executionStats" : {
            "executionSuccess" : true,
            "nReturned" : 5, -- nombre de documents retournés
            "executionTimeMillis" : 62, -- temps de réponse
            "totalKeysExamined" : 0, -- n'examine pas d'index
            "totalDocsExamined" : 106041, -- nombre de documents examinés
    				...
        },
        ...
    }
    

    Avoir une requête qui n’utilise pas d’index n’est pas un problème en soit. Ce qu’il faut regarder c’est le ratio entre le nombre de document examiné (ici 106 041, c’est à dire toute notre collection !) et le nombre de documents retournés (ici, uniquement 1). Cette collection stocke des transactions bancaire, elle sera amenée à grossir rapidement : on ne peut pas se permettre de scanner toute la collection à chaque fois.

    Créons un index sur les dates avec db.getCollection('transactions').createIndex({ "date" : 1 }) . On aura alors un résultat plus satisfaisant :

    {
        "queryPlanner": {
            "winningPlan": {
                "stage": "FETCH",
                "inputStage": {
                    "stage": "IXSCAN",
                    "indexName": "date_1", -- index utilisé
                    ...
                },
            },
            "rejectedPlans": [] -- n'a pas trouvé d'autre possibilité d'exécution
        },
        "executionStats": {
            "executionSuccess": true,
            "nReturned": 5, -- nombre de documents retournés
            "executionTimeMillis": 6, -- temps de réponse
            "totalKeysExamined": 8, -- nombre d'indexes examinés
            "totalDocsExamined": 8, -- nombre de documents examinés
            ...
        },
        ...
    }
    

    Le temps de réponse a grandement diminué, on utilise un index et on examine beaucoup moins de documents.

    Mais il est encore possible d’améliorer notre résultat en examinant des indexes à la place des documents.

    Supprimons notre index pour en créer un autre :

    db.getCollection('transactions').createIndex({ 
    	'id_user': 1, 
    	'subdivisions.accounting_account': 1,
    	'date': 1
    })
    

    Ce qui nous donne :

    {
        "queryPlanner": {
            "winningPlan": {
                "stage": "FETCH",
                "inputStage": {
                    "stage": "IXSCAN",
                    "indexName": "id_user_1_subdivisions.accounting_account_1_date_1", -- index utilisé
                    ...
                },
            },
            "rejectedPlans": []
        },
        "executionStats": {
            "executionSuccess": true,
            "nReturned": 5, -- nombre de documents retournés
            "executionTimeMillis": 5, -- temps de réponse
            "totalKeysExamined": 45, -- nombre d'indexes examinés
            "totalDocsExamined": 5, -- nombre de documents examinés
            ...
        },
        ...
    }
    

    Avec cette index, le nombre de documents examinés est égal au nombre de documents retournés. Le nombre d’indexes examinés a augmenté mais cela n’est pas un problème pour les performances sur cet ordre de grandeur.

    Attention toutefois, ici l’exemple ne s’appuie que sur une seule requête. Il est peu probable que vous ayez à faire une seule requête par collection. Il vous faudra alors penser vos index pour qu’ils servent sur la majorité de vos requêtes.

    Créer des index dans tous les sens pourra aussi vous desservir : Mongo choisira d’utiliser un des indexes créés, il est possible que ce ne soit pas forcément celui auquel vous pensiez !

    Pour visualiser l’utilisation de vos indexes, utilisez $indexStats : db.getCollection('transactions').aggregate( [ { $indexStats: { } } ] )

    Monitorer

    Les graphiques disponibles sur Atlas présentés plus haut nous donnent une idée de l’état de la base à un instant t. Sur Query Executor et Query Targeting il n’est pas possible de différencier les collections. Sur le Profiler, c’est un petit peu plus précis, mais on ne peut remonter que sur les dernières 24h.

    Pour avoir une vue plus globale des évolutions de performance suite aux différentes modifications, il est possible de monitorer les temps de réponse des routes. Certes, on mesurera tout un tas de choses en plus de “l’amélioration de la requête”, mais cela permet de voir si notre feature a besoin de plus de travail (possiblement autre que sur Mongo).

    Par exemple, une des requêtes améliorées lors de nos sessions avec la guilde Mongo a été monitorée. Cette route ne fait qu’une requête Mongo et peu de traitements JS, nous étions donc assez confiants sur les impacts positifs de notre travail.

    Pour aller plus loin …

  • Why we replaced written dailies with synchronous dailies

    Why we replaced written dailies with synchronous dailies

    TL;DR: Through trials and feedback, we gradually transition from written to synchronous dailies. When the entire team work on the same project, synchronous dailies win by making unblocking faster

    Going from asynchronous (written) to synchronous (spoken) dailies

    Foreword : we don’t follow scrum, but we have dailies just like it.

    This is the story of how dailies in our team evolved in the past 12 months. The team size ranged from 6 to 9, including product managers.

    • We started with written dailies on Slack, through the help of Geekbot, a tool we really liked and had already been using for more than a year. At the start of the day, the slack bot asks questions (how are you, what are you working on, any blockers, etc) and the answers are posted to a channel. 5 written daillies a week.
    • To organize our week, we added a “team meeting” on Mondays. This team meeting was synchronous and we ended it with a synchronous daily, we were down to 4 written daillies, 1 sync.
    • A few months later, we had a company-wide and time-sensitive objective. To allow faster unblocking, we replace another written daily with a synchronous daily over Google Meet. The purpose of the daily was answering the question “what’s preventing us from reaching the objective, what will we do about it today?”. 3 written dailies, 2 sync.
    • In the retrospective about this company-wide objective, the feedback on synchronous dailies was unanimously positive, so we decided to stay with this rhythm of 3 written dailies and 2 sync dailies.
    • Later, feedback on synchronous dailies was still positive, so we decided to go 100% with sync dailies. We left the Geekbot tool enabled for those working alone on projects that are not the main focus of the team. 0 written dailies, 5 sync.
    • After a month, there was very little use of the written tool, so we disabled it

    Our takeaways on sync vs written dailies

    • Pros of written dailies: 1) one fewer meeting, 2) we can catch up by reading the messages, and go back to them when needed, 3) we can take our time to prepare and write what we want to communicate, 4) we get more flexible working hours
    • Pros of sync : 1) more social interactions within the team, 2) faster unblocking, we get immediate reaction when asking for help. This was enough benefits to overcome the drawbacks
    • We also learned that synchronous dailies works best when the entire team is working on the same project
  • Commit atomique ? Kezako ?

    Commit atomique ? Kezako ?

    Lorsque l’on s’intéresse de près ou de loin au merveilleux monde du développement, on ne peut pas passer à côté de Git (merci Linus Torvald) et des commits. Et un jour, on tombe sur ces 2 mots commit atomique. On se demande alors à quoi cela pourrait faire référence. Ici, je vais vous donner mon interprétation de ce type de commit et pourquoi il est important de les utiliser au quotidien.

    Une petite histoire pour un peu de contexte

    Actuellement à Indy, on utilise GitHub, donc chaque commit sur master est en théorie relié à une PR. Cette PR est alors normalement reliée à une tâche Jira, donc on peut avoir un historique assez simplement.

    Ainsi, si on tombe sur un commit qui a comme seul nom fix, on va voir la PR, même si le message de PR est vide lui aussi, on a accès aux autres commits compris dans cette PR, et on peut comprendre à quoi correspond ce “fix” et avoir un peu de contexte (et encore, on vient de perdre 5min).

    Maintenant, imaginons que cette PR contient uniquement ce commit, il est alors nécessaire d’aller à la tâche Jira rattachée et cette fois, on comprend ce qu’il se passe parce qu’on a du contexte. Mais seulement si une tâche Jira est rattachée à la PR !

    Bon ça, c’est dans le meilleur des cas en fait.

    Imaginons, qu’on touche à du legacy et qu’on essaie de comprendre ce qu’il y a bien pu se passer. Sauf que ce legacy date d’il y a 5 ans. Et qu’à l’époque on n’était pas sous GitHub, mais GitLab et qu’on n’a donc aucun historique des PRs/MRs, et qu’à l’époque on n’avait pas Jira mais Trello (instance de Trello qui n’existe plus bien sûr). Et, bien entendu, la ligne de code qu’on essaie de modifier est rattaché à un seul commit qui s’appelle fix et qui fait 600 lignes. Clairement, personne n’a envie d’arriver dans cet état-là. Donc, on modifie en croisant les doigts, on rajoute des tests comme on peut, et on fait des incantations pour que notre patch ne casse pas d’autres choses tout en se disant “J’aurai dû être éleveur de lamas dans le Périgord”.

    Alors clairement, qu’est-ce que c’est un commit atomique ?

    Pour faire simple, c’est un commit qui ne dépend que de lui.

    En d’autre mot, il contient plusieurs caractéristiques :

    • Très simple, peu de lignes modifiées (lorsque cela est possible) et doit pouvoir se lire facilement
    • Ses tests associés (👋 TDD)
    • Un titre simple expliquant le but. Pour t’aider, tu peux utiliser Conventional Commits et Conventional Comments.
    • Une description claire, et qui répond à plusieurs questions :
      • Pourquoi ?
      • Qu’est-ce qu’on cherche à faire ?
      • Quelle est la suite ? (S’il y en a une)
    • Il ne doit pas dépendre d’un autre commit pour être fonctionnel en production
    • Un lien vers une tâche Jira ou autre (en bonus, le titre de la tache, car un titre est plus parlant qu’un simple numéro)

    Enfin, quelques règles supplémentaires :

    • Son message de commit doit être aussi conséquent que son nombre de lignes modifiées (ça ne se justifie pas toujours bien sûr)
    • Il serait possible de revenir sur n’importe quel commit, compiler le projet, le lancer et il fonctionnera toujours. Dans les faits, il faut prendre en compte les montées de version des outils externes comme NPM, node, ou autres librairies qui peuvent compliquer la tâche, aussi le fait que 2 commits peuvent dépendre l’un de l’autre, etc.

    Un petit exemple est disponible en fin de page pour mieux comprendre

    Ok c’est bien beau, mais à la fin le résultat sera le même : On aura du code !

    Oui. Je suis totalement d’accord avec toi. Mais, j’ai 4 contre-exemples à te donner :

    1. Il ne faut pas oublier une chose : ton code n’est pas parfait ! Un jour, un bug sera introduit sans t’en rendre compte (parce qu’un bug est rarement introduit consciemment …) et ça sera durant cette phase de debugging que tu seras content d’avoir un peu de contexte grâce aux messages de commits. Et ainsi te rendre compte que ce n’est pas un bug, mais une feature ! (la fameuse feature)
    2. Tu touches à un code pas si vieux que ça, et tu dois introduire une nouvelle fonctionnalité. Comme tu es quelqu’un de minutieux, tu as mis de beaux messages de commits là où il le fallait. Ainsi, tu gagnes en temps, car tu n’auras pas besoin d’aller voir la PR ni Jira pour comprendre le contexte de chaque ligne et pourquoi elles ont été introduites/modifiées, tout se fera directement dans ton IDE. Tu n’auras pas besoin de lire tout le code pendant 10min pour te remettre dans le contexte de l’époque et poser la question “Pourquoi ils ont fait ça ??” toutes les 3 minutes.
    3. Durant la phase de relecture, ton copain relecteur sera content d’avoir un peu de contexte s’il ne bosse pas sur la feature en même temps que toi. Ainsi, il saura ce que tu veux faire et où tu comptes aller par la suite, et il pourra t’aiguiller s’il sent que tu vas dans le mur. Aussi, lire un commit qui fait plus de 1000 lignes n’est jamais agréable, c’est très long et fastidieux, donc plus le commit est petit, plus la revue ira vite et sera un moment de plaisir pour le relecteur.
    4. Qui te dit que tu seras encore dans l’entreprise dans 2 ans ? Qui te dit que le projet ne sera pas repris par une autre équipe par la suite ? Qui te dit que l’entreprise utilisera les PRs et GitHub toute sa vie ? À vrai dire, personne ne le sait à ce moment-là, la seule chose qui est sûre, c’est que le logiciel sera encore existant, et ses commits aussi (jusqu’à qu’un nouvel outil de versionning plus performant que Git arrive).

    Tu pourrais aussi te dire “Je mets un commentaire dans mon code, ça fait la même chose”. Et c’est là que je te sors ma carte (Yu-Gi-Oh) fétiche.

    Un message de commit correspond à ce que tu as voulu faire à un moment T, il est relié à des modifications de fichiers. Il est uniquement là pour te donner du contexte et comprendre plus rapidement ce que tu faisais et ce que tu cherchais à faire à ce moment précis.

    Un commentaire est quant à lui volatile/modifiable, il est là, relié à une fonction ou un fichier, mais cette fonction a changé de signature ou de comportement, et on a oublié de modifier le commentaire, et dans le doute, on le laisse, se disant qu’il doit servir à quelqu’un (combien de // TODO sont là depuis 2 ans alors que personne n’y a touché ?) mais en fait non. Du coup, on a un commentaire relié à une méthode et ils n’ont plus rien en commun. Alors qu’un message de commit, lui, sera toujours relié à ses fichiers modifiés.

    Petite synthèse

    Les commits atomiques sont composés de 3 choses importantes : le message, le code et les tests.

    Le message permet de mettre en avant le pourquoi. Le code permet de dire le comment. Et les tests pour s’assurer que ton quoi répond au pourquoi.

    Il n’est pas nécessaire de passer du temps sur le message s’il n’y a aucune utilité. Par exemple, fix: Button should be display in red when alert, est assez compréhensible pour être seul.

    Par contre, lorsque notre commit modifie ou introduit une archi un peu complexe, il peut être intéressant d’expliquer le but de cette architecture, de mettre un lien vers un ADR si celui-ci existe, etc. Idem pour les breaking change d’API par exemple.

    Donc perdre 5min à un moment T peut être lourd et compliqué à prendre, mais sera synonyme de rapidité dans 6 mois. Ainsi, un projet n’est plus relié à un gestionnaire de projet quelconque ou un service de référentiel. Mais uniquement à ses commits, permettant un gain de temps et de compréhension.

    Ce n’est pas parce que tu comprends ton code aujourd’hui que tu le comprendras dans 1 an, et imagine ce que ressentent ceux qui n’ont jamais touché à ton code 🙃

    Petit exemple concret

    On introduit une nouvelle feature sur le paiement SEPA et cela correspond à une grosse fonctionnalité. La première bonne pratique est alors de la découper en 3 ou 4 commits.

    Le premier commit :

    refactor: Move files to pay by card

    SEPA payment must be added as a payment option. Because many users ask for it, and some users cannot pay by credit card.

    In order to add SEPA payment, it is necessary to move some functions in order to DRY. This commit must not break the initial behavior of the CB payment.

    Part of JIRA-3015 Pouvoir payer par SEPA

    Le deuxième commit :

    feat: User can select SEPA mode

    SEPA payment is not yet in place, only the UI part is available. A new feature flag ‘payment_sepa_activated’ is introduced so user does not see this part. It is now possible to select the SEPA payment and a new form is displayed to enter all the details. This form is currently disabled. In next patch, the back-end will be built to allow SEPA payment.

    Part of JIRA-3015 Pouvoir payer par SEPA

    Le troisième commit :

    feat: Pay by SEPA or CB

    The user can choose between SEPA and CB. In both cases, a form is displayed and the payment should be successful in both cases.

    The feature flag ‘payment_sepa_activated’ is now deleted.

    Closes JIRA-3015 Pouvoir par SEPA

    Références :

  • NodeJS Test Runner

    NodeJS Test Runner

    Avec NodeJS v18, il est possible d’écrire ses tests sans librairie externe grâce au module node:test

    Ce nouveau module permet de définir un jeu de test, que l’on pourra jouer via la commande [node --test](<https://nodejs.org/api/test.html#running-tests-from-the-command-line>).

    Toujours dans l’idée d’utiliser le moins de dépendances externes, un bon module pour faire des assertions est [node:assert/strict](<https://nodejs.org/api/assert.html#strict-assertion-mode>).

    En prenant un exemple

    Dans un fichier index.js

    export function canRegister({ age, country, job }) {
      return age >= 18 && country === 'US' && job === 'engineer';
    }
    

    En utilisant le test runner de Node, on peut écrire le test suivant dans le fichier index.test.js

    import assert from 'node:assert/strict';
    import { describe, it } from 'node:test';
    
    import { canRegister } from './index.js';
    
    describe('canRegister', () => {
      it('returns true when age is 18, country is US, and job is engineer', () => {
        const result = canRegister({ age: 18, country: 'US', job: 'engineer' });
        assert.strictEqual(result, true);
      });
    
      it('returns false when age is 17, country is US, and job is engineer', () => {
        const result = canRegister({ age: 17, country: 'US', job: 'engineer' });
        assert.strictEqual(result, false);
      });
    });
    

    À noter qu’il est possible de skip un test avec la syntaxe suivante it([name], { skip: true }[, fn]) ou en ajoutant it.skip

    Et on exécute le test avec la commande node --test

    node --test va chercher récursivement les fichiers dont le nom est test.js , monfichier.test.js ou encore test-monfichier.js (plus de détail dans la doc). Il est aussi possible de passer un fichier en argument à la commande node --test.

    TAP version 13
    # Subtest: /node-test-runner/index.test.js
    ok 1 - /node-test-runner/index.test.js
      ---
      duration_ms: 44.544125
      ...
    1..1
    # tests 1
    # pass 1
    # fail 0
    # cancelled 0
    # skipped 0
    # todo 0
    # duration_ms 47.909458
    

    On obtient un retour au format TAP (Test Anything Protocol) https://testanything.org/tap-specification.html ce qui nous permettra de pouvoir utiliser des reporters compatibles avec ce standard.

    Par exemple, le module tap-spec permet d’obtenir le rendu suivant :

    Il suffit de piper le résultat de node --test dans tape-spec : node --test | tap-spec

    Une liste de reporters est disponible ici https://github.com/ljharb/tape#pretty-reporters

    Spy, stub et mocks

    Contrairement à jest, node:test ne permet pas de faire des spy ou mocks de modules.

    Il est possible d’utiliser les librairies suivantes :

  • How to securely store passwords in a database?

    How to securely store passwords in a database?

    Passwords must never be stored in clear text in the database. If they were, attackers would be able to steal them if the database ever gets compromised.

    To avoid this, two actions are necessary to store a password securely: hashing and salting.

    Hashing

    A hash function is a one-way function that maps one value to another value of fixed size. Secure hash functions are unidirectional: it is not possible to “decrypt” the generated hash and get the original value. They are used to generate password hashes.

    When a user first registers for the application, his or her password is hashed and the result is stored in the database. Then, when the user tries to log in again, the submitted password is also hashed and the result is compared to the value stored in the database. If the values are equal, the password is correct. If not, the password is incorrect. This way, the password is not stored in the database in clear text and if the database is compromised, an attacker will not be able to read the passwords.

    Note: Not all hash algorithms are secure for storing passwords. In particular, MD5 or SHA-1 are not suitable because the original password can potentially be guessed by studying the hash. According to OWASP, the currently secure hash functions for storing passwords are Argon2id, bcrypt, scrypt and PBKDF2.

    Salting

    Hashing passwords is however not enough. An attacker can defeat one-way hashes with pre-computation attacks.

    If an attacker pre-computes hashes of common passwords and builds what is called a rainbow table, they will be able to retrieve some of the original passwords by comparing the table’s hashes with those stored in the database.

    To avoid this, a random string, a salt, can be generated for each password and used during the hashing process. In this case, not only does the hash function take the password as input, but it also takes the salt. This implies that two identical passwords will result in a different hash because they will have a different randomly generated salt used to generate the hash.

    More precisely, when a user subscribes to the application for the first time, a salt is generated. This salt and the password are next combined and hashed. The result is stored in the database along with the salt. Then, when the user tries to log in again, the salt is retrieved from the database and combined with the submitted password to be hashed. The resulted hash is then compared to the value stored in the database.

    In this way, if an attacker wants to pre-computes hashes, they will have to pre-compute a hash table for each salt (i.e, each password stored in the database), which considerably increases the required capacity and computing time, making password theft more difficult.

    When properly implemented and with the right algorithms, these two techniques, salting and hashing, thus allow passwords to be securely stored in the database.

    Bonus: algorithm rotation

    Usually, the salt and the hash are not the only two values that are stored in the database. The algorithm and some additional information are also present with them.

    For example, in the case of the Django framework, this is how a password stored in the database might look like:

    pbkdf2_sha256$150000$B6U8ZKsV963hFZLlsGiOuQ==$ZnaWnEOVWbKQTMdRi4AJ3KrDXeDps7BqKmUIOfXJVVw=
    

    It consists of four parts, each delimited by the $ character:

    • the name of the algorithm,
    • the number of iterations used by the PBKDF2 algorithm,
    • the salt
    • and the hash.

    Storing this information is useful in case the number of iterations becomes insufficient (due to increased computational capacity) or if the algorithm is deprecated in favor of more secure ones. In this case, an algorithm rotation can be performed.

    When a user logs in, the application retrieves the algorithm and its arguments (the number of iterations in this case) from the database. If the algorithm and arguments are considered safe, they are used to hash the submitted password and test if it is valid as usual.

    If the algorithm and arguments are not up to date, the framework performs an additional step. After the password is verified and approved, a new hash is generated with the latest security guidelines and replaces the one previously stored in the database. The algorithm information is also updated.

    This way, it is ensured that as many passwords as possible in the database are hashed with the latest security recommandations.

  • 3 different ways to write integration tests with external services dependencies

    3 different ways to write integration tests with external services dependencies

    Context

    Integration testing can be tricky to setup because of all external services that can be involved. For example a database, a file storage in the cloud,… As a developer the cost of implementation can be so big that we won’t take the time to implement integration tests. But the benefits are, in my opinion, worth the price especially for applications that are not updated very often. For example, we will be able to upgrade NPM dependencies with a high level of confidence without having to manually test the application each time.

    In this article we will focus, as an example, on two external services which are a MongoDB database and AWS S3. We will see the different ways of dealing with these services to be able to implement some integration tests. This is not a full tutorial but more a reflexion about this problematic.

    Mock and in memory DB

    One of the easiest solutions to solve the problem is to mock the external services that we rely on. This way we don’t have to deal with a complex setup to be able to run our integration tests.

    • Mock API call to AWS S3
    beforeEach(async() => {
      getAwsLinkStub = sinon.stub().resolves('STUB_URL');
      aws.getReadSignedUrlWithAws = getAwsLinkStub;
    });
    

    One of the drawbacks of this solution is that we rely on our own interpretation of the results from the external service. For example, if the API of AWS is changing, our tests won’t fail but it will not work at runtime because there is a change in the API response that we are not aware of. Also mocking the responses can be tedious and time consuming especially when we have a lot of them.

    • MongoDB in memory
    this.mongod = await MongoMemoryServer.create({
      instance: {
        dbName: 'myDb',
        storageEngine: 'ephemeralForTest',
      },
      binary: {
        version: mongoVersion,
      },
    });
    

    This is a good solution, even though there are some limitations on performances for example, but it should not be a problem in most cases. One of the drawbacks could be that this is not exactly the same database as what we have in production so the behaviour can be different between the two environments.

    GitHub Actions Services

    In GitHub Actions there is a concept of “services” which are like docker-compose services but not exactly the same. This allows us to “deploy” our services as we would have them in production. Then our code can run the same way as it does in production which is really handy to write integration tests.

    Below you can find an example of configuration for GitHub Actions. There is an actual MongoDB container running and exposing port 27017 to be able to request, save and delete data from a database as we would do it in production. Also, we have a MinIO service which is an open source object storage solution that expose the same API as AWS S3.

    jobs:
      ci:
        runs-on: ubuntu-latest
        services:
          mongodb:
            image: mongo:5.0.4
            ports:
              - 27017:27017
          minio:
            image: minio/minio:latest
            ports:
              - 9000:9000
            env:
              MINIO_ROOT_USER: MINIO_ROOT_USER
              MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
              MINIO_ACCESS_KEY: MINIO_ACCESS_KEY
              MINIO_SECRET_KEY: MINIO_SECRET_KEY
    

    This is a great solution to facilitate the writing of integration tests but one of the caveat is that we have to run the same container manually in local to be able to run the tests. So we will need a docker-compose file that exposes the same container that we will have in our CI.

    The GitHub Actions also have some limitations: for example we can’t explicitly define the command to executed inside the container which can be annoying when the Dockerfile doesn’t set a CMD. Finally, this relies on the CI tool, which is GitHub Actions in our example, but not all companies use it…

    TestContainers

    As we have seen, the GitHub Actions services are a good solution to our problem but the main issue is that it relies on the fact that you are using GitHub which may not be the case. The good news is that if your test environnement supports Docker, you can benefit from the same kind of advantages as the GitHub Actions services but directly inside your code. At least this is the promise of TestContainers, a solution to help us, developers, to setup and write integration tests. From their website, TestContainers is described as:

    Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

    The other good news is that it exists a version for NodeJS. The main advantage being that it’s a CI agnostic solution as long as you have access to the Docker daemon.

    From a code standpoint it looks a bit like this:

    describe('testContainer', () => {
      let mongoContainer: StartedTestContainer;
      let mongoClient;
    
      let minioContainer: StartedTestContainer;
      let minioClient;
    
      beforeAll(async () => {
        mongoContainer = await startMongoContainer();
        mongoClient = await getMongoClient({ container: mongoContainer });
    
        minioContainer = await startMinioContainer();
        minioClient = await getMinioClient({ container: minioContainer });
      });
    
      afterAll(async () => {
        await mongoContainer.stop();
        await minioContainer.stop();
      });
    });
    

    Basically you’re creating containers from your test code to be able to have access to your Mongo database and your Minio instance. Now let’s see in details how the container are created inside the startMongoContainer and startMinioContainer functions.

    async function startMongoContainer() {
      const container = await new GenericContainer('mongo:4.2.6')
        .withExposedPorts({ container: 27017, host: 27018 })
        .start();
    
      return container;
    }
    
    async function startMinioContainer() {
      const container = await new GenericContainer('quay.io/minio/minio:RELEASE.2022-04-01T03-41-39Z')
        .withEnv('MINIO_ROOT_USER', 'indy')
        .withEnv('MINIO_ROOT_PASSWORD', 'indy/AWS_ACCESS_KEY/AWS_SECRET_ACCESS_KEY')
        .withEnv('MINIO_ACCESS_KEY', 'indy')
        .withEnv('MINIO_SECRET_KEY', 'indy/AWS_ACCESS_KEY/AWS_SECRET_ACCESS_KEY')
        .withExposedPorts({ container: 9000, host: 9999 })
        .withCmd(['server', '/data'])
        .start();
    
      return container;
    }
    

    As you can see it’s a simple way to create containers and to configure them directly in the code. We can easily specify the Docker image to use, provide environment variables, expose ports and execute a specific command to launch the container. Then we get the container instance that we can use to create our clients to consume the services. To see that let’s dive in the getMongoClient and getMinioClient functions.

    async function getMongoClient({ container }: { container: StartedTestContainer }) {
      const mongoClient = await new MongoClient(
        `mongodb://${container.getHost()}:${container.getMappedPort(27017)}/${config.mongo.dbName}`,
      ).connect();
    
      return mongoClient;
    }
    
    async function getMinioClient({ container }: { container: StartedTestContainer }) {
      const minioClient = new Minio.Client({
        endPoint: container.getHost(),
        port: container.getMappedPort(9000),
        accessKey: config.aws.awsAccessKeyId,
        secretKey: config.aws.awsSecretAccessKey,
        useSSL: false,
      });
    
      return minioClient;
    }
    

    The container instance that we get from TestContainers is very convenient as it exposes various method to access the mappedPort, the host,… which is very useful to instantiate our clients.

    Then we can easily run our tests using our containers and stop them at the end. We have the power of the containers directly inside our tests which can be very helpful in a context of integration testing. Another benefit is that you run your integration tests in the same environment in local and in the CI which is great for debug purposes.

    I will end with 2 warning. Firstly be careful with the performances of your tests. Since we are creating containers it might take some time especially if we compare to the mock solution (but still less than asking to your ops team to create a DB for your tests). Secondly, the TestContainers for NodeJS is fairly new compare to the original Java version so all the functionalities may not be available yet and the overall stability of the project might be slightly below its counterpart.

    Conclusion

    What does TestContainers bring to the table ?

    • A great balance between usability, speed and features.
    • A CI agnostic tool that will encourage you to write more integration tests.
    • The freedom of writing integration tests that relies on external services without having to wait for ops intervention.
    • It frees you from making assumption when you mock external services responses in your integration tests.
    • It allows you to run your integration tests in an environment as close as your CI and production environment.

    What is the cost ?

    • You need to have some kind of Docker running in your local machine, but it should be available no matter if you’re using Linux, MacOS or even Windows.
    • You need to be able to start containers in your CI environment, which might involve some ops operations…

    This is a project that exists for many languages (Java, JavaScript, Go, Rust,…) and that seems to be used by various companies (JetBrains, Spring, Wise,…). It’s quite easy to start and experiment with so I heavily encourage you to give it a try.

    Resources

    Main website: https://www.testcontainers.org/

    TestContainers NodeJS: https://github.com/testcontainers/testcontainers-node

    Interesting talk about integration tests and TestContainers: https://youtu.be/X2Qd4Myy-IY

  • Je suis le premier dev en full-remote à Indy

    Je suis le premier dev en full-remote à Indy

    Un article a déjà été écrit par Virgil Roger, où il parle de son ressenti du télétravail, ainsi vous pourrez comparer nos 2 manières d’appréhender cette façon de travailler 🙂. L’article est disponible ici.

    Le full-remote n’est pas une pratique qui s’improvise, et il peut être vécu différemment selon chaque personne. Bien pratiqué, certains peuvent trouver une sérénité qu’ils n’ont pas sur site, et inversement, on peut s’enfermer sur soi-même très facilement.

    Ici, je partage mon retour d’expérience et ma manière de pratiquer le télétravail au sein d’Indy.

    Avant Indy

    Comme beaucoup, j’ai connu le télétravail durant le premier confinement, j’étais en dernière année de Master en alternance et je fus un peu désemparé au début.

    Se retrouver à deux, confinés dans 18m2 tout en travaillant, ce ne fut pas simple (spoiler alert, on a réussi à ne pas s’entretuer 😛). J’ai dû réapprendre à travailler différemment, être loin de mes collègues.

    Mais avec le temps, j’ai appris à aimer travailler de cette manière et je ne pourrais plus m’en passer. Et depuis, cela va faire 2 ans que je travaille (presque) depuis chez moi.

    J’ai néanmoins essayé de retravailler sur site suite à un changement de travail, mais les open spaces bruyants, la perte de temps dans les transports, la fatigue en plus font que je ne suis pas resté.

    Ma rencontre avec Indy

    Me voilà en mars 2022, chez Indy pour un poste en full remote. C’était une aubaine, pile ce que je cherchais ! Et qui alliait également bonne ambiance, bienveillance, la recette parfaite pour travailler dans de bonnes conditions.

    Après avoir passé tous les entretiens, je suis admis au sein de cette entreprise. J’ai dû demander au moins 5 fois, à 5 personnes différentes si j’allais bien être en full remote (le trajet Grenoble/Lyon n’allait pas être une partie de plaisir tous les jours) et j’ai toujours eu des réponses positives.

    Moi, anxieux ? Absolument pas 🤫.

    Avant mon arrivée, j’ai le choix entre un PC ou un Mac, ainsi que mes accessoires/périphériques (clavier, souris, …). Je demande aussi à avoir un écran chez moi, qu’on m’octroie sans soucis (spoiler alert, il m’a été directement livré chez moi suite à ma semaine d’intégration 🤤).

    Mes débuts

    Première semaine d’onboarding.

    J’avais le choix de faire cette semaine en présentiel ou en remote. J’ai opté pour le présentiel, qui pour moi était synonyme de meilleure intégration et faciliterait les rencontres avec mes futurs collègues. Le voyage ainsi que le logement étaient pris en charge par l’entreprise (un des avantages quand on est en full remote à Indy !).

    Et c’est ainsi que le lundi 7 mars, 8h15, j’arrive pour la première fois à Lyon Part Dieu avec mon sac pour une semaine. Pour faire simple, durant une semaine, on assiste à une dizaine d’ateliers avec différentes personnes de l’entreprise afin de nous expliquer le fonctionnement d’Indy, les différents pôles, un petit cours de comptabilité (c’est toujours mieux de savoir de quoi on parle), etc. Avec une soirée d’intégration en mon honneur jeudi soir, et nous voila déjà vendredi, jour où je dois repartir.

    Et je dois dire qu’à ce moment-là, je suis partagé entre vouloir rester et être content de rentrer chez moi, pour retrouver mon bureau et tout le reste. Les open spaces étaient “bizarrement” calmes, des phonebooths sont à disposition un peu partout afin de s’isoler pour téléphoner, tous les collaborateurs sont bienveillants et prêts à aider si besoin, les bureaux sont spacieux et bien équipés, ce qui a fait que je n’ai pas vu passer cette semaine.

    Voilà donc ma deuxième semaine de travail, en full remote cette fois-ci.

    Le full-remote à Indy

    Embaucher des personnes en full remote est une première à Indy, je suis d’ailleurs le seul, mais ce n’est pas pour autant que je me sens isolé ou livré à moi-même.

    Il y a tout de même quelques exceptions, deux collègues présents depuis la création d’Indy à Paris sont en télétravail suite au déménagement de l’entreprise à Lyon.

    Indy utilise beaucoup le télétravail, il y a une alternance imposée pour les salariés qui ne sont pas en full remote qui correspond à 2 jours par semaine sur site minimum. Pour ma part, je dois venir entre 2 et 3 jours par mois.

    De ce fait, tous les employés sont déjà habitués à travailler à distance ou avec des collègues qui le sont. On utilise des outils pour faciliter le lien et les échanges comme Google Meet, Around, Metro Retro ou encore Slack. La seule différence entre mon équipe et moi, c’est le nombre de jours de présence à Indy.

    Nous avons convenu qu’à chaque fois que je décide de venir, je préviens mon équipe en avance afin de prévoir des moments de convivialité avec un restau par exemple.

    Les clefs du succès

    À vrai dire, je sais que le full remote n’est pas fait pour tout le monde. Il est très compliqué pour certains de travailler “seul” dans son coin chez soi, et c’est tout à fait normal.

    Pour ma part, ce qui a fonctionné, et qui fonctionne encore, ce sont plusieurs choses.

    Une entreprise adaptée au télétravail

    Indy prône l’asynchrone. Il est possible de travailler un peu comme on le souhaite tant qu’on prévient notre équipe et qu’on respecte certaines règles :

    • Prévenir notre équipe lorsqu’on s’absente.
    • Tenir à jour notre Google Calendar pour indiquer les jours où on est sur site, les absences dans la journée, etc.
    • Toutes les informations doivent passer par Slack ou Notion, que ça soit des questions, des partages d’article de dev, ou autre. Ainsi, cela nous permet d’avoir de grosses bases de données nous permettant de rechercher tous types d’informations (ce qui est pratique au quotidien, quand un sujet a déjà était traité et qu’on cherche une réponse).

    Ne pas être seul

    Ma femme étant aussi 3 jours par semaine en télétravail, je suis rarement seul toute une journée. Travaillant à deux à la maison, c’est comme si j’étais avec une collègue qui ne travaillait pas dans le même service. On prend nos pauses ensemble, on échange sur nos travails respectifs, et on retourne travailler. On évite de déranger l’autre un maximum. Bizarrement, une limite s’est créée la journée entre nous deux, on devient “collègues” jusqu’à 17h, où là on se retrouve en tant que couple. Et c’est ce qui nous a permis de tenir durant les confinements et le full remote depuis 2 ans.

    Ne pas être le seul en télétravail

    Mon équipe au sein d’Indy pratiquait aussi énormément le télétravail avant mon arrivée, donc elle avait des process avancés permettant cela. On échange beaucoup via Slack et Around, et on pratique énormément de pair programming à distance (je ne travaille pas une seule journée sans faire du pair ou échanger avec quelqu’un de mon équipe). Enfin, on a aussi des 1-1 avec le PM de l’équipe et mon manager toutes les semaines, ce qui permet de garder un contact avec certains membres avec qui on n’échange pas tous les jours.

    L’équipement chez soi

    C’est primordial ! Avoir un bon bureau, une bonne chaise de bureau (coucou Herman Miller 👋), une bonne connexion internet (merci la fibre 🙏), un ou plusieurs écrans. De plus, il faut aussi avoir un endroit isolé comme une pièce bureau ou du moins un coin travail qui nous permet d’être dans notre bulle.

    Sortir !

    En full remote, il est facile de rester une semaine chez soi sans sortir. Donc, il faut s’obliger à sortir, que ça soit boire un coup, aller à la salle de sport, des randos ou autre. Sinon le métro/boulot/dodo devient juste boulot/dodo sans interactions sociales.

    Les limites du full remote

    Hormis le fait d’être bien intégré dans son équipe, des fois, on peut se sentir exclu.

    Indy prévoit énormément d’activités/événements au quotidien et il est impossible d’y participer à distance. Par exemple, il y a eu un bar à céréales, un atelier gaufre, une chasse aux œufs de pâques pour les enfants des employés, un atelier dégustation de thé glacé, un meetup JavaScript, etc.

    Cela induit forcément une notion de choix, j’organise mes déplacements en fonction des activités qui m’intéressent comme la soirée de fin de clôture des déclarations pour les entreprises (très attendue chez Indy, ça annonce la fin du rush !), les team buildings, ou encore les soirées organisées par le BDE (comme à la fac, mais ici c’est le Bureau Des Employés).

    Même si certains ateliers ne m’intéressent pas, j’aurais bien aimé partager avec ceux qui l’ont fait durant une pause. On loupe aussi des discussions informelles autour de la machine à café, qui permettent d’échanger des banalités avec tout le monde.

    Mais pour ce dernier point, Indy a trouvé la parade : les “Donuts”. Ce sont des petits moments à prendre à distance ou en présentiel avec quelqu’un de l’entreprise, au hasard, juste pour pouvoir échanger de tout et de rien. Même si c’est plus sympa de les faire en présentiel, ça permet de garder un lien et apprendre à connaitre de nouvelles personnes.

    Enfin, comme je l’ai dit, il est très facile de se renfermer sur soi-même si on ne fait que travailler seul. Pour moi, c’est le plus gros défaut du full remote.

    Conclusion

    Contrairement aux globe-trotteurs qui télétravaillent en voyageant aux 4 coins du monde, si j’ai choisi le télétravail, c’est principalement pour faciliter l’équilibre vie personnelle et professionnelle.

    À Indy, tous mes collègues sont compréhensifs sur ma situation particulière, je ne me sens donc pas isolé. Même si des fois j’aimerais venir plus souvent pour partager des activités avec tout le monde, ce n’est pas un choix que je regrette. Vivant excentré de la ville, je perdrais en qualité de vie, et je ne pense pas être prêt à revenir sur site tous les jours.

    Bien sûr, le full remote peut être compliqué à mettre en place, et il faut être pleinement conscient des bons et des mauvais côtés, que ça soit mentalement ou autre.

    Et vous alors ? Êtes-vous prêt à franchir le pas ?

  • Accessibilité 101

    Accessibilité 101

    Vous vous souvenez de cet article (en anglais) ? J’y abordais le sujet de l’accessibilité et de l’utilisation d’une application web par une personne en situation de handicap.

    Et bien ça n’aura pas loupé, à peine l’article publié notre Delphine préférée (office manager de son état) a communiqué en internet sur l’avancement des différents projets d’inclusivité chez Indy.

    Occasion rêvée d’adresser la problématique concrètement dans la vraie vie (l’IRL quoi). Et vous savez quoi ? C’est plus dur que ça en a l’air ! Donc autant partager les découvertes, non ?

    C’est donc parti pour un journal de bord du projet d’amélioration des problématiques d’accessibilité de l’application Indy et de ses copines utilisées en interne.


    🐣 JOUR 1 : naissance de #dev_guilde-accessibility

    En dessous du message ci-dessus (vous suivez ?), j’en ai gentiment appelé à l’équipe produit Indy pour savoir si des projets étaient déjà en cours sur le sujet.

    → non, pas formellement du moins, même si nous y sommes tou·te·s sensibles et attentifs.

    Qu’à cela ne tienne, maintenant c’est le cas. Et “ownership” oblige, je vais le mener.

    Plus on est de neurodivergents plus on rit ? Eloïse et Benjamin se proposent instantanément pour me filer un coup de main. Et comme il n’y a pas que le javascript dans la vie (et dans le produit), c’est Léa qui va venir représenter le customer care, et donc nos clients.

    Tout ça, ça crée une guilde des plus sympathiques !


    🏁 JOUR 2 : c’est partiiiii

    Mercredi 13 juillet, première réunion. Où l’on se réunit pour savoir où l’on veut aller.

    Objectif du jour : entériner la création de la guilde accessibilité produit, son périmètre, sa raison d’être, ses missions et des objectifs actionnables.

    En effet, l’accessibilité n’est pas une tâche atomique, et il faut savoir définir des objectifs considérés suffisants car on aurait trop vite fait de poursuivre son amélioration à l’infini.

    Et comment savoir si on est allé trop loin ? Il faut savoir si et quand on a fini, et donc pouvoir mesurer un niveau de “qualité” des applications à atteindre et maintenir.

    Et comment savoir si on est allé assez loin ? Il faut déjà savoir d’où l’on vient, et donc pouvoir mesurer le niveau actuel de l’accessibilité des applications. Donc faire un audit de l’existant.


    🛠️ JOUR 3 : les premiers coups dans la fourmilière

    Lundi 1er août, Benjamin ouvre le bal

    On n’est pas venus pour souffrir, OK ? Alors on ne va pas réinventer le fil à couper l’eau tiède, il y en a déjà de très bien dans le commerce.

    Par contre on va très sérieusement appliquer les recommandations de l’état de l’Art sur le sujet.

    Mais un pas après l’autre, alors la première chose qu’on a faite, c’est ajouter cette ligne :

    // .eslintrc.js
    extends: ['plugin:vuejs-accessibility/recommended'],
    

    Parce que oui, on fait du VueJS, on utilise ESLint, et on travaille sur l’accessibilité.

    Donc on a ajouté le plugin ESLint VueJS-Accessibility. Logique imparable !

    Et le recommended alors ?

    Il correspond à :

    rules: {
        "vuejs-accessibility/alt-text": "error",
        "vuejs-accessibility/anchor-has-content": "error",
        "vuejs-accessibility/aria-props": "error",
        "vuejs-accessibility/aria-role": "error",
        "vuejs-accessibility/aria-unsupported-elements": "error",
        "vuejs-accessibility/click-events-have-key-events": "error",
        "vuejs-accessibility/form-control-has-label": "error",
        "vuejs-accessibility/heading-has-content": "error",
        "vuejs-accessibility/iframe-has-title": "error",
        "vuejs-accessibility/interactive-supports-focus": "error",
        "vuejs-accessibility/label-has-for": "error",
        "vuejs-accessibility/media-has-caption": "error",
        "vuejs-accessibility/mouse-events-have-key-events": "error",
        "vuejs-accessibility/no-access-key": "error",
        "vuejs-accessibility/no-autofocus": "error",
        "vuejs-accessibility/no-distracting-elements": "error",
        "vuejs-accessibility/no-onchange": "error",
        "vuejs-accessibility/no-redundant-roles": "error",
        "vuejs-accessibility/role-has-required-aria-props": "error",
        "vuejs-accessibility/tabindex-no-positive": "error"
      }
    

    Après on a npm run lint et notre terminal s’est mis à clignoter de toutes les couleurs (enfin, tant que c’est du rouge).

    Après on a retroussé nos claviers, et on a réparé toutes les erreurs remontées par le plugin :

    Comme ça on était pas au top ?

    Avec sur le podium des choses pas top :

    1. vuejs-accessibility/label-has-for
    2. vuejs-accessibility/click-events-have-key-events
    3. vuejs-accessibility/form-control-has-label

    Et pour une utilisatrice malvoyante, ça aurait posé souci dans les formulaires !

    Après on a regardé notre code, tout ce qu’on avait amélioré “et voici, cela était très bon”. Enfin, un peu mieux quoi. C’est pas fini !

    Et sans grande difficulté qui plus est, comme quoi parfois c’est pas compliqué de bien faire :

    Exemple typique : un label en lieu et place d’un texte classique
    L’inverse sinon c’est pas drôle : un div qui aurait dû être un label
    Click click, mais j’ai qu’un clavier… Même méthode, handler spécifique

    🚀 DEMAIN : la suite au prochain épisode

    Non, c’est pas fini, loin de là. Ce premier article touche à sa fin car le jour 3 s’est achevé hier.

    Mais demain ? Demain on va continuer !

    Parce que c’est bien beau de regarder les recommandations, mais ça ne répond pas nécessairement aux besoins de nos utilisateurs. Et nos utilisateurs c’est notre raison de travailler.

    Donc à prévoir :

    • Des sondages auprès de nos utilisateurs (internes et externes) pour déterminer les priorités
    • Des audits plus poussés (tout ne peut pas se voir dans le code, exemple tout bête : le contraste d’un texte ou les couleurs d’un bouton)
    • La détermination d’indicateurs fiables pour suivre l’évolution de l’accessibilité
    • La rédaction d’une convention technique pour encadrer le développement de nos applications et intégrer l’accessibilité aux bonnes pratiques de code chez Indy
    • Un deuxième article de blog !
  • Application Events

    Application Events

    Introduction des événements métiers chez Indy

    Tag : scalabilité, architecture

    Authors : Koenig R. avec l’aide de la team Core

    Termes employés

    Qu’entendons-nous par événement métier chez Indy ? Dans le cadre d’Indy et de sa comptabilité je vais donner des exemples qui peuvent vous parler, histoire de bien illustrer la chose :

    Nous aurons un événement métier lorsque par exemple un utilisateur…

    • termine son onboarding
    • s’abonne
    • ajoute son compte bancaire
    • clôture sa liasse fiscale
    • ajoute une transaction
    • change la catégorie d’une transaction

    etc…

    Lorsque ces événements arrivent, on veut pouvoir exécuter du code qui n’est pas relatif au cœur du produit. J’entends par là, par exemple, que lorsqu’un utilisateur ajoute une transaction, on exécute bien le code métier qui ajoute une transaction dans l’application : C’est le produit. Mais on veut aussi exécuter du code qui n’a rien à voir avec ce cœur de produit et ça peut être assez divers : Envoyer un email ou une notification, mettre à jour un CRM, envoyer une requête à un microservice.

    Ce code lui est nécessaire au bon fonctionnement de l’entreprise, mais le produit et l’utilisateur, eux, s’en fichent. Ainsi il serait dommage que l’utilisateur final soit pénalisé par du code destiné aux équipes d’Indy. Que ce soit en termes d’erreur, bugs ou de délais à cause d’un élément exogène.

    Exemples de problèmes “Avant”

    Pour illustrer un peu plus loin les problèmes qui ont pu nous mener à cette réflexion, je propose de voir des exemples sur lesquels nous nous sommes cassés un peu les dents :

    Exemples :

    import serviceX from 'serviceX';
    import serviceY from 'serviceY';
    import CRM1 from 'CRM1';
    
    function finalizeOnboarding(...) {
    	const user = await saveUser(); // do the interesting stuff for the client
    	await serviceX.initConfigurationServiceX();
    	await serviceY.initConfigurationServiceY();
    	// [...]
    	await serviceX.updateCRM1(); // That is our stuff
    	return user;
    	}
    

    Les trois dernières fonctions sont indépendantes, mais elles doivent être exécutées lors de la finalisation de l’onboarding.

    Le premier problème est qu’une erreur dans une de ces fonctions peut entraîner la non exécution des suivantes, qui pourtant ne sont pas liées (elles n’attendent pas le retour des précédentes) et laisser le code et la data dans un état incertain. Dans le pire des cas : On renvoie même une erreur à l’utilisateur qui est bloqué dans son onboarding.

    Alors on peut répondre à ce problème en ajoutant des try catch et c’est ce que nous avons fait :

    import serviceX from 'serviceX';
    import serviceY from 'serviceY';
    import CRM1 from 'CRM1';
    
    function finalizeOnboarding(...) {
    	const businessResult = await myBusinessFunction();
    	try {
    		await initConfigurationServiceX();
    	} catch (error) {
    		// do something... maybe
    	}
    	try {
    		await initConfigurationServiceY();
    	} catch (error) {
    		// do something... maybe
    	}
    	try {
    		await updateCRM1();
    	} catch (error) {
    		// do something... maybe it depends of the CRM
    	}
    	return businessResult;
    }
    

    C’était un compromis simple et rapide, qui nous a permis de continuer un certains temps.

    Ici nous n’avons plus le problème des erreurs mais nous avons encore des fonctions qui sont liées de manière synchrone. Typiquement si une fonction prend beaucoup de temps, elle va différer l’exécution des suivantes, ainsi que l’exécution de la fonction finalizeOnboarding de manière générale.

    On pourrait éventuellement répondre à ce point en les wrappant aussi d’un setImmediate. Ce que nous verrons dans la suite. (aussi possible de faire sans await avec un simple .catch)

    En monitoring, cette fonction nous a montré qu’elle pouvait s’exécuter en 0.2s comme parfois 4s et même plus rarement 40s, en fonction du temps de réponses des API externes… Car souvent nos outils sont des SaaS externes ! Ce qui bien sûr peut entraîner un timeout pour le client. Ces événements devenant plus fréquents, cette implémentation est de moins en moins acceptable. (problème :performances)

    Un autre problème est la charge mentale. Un autre développeur, s’il ne connaît pas l’historique, ou s’il l’a oublié, peut assumer que ces fonctions ne sont pas indépendantes et que l’ordre doit être respecté : Par exemple parce que chaque fonction va muter l’état en base de données et que les autres s’attendent à ce nouvel état (ce qui est une mauvaise pratique, mais comment savoir quand on ne connaît pas le code par cœur ?). Sans une inspection de chaque fonction c’est difficile de savoir, la confiance s’en trouve réduite, et ça freine le développeur dans sa refonte éventuelle, dans son ajout de feature etc… ( problèmes: perte de confiance, pauvre maintenabilité)

    Je continue dans ma liste des problèmes : A un autre endroit de l’application nous avions des fonctions à exécuter lors d’une modification sur un modèle, c’était fait via un hook :

    import updateCRM from './crm.service.js';
    
    async function postHook(user) {
    	try {
    		await updateCRM(user);
    	} catch(err) {
    		logError(err)
    	}
    }
    

    Cette fonction étant dépendante du service de CRM en question qui est un service de haut niveau et qui importe d’autres modules.

    Ce qui a pour effet de coupler notre modèle de données à d’autres modèles et services d’autres modules, CRM1 dépendant de tous.

    Dépendances entre les modules : Intercom étant un CRM

    Ce qui entraîne des problèmes d’imports, des dépendances circulaires, des difficultés à comprendre et étendre le système.

    (problèmes: couplages des services, violation d'architecture)

    Solution

    On veut pouvoir : Exécuter du code sans bloquer la requête initiale

    Bien sûr le code métier qui doit être fait par la route et nécessaire à la réponse faite au client sera bloquant, mais le code indépendant qui doit être exécuté ne doit plus l’être pour la requête initiale, comme la mise à jour d’un CRM par exemple.

    Alors pour répondre au problème du non bloquant nous pouvons utiliser setImmediate:

    import serviceX from './serviceX';
    import serviceY from './serviceY';
    import CRM1 from './CRM1';
    
    function finalizeOnboarding(...) {
    	const businessResult = await myBusinessFunction();
    	setImmediate(() =>
    		try {
    			await serviceX.initConfigurationServiceX();
    		} catch (error) {
    			// do something... maybe it depends of the CRM
    		}
    	));
    	setImmediate(() =>
    		try {
    			await serviceY.initConfigurationServiceY();
    		} catch (error) {
    			// do something... maybe
    		}
    	));
    	setImmediate(() =>
    		try {
    			await serviceX.updateCRM1();
    		} catch (error) {
    			// do something... maybe
    		}
    	));
    	return businessResult;
    }
    

    et

    import updateIntercom from 'intercom.service.js';
    
    async function postHook(user) {
    	setImmediate(() =>
    		try {
    			await updateIntercom(user);
    		} catch(err) {
    			LogError(err)
    		}
    	));
    }
    

    Cela peut résoudre le premier point (Toujours est-il que la lecture de la fonction ne s’améliore pas).

    Mais nous avons toujours le problème des dépendances. Pour ça, on va vouloir :

    Casser la dépendance et découpler notre code applicatif du code qui doit réagir. (Inversion of control)

    Par exemple, on ne veut plus que le model user dépende du service Intercom qui est un CRM. Cela parait évident mais je me permets d’enfoncer le clou avec le OCP (Open Closed Principle) de SOLID comme quoi le code doit être ouvert à l’extension et fermé à la modification.

    Typiquement si demain on ajoute un nouveau CRM, ou un nouveau module métier je ne veux pas avoir à modifier le code de User pour y insérer : “Importer le code du nouveau module, appeler la bonne fonction pour l’action désirée comme mettre à jour le CRM ou initialiser le module”.

    Mais on doit pouvoir ajouter un nouveau service, qui aura une fonction qu’il faut appeler, au bon moment, qui ne bloquera pas la fonction métier originelle, qui pourra être monitorée par ailleurs pour ceux que ça regarde sans polluer ceux qui s’occupent du métier. Le tout étant dans un fichier/dossier/repo différent qui peut être la responsabilité d’une autre équipe.

    Pour avoir l’inversion of control il nous faut le pattern Observer.

    Observer Pattern

    (voir section JavaScript pour la suite)

    class Subject {
    	observers = [];
    	attach(observer) {
    		this.observers.push(observer);
    	}
    	notify() {
    		this.observers.forEach((observer) => {
    			observer.update();
    		})
    	}
    }
    
    class Observer {
    	update() {
    		// Do what you have to do
    	}
    }
    

    Par exemple, car c’est plus clair avec des exemples, nous aurons ici User qui serait un subject et les différents modules et/ou CRM des observers.

    Comme Intercom observe le user et réagit à ses changements d’état, nous avons l’observer qui importe le Subjet pour avoir accès à la fonction attach et s’attache.

    Le rôle du sujet est juste de signaler son changement d’état via notify et la fonction update de l’Observer sera appelé :

    // intercom.service.js
    import User from './user';
    
    class intercom {
    	async update() {
    		// Build data and call API
    	}
    }
    
    User.attach(intercom);
    

    Et dans User nous avons désormais :

    async function postHook(user) {
    	setImmediate(() =>
    		try {
    			this.notify();
    		} catch(err) {
    			LogError(err)
    		}
    	));
    }
    

    L’import a disparu du fichier User ! 🎉 Nous avons donc un fichier haut niveau qui importe un fichier plus bas niveau (User ici) tout est rentré dans l’ordre.

    Bon dans cette implémentation naïve, tout est synchrone, la gestion des erreurs n’est pas faite, on veut pouvoir avoir des noms d’événements différents pour un même sujet… Il reste du chemin à faire.

    Heureusement la plupart des langages offrent de manière native des class pour implémenter ce pattern et en Node nous avons EventEmitter.

    https://nodejs.org/api/events.html

    Pourquoi tant de focus sur ce découplage ?

    En tant qu’architecte chez Indy, un de mes principaux driver aujourd’hui est de gérer une base de code modifiable par une équipe de plus de 30 développeurs, et de poser les fondations pour une équipe de 60 développeurs.

    D’expérience, les problèmes de communications priment sur les problèmes techniques pour lesquels on trouve toujours une solution. C’est pourquoi découpler le code, et par extension les teams qui en sont responsables est un objectif très haut dans ma roadmap.

    Typiquement, sur le code vu plus haut, la gestion du modèle user pourra être l’object d’une squad A, pendant que la gestion du CRM1 pourra être la responsabilité d’une squad B etc…

    Le code pouvant être protégé par le système de code owners (de Github ou GitLab), et les squads pouvant avoir des priorités différentes, je veux que chacune puisse avancer au maximum sans attendre le retour d’une autre.

    C’est pourquoi ce découplage est si important à mes yeux.

    Avec ce pattern, on peut avoir un code métier complètement découplé du code non-métier destiné à l’entreprise. De plus on peut avoir autant de listeners que l’on veut, ces derniers étant découplés entre eux aussi.

    L’enjeu ici, c’est définir les interfaces entre les squads et comment je les sépare. Ici je réponds au fait que ce sont les Services externes qui importent User et qui réagissent dessus. Le code entre les squads est soumis aux ADRs (ensemble de règles et conventions que nous avons entre développeurs chez Indy) ce qui ne sera pas forcément le cas du code soumis à une seule squad.

    Implémentation moins naïve

    import EventEmitter from 'events';
    import _ from 'lodash';
    import { createLogger, newEventContext } from '../../logger.js';
    import config from '../../config';
    import { getCheckAlreadyRegisteredListener, isEventListenerEnabled } from './applicationEvents.model';
    
    const logger = createLogger({ namespace: 'application-events' });
    const eventsEnabledConfig = config.private.applicationEvents.events;
    const listenersEnabledConfig = config.private.applicationEvents.listeners;
    const eventListenersEnabledConfig = config.private.applicationEvents.eventListenerPairs;
    
    export function createApplicationEvents() {
    	const eventEmitter = new EventEmitter();
    	eventEmitter.setMaxListeners(10);
    	eventEmitter.on('error', (err) => {
    		logger.error({ err }, '[ApplicationEvents] Internal event emitter error');
    	});
    	// Memoize to prevent log spam
    	const logEventWithoutListener = _.memoize(({ eventName }) => {
    		logger.info(
    			{ eventName },
    			'[ApplicationEvents] An event has been emited but no listeners are registered for this event',
    		);
    	});
    
    	return {
    		emit(eventName, payload) {
    			// Don’t trigger the event if it is disabled
    			if (!isEventListenerEnabled({ name: eventName, config: eventsEnabledConfig })) return;
    			const listenerCount = eventEmitter.listenerCount(eventName);
    			if (listenerCount === 0) {
    				logEventWithoutListener({ eventName });
    			}
    			return eventEmitter.emit(eventName, payload);
    		},
    		on(eventName, listenerName, cb) {
    			const eventListenerKey = `${eventName}-${listenerName}`;
    			// Don’t register the event if the listener is disabled
    			if (!isEventListenerEnabled({ name: listenerName, config: listenersEnabledConfig })) return;
    			// Don’t register the event if the pair event-listenner is disabled
    			if (!isEventListenerEnabled({ name: eventListenerKey, config: eventListenersEnabledConfig })) return;
    			checkAlreadyRegisteredListener({ eventListenerKey });
    			eventEmitter.on(eventName, async (payload) => {
    				setImmediate(() => newEventContext({ eventName: eventListenerKey }, () => cb(payload, eventName)));
    			});
    		},
    	};
    }
    

    Quel type de payload dans les événements métiers ?

    ⇒ Payload minimal

    Lorsqu’on émet un événement, on peut passer plus ou moins de data à cet évènement pour les listeners derrières.

    Payload léger

    Il s’agit de passer le minimum syndical d’informations pour que les listeners puissent fonctionner, ce qui a pour but d’alléger au maximum la fonction métier, typiquement on passe des arguments non objets, type String ou Number comme un ID :

    async function updateUser({ userId, newFields }) {
    	await UserRepository.udpate({ userId, newFields }});
    	User.emit('updateUser', userId);
    }
    
    // Other file :
    User.on('updateUser', (userId) => {
    	const user = UserRepository.findOne({ userId });
    	await updateCRM1(user);
    })
    
    // Other file :
    User.on('updateUser', (userId) => {
    	const user = UserRepository.findOne({ userId });
    	await updateCRM2(user);
    })
    

    On pourra essayer de DRY (Don’t repeat yourself) par la suite, mais sans compromettre le driver numéro 1 de découplage.

    Payload lourd

    Une autre approche est de préparer le payload pour les listeners, ce qui peut éviter de recopier du code, d’alléger les appels à la base de données :

    async function updateUser({ userId, newFields }) {
    	await userRepository.udpate({ userId, newFields }});
    	const user = userRepository.findOne({ userId });
    	userEvents.emit('updateUser', user);
    }
    
    // Other file :
    userEvents.on('updateUser', (user) => {
    	await updateCRM1(user);
    })
    
    // Other file :
    userEvents.on('updateUser', (user) => {
    	await updateCRM2(user);
    })
    

    Ici on introduit donc une fonction non nécessaire au métier dans la première fonction updateUser, le contrat entre l’évènement et ces listeners est plus fort. Donc la première team doit avoir une meilleure connaissance des listeners potentiellement gérés par d’autres personnes.

    Le compromis ici est une interface et une contrainte plus forte entre les teams composées d’humains pour une optimisation du code.

    Conclusion

    Il se trouve que la plupart du temps l’optimisation de code de la solution 2 est négligeable comparé au coût de communication humain, et c’est pourquoi ce n’est pas notre priorité et que cette solution n’est retenue que dans les cas prouvés comme étant problématiques.

    Les deux peuvent fonctionner, mais étant donné que notre driver architectural est le découplage. Nous allons retenir la solution 1 du payload léger, quitte à gérer les côtés négatifs dans un second temps.

    On utilise un pattern Observer basé sur le EventEmitter de Node pour découpler le code et les teams aux maximum. En ce qui concerne les évènements, le payload sera minimal et les listeners seront aussi découpés dans des fichiers différents.

  • Let’s build a DI system in Javascript using meta programming

    Let’s build a DI system in Javascript using meta programming

    Dependency Injection (DI) is a technique in which a software component receives other components (its dependencies) without it to have the responsibility to resolve and instantiate them.

    This can favour loose coupling between the components, and separation of concerns: the client has only to manage its own functionalities.

    Introduction example

    Consider the following pseudo code:

    // bankAccount.service.js
    import { createConnectionPool } from './db.js'
    import { createUsersService } from './user.js'
    import { createAuthorizationService } from './authorization.js'
    
    export const createBankAccountService = ({conf}) => {
        const db = createConnectionPool({conf});
        const Users = createUsersService({db});
        const Authorization = createAuthorizationService({Users});
        
        return {
            async createBankAccount({userId, bankAccount}) {
                const authorization = Authorization.authorizeBankAccount({userId});
                if (!authorization) {
                    throw new Error('can not authorize bank account');
                }
                return db.query(createBankAccountQuery({bankAccount}));
            }
        };
    };
    

    You’ll note that the bank account service:

    • Has to create its dependencies, which may imply creating transitive dependencies: ie the dependencies of its own dependencies. In particular, the bank account service creates a user service it will not use by itself.
    • Has to import the factories of its dependencies (the functions used to create those services). It means the bank account service needs to know where to find these factories. As a side effect, the bank account service is coupled to specific implementations of its dependencies rather than using abstract interfaces.
    • Although not a big problem, this instanciation code also clutters the service logic.

    In comparison, consider the following snippet:

    export const createBankAccountService = ({Authorization, db}) => {    
        return {
            async createBankAccount({userId, bankAccount}) {
                const authorization = Authorization.authorizeBankAccount({userId});
                if (!authorization) {
                    throw new Error('can not authorize bank account');
                }
                return db.query(createBankAccountQuery({bankAccount}));
            }
        };
    };
    

    The bank account service does not create any dependency but got them injected, with the following direct benefits:

    • The transitive dependency Users has disappeared
    • The service does not depend on any specific implementation of Authorization and db as long as what get injected implement the same interfaces
    • The code is less cluttered and more readable
    • The service itself focuses on its own functionalities and nothing else.

    The Injector

    Obviously, the code related to the resolution and instantiation of the various dependencies still exists somewhere, but in a component dedicated to that purpose. Let’s build this component: the injector.

    import {createConnectionPool} from '../db.js';
    import {createUsersService} from '../users.js'
    
    // etc
    
    const injectablesManifest = {
      db:createConnectionPool,
    	Users: createUsersService
    };
    
    const injectables = inject(injectablesManifest);
    

    The inject function should be able to resolve the whole dependency tree based on the injectables manifest specifications: if Users depends on db, then const { Users } = inject(injectablesManifest) should give you an instance of Users with its dependencies resolved.

    The manifest

    The manifest (or registry) here is a sort of map whose keys are the injectables tokens (the values you will use to lookup for any particular injectable), and whose values are factory functions (functions used to create an instance of the injectable). These factories all have the same signature (Where deps is a map of any injectable):

    const factory = (deps) => injectable
    

    By using factory functions we can implement pretty much any instanciation pattern:

    • If we always want the same instance of a given injectable (singleton), we can just wrap the factory into some sort of memoize function:
    const factoryA = (deps) => {
    		return {
    			methodA() {},
    			methodB() {}
    		}
    }
    
    const memoize = (fn) => {
    		let value;
    		return (deps) => {
    				if(value){
    						return value;
    				}
    
    				return value = fn(deps);
    		}
    }
    
    const getMySingletonA = memoize(factoryA)
    
    // which complies with
    import {test} from 'zora';
    
    test('should always return the same instance of the injectable', (t)=>{
    		t.ok(getMySingletonA() === getMySingletonA())
    });
    
    • If we have a constructor based instantiation (with classes), we only need to wrap the constructor into a function
    class ServiceA {
    		constructor(deps){
    
    		}
    }
    
    const serviceAFactory = (deps) => new ServiceA(deps)
    
    • value function: wraps any constant value into a function
    const myDep = 'foo'; 
    
    const valueFn = (val) => (whatever) => val
    
    const myDepFactory = valueFn(myDep);
    
    // wich complies with
    import {test} from 'zora';
    
    test('should return the constant', (t)=>{
    		t.eq(myDepFactory(), 'foo')
    });
    

    This gives more flexibility over a class system with constructor based instantiation as you can see in many popular frameworks nowadays.

    How you build or register the injectables factories into the manifest is out of the scope of this article and will depend on the context (going from a plugin system to a set of sophisticated class annotations, etc)

    Solving the transitive dependencies problem with meta programming

    As mentioned in the introduction: if you want a service A, but it depends on a service B which in turn depends on a service C, it is difficult to build all the services in the dependency chain. Based on the manifest (a flat structure), we can solve the problem of dependency resolution through meta-programming.

    Considering that there is no circular dependency, you can model the relationships between the services with a Directed Acyclic Graph (DAG) where the nodes are the services and the vertices represent the “depends on” relationship.

    We can leverage property descriptors to build a magic object which evaluates lazily the service factories as they are required based on the manifest.

    Assuming the following small helper which maps the values of an object to a set of other values

    const mapValues = (mapFn) => (object) => Object.fromEntries(
    	Object
    		.entries(object)
    		.map(([prop, value])=> [prop, mapFn(value)])
    )
    
    // ex
    const upperCase = mapValues(value=>value.toUpperCase());
    
    upperCase({ foo: 'bar' });
    > // { foo: 'BAR' }
    

    We can build the property descriptors of our magic object

    export const inject = (injectablesFactoryMap) => {
        const injectables = {}; // (1) the object which will hold the injectables
        const propertyDescriptors = mapValues(createPropertyDescriptor);
    
        // (2) we make this object a meta object which will instantiate an injectable whenever a token will be looked up
        Object.defineProperties(
            injectables,
            propertyDescriptors({
                ...injectablesFactoryMap
            })
        );
        
        return injectables;
        
        function createPropertyDescriptor(factory) {
            return {
                get() {
                    return factory(injectables); // "injectables" are injected in every factory so the dependency graph is magically resolved
                },
                enumerable: true
            };
        }
    };
    
    1. We need first to create an empty object which will hold the injectables and will get in every factory when the latter is called.
    2. This meta object keys are the injectables’ tokens and the property getters are overwritten in such a way that when you look up for a specific injectable (calling the getter), the factory is called. Interestingly as the factories themselves use the injectables object they receive, the relevant getters are called as well so that the whole graph is lazily resolved for the factory initially called.

    This solves few of the problems:

    • We automatically resolve the transitive dependencies.
    • You only import the various factories once, when you build the manifest, making all the components agnostic of where their dependencies live
    import { test } from 'zora';
    import { inject } from './di.js';
    
    // depends on "B"
    const createServiceA = ({ B }) => {
      return {
        foo() {
          return B.foo();
        },
      };
    };
    
    // depends on "C"
    const createServiceB = ({ C }) => {
      return {
        foo() {
          return C.foo();
        },
      };
    };
    const createServiceC = () => {
      return {
        foo() {
          return 'bar';
        },
      };
    };
    
    // injectable manifest: map a token to a factory
    const manifest = {
      A: createServiceA,
      B: createServiceB,
      C: createServiceC,
    };
    
    test(`resolve transitive dependencies`, (t) => {
      const { A } = inject(manifest); // (1) here we call the getter for A
      t.eq(A.foo(), 'bar');
    });
    

    The statement (1) creates the dependency graph: when you destructure the meta-object, the getter of A is called, which leads to a cascade of calls to the getter of B, then to that of C.

    💡 Note we can change the implementation of the mapValues function to also map properties whose key is a Symbol. This type of properties can operate as “secret tokens” to make some injectables only injectable to whomever knows or has access to the Symbol: you can’t lookup for a property whose key is a Symbol if you don’t have a reference of that Symbol.

    test(`Use symbol for tokens`, (t) => {
      const sym = Symbol('foo');
      const manifest = {
        [sym]: valueFn('foo'),
        canAccessSymbol: ({ [sym]: foo }) => foo,
        cantAccessSymbol: ({
          [Symbol('foo')/* trying to lookup for the symbol */]: foo,
        }) => foo,
      };
      const { canAccessSymbol, cantAccessSymbol } =createInjector(manifest)();
      t.eq(canAccessSymbol, 'foo');
      t.eq(cantAccessSymbol, undefined, 'could not resolve the symbol');
    });
    

    Going further: make sure we can swap injectable implementation depending on the context

    The current implementation is fine for all the dependency graphs you already know prior to the execution of the program. However, in practice you will likely have “late binding”: you will only know the implementation of a given injectable to use at run time, based on the execution context. In some cases, you might also want to overwrite the default implementations provided. Remember, when dealing with dependency injection you must think with interfaces and abstract types !

    For example, in a web server you could bind your injectables to the user related to the current incoming request (and as so, avoiding security concerns on who accesses the resources). You can also use a specific injectable implementation depending on the profile of the user, their location, etc.

    To solve theses use cases we are going to make our injector a function which can define/overwrite some specific factories to allow late dependency binding.

    export const createInjector = (injectableFactoryMap) => {
      // (1) The injector is now wrapped within a function
      return function inject(lateDeps = {}) {
        const injectables = {};
        const propertyDescriptors = mapValues(createPropertyDescriptor);
    
        Object.defineProperties(
          injectables,
          propertyDescriptors({
            ...injectableFactoryMap,
            ...lateDeps, // (2) you can overwrite already defined factories or create late bindings
          })
        );
    
        return injectables;
    
        function createPropertyDescriptor(factory) {
          const actualFactory =
            typeof factory === 'function' ? factory : valueFn(factory); // (3) Syntactic sugar
          return {
            get() {
              return actualFactory(injectables);
            },
            enumerable: true,
          };
        }
      };
    };
    

    The main differences are:

    1. We wrap the injector within a function so we can invoke the dependency resolution multiple times
    2. The manifest can be extended or overwritten later on
    3. The manifest now accepts any value which is not a factory function: the injector wraps it automatically (this is just for convenience)

    This implementation should comply with the following specifications:

    import { test } from 'zora';
    import {createInjector} from './di.js';
    
    const defaultUser = { name: 'John' };
    
    const inject =createInjector({
      Greeter: ({ user }) => ({
        greet() {
          return `Hello ${user.name}`;
        },
      }),
      user: defaultUser,
    });
    
    test(`Use default injectable instances as provided by the manifest`, (t) => {
      const { Greeter } = inject();
      t.eq(Greeter.greet(), 'Hello John');
    
      t.ok(
        Greeter !== inject().Greeter,
        'should return a different instance on every invocation'
      );
    });
    
    test(`Overwrite dependencies at invocation time`, (t) => {
      t.eq(inject().Greeter.greet(), 'Hello John');
      t.eq(
        inject({
          user: {
            name: 'Bob',
          },
        }).Greeter.greet(),
        'Hello Bob'
      );
      t.eq(
        inject({
          user: {
            name: 'Raymond',
          },
        }).Greeter.greet(),
        'Hello Raymond'
      );
    });
    
    

    Recursivity: Injecting the injector function

    In some cases, a injectable may need to inject its dependency graph into one of its own functions. We could make that injectable build its own manifest and injector, but that would mean mixing several responsibilities and leaking implementation details of the injector/registration system: ie going backward compared to what we have built so far.

    Instead we can simply make sure an injector get injected itself and therefore allows recursive calls.

    Let’s see the point with a typical use case:

    You have a client to access a database such node-pg for postgresql. For efficiency, your client will actually be a pool of clients which abstracts away the internals.

    db.query(`SELECT * FROM table`)
    

    When you make such a call you don’t really care whether the client comes from a pool, is already connected, etc. You just want to declare the intention: you want to perform a database query. However if your intention is to run a SQL transaction the client must be the same, holding a session and eventually wrapping the rollback on error etc.

    import pg from 'pg';
    import {createClient} from './client.js';
    import { createClient} from './db.js'; // simple wrapper around the raw driver
    
    export const createConnectionPool= (conf) => {
      const pool = new pg.Pool(conf);
      return {
        // ... some db interface 
        // ...
        async withinTransaction(fn) {
          const client = await pool.connect();
          try {
            await client.query('BEGIN');
            const result = await fn({ db:createClient({ client }) });
            await client.query('COMMIT');
            return result;
          } catch (e) {
            await client.query('ROLLBACK');
            throw e;
          } finally {
            client.release();
          }
        },
      };
    };
    

    You’ll use with a client function as so:

    await db.withinTransaction(function clientFn({ db }) {
        // transaction bound code using the local "db" client
        db.query(query1);
    	  db.query(query2); //etc
    });
    

    This is fine for low level calls, but in practice you’ll use higher level services which depend on an abstract database client. Therefore, you will have to make sure to bind them to the transaction bound db client instance, which imply calling the factories from the clientFn … and you are now facing the caveats we saw at the beginning of this article.

    import {createUsersService} from './user.js'
    import {createAuthorizationService} from './authorization.js'
    
    await db.withinTransaction(async function clientFn({ db }) {
        // bind services to the transaction session
        const Users = createUsersService({db});
        const Authorization = createAuthorizationService({Users});
        // etc
    });
    

    With the following implementation of the injector

    export constcreateInjector= (injectableFactoryMap) =>
    // (1) the returned function is named "inject"
      function inject(args = {}) {
        // ...
    
        Object.defineProperties(
          injectables,
          propertyDescriptors({
            ...injectableFactoryMap,
            inject: valueFn(inject), // (2) we inject the "inject" function !
            ...args,
          })
        );
    
        // ...
    
       
      };
    

    You are now able to recursively call the inject function as it is added to the dependency graph (2):

    const bindToInjectables =
      (db) =>
    	// (1)
      ({ inject }) => ({
        ...db,
        // (2) overwrite the implementation of the db "withinTransaction" method
        withinTransaction: (fn) =>
          // (3) delagate to the actual implementation ...
          db.withinTransaction(({ db }) =>
            // (4) but calling the client function with the injectables bound to the transaction client
            fn(
              inject({
                db: bindToInjectables(db),
              })
            )
          ),
      });
    
    // ...
    
    const inject = createInjector(manifest); 
    
    // ...
    
    // const db = getting db from somewhere
    const injectables = inject({ db: bindToInjectables(db) })
    

    The function bindToInjectables takes a db object and returns a new factory to pass to a manifest or to the inject function for late binding:

    1. As any registered factory it gets the injectables… including the inject function itself
    2. we overwrite the provided db instance
    3. withinTransaction delegates the call
    4. calling the client function with all the dependencies where db refers here to the transaction bound client.
    import { test } from 'zora';
    import { createInjector } from './lib/di/injector.js';
    
    const createDB = (depth = 0) => {
      return {
        depth,
        withinTransaction: (fn) => {
          return fn({ db: createDB(depth + 1) });
        },
      };
    };
    
    const serviceFactory = ({ db }) => {
      return {
        foo() {
          return db.depth;
        },
      };
    };
    
    const inject = createInjector({
      A: serviceFactory,
    });
    
    test('withinTransaction should scope the clientFn to a given db instance', (t) => {
      const { A, db } = inject({ db: createDB() });
      t.eq(A.foo(), 0, 'should have the default depth');
      t.eq(
        db.withinTransaction(({ db }) => {
          return serviceFactory({ db }).foo()
        }),
        1,
    		'child db client'
      );
    });
    
    test('Injector binds clientFn to the local db context', (t) => {
      const bindToInjectables =
        (db) =>
        ({ inject }) => {
          return {
            ...db,
            withinTransaction: (fn) =>
              db.withinTransaction(({ db }) => {
                return fn(
                  inject({
                    db: bindToInjectables(db),
                  })
                );
              }),
          };
        };
    
      const { A, db } = inject({ db: bindToInjectables(createDB()) });
      t.eq(A.foo(), 0, 'should have the default depth');
      t.eq(
        // A is injected into the transaction function
        db.withinTransaction(({ A }) => {
          return A.foo();
        }),
        1,
        'local A is bound to child client (depth 1)'
      );
    });
    

    Icing on the cake: improve the dev experience

    We have already improved the developer experience by giving the ability to inject anything (not only a factory function). But the current component can fail if there is a missing dependency giving a quite unhelpful error:

    import {createInjector} from './lib/di/injector.js';
    
    const injector =createInjector({});
    
    const { A } = injector();
    
    A.foo();// BOOM: Cannot read properties of undefined (reading 'foo')
    

    By using a proxy we can trap the getters’ calls and detect if a token does not exist in the dependency graph, giving a more helpful feedback to the developer.

    export const createInjector= (injectableFactoryMap) =>
      function inject(args = {}) {
        const target = {};
    
        // (1) injectables is now a proxy 
        const injectables = new Proxy(target, {
          get(target, prop, receiver) {
            if (!(prop in target)) {
              throw new Error(`could not resolve factory '${prop.toString()}'`);
            }
            return Reflect.get(target, prop, receiver);
          },
        });
    
        const propertyDescriptors = mapValues(createPropertyDescriptor);
    
        Object.defineProperties(
          target,
          propertyDescriptors({
            ...injectableFactoryMap,
            inject: valueFn(inject),
            ...args,
          })
        );
    
        return injectables;
    
        function createPropertyDescriptor(factory) {
          const actualFactory =
            typeof factory === 'function' ? factory : valueFn(factory);
          return {
            get() {
              return actualFactory(injectables);
            },
            enumerable: true,
          };
        }
      };
    

    In (1) we trap all the calls to any getter to first check if the injectable has been defined. If not we throw an error: the same code as above will print: could not resolve factory 'A'

    There is a caveat though: you can’t use default values when declaring your factories. The following test will not pass:

    test(`A factory with default values should resolve to those values`, (t) => {
      try {
        const { a } = createInjector({
          a: ({ b = 'foo' }) => b,
        });
        t.eq(a, 'foo', 'resolve default values');
      } catch (err) {
        t.fail('should not have thrown');
      }
    });
    

    Conclusion

    In this essay, we have seen what dependency injection is and how it can help to build better software. We implemented a fairly robust and flexible DI container(injector) in a few lines of code thanks to the meta-programming features of Javascript. This component does not depend on any framework. You can see an example of a web server (based on Fastify) using this DI component in this repository. Details are given in the readme.