Analyse des performances de l’application Notion · PerfPerfPerf


Les performances Web ne vont pas vous sauver dans cette crise.

Mais si vous créez un produit logiciel, il est probable que vous ne soyez pas relativement affecté. Et dans ce cas, avoir une application rapide est plus important que jamais. l’Internet ralentit en raison de la demande accrue, et les gens tiennent leurs téléphones plus longtemps – donc si votre application est lente, vos utilisateurs volonté en être affecté.

Et application lente veux dire pire affaire.

Aujourd’hui, regardons Notion – une application Web de prise de notes avancée. Notion est un excellent produit, mais une chose dont les clients se plaignent est son heure de démarrage:

Notion expédié des améliorations de vitesse énormes récemment, mais il reste encore beaucoup d’espace à améliorer. Rétro-concevons-le et voyons quoi d’autre peut être optimisé!

Notion est une application Web React intégrée à un shell natif. Cela signifie que son «temps de démarrage» est en grande partie le «temps de chargement du composant WebPart».

Pour être concret. Sur le bureau, l’application Notion est une application Web intégrée à Electron. Sur mobile, à ma connaissance, l’application Notion exécute à la fois les parties React Native (probablement responsables de certaines expériences mobiles) et les composants WebPart (probablement responsables de l’interface utilisateur d’édition globale).

En raison (apparemment) de l’épinglage du certificat HTTPS dans l’application Android Notion, je n’ai pas pu vérifier si l’application mobile exécute les mêmes offres que l’application de bureau. Mais même si les offres groupées sont différentes, les problèmes qu’elles rencontrent sont probablement similaires.

Pour comprendre le chargement du composant WebPart, créons une page Notion publique:

et exécutez un audit WebPageTest dessus. (Cela fonctionne car les pages publiques exécutent le même code que l’ensemble de l’application.)

WebPageTest est un outil de test de performance avancé.

WebPageTest renvoie de nombreuses informations utiles, mais la partie la plus intéressante est la cascade de chargement:

Woah, c’est beaucoup d’informations. Que se passe-t-il ici?

Voici ce qui se passe:

  1. Vous ouvrez la page. La page charge quelques feuilles de style – et deux bundles JavaScript, vendor et app.
  2. Une fois les deux bundles chargés, ils commencent à s’exécuter – et passent une seconde entière à le faire.
  3. Une fois l’application initialisée, elle commence à envoyer des demandes d’API pour les données de page, à charger des analyses …
  4. et exécuter plus de code …
  5. jusqu’à ce que, à 5,6 secondes, la première peinture arrive:

    … mais c’est juste un fileur.

  6. À 6,2 secondes, le contenu de la page est réellement rendu.

    Il faut quelques secondes de plus pour terminer le chargement de toute l’image du héros.

6,2 secondes pour un ordinateur de bureau, c’est beaucoup. Cependant, avec un téléphone de niveau moyen comme le Nexus 5, cette fois augmente à 12,6 secondes. Voici ce que ça fait:

Voyons comment nous pouvons l’améliorer.

Quand on parle de «vitesse de chargement», on entend généralement performances de mise en réseau. Du point de vue du réseau, Notion se porte bien: ils utilisent HTTP / 2, ils compressent des fichiers et ils utilisent Cloudflare comme CDN mandataire.

Cependant, une autre partie de la «vitesse de chargement» dont les gens parlent moins est performances de traitement. Toutes les ressources téléchargées ont un coût de traitement: les archives gzip doivent être décompressées; les images doivent être décodées; JS doit être exécuté.

Contrairement aux performances de mise en réseau, les performances de traitement ne s’améliorent pas avec de meilleurs réseaux – elles ne sont aussi rapides que le processeur de l’utilisateur. Et les processeurs des utilisateurs dans les téléphones – en particulier les téléphones Android – sont mauvais:

Pour Notion, les performances de traitement sont encore plus importantes. Il est facile d’éviter les coûts de mise en réseau en mettant en cache les ressources réseau dans l’application. Mais les frais de traitement sont payés A chaque fois l’application démarre – ce qui signifie qu’un utilisateur de téléphone peut voir un écran de démarrage de 10 secondes plusieurs fois par jour.

Sur notre test Nexus 5, exécution vendor et app les paquets prennent 4,9 secondes. Pendant tout ce temps, la page – et l’application – restent non interactives et vides:

Ce qui se passe là-bas? WebPageTest n’enregistre pas les traces JS, mais si nous allons sur DevTools et exécutons un audit local, nous verrons ceci:

Premièrement les vendor le bundle est en cours de compilation (pour 0.4s). Deuxièmement, le app le bundle est en cours de compilation (pour 1.2s). Troisièmement, les deux bundles commencent à s’exécuter – et passent 3,3 secondes à le faire.

Alors, comment pouvons-nous réduire ce temps?

Jetons un œil à la phase d’exécution du bundle. Quelles sont toutes ces fonctions?

Il s’avère que c’est l’initialisation du bundle:

  • Fonctions avec des noms à quatre caractères, comme bkwR ou Cycz, sont des modules d’application.

    |
    | Lorsque webpack crée un bundle, il enveloppe chaque module avec une fonction – et lui attribue un ID. Cet ID devient le nom de la fonction. Dans le bundle, cela ressemble à ceci:
    |
    | Avant:
    |
    | js
    | import formatDate from './formatDate.js';
    |
    | // ...
    |

    |
    | Après:
    |
    | js
    | fOpr: function(module, __webpack_exports__, __webpack_require__) {
    | "use strict";
    |
    | __webpack_require__.r(__webpack_exports__);
    |
    | var _formatDate__WEBPACK_IMPORTED_MODULE_0__ =
    | __webpack_require__("xN6P");
    |
    | // ...
    | },
    |

  • Et le s une fonction est en fait __webpack_require__.

    __webpack_require__ est la fonction interne du webpack qu’il utilise pour exiger des modules. Chaque fois que vous écrivez un import, webpack le transforme en __webpack_require__().

L’initialisation du bundle prend tellement de temps car elle exécute tous les modules. Chaque module peut prendre quelques millisecondes à exécuter, mais avec les modules 1100+ de Notion, cela s’ajoute.

La seule façon de résoudre ce problème est d’exécuter moins de modules à l’avance.

Utiliser le fractionnement de code

La meilleure façon d’améliorer le temps de démarrage consiste à séparer par code certaines fonctionnalités qui ne sont pas nécessaires immédiatement. Dans webpack, cela se fait en utilisant import():


<Button onClick={openModal} />


<Button
  onClick={() => import('./Modal').then(m => m.openModal())}
/>

Le fractionnement de code est la meilleure première optimisation que vous puissiez faire. Cela apporte d’énormes avantages en termes de performances: après avoir effectué le fractionnement du code, a déclaré Tinder une diminution de 60% du temps de chargement complet; et notre client, Framer, a réussi à réduire de 40 à 45% le temps d’inactivité du processeur.

Il y a plusieurs approches courantes pour le fractionnement de code:

  • fractionnement du bundle par pages,
  • diviser le code en dessous de la ligne de flottaison,
  • et séparer le contenu conditionnel (toutes les interfaces utilisateur dynamiques qui ne sont pas visibles immédiatement)

L’application Notion n’a pas de pages, et le partage de code sous le pli est difficile car les pages sont très dynamiques. Cela signifie que la seule approche utile est le partage conditionnel de code. Les parties suivantes peuvent être de bons candidats pour cela:

  • Réglages, Importation, Poubelle – toutes les interfaces utilisateur rarement utilisées
  • Barre latérale, Partager, Options de page – toutes les interfaces utilisateur fréquemment utilisées mais qui ne sont pas nécessaires immédiatement au démarrage de l’application. Ceux-ci pourraient être préchargés et initialisés juste après le démarrage de l’application
  • Blocs de pages lourds. Certains blocs de pages sont assez lourds – par exemple le Code Le bloc prend en charge la mise en évidence de 68 langues, qui regroupe plus de 120 Ko de définitions de langue réduites de Prism.js. Notion semble déjà fractionner le code de certains blocs (par exemple, Équation mathématique), mais il pourrait être judicieux de l’étendre à d’autres également.

Vérifiez que la concaténation du module fonctionne

Dans webpack, le concaténation de module est responsable de la fusion de plusieurs petits modules ES en un seul grand. Cela réduit la surcharge de traitement du module et rend la suppression du code inutilisé plus efficace.

Pour confirmer que la concaténation de module fonctionne:

Fait amusant. N’oubliez pas que toutes les importations sont transformées en __webpack_require__ une fonction?

Eh bien, que se passe-t-il lorsque la même fonction est appelée 1100 fois pendant l’initialisation? À droite, cela devient un chemin chaud prenant 26,8% du temps total:

(s est le nom minifié de __webpack_require__.)

Malheureusement, outre la concaténation de plusieurs modules, il n’y a pas grand chose à y optimiser.

Essaie le lazy option de Babel plugin-transform-modules-commonjs

Remarque: cette suggestion repose sur la désactivation de la concaténation des modules. Pour cette raison, il est incompatible avec le précédent.

@babel/plugin-transform-modules-commonjs est un plugin Babel officiel qui transforme les importations ES en CommonJS require()s:


import formatDate from './formatDate.js';
export function getToday() {
  return formatDate(new Date());
}


const formatDate = require('./formatDate.js');
exports.getToday = function getToday() {
  return formatDate(new Date());
};

Et avec son lazy option activée, elle intègre également requires directement là où ils sont utilisés:


exports.getToday = function getToday() {
  return require('./formatDate.js')(new Date());
};

Grâce à cette transformation, si le getToday() la fonction n’est jamais appelée, ./formatDate.js n’est jamais importé! Et nous ne payons pas le coût d’importation.

Il y a cependant quelques inconvénients:

  • Basculement de la base de code existante vers lazy pourrait être délicat. Certains modules peuvent s’appuyer sur les effets secondaires d’autres modules, que nous retardons ici. Aussi, la documentation du plugin avertir que le lazy option brise les dépendances cycliques
  • Le passage aux modules CommonJS désactive concaténation de module. Cela signifie que la surcharge de traitement du module sera plus élevée

Ces inconvénients rendent cette option plus risquée par rapport aux autres – mais si elle fonctionne correctement, ses avantages pourraient largement dépasser ses coûts.

Combien de modules pourraient être différés comme ça? Chrome DevTools nous permet de trouver une réponse simple. Ouvrez n’importe quelle page JS (par ex. celui de Notion), accédez à DevTools, appuyez sur Ctrl + Maj + P (Windows) ou ⌘⇧P (macOS), saisissez «démarrer la couverture» et appuyez sur Entrée. La page se rechargera et vous verrez combien de code a été exécuté dans le rendu initial.

Dans Notion, 39% du groupe de fournisseurs et 61% du groupe d’applications ne sont pas utilisés après le rendu de la page:

Examinons à nouveau la trace d’initialisation du bundle:

Une partie importante ici est «Compile Script» (parties 1 et 2), qui prend 1,6s au total. Qu’est-ce que c’est?

V8 (moteur JS de Chrome), tout comme les autres moteurs JS, utilise une compilation juste à temps pour exécuter JavaScript. Cela signifie que tout le code qu’il exécute doit d’abord être compilé en code machine.

Et plus il y a de code, plus il faut de temps pour le compiler. En 2018, en moyenne, le V8 dépensait 10-30% du temps d’exécution total dans l’analyse et la compilation de JavaScript. Dans notre cas, l’étape de compilation prend 1,6 seconde sur un total de 4,9 secondes, soit 32%.

La seule façon de réduire le temps de compilation est de diffuser moins de JavaScript.

Une autre excellente approche serait de précompiler JavaScript en code machine – et d’éviter complètement l’analyse des coûts en exécutant JavaScript compilé. cependant, ce n’est actuellement pas possible.

Utiliser le fractionnement de code

Oui, encore une fois. En fractionnant le code des fonctionnalités inutilisées, vous réduisez non seulement le temps d’initialisation du bundle, mais également le temps de compilation. Moins il y a de code JS, plus il se compile rapidement.

Consultez la section précédente sur le fractionnement de code où nous avons parlé des approches courantes de fractionnement de code et comment Notion pourrait en bénéficier.

Supprimer le code fournisseur inutilisé

Comme nous l’avons vu, lorsqu’une page se charge, près de 40% des vendor reste inutilisé:

Une partie de ce code sera nécessaire plus tard lorsque l’utilisateur fera quelque chose dans l’application. Mais combien?

Notion ne publie pas de cartes sources, ce qui signifie que nous ne pouvons pas utiliser source-map-explorer pour explorer le bundle et voir les plus gros modules. Cependant, nous pourrions toujours deviner les bibliothèques à partir de leur source minifiée – en regardant les chaînes non minifiées et en les recherchant dans GitHub.

Sur la base de mon analyse, voici les 10 plus grands modules du vendor paquet:

  1. fingerprintjs2 → 29 Ko
  2. moment-timezone → 32 Ko
  3. chroma-js → 35 Ko
  4. tinymce → 48 Ko
  5. diff-match-patch → 54 Ko
  6. amplitude-js → 55 Ko
  7. lodash → 71 Ko
  8. libphonenumber-js/metadata.min.json → 81 Ko
  9. react-dom → 111 Ko
  10. moment avec tous les paramètres régionaux → 227 Ko

Cette liste ne comprend pas les bibliothèques composées de plusieurs petits fichiers.
Par exemple, le bundle comprend également core-js, lequel occupe 154 Ko mais se compose de plus de 300 petits fichiers.

De tous ces modules, le plus important et ceux qui sont faciles à optimiser sont moment, lodash et libphonenumber-js.

moment, une bibliothèque JS pour manipuler les dates, regroupe plus de 160 Ko de fichiers de localisation minifiés. Étant donné que Notion n’est disponible qu’en anglais, cela n’est guère nécessaire.

Que peut-on faire ici?

  • Tout d’abord, laissez tomber inutilisé moment paramètres régionaux utilisant moment-locales-webpack-plugin.
  • Deuxièmement, envisagez de passer de moment à date-fns. Contrairement à moment, lorsque vous utilisez date-fns, vous importez uniquement les méthodes de manipulation de date spécifiques dont vous avez besoin. Donc, si vous utilisez uniquement addDays(date, 5), vous ne finirez pas par regrouper l’analyseur de date.

    You-Dont-Meed-Momentjs: Liste des fonctions que vous pouvez utiliser pour remplacer moment.js

lodash, un ensemble d’utilitaires de manipulation de données, regroupe plus de 300 fonctions pour travailler avec des données. C’est trop – d’après ce que j’ai vu, les applications utilisent généralement 5 à 30 de ces méthodes au maximum.

Le moyen le plus simple de supprimer les méthodes inutilisées consiste à utiliser babel-plugin-lodash. Mis à part cela, lodash-webpack-plugin prend en charge la suppression de certaines fonctionnalités lodash (comme la mise en cache ou la prise en charge Unicode) de l’Intérieur ces méthodes.

libphonenumber-js, une bibliothèque pour analyser et formater les numéros de téléphone, les bundles un fichier JSON de 81 Ko avec les métadonnées du numéro de téléphone.

Je ne vois aucun endroit où les numéros de téléphone sont utilisés, il est donc probable que cette bibliothèque prenne en charge un cas d’utilisation unique quelque part au fond de l’interface utilisateur de Notion. Ce serait formidable de le remplacer par une autre bibliothèque ou un code personnalisé – et de supprimer toute la dépendance.

Supprimer les polyfills

Une autre dépendance importante présente dans le vendor le paquet est polyfills de le core-js bibliothèque:

Il y a deux problèmes avec cela.

C’est inutile. Nous testons Notion dans Chrome 81, qui prend en charge toutes les fonctionnalités JS modernes. Cependant, le bundle comprend toujours des polyfills pour Symbol, Object.assignet de nombreuses autres méthodes. Ces polyfills doivent être téléchargés, analysés et compilés – le tout pour rien.

Cela affecte également les applications Notion. Dans l’application de bureau (et probablement aussi dans l’application mobile), la version du moteur JS est moderne, fixe et bien connue. Il n’y a aucune chance Symbol ou Object.assign y serait absent – cependant, l’application télécharge toujours les mêmes polyfills.

Que devons-nous faire à la place? Expédiez des polyfills pour les navigateurs plus anciens, mais ignorez-les pour les navigateurs modernes. Voir «Comment charger des polyfills uniquement en cas de besoin» pour quelques façons de procéder.

Il est regroupé plusieurs fois. le vendor le pack comprend le core-js copyright 3 fois. À chaque fois, le copyright est identique, mais est expédié dans un module différent et avec différentes dépendances:

Ça signifie core-js lui-même est groupé 3 fois. Mais pourquoi? Creusons plus profondément.

Sous une forme non réduite, le module avec le copyright ressemble à ça:

var core = require('./_core');
var global = require('./_global');
var SHARED = '__core-js_shared__';
var store = global[SHARED] || (global[SHARED] = {});

(module.exports = function (key, value) {
  return store[key] || (store[key] = value !== undefined ? value : {});
})('versions', []).push({
  version: core.version,
  mode: require('./_library') ? 'pure' : 'global',
  copyright: '© 2019 Denis Pushkarev (zloirock.ru)',
});

Ici, nous avons deux bits qui décrivent la bibliothèque:

  • var core = require('./_core'); core.version pour la version bibliothèque, et
  • require('./_library') ? 'pure' : 'global' pour le mode bibliothèque

Dans le code minifié, cela correspond à:

  • var r=n();r.version pour la version bibliothèque, et
  • n()?"pure":"global" pour le mode

Si nous suivons ces ID de module dans le bundle, nous verrons ceci:

Woah. Cela signifie que ces trois versions de core-js sont:

  • 2.6.9 dans le global mode,
  • 2.6.11 dans le global mode, et
  • 2.6.11 dans le pure mode

Il s’avère que, c’est un problème courant. Cela se produit lorsque votre application dépend d’une version de core-js, mais certaines de vos dépendances en dépendent.

Comment le résoudre? Courir yarn why core-js pour comprendre ce qui dépend des deux versions restantes. Et supprimez / reconfigurez les dépendances qui regroupent core-js versions; ou dédupliquez les trois versions en une à l’aide du resolve.alias:

Jetons un autre regard sur comment se charge Notion:

Quelques éléments retiennent l’attention ici:

  • Les demandes d’API ne commencent pas à se produire tant que l’ensemble n’est pas entièrement téléchargé
  • La peinture contentieuse (c’est-à-dire lorsque le contenu réel devient visible) ne se produit pas tant que la plupart des demandes d’API ne sont pas terminées. (Plus précisément, il attend la demande 35, loadPageChunk)
  • Les demandes d’API sont mélangées avec des tiers: Intercom, Segment et Amplitude

Voici comment optimiser cela.

Différer les tiers

Dans la vraie vie, nous ne pouvons pas simplement supprimer tous les tiers Notion. Mais nous pouvons les reporter – comme ceci:


async function installThirdParties() {
  if (state.isIntercomEnabled) intercom.installIntercom();

  if (state.isSegmentEnabled) segment.installSegment();

  if (state.isAmplitudeEnabled) amplitude.installAmplitude();
}


async function installThirdParties() {
  setTimeout(() => {
    if (state.isIntercomEnabled) intercom.installIntercom();

    if (state.isSegmentEnabled) segment.installSegment();

    if (state.isAmplitudeEnabled) amplitude.installAmplitude();
  }, 15 * 1000);
}

Cela garantirait qu’ils ne sont pas chargés jusqu’à ce que l’application soit complètement initialisée.

setTimeout contre requestIdleCallback vs événements. setTimeout n’est pas la meilleure approche (coder en dur le délai d’attente est hacky), mais c’est assez bon.

La meilleure approche serait d’écouter une sorte d’événement dans l’application «page entièrement rendue», mais je ne sais pas si Notion en a un.

requestIdleCallback peut sembler être l’outil parfait pour le travail, mais ce n’est pas le cas. Dans mes tests dans Chromium, il se déclenche trop tôt – seulement 60 ms après que le thread principal est devenu inactif.

Chargement des analyses sur l’interaction. Une autre excellente approche pour différer l’analyse est d’éviter de la charger jusqu’à la première interaction du premier utilisateur – le premier clic ou tapotement.

Cependant, notez que cela rend l’analytique invisible pour les tests synthétiques (comme Lighthouse ou PageSpeed ​​Insights). Pour mesurer le coût réel de JavaScript pour les utilisateurs, vous devez installer une bibliothèque de surveillance des utilisateurs réels – par exemple LUX de SpeedCurve ou Aperçu du navigateur de Cloudflare.

Précharger les données de l’API

Dans Notion, avant le rendu de la page, le navigateur doit envoyer 9 requêtes à l’API:

Chaque demande peut prendre de 70 ms (en cas de une connexion par câble) à 300-500 ms (en cas de une connexion 4G et un téléphone de niveau moyen). Et certaines de ces demandes semblent séquentielles – elles ne sont pas envoyées avant la fin des demandes précédentes.

Cela signifie que les requêtes API lentes peuvent facilement entraîner une latence importante. Dans mes tests, supprimer cette latence accélère le rendu de la page de 10%.

Mais comment supprimer la latence dans la vraie application?

Insérez les données de la page dans le code HTML. La meilleure approche serait de calculer les données API côté serveur – et de les inclure directement dans la réponse HTML. Par exemple, comme ceci:

app.avoir('*', (req, res) => {
  
  
  
  res.écrire("
    
"
); const stateJson = attendre getStateAsJsonObject(); res.écrire(" "); })

Assurez-vous de:
a) encoder les données en JSON pour de meilleures performances;
b) échapper des données avec jsesc (json: true, isScriptContext: true) pour éviter les attaques XSS.

Notez également que les bundles ont le defer attribut. Nous en avons besoin pour exécuter des bundles après le __INITIAL_STATE__ scénario.

Avec cette approche, l’application n’aura pas besoin d’attendre les réponses de l’API. Il récupérera l’état initial de la window et commencez le rendu immédiatement.

Travailleurs Cloudflare. Notion utilise Cloudflare en tant que fournisseur CDN. Si les pages HTML de Notion sont statiques (par exemple, elles sont servies par AWS S3), Travailleurs Cloudflare pourrait être utile à la place.

Avec les travailleurs Cloudflare, vous pouvez intercepter la page, récupérer des données dynamiques directement à partir du travailleur CDN et ajouter les données à la fin de la page. Voir:

Insérez un script pour extraire les données de la page. Une autre approche consiste à écrire un script en ligne qui demandera les données à l’avance:

<div id="notion-app">div>
<script>
  fetchAnalytics();
  fetchExperiments();
  fetchPageChunk();

  function fetchAnalytics() {
    window._analyticsSettings = fetch(
      '/api/v3/getUserAnalyticsSettings',
      {
        method: 'POST',
        body: '{"platform": "web"}',
      }
    ).then((response) => response.json());
  }

  async function fetchExperiments() {  }

  async function fetchPageChunk() {  }
script>
<script src="/vendors-2b1c131a5683b1af62d9.js">script>
<script src="/app-c87b8b1572429828e701.js">script>

L’application peut alors simplement await sur window._analyticsSettings (et promesses similaires). Si les données sont chargées à ce moment-là, l’application les obtiendra presque immédiatement.

Le plus important: le script devrait commencer à envoyer des requêtes dès que possible. Cela augmentera les chances que les réponses arrivent – et soient traitées – pendant que les bundles sont toujours en cours de chargement et que le thread principal est inactif.

Les optimisations ci-dessus devraient apporter le plus d’avantages. Mais il y a quelques autres choses qui méritent une attention particulière.

Cache-Control sur les réponses

Notion ne définit pas la Cache-Control en-tête sur ses réponses. Cela ne désactive pas la mise en cache, mais signifie chaque navigateur mettrait en cache la réponse différemment. Cela pourrait entraîner des bogues inattendus côté client.

Pour éviter cela, définissez le Cache-Control en-tête sur les actifs du bundle et les réponses API:

Chargement du squelette

L’application Notion a un spinner qui s’affiche pendant le chargement de la page:

Le spinner aide à signifier que «quelque chose se charge». Cependant, parfois, le spinner fait aggrave la performance perçue. Les utilisateurs voient le spinner et Faites attention au fait que quelque chose se charge – ce qui rend l’application plus lente.

Ce qui pourrait être fait à la place est montrant un squelette de l’interface utilisateur:

Il est suffisamment petit pour être intégré et prépare l’utilisateur à l’interface utilisateur réelle.

Alors, combien de temps toutes ces optimisations peuvent-elles nous faire gagner?

Au total, sur la base de ce calcul (très approximatif), nous économisons 3,9 sur 12,6 secondes – une amélioration de 30% juste en ajustant certaines configurations et en différant certains chargements. Et c’est après de grandes améliorations de vitesse que l’équipe Notion a déjà fait.

Il s’avère que presque toutes les applications ont des fruits bas qui pourraient être implémentés simplement en ajustant la configuration du bundler et en faisant quelques changements de code précis. Voici donc le moyen le plus simple de les trouver et de les choisir. Et si vous avez lu jusqu’ici et que vous avez aimé cette étude de cas, pensez à en parler:

Merci à Radion Chernyakov, Semyon Muravyov, Victor Kolb, Nikolay Kost pour leurs versions préliminaires et leurs suggestions utiles.



Auteur de l’article : manuboss