-
What makes Fastify highly performant part 1: JSON serialization
At Indy, we use Fastify as the framework for our backend servers. Fastify is a Node.js framework for developing web servers. According to its benchmark, it’s more performant than most of the common Node.js frameworks. Many people may argue that the benchmark is not relevant and it depends on the usage. That might be true but it’s not our topic today. I’m going to look into some of the interesting design decisions of the framework that makes it faster. There will be a series of articles and this is the first one that focuses on JSON serialization.
Since JSON is by far the most dominant format used in HTTP request/response data exchange. JSON serialization can have a big impact on the performance of a web server.
The native function:
JSON.stringify
A native function exists in Javascript to do JSON serialization:
JSON.stringify
. But due to the nature of Javascript that all variables have dynamic types, it has to do many checks at the runtime to determine how to serialize each key of the JSON input according to its type, thus the performance can be hardly optimized.How did Fastify do
So here comes the interesting part: how Fastify achieves a significantly better performance of JSON serialization than
JSON.stringify
? The key is to use JSON Schema. We know that a part of the poor performance ofJSON.stringify
is due to the dynamic type checks at runtime, so what if we know beforehand the structure of the JSON object (its keys, the type of each key) that we need to serialize? We could easily build a function based on the JSON Schema, and save all the time wasted on checking the type of each key at runtime. That’s exactly what fast-json-stringify does, the internal library that Fastify uses to do JSON serialization. It takes a JSON Schema object as argument and generates a custom stringify function for future use.Dive into the code
Let’s do a simple experimentation to see how stringify functions are generated in fast-json-stringify.
Imagine that we want to serialize the following JSON structure:
{ "fisrtName": "Foo" "lastName": "Bar", "age": 20 }
First of all, we need a JSON Schema that defines the structure, for example:
const schema = { "title": "Person schema", "type": "object", "properties": { "firstName": { "type": "string" }, "lastName": { "type": "string" }, "age": { "type": "integer", "minimum": 0 } }, "required": ["firstName", "lastName"] }
Next step we should build a function based on the JSON Schema that serializes our JSON object to a string. As we got the JSON schema, we could simply loop over each property and transform the value to string according to its type. For example,
firstName
is of typestring
, we need to add double quotes to its value and the result will be"firstName": "${obj.firstName}"
.function build(schema) { return function(obj) { let json = '{'; Object.keys(schema.properties).forEach((key, i, array) => { json += `"${key}":`; const type = schema.properties[key].type; switch (type) { case 'string': json += `"${obj[key]}"`; break; case 'integer': json += '' + obj[key]; break; default: throw new Error(`${type} unsupported`); } if (i < array.length - 1) { json += ','; } }); json += '}'; return json; } } // serialization const customStringify = build(schema); customStringify(obj);
If we do a benchmark test, it’s over 30% faster than
JSON.stringify
.JSON.stringify x 4,586,512 ops/sec ±0.42% (99 runs sampled) custom-json-stringify x 6,236,179 ops/sec ±0.51% (90 runs sampled) Fastest is custom-json-stringify
The above function saves some type checking at runtime, but it’s still not optimal. We have to iterate over each property of the JSON schema and find the right way to serialize its value whenever we call the stringify function. The JSON schema will not change once set, so can we do the iteration earlier before calling the stringify function? Actually we need a “generated” function instead of a closure that has access to the JSON schema. There is a way in Javascript to write code dynamically and executes it as a function:
new Function()
. Here is the improved version:function build(schema) { let code = ` 'use strict' let json = '{' ` Object.keys(schema.properties).forEach((key, i, array) => { code += ` json += '"${key}":' ` const type = schema.properties[key].type switch (type) { case 'string': code += ` json += '"' + obj.${key} + '"' ` break; case 'integer': code += ` json += '' + obj.${key} ` break; default: throw new Error(`${type} unsupported`) } if (i < array.length - 1) { code += 'json += \\',\\'' } }) code += ` json += '}' return json ` return new Function('obj', code) }
The generated function looks like this:
function stringify(obj) { let json = '{' json += '"firstName":' json += '"' + obj.firstName + '"' json += ',' json += '"lastName":' json += '"' + obj.lastName + '"' json += ',' json += '"age":' json += '' + obj.age json += '}' return json }
In the end, it’s just some string concatenation when we call the stringify function. The benchmark shows that the performance is significantly boosted.
JSON.stringify x 4,653,809 ops/sec ±0.28% (97 runs sampled) fast-json-stringify x 1,032,584,240 ops/sec ±0.23% (100 runs sampled) Fastest is fast-json-stringify
Conclusion
The above code is from the first commit of fast-json-stringify. Of course the actual library is more complex than that. For example it needs to validate the input to make sure that it has the correct structure, it should deal with circular JSON reference, it also has to sanitize the JSON Schema for security concerns as we can see from above that it uses
new Function()
internally, and maybe we should never pass an unknown or user-generated schema to it.The idea behind it is a really simple but yet great and clever one which essentially moves the runtime analysis to “compile time” to achieve performance boost.
-
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
ettotalKeysExamined
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 là.
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
etexecutionStats.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 indexwinningPlan.inputStage.stage
qui vautIXSCAN
(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 indexFETCH
quand on récupère des documentsGROUP
quand on groupe des documentsSHARD_MERGE
pour fusionner les résultats des shardsSHARDING_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 desIXSCAN
.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 …
- Créer un index B-tree composé efficace https://tech.indy.fr/2022/04/07/creer-un-index-b-tree-compose-efficace/
- Let’s .explain() MongoDB Performance | Twitch Live Coding https://www.youtube.com/watch?v=HAtnkHw_fJ8
- Mongo University https://university.mongodb.com/courses/M201/about
-
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 ?
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 :
- 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)
- 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.
- 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.
- 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
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 esttest.js
,monfichier.test.js
ou encoretest-monfichier.js
(plus de détail dans la doc). Il est aussi possible de passer un fichier en argument à la commandenode --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
danstape-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 :
- spying et stubbing avec sinon
- snapshots avec snapshot-assertion
-
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
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 aCMD
. 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
andstartMinioContainer
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
andgetMinioClient
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
, thehost
,… 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
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
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 :
vuejs-accessibility/label-has-for
vuejs-accessibility/click-events-have-key-events
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
Introduction des événements métiers chez Indy
Tag :
scalabilité
,architecture
Authors :
Koenig R.
avec l’aide de la teamCore
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 sansawait
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.
(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 unsubject
et les différents modules et/ou CRM desobservers
.Comme Intercom observe le user et réagit à ses changements d’état, nous avons l’
observer
qui importe leSubjet
pour avoir accès à la fonctionattach
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 avonsEventEmitter
.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.