Licence CC BY-SA

J'aime [toujours] ma stack

Où je dresse un bilan après 2 ans

En juin l’année dernière, je vous racontais les débuts d’un projet ambitieux et passionnant : le développement du backend et de l’infrastructure d’un jeu massivement multijoueur. Ce billet avait reçu un accueil chaleureux, et vous avez été nombreux à me poser des questions, en particulier sur son architecture !

Aujourd’hui, il est temps que je dresse à nouveau un bilan de cette expérience, des leçons que j’ai acquises en faisant évoluer ce projet, et de ce que j’en tire humainement.

Où en est le projet ?

Alors, quel est ce jeu ?

Le jeu sur lequel je travaille s’appelle Stories One. Il s’agit d’un jeu d’aventure multijoueur et communautaire, dans un monde persistent sur mobile. Pour vous en dresser un pitch, les joueurs incarnent des lycéens de la ville fictive de Lakewood aux États-Unis, où se produisent des événements étranges (disparitions mystérieuses, apparitions de monstres et autres êtres surnaturels…). Bien décidés à lever le voile sur ces mystères, ils décident de partir à l’aventure et de mener leur propre enquête pour comprendre ce qu’il est en train de se passer dans leur ville et les dangers qui la menacent.

Les ambitions et motivations de ce projet sont multiples, mais dans l’immédiat, si vous ne deviez en retenir qu’une seule, c’est que nous souhaitons offrir aux joueurs une opportunité de vivre des aventures qui donnent corps à une histoire partagée, car nous sommes convaincus que ce sont les ingrédients de base qui servent à créer d’authentiques amitiés en ligne.

Je peux y jouer ? Il sort bientôt ?

Nous n’avons pas encore de date de sortie. En fait, nous considérons que nous sommes encore dans les phases initiales du développement : les bases du monde ouvert et gameplay sont posées, et nous itérons actuellement sur le game design en réalisant des playtests privés avec les membres de notre communauté d’environ 10 000 membres.

Ces playtests qui s’étendent sur plusieurs semaines sont notre phare, ce sont eux qui nous permettent de rester ancrés dans la réalité en observant les réactions des joueurs, mais aussi le comportement du produit d’un point de vue technique, en conditions à la fois réelles et cadrées, et c’est une aubaine car cela nous permet d’estimer à quel point notre socle technique tient la route.

Là où ils sont le plus impactants, c’est évidemment du point de vue du game design : notre but est de nous assurer que la définition du jeu lui-même (son squelette, ses règles, ce qui fait que c’est un jeu…) nous permet de remplir nos objectifs, tant du point de vue des motivations du projet (Est-ce que ça fonctionne ? Est-ce que le fait de jouer à ce playtest semble avoir l’impact que nous visons sur les membres de la communauté et les relations entre eux ? Est-ce qu’il se produit tous ces shared moments qu’on a l’ambition de susciter ?), que du point de vue purement business (comprendre par là, prendre des métriques sur lesquelles on peut s’appuyer pour convaincre et rassurer les investisseurs).

Quid de l’équipe de développement, a-t-elle beaucoup grossi ?

Eh bien… on commence à recruter, mais nous sommes toujours à la même échelle. À l’heure où sont écrites ces lignes, notre équipe se compose toujours de :

  • Deux game designers,
  • Quatre développeurs qui travaillent sur le client et le serveur de jeu avec Unity,
  • Un technical artist et deux game artists,
  • Deux personnes chargées de construire et faire vivre notre communauté, auxquelles s’ajoutent quatre modérateurs bénévoles,
  • Une personne (votre serviteur), pour développer le backend et l’infrastructure du jeu dans le cloud.

Pour revenir au contexte de ce billet, on peut simplement dire que le projet avance dans la mesure où nous avons des bases de tout à fait confortables sur lesquelles itérer, et nous avons eu l’occasion d’accumuler beaucoup d’expérience sur plus ou moins tous les aspects du projet, mais nous sommes structurellement à la même échelle, et les contours de mon poste n’ont pas changé (je suis toujours seul sur ma partie du projet, en complète autonomie).

Quelle portée ont eu mes choix techniques ?

Vous vous souvenez sûrement que dans le billet précédent, je me posais la question de la stack technique et de l’architecture avec lesquelles j’allais démarrer ma partie de ce projet. Je pense qu’il est intéressant de regarder maintenant quels éléments ont bougé depuis, quels éléments ont au contraire pris racine, et surtout de se demander pourquoi, et surtout ce que j’aurais fait différemment si j’avais su tout ça dès le début.

"Microservices vs. Monolithe" n’est PAS la question

Durant ces deux ans, j’ai constaté une recrudescence (jusqu’à saturation) des questions autour des microservices : est-ce que c’est une bonne architecture ? Est-ce qu’il vaut pas mieux un monolithe ? Est-ce que… stop ! Je vais être volontairement très direct, mais si vous vous posez cette question pour savoir comment bien partir sur un nouveau projet, et si vous comptez vous servir de la réponse pour architecturer votre code, vous faites fausse route.

Ces deux buzzwords ne sont que les deux extrêmes du spectre sur lequel votre projet se retrouvera fatalement, et dans tous les cas, votre soucis le plus immédiat est d’isoler les composants chacun derrière des interfaces publiques clairement définies. Que le système final fasse tourner ces composants chacun dans son petit binaire derrière une API exposée sur un réseau, ou bien tous dans le même programme, ou encore dans ce que j’ai envie de nommer une collection de "microlithes" (c’est-à-dire plusieurs services par binaire, mais quand même organisés en plusieurs binaires distincts) ne devrait dépendre que de la taille de la société et de l’organisation de l’équipe de développement : si vous avez 100 ingénieurs qui travaillent sur le système, il est plutôt évident que vous allez les séparer en petites équipes (ou squads) de 5–6 personnes, et dans ce cas tendre plutôt naturellement vers des microservices. Si vous avez 5–6 ingénieurs ou moins, alors vous pouvez tout à fait partir sur un monolithe et vous épargner toute la classe de problèmes qui vient avec le fait de coordonner les communications entre différents services qui suivent des cycles de release indépendants. Et ce, quel que soit le système que vous développez.

Dans tous les cas, ce qui importe réellement, c’est :

  1. que les composants du système soient bien séparés par une API qui forme un contrat explicite et clairement défini entre eux,
  2. qu’il n’y ait pas de couplage trop épais entre ces composants et l’infrastructure sous-jacente.

Autrement dit la question "microservices vs. monolithe" n’apporte rien au schmilblick car le problème est fondamentalement le même depuis 20 ans : si le code est correctement architecturé en respectant des interfaces claires et indépendantes, vous devriez pouvoir vous promener sans problème sur le continuum qui existe entre ces deux buzzwords, et c’est ça qui compte.

En ce qui me concerne, les services de mon backend sont organisés et regroupés en "microlithes". Le découpage entre ces microlithes (quel service tourne dans le même binaire que tel autre) a bougé dans le temps en fonction de ce qui était le plus commode à maintenir pour moi, et ça n’a jusqu’ici posé strictement aucun problème (comprendre par là, "j’ai pu faire à chaque fois la bascule sans que mes utilisateurs ne s’en rendent compte"), parce que la décision d’envoyer une requête vers tel ou tel autre service est prise au niveau de l'ingress, c’est-à-dire la gateway publique sur laquelle tous les clients de mon backend vont de toute façon taper pour atteindre le service correspondant. Si demain je veux éclater tout ça en microservices ou au contraire regrouper tout mon backend dans un binaire unique, la seule chose que j’aurai à faire sera d’écrire ou de supprimer autant de fichiers main.go que nécessaire et ajuster les charts Helm correspondants.

En toute honnêteté, l’importance que l’on donne à cette question me laisse perplexe, et j’ai un peu de mal voir à quel moment c’est devenu un débat qui mérite de faire autant de bruit. Il est possible que cela apparaisse à mes yeux comme un "non-problème" parce que j’avais choisi d’utiliser GRPC dès le départ, donc été amené à définir ces API et me mettre d’accord immédiatement avec leurs utilisateurs, ce qui aura fatalement poussé mon design à séparer proprement tous ces services. Si c’est bien le cas, cela tendrait à prouver que la question n’est pas de savoir si on va architecturer le projet en "microservices ou monolithe", mais bel et bien en réfléchissant, comme on est censés le faire depuis qu’existe la programmation structurée, sur les interfaces qu’exposent les différents composants du système… et cela tendrait aussi à confirmer que GRPC est une excellente solution à ce problème, car elle incite à se poser les bonnes questions au bon moment.

Quand l’automatisation coûte plus cher que la répétition

L’une des (nombreuses) raisons qui m’ont convaincu de réaliser mes API en GRPC plutôt qu’en REST/HTTP à la base, c’était que moyennant quelques annotations dans la déclaration de l’API, il était possible de générer automatiquement tout le code d’un service lorsque celui-ci se contente d’exposer des méthodes CRUD sur une simple entité dans une base de données SQL. Autrement dit la définition du service suffisait à m’éviter toute son implémentation (juste d’écrire les migrations de la base SQL). D’ailleurs, qui dit "code généré" dit "code que je n’ai pas besoin de tester". Tant de code à ne pas écrire, quelle aubaine !

J’utilisais pour ce faire un outil appelé protoc-gen-gorm, qui utilisait en coulisse l’un des ORM les plus populaires de l’écosystème de Go (gorm), que j’avais d’ailleurs également choisi pour interagir avec mes bases SQL. Ce choix était donc parfaitement cohérent sur le papier.

… Et puis LaVraieVie™ est arrivée avec son grain de sel :

  • Protobuf a eu de nouvelles releases, gorm aussi (et même une release majeure), mais pas de signe d’activité du côté du fameux générateur : il reposait sur des versions vieillissantes de ses dépendances, sans être mis à jour lui-même.
  • Plus embêtant : au fur et à mesure que le backend s’est construit, ces fameuses méthodes CRUD ont eu besoin de se complexifier, en ajoutant des vérifications à plusieurs endroits : le code généré prévoyait ce cas, avec un système de hooks, et les hooks ont commencé à se multiplier dans un package qui étendait le code auto-généré… ce qui avait pour conséquence évidente que l’intelligence se retrouvait partagée entre deux endroits distincts du code.

Alors un beau matin, je me suis posé les questions suivantes :

  • combien de temps je mettrais réellement à développer ces services à la main plutôt que d’en générer le code et le préciser avec des hooks ?
  • à quelle fréquence est-ce que l’on ajoute de nouvelles entités qui pourraient être automatiquement générées dans le backend ?

Pour développer un tel service qui faisait du CRUD à la main, cela me prenait environ deux heures (contre, disons, 15 minutes pour annoter les définitions protobuf sur lesquelles se base la génération automatique), que je n’avais besoin de dépenser que très rarement (même pas une fois par mois). Par contre, j’en tirais un bénéfice certain : le code de mes services écrits à la main était largement plus simple et facile à suivre qu’un code générique ponctué de hooks qui vivaient dans un fichier séparé. Cela rendait donc leur maintenance et leur évolution bien plus aisées.

En conséquence, ce générateur automatique a été la toute première pièce à dégager de mon projet, parce qu’il ajoutait plus de complexité, d’effort de maintenance et de contraintes sur mon code qu’il ne me faisait gagner de temps dans l’absolu.

Sans cet outil, il m’arrive de récrire une certaine quantité de code très similaire à ce qui existe déjà dans mon backend, et devoir en plus le tester. Et donc il m’arrive de me répéter de temps en temps, mais pas du tout assez pour que cela justifie d’automatiser cette tâche.

Si j’avais su…

xkcd #1319: "Automation"
xkcd #1319: Automation

Il est plutôt difficile de prévoir dans quelle mesure l’automatisation d’une tâche est réellement bénéficiable, même quand l’outil qui automatise cette tâche existe déjà et ne coûte rien à mettre en place. Ce qui est certain, c’est qu’on ne me reprendra plus à automatiser quelque chose avant de l’avoir déjà fait et répété à la main.

On peut y voir une extension assez lointaine du principe YAGNI (You Ain’t Gonna Need It) : ça ne sert à rien de chercher à résoudre des problèmes par anticipation, tant qu’ils ne sont pas avérés dans la réalité.

Ces "roues" qu’il vaut mieux parfois "réinventer"

L’un des dictons les plus populaires dans le métier, est qu'il ne faut pas réinventer la roue. En effet, s’il existe une technologie (bibliothèque, outil…) déjà existante et open source qui fait à peu près ce dont nous avons besoin, il serait idiot de refaire la même chose nous-même, alors que l’on gagne un temps fou en implémentation et en maintenance à réutiliser ce qui existe déjà. Cela semble relever du bon sens.

Pour cette raison, au début du projet, cette règle de "ne pas réinventer la roue" avait guidé à peu près tous mes choix, dans un soucis d’assembler le plus vite possible des services fonctionnels. Dans de nombreux cas je me félicite du temps que ces éléments m’ont fait gagner, cependant cette ligne de conduite a des limites que j’aimerais mettre en valeur ici, exemples à l’appui.

YAGNI, vraiment !

La génération de code automatique n’est pas le seul élément que j’ai fini par dégager de mon projet. En voici deux autres qui peuvent paraître assez extrêmes à première vue :

  • J’ai remplacé mon ORM (gorm) par une bibliothèque beaucoup plus fine (sqlx) qui n’est rien d’autre qu’une extension de l’API standard de Go (database/sql). Autrement dit, je discute avec ma BDD en SQL directement, en utilisant une bibliothèque qui me facilite surtout la vie pour faire les conversions de données entre Go et SQL. En gros, je n’ai plus un "ORM", mais juste un "M".
  • Je me suis presque totalement débarrassé du framework de tests que j’utilisais au départ (testify), pour le remplacer par… rien du tout. C’est-à-dire que j’écris désormais mes tests en me servant directement du package testing standard de Go (qui est très bas niveau) et rien d’autre.

Cela signifie donc que sur ces deux outils qui me semblaient aller de soi au début du projet, j’ai pris la décision mûrement réfléchie de "réinventer la roue". Du moins en apparence !

Dans la réalité, je n’ai rien "réinventé" ; je me suis plutôt débarrassé de ces éléments, après avoir déterminé qu’ils m’encombraient et m’obscurcissaient la vue plus qu’autre chose.

La maîtrise du problème importe bien plus que la solution

S’il y a vraiment une vérité absolue à tirer de tout ce billet, une leçon primordiale du métier d’ingénieur logiciel que j’avais toujours effleurée du doigt jusqu’à présent et que j’ai pu confirmer en travaillant sur ce projet, c’est bien celle-là : la maîtrise du problème est beaucoup plus importante que la solution qu’on lui apporte.

En tant qu’ingénieurs, on a tendance à croire que notre travail consiste à apporter des solutions. Et c’est plutôt vrai, mais ce n’est pas pour autant qu’il faut que notre attention soit accaparée par ces solutions. Plus le temps passe, et plus je suis convaincu que notre travail consiste en premier lieu à comprendre et maîtriser les problèmes, que ce soit ceux de nos utilisateurs, de notre équipe ou même les nôtres. Et c’est seulement de notre compréhension de ces problèmes que peuvent naître des solutions efficaces et adaptées au contexte de nos clients/employeurs/collègues/utilisateurs.

Ceci est d’ailleurs applicable à plein de niveaux différents, pas seulement dans le contexte d’une bibliothèque ou d’un framework… Sans l’avoir encore écrite, je suis presque certain que cette phrase sera amenée à réapparaître dans la suite de ce billet, mais ne nous éparpillons pas.

En choisissant dès le départ un ORM et un framework de tests, j’avais réfléchi à contresens : j’avais choisi d’employer des solutions avant de comprendre réellement les problématiques inhérentes aux échanges entre mon code et la BDD, et à la façon de tester ce code. J’étais parti tête baissée dans l’utilisation de solutions qui me semblaient raisonnables à des problèmes que je ne maîtrisais pas encore, tout ça parce que ces solutions étaient connues pour régler ces problèmes dans d’autres projets similaires, ressemblaient à ce que j’avais déjà vu ou utilisé auparavant, et donc ramenaient le problème à un cadre que je connaissais déjà par expérience.

Ce faisant, j’avais assemblé quelque chose qui fonctionnait et répondait pas trop mal aux besoins, mais je m’étais aussi créé gratuitement de nouveaux problèmes :

  • Je me retrouvais régulièrement à me battre contre l'ORM pour comprendre et débugger le SQL qu’il allait générer dans telle ou telle situation, en perdant finalement plus de temps que si j’avais écrit ce code SQL directement,
  • Je ne savais pas trop où situer le code de mes modèles : l'ORM me fournissant effectivement une abstraction qui séparait le code des handlers des services du code qui discute effectivement avec la BDD, je suis parti du principe que cette abstraction était une séparation souhaitable et suffisante, et pourtant le code qui manipulait l'ORM avait tendance à grossir et se faire bien trop présent dans la logique des handlers, ce qui semblait indiquer que l’abstraction qui sépare les handlers de la connaissance précise de mon schéma de BDD ne se situait pas du tout au bon endroit,
  • J’exprimais mes tests sans trop réfléchir en utilisant les "assertions génériques" fournies par testify, en structurant ces tests dans des suites un peu à la XUnit. Autrement dit, le framework faisait son travail en cadrant ma pensée et ma façon d’exprimer celle-ci dans mon code, sans me demander si c’était la meilleure façon de faire en Go d’une part, dans le contexte métier des briques que je développais d’autre part. Cela avait un impact assez frappant sur la lisibilité des tests qui empirait au fur et à mesure que je testais des briques complexes, et vue mon utilisation intensive des tests (sur laquelle je reviendrai plus bas), cela avait un impact tout à fait sensible sur la quantité d’efforts à fournir pour naviguer dans les tests et les étendre, soit un surcoût permanent et croissant.

Ceux qui me connaissent vont certainement lever les yeux au ciel à force de me voir la répéter, mais cela résonne avec une de mes citations préférées d’E.W. Dijkstra :

Are you quite sure that all those bells and whistles, all those wonderful facilities of your so called powerful programming languages, belong to the solution set rather than the problem set?

En virant mon ORM, j’ai déplacé la frontière de l’API de mon modèle de données, et repris explicitement le contrôle (que je n’avais jamais vraiment voulu céder) sur le code SQL généré. En bonus, cela me facilitera d’autant plus la tâche si je décide de remplacer ma base de données PostgreSQL par quelque chose qui n’a rien à voir, comme Mongo, Redis ou autre technologie NoSQL. Je n’aurais qu’à changer le code qui se situe derrière l’abstraction de mon modèle de données, sans rien toucher aux tests qui reposent sur cette interface. Cette nouvelle abstraction, qui est effectivement la roue que j’ai réinventée, est la seule dont j’aie réellement besoin. Accessoirement, j’ai aussi gagné en performances.

En virant mon framework de tests, je me suis forcé à repenser la façon dont mes tests devaient être exprimés pour être le plus compréhensible possible pour quiconque découvrirait ce code pour la première fois (ce qui revient à les rendre du même coup "plus faciles à rentrer dedans après avoir oublié ce code pendant 3 mois"). Ce faisant, je me suis finalement retrouvé à définir un genre de DSL qui me permet de m’exprimer en fonction "domaine métier" du code testé, plutôt qu’avec les fonctions d’assertion génériques d’un framework de test, ce qui revient à rapprocher l’expression de mes tests du vocabulaire des spécifications fonctionnelles des briques logicielles que je développe, et ça, ça n’a pas de prix ! Mais je reviendrai sur ce sujet plus bas.

Dans les deux cas, je me suis aperçu que je n’écrivais ni spécialement plus, ni spécialement moins de code. Par contre, cela fait deux dépendances de moins à maintenir, et je suis incité à réfléchir à des problématiques dont la maîtrise m’est de toute façon indispensable sur le long terme. En me "réinventant ces roues", je me suis aussi débarrassé de biais qui orientaient ma pensée dans le design des briques logicielles que je développe et dans l’architecture de mon code. Et surtout j’ai réduit très nettement la quantité d’efforts que j’ai besoin de fournir pour maintenir et faire évoluer mon projet, ce qui est paradoxalement tout l’intérêt de "ne pas réinventer la roue" à la base !

Si j’avais su…

Cela ne veut bien évidemment pas dire qu’il n’y aura plus jamais d'ORM ou de framework de tests dans ce projet (quoiqu’à l’horizon visible, ça m’étonnerait). Cela veut simplement dire qu’ils n’auraient pas dû être présents dès le début, et qu’à l’heure actuelle en tout cas, je les vois plus comme des dépendances superflues que des "roues" que je "réinvente".

La leçon que j’en tire semble être un conseil très présent dans la philosophie des utilisateurs de Go : ça n’est pas utile de partir sur un projet avec un framework ou un ORM ou une lib de DI ou tout autre lib-magique-qui-sructure-les-projets. En effet, je lis ce conseil presque quotidiennement sur le subreddit du langage, très souvent adressé à des développeurs qui voudraient venir à Go depuis Java ou C# et qui cherchent "un équivalent à tel truc que leur équipe utilise déjà, pour les convaincre de sauter le pas". Il n’y a pratiquement qu’à cet endroit que je le vois formulé aussi explicitement.

Sans aller jusqu’à dire aux gens ce qu’ils doivent faire ou non, j’aurais plutôt envie de le formuler comme ceci : on n’est jamais assez KISS.

Il existe dans ce métier une tendance à se poser des questions beaucoup trop compliquées, trop tôt. Par exemple : "quelle est la meilleure façon de structurer/organiser les fichiers d’un projet avec telle techno ?", "quelles abstractions/packages vais-je avoir besoin de créer en isolation du reste ?". Or la "philosophie des Gophers", mais aussi plus généralement celle du "développement pragmatique" repose sur le fait qu’il n’existe pas de réponse unique à ces questions : commence donc ton projet en collant tout dans ton main.go, tu réfléchiras à tout ça quand le fichier sera trop gros. C’est ça, notamment, "être KISS" : c’est voir la refactorisation comme une activité normale et quotidienne, inhérente au développement, et donc raisonner en sachant que l’on pourra corriger le cap quand on aura un problème sous les yeux.

Tant qu’il n’y a pas de code sur lequel raisonner, il n’y a pas de problème à résoudre, donc pas de solution à chercher.

Plus important que de ne pas "réinventer la roue", il faut s’assurer que l’on a vraiment besoin de ce que l’on rajoute dans le projet. C’est valable pour tout ce qui peut avoir un impact significatif sur la structure du code, comme un ORM ou un framework, et le seul moyen de déterminer que l’on en a besoin, c’est de commencer sans, puis de procéder par addition quand le cas se présente.

L’expérience suggère que cela demande moins d’efforts que de commencer avec au risque de devoir s’en débarrasser par la suite : que ce soit virer mon ORM ou mon framework de tests, cela m’a demandé d’étaler l’effort en appliquant la règle du Boy Scout, c’est-à-dire de les remplacer progressivement dans le code où j’étais amené à intervenir, de façon opportuniste, jusqu’à pouvoir porter le coup fatal en un temps raisonnable (une journée) pour dégager le reste. C’est d’ailleurs la raison pour laquelle j’ai toujours un peu de code testé avec testify dans mon projet… Autrement dit, virer quelque chose est un chantier longue durée, là où ajouter une dépendance est instantané. Et encore, je m’estime heureux car je suis le seul décisionnaire sur ce code : dans un tout autre contexte, ces choix auraient certainement été extrêmement difficiles à faire passer auprès d’une équipe de dev ou de la hiérarchie d’un point de vue purement politique, car les gens n’aiment pas avoir l’impression de "perdre" quelque chose, même si ce quelque chose est inutile ou préjudiciable.

L’autre point que je retiens de cela, c’est que désormais j’accueille l’expression réinventer la roue avec énormément de circonspection. En général, on l’utilise comme invective, voire comme un argument pour balayer une idée de la main sans en observer les tenants et les aboutissants ("mais non on va pas faire ça nous même, c’est réinventer la roue !"), ce qui est systématiquement désagréable et condescendant, et très souvent le résultat d’une approche dogmatique de la question. Or je viens de mentionner deux cas de "réinvention de la roue" (et pas les plus petites roues que l’on puisse imaginer…) qui ont été tout bénef' dans ce projet (et pas un projet fictif : on parle de celui qui me fait manger depuis 2 ans).

Par conséquent, je bannis ce dicton de mon vocabulaire. Un dicton qui doit venir avec des petites astérisques et une note explicative en bas de page n’a rien à faire dans ce métier. Surtout quand il se situe à la seconde place des proverbes les plus dévoyés 1.

Tester, tester, tester !

Tout tester…

Avant de commencer à travailler sur ce projet, je me déclarais « pas spécialement adepte du 100% coverage ». Depuis que j’ai adopté le TDD2, et donc fatalement atteint ces 100% de coverage (ou plutôt, "entre 90 et 100%") sur la plupart de mes composants, j’ai changé d’avis au point de me demander ce qui justifie encore le silence assourdissant qui entoure le sujet des tests dans l’apprentissage de la programmation.

Jusqu’alors, j’avais toujours considéré les tests comme une tâche annexe du développement, un truc pas spécialement intéressant qui est parfois tellement gonflant que l’on fait l’impasse dessus. "Pas le temps !" est l’excuse que j’utilisais le plus souvent pour ne pas tester mon code. "C’est du code one-shot de toute façon". Avant de retourner passer des heures à faire du debug ou éplucher des logs pour comprendre ce qui foirait dans mon code.

Je vous le donne en mille : la raison pour laquelle je n’avais "pas le temps" d’écrire des tests, c’était que je n’avais pas de tests.

Aujourd’hui, le "100% coverage" me permet :

  • De réduire, évidemment, la fréquence des bugs, mais de façon vraiment drastique. À titre d’exemple, j’ai pu partir un mois en congé paternité en laissant mon backend tourner en pleine période de playtest en ne laissant pour ainsi dire personne pour surveiller le système sans que le moindre incident ne se produise.
  • De déployer sans jamais avoir besoin d'espérer que ça fonctionne : j’en ai l’assurance, et je déploie les yeux fermés jusqu’à plusieurs fois par jour.
  • Quand un incident se produit, d’en trouver la cause (en parcourant les tests à la recherche de cas manquants), et de la corriger (en rajoutant le cas manquant et en le faisant passer au vert) de manière à ce que le correctif soit déployé moins de 5 minutes après la découverte du problème.

Ce genre de choses avait toujours relevé de la science-fiction à mes yeux, notamment dans mes précédents jobs. D’autant plus qu’il s’agit de code sur lequel je suis le seul développeur/mainteneur/opérateur et que les incidents peuvent se produire sur des composants auxquels je n’ai plus le code en tête depuis des semaines.

Mais je me contente ici de répéter ce que des Kent Beck et autres pointures de la littérature ânonnent depuis 20 ans. Le fait que ma propre expérience confirme "qu’ils ont raison" n’a pas grande valeur. Ce qui peut en avoir en revanche, c’est plutôt de parler de la façon dont cela s’inscrit dans la vue plus générale.

… Mais surtout, tester correctement !

Je ne peux pas avoir débuté mon speech en parlant du « 100% coverage » sans préciser ce qui suit en gros, en gras et en coloré :

Le coverage n’est pas un KPI, ce n’est pas une fin en soi.

"Rajouter" des tests sur le code a posteriori "juste pour gonfler le coverage" est la meilleure façon de plomber un projet.

J’irai même plus loin en précisant que la seule façon réellement sûre et utile d’atteindre 100% de coverage, à mes yeux, c’est de le faire par construction au moyen du TDD. Sans cela, le coverage est littéralement un piège qui n’attend qu’à bétonner tout votre code et vous feriez mieux d’ignorer cet indicateur purement et simplement en le fuyant comme la peste.

Ce que ces deux années m’ont appris, c’est justement comment approcher les tests de manière à ce que ceux-ci soient uniquement un gain de temps, de qualité et d’énergie. Cela inclut notamment les différentes remarques que j’ai eu l’occasion de faire dans les billets précédents à propos des katas :

  • On doit savoir comment le code va être testé avant même de commencer à l’écrire, sinon c’est qu’il faut encore réfléchir ;
  • Tester du code, c’est d’abord s’assurer que celui-ci est testable, ce qui impose en général de bonnes pratiques de conception (l’injection de dépendances dans le constructeur, la définition d’une interface claire — eh oui, encore les interfaces…) ;
  • etc.

En fait, ces remarques naissent d’une prise de conscience plus générale sur le sujet.

Tout d’abord les tests ne sont rien d’autre que les spécifications du comportement du code. Cela signifie notamment que l’on ne peut écrire ces spécifications qu’une fois que l’on a correctement intégré le problème que ce code va résoudre, mais surtout que les tests sont censés exprimer le problème tel que nous le comprenons, et c’est la première chose que l’on va lire quand le code ne se comporte pas comme prévu, ou quand on cherche simplement à savoir comment le code est censé se comporter.

Si c’est la première (et parfois la seule) chose que l’on ira lire, alors il importe d’apporter un soin tout particulier à leur lisibilité et leur expressivité. Là où toutes les étoiles s’alignent parfaitement, c’est que des tests lisibles et expressifs sont forcément obligés de manipuler le code au travers d’une interface lisible et compréhensible : son interface publique. Donc soigner les tests, c’est soigner l’interface du code, or, enfonçons à nouveau cette porte ouverte, un code avec une interface publique claire est un prérequis pour que le projet reste facile à maintenir.

Partant de là, on retombe sur le fait que notre maîtrise du problème (exprimée au moins indirectement par les specs/tests) est plus importante que la solution (le code métier), dans le sens où ils mériteraient presque d’être encore plus lisibles que le code métier, car des tests faciles à comprendre et à maintenir impliquent forcément que le code a non seulement le comportement attendu, mais en plus que son interface est claire.

La boucle est bouclée. :)

Depuis que j’aborde mon code dans cet état d’esprit, l’écriture des tests n’a plus jamais été une contrainte, ni un truc chiant qu’il faudrait faire pour bien faire : elle me semble aller de soi, et même être un challenge plutôt amusant, car il s’agit alors non plus d’écrire "du code pour tester du code", mais de s’assurer que je suis capable d’exprimer lisiblement et de façon compréhensible le problème que je cherche à résoudre. Dans ces conditions, il est tout naturel que l’expression de ce problème doit être claire dans ma tête (ou sous mes yeux) avant que ne puisse exister une solution convenable.

Et c’est un cercle vertueux, puisque ce qui est ainsi devenu amusant à mes yeux, ce sont autant d’éléments qui me permettent de maintenir sans effort un code de qualité que je peux déployer en toute tranquilité, tout en gagnant un paquet de temps.

Bien évidemment, je ne peux parler qu’en mon nom dans le contexte de mon projet, mais atteindre ce genre de clarté dans la compréhension de son métier et de cercle vertueux dans sa pratique, d’autant plus après avoir constaté à quel point on pouvait finir blasé et épuisé dans le cas contraire, est réellement tout le mal que je puisse souhaiter à n’importe quel professionnel.


  1. La palme d’or revient à la citation de Knuth sur les optimisations prématurées.
  2. Je ne suis pas non plus un fou furieux du TDD : je ne l’applique pas tout le temps ! Mais plus ça va, plus je me discipline pour le faire…

Levons le nez du code !

Si j’arrêtais ce billet ici, ne pourriez tirer qu’une vision complètement biaisée de cette expérience. Jusqu’à présent, vous avez eu un aperçu de la partie émergée de l'iceberg : comment mon code a évolué. Mais dans les faits, ce n’est pas ça le plus important !

La maîtrise du problème… encore une fois

Une chose qui a grandement changée depuis que j’ai commencé à travailler sur ce projet, c’est la fréquence, la qualité, et la raison de mes interactions avec le reste de l’équipe.

En effet, si je me suis à ce point pris la tête pour avoir une codebase d’une qualité irréprochable et facile à maintenir, ce n’est pas pour le plaisir de dire « regardez ce que j’ai fait comme c’est beau ! ». D’ailleurs, je ne le dis pas vraiment, puisque comme je suis le seul ingénieur back & infra de la boîte, je travaille surtout avec des gens à qui tout cela ne parle pas forcément, et donc j’ai plutôt tendance à faire référence à tous ces menus détails comme à des « trucs ennuyeux qui n’intéressent que les gens bizarres comme moi ».

Non, si j’ai concédé un tel effort dans la qualité de ma codebase c’est pour me libérer du temps que je peux employer à… mieux comprendre les problèmes qu’elle est censée résoudre.

Ce qui est vraiment primordial à mes yeux, c’est que mon travail soit une solution efficace aux problèmes de mes utilisateurs (qui ne sont pas seulement les joueurs, ni seulement les game developers, ni seulement les modérateurs…). Or ces problèmes ne vont pas se décrire tous seuls à moi par le biais d’une liste de requirements : si je veux les comprendre et pouvoir y répondre efficacement, il m’appartient d’en faire l’expérience.

Partant de là, je ne dois pas me borner à être seulement le mainteneur de mon code : si je veux que mon travail soit réellement utile, je dois en devenir moi-même l’utilisateur.

  • Je dois moi-même tester le jeu afin d’en comprendre le design, en appréhender les mécaniques et savoir où interviennent précisément tous les services de back que je suis amené à implémenter dans l’expérience du joueur.
  • Je dois moi-même me mettre à la place d’un modérateur du jeu en utilisant les outils que j’ai créés pour apprécier à quel point c’est facile (ou non) de bannir un utilisateur (sans se tromper :p).
  • Je dois moi-même être à la place d’un membre de la communauté qui veut participer au prochain playtest pour comprendre tous les chemins improbables qui peuvent être pris quand je suis soumis au questionnaire de mon bot.
  • Je dois moi-même être le client de ma propre infrastructure, pour m’assurer que les game developers, au quotidien, n’éprouvent aucune difficulté ni aucun effort à spawner un nouveau serveur de jeu dans le cloud pour tester leur travail.
  • Etc.

Ce principe porte un nom : on appelle cela du dogfooding.

Cela vient de l’expression "Eat your own dog food!" c’est-à-dire, métaphoriquement, goûter la pâtée pour chiens que l’on produit, pour s’assurer que même nous avons envie de "la manger".

Plus le temps passe, et plus je m’aperçois que je consacre du temps à cette activité. Je ressens vraiment que c’est elle qui me permet de rester connecté à la réalité de mon travail, et d’en apprécier l’importance et la qualité (celle perçue par les gens qui l’utilisent, s’entend).

Croire en son projet et en sa boîte, ça aide quand même beaucoup !

Quand je dis y croire, je ne suis pas du tout en train de pratiquer la langue-de-bois façon LinkedIn en disant que "les-valeurs-de-la-boîte-et-du-produit-sont-alignées-à-mes-valeurs-professionnelles-et-cela-crée-un-cadre-favorable-à-une-collaboration-fructueuse".

Je parle vraiment d’y croire, dans le sens où je ressens vraiment cette impatience de le voir aboutir un beau jour, où l’on pourra se retourner, se regarder les uns les autres et se dire : « j’y crois pas, on l’a fait ! ».

Je parle de l’intérêt authentique qui me pousse à aller lire les réflexions des game designers ou regarder et commenter les concepts des artistes, ou les threads des développeurs C#/Unity et du tech artist, même si souvent je n’en pige pas un mot.

Je parle de devoir faire un plus gros effort pour décrocher le soir que pour se lever le matin.

Je parle aussi de cette excitation et du gigantesque sourire qui nous traversait le visage en regardant les réactions des joueurs qui découvraient Lakewood pour la première fois.

Je parle d’avoir toujours le même feu sacré après deux ans qu’au premier jour.

Clairement, avec un tel moteur, je ne me sentirais plus vraiment capable de travailler pour moins que tout ça.


11 commentaires

Salut nohar,

Un grand merci pour ce retour d’expérience détaillé. :)
Il rappelle finalement, avec beaucoup de pragmatisme, des choses fondamentales, mais souvent oubliées.

Je ne peux que partager ton opinion en ce qui concerne les choix d’outils, de librairies ou de techno trop souvent fait « par défaut » dans pas mal de projets sans s’être véritablement posé la question de savoir s’ils étaient nécessaires et, surtout, finalement bénéfiques ?

Tu fais allusion au principe KISS dans ce billet, je lui préfère pour ma part cette citation d’Antoine de Saint-Exupéry : « La perfection est atteinte, non pas lorsqu’il n’y a plus rien à ajouter, mais lorsqu’il n’y a plus rien à retirer. » Qui est très belle, mais aussi très dangereuse. :-°

+6 -0

Hello, super billet !

En conséquence, ce générateur automatique a été la toute première pièce à dégager de mon projet, parce qu’il ajoutait plus de complexité, d’effort de maintenance et de contraintes sur mon code qu’il ne me faisait gagner de temps dans l’absolu.

Ça signifie que tu as tout fait à la main ? Ou tu es simplement passé par protoc pour générer du code et ensuite implémenter la logique avec la DB ?

Merci !

@Taurre : en effet, j’adore aussi cette citation que je trouve valable dans tout un tas de domaines, notamment en musique. Ce qui la rend "dangereuse" ou "imparfaite" à mes yeux, c’est qu’elle parle de "perfection", et que le perfectionnisme est un autre travers dans lequel il est facile de tomber. Mais on va pas chipoter : elle fait le job. :D

@WinXaito : je parle juste de la surcouche à protobuf qui implémente les méthodes du service et le modèle de BDD (protoc-gen-gorm). Bien sûr, je me sers toujours de protoc pour générer les structures et les clients en Go et en C#, ainsi que les stubs des services, sinon ça n’aurait plus aucun intérêt d’utiliser GRPC. :p

+1 -0

@WinXaito : je parle juste de la surcouche à protobuf qui implémente les méthodes du service et le modèle de BDD (protoc-gen-gorm). Bien sûr, je me sers toujours de protoc pour générer les structures qui font les clients en Go et en C#, ainsi que les stubs des services, sinon ça n’aurait plus aucun intérêt d’utiliser GRPC.

C’est bien ce que je pensais, merci de la confirmation.

Petite question encore, car je vais travailler avec Go et gRPC pour mon travail (actuellement dans les specs).
Pour ce qui est des modèles, tu utilises ce qui est générer à partir du protobuf ou tu les redéfini toi même ? (Avec des méthodes pour convertir d’un à l’autre)

@WinXaito

Alors la façon vers laquelle j’ai convergé et qui marche plutôt bien pour moi, c’est d’avoir l’intelligence dans un package dédié, et de me contenter de fonctions de conversions que je place dans le package qui implémente le service.

Par exemple si j’ai un service Users défini dans user.proto.

  • Le code généré va dans gitlab.com/mon/projet/grpc/userpb (ici protoc génère 2 fichiers)
  • Le service est implémenté dans gitlab.com/mon/projet/pkg/service/users
  • Le modèle va dans gitlab.com/mon/projet/pkg/sql/model/user

Dans le package du modèle je définis effectivement une structure User qui décrit la table, et j’ai des fonctions/méthodes du style :

func GetUserByID(ctx context.Context, db *sqlx.DB, id interface{}) (*User, error)

Le fait que j’accepte une interface{} pour l'id me permet d’appeler cette fonction soit en lui passant un string qui vient de la structure protobuf, soit en lui passant un uuid.UUID qui vient de la structure du modèle (en coulisse, sqlx accepte à son tour une interface{} donc je me contente de passer directement l’id tel quel et c’est sqlx qui se débrouille).

Dans le package qui implémente le service je me contente de deux petites fonctions privées userFromPB(*userpb.User) *user.User et userToPB(*user.User) *userpb.User qui font les conversions entre les deux.

Ce qui m’a poussé à séparer comme ça, c’était de réfléchir en termes d’imports et de dependances à la compilation/pendant les tests : je ne voulais pas que mon package de modèle ait une quelconque dépendance (même transitive) vers grpc ou protobuf, or le seul endroit où j’importe à la fois le modèle et protobuf généré, c’est dans le package du service, donc ça me semble naturel que ce soit là que se font les conversions.

Pour les tests :

  • L’intelligence et le comportement sont testées contre l’interface publique du modèle (avec une vraie BDD derrière, parce que de mon point de vue l’intelligence se trouve essentiellement dans le code SQL du modèle et des migrations, ça peut être un petit peu subtil à mettre en place mais on en reparlera si ça t’intéresse)
  • J’ai en plus de ça les tests du service grpc (qui crée une fausse connexion réseau en mémoire, sur laquelle je sers le vrai service, et à laquelle je me connecte avec le vrai client généré), pour valider surtout le contrat de l’API (les codes d’erreur, etc.).

Je suis pas sûr que ce soit forcément la meilleure manière de s’y prendre, les tests semblent parfois un peu redondants (même s’ils ne valident pas la même chose), mais dans les faits je trouve que ça me permet de garder un effort d’implémentation / de maintenance constants, de garder des dépendances bien propres et des responsabilités bien claires, et que tout le code reste facile à suivre.

+0 -0

Tu fais allusion au principe KISS dans ce billet, je lui préfère pour ma part cette citation d’Antoine de Saint-Exupéry : « La perfection est atteinte, non pas lorsqu’il n’y a plus rien à ajouter, mais lorsqu’il n’y a plus rien à retirer. » Qui est très belle, mais aussi très dangereuse. :-°

Taurre

(Par simple acquis de conscience parce que j’ai vu trop souvent de mauvaises interprétations avec leurs conséquences)

De mon point de vue, la citation de Saint-Exupéry appliquée au code n’implique pas de réduire ce dernier mais sa complexité

Je préfère voir le principe KISS comme casser les problèmes en plus petits problèmes et respecter le SRP. Mais surtout comme un conseil d’humilité : on n’est pas tous experts dans l’équipe. Par contre, on doit tous travailler sur le code.

Par exemple, je sors souvent les prédicats dans des fonctions à part (voire je découpe en plusieurs blocs de if) pour des questions de lisibilité. Je préfère ne pas faire appel à map/reduce quand tout le monde n’est pas encore à l’aise avec le principe, etc.

Je pars du principe que le code vit et qu’il vaut mieux faire des choses basiques que tout le monde comprend. Puis passer du temps sur des formations afin de mettre toute l’équipe à niveau

Ça me rappelle cette autre phrase de Dijkstra :

The competent programmer is fully aware of the limited size of his own skull. He therefore approaches his task with full humility, and avoids clever tricks like the plague.

Un des trucs notoires que j’en tire personnellement, c’est que j’apprécie désormais quand le code est fade (boring) et sans surprise, donc facile à lire, limite "trop évident". C’est ça qui est devenu le nouveau "sexy" à mes yeux, plutôt que les one-liners.

J’ai l’impression que c’est le résultat d’un long processus qui se termine par la réalisation du fait que notre code n’a rien à prouver aux gens qui le lisent, si ce n’est qu’il fait ce qu’on a besoin qu’il fasse.

+3 -0

De mon point de vue, la citation de Saint-Exupéry appliquée au code n’implique pas de réduire ce dernier mais sa complexité.

Mzungu

Pourquoi pas les deux, finalement ? :D
Mais oui, je l’ai citée avec un esprit de minimalisme et de simplicité. Ce qui est important à mes yeux c’est d’éviter le complexe et le superflu. La taille du code n’est pas pertinente à cet égard (les IOCCC sont une parfaite illustration d’un code court, mais parfaitement complexe et illisible).

+0 -0

Merci beaucoup Nohar pour ces retours très détaillés, c’est rare et précieux.

A propos des frameworks et librairies, la partie sur l’ORM me parle particulièrement, parce que c’est un peu la même histoire en Java. Hibernate et JPA, quand on fait globalement du CRUD, c’est magique ! C’est une des briques qui permettent de monter très rapidement une interface REST. Quand on commence un nouveau projet, on se demande bien pourquoi on devrait s’en priver. Mais dès qu’on commence d’avoir des requêtes vraiment complexe avec des objets métier eux-mêmes complexes avec des tonnes de validations non conventionnelles, qu’on écrit énormément de requêtes HQL à la main, et qu’on commence à avoir du mal de comprendre pourquoi ça génère tel SQL, pourquoi il a besoin de faire telle ou telle requête, ou pourquoi il tire la moitié de la base par le jeu des eager/lazy, on commence sérieusement à questionner le bien fondé de l’outil.

En fait, on troque très probablement du temps à court terme contre de la performance à long terme, mais c’est malheureusement souvent comme ça…

Par contre grâce à un de mes plus gros projets perso, j’ai aussi expérimenté l’inverse: pendant très longtemps, je me suis cassé les pieds à n’en plus finir avec des sockets et du réseau. J’ai commencé de façon très simple et avec les connaissance que j’avais à l’époque, en mode I/O synchrone, une connexion = un thread = un joueur. Tout le monde commence certainement comme ça, et ça marche finalement plutôt bien quand on a moins de 100 joueurs connectés en même temps. J’ai même réimplémenté moi-même websocket selon la RFC 6455 (pour être plus précis, uniquement le strict minimum nécessaire pour que ça marche, parce qu’évidemment osef des trois quarts des détails).

Le problème, c’est que certains joueurs ont des connexions tellement pourries/bizarres qu’ils arrivaient toujours à faire planter ou geler les autres joueurs de la même partie, ou même carrément le serveur en entier sans qu’on ne sache trop comment, et évidemment, impossible de reproduire (comment on simule une mauvaise connexion ?)

Et puis, j’ai découvert la bibliothèque Netty, ce qui m’a forcé à découpler la gestion réseau de la logique du jeu. C’était là mon erreur ! Mais sans l’aide de cette bibliothèque et ce qu’elle m’a obligé à repenser, je ne m’en serais sans doute jamais rendu compte.

Du coup, je crois que j’en arrive à la même conclusion que toi: c’est seulement après avoir été concrètement confronté à un problème qu’on devrait exploiter les solutions proposées par d’autres, plutôt que de se jeter sur des solutions à des problèmes qu’on n’a pas. Il faut résoudre les problèmes dans l’ordre où ils arrivent, pas essayer de les anticiper. J’ai malheureusement l’impression que c’est rarement dans ce sens qu’on fonctionne. Parfois même, ça bloque pour se lancer sur de nouveaux projets, car avant même de commencer, on imagine des problèmes qu’on n’aura peut-être jamais.

Du côté des tests, je suis toujours impressionné de la méthode TDD que tu sembles si bien maîtriser maintenant. J’ai toujours du mal, malgré tes billets précédents sur le sujet.

Encore une fois, merci pour cet article, félicitations, et bonne chance pour la suite.

+6 -0

Ça me rappelle cette autre phrase de Dijkstra :

The competent programmer is fully aware of the limited size of his own skull. He therefore approaches his task with full humility, and avoids clever tricks like the plague.

Un des trucs notoires que j’en tire personnellement, c’est que j’apprécie désormais quand le code est fade (boring) et sans surprise, donc facile à lire, limite "trop évident". C’est ça qui est devenu le nouveau "sexy" à mes yeux, plutôt que les one-liners.

J’ai l’impression que c’est le résultat d’un long processus qui se termine par la réalisation du fait que notre code n’a rien à prouver aux gens qui le lisent, si ce n’est qu’il fait ce qu’on a besoin qu’il fasse.

nohar

J’en suis arrivé à la même idée : plus le coût de développement est faible, plus notre équipe sera motivée pour avancer et on pourra faire des trucs vraiment cools.

Et, un code clair, simple et lisible nous garantit que nous n’avons pas de héros dans notre équipe ou qu’on sera encore capable de le comprendre dans quelques mois. Que tout le monde en a la maîtrise, finalement.

On a aussi énormément travaillé sur nos tests pour qu’ils deviennent une documentation vivante du code. Du coup, on a des tests lisibles et maintenables (voire plus que notre code :) )

Une nuance tout de même : tout ne peut pas être résolu avec un code simple et sans un peu de magie noire. On est arrivé à la conclusion que la simplicité du code doit évoluer en même temps que nos compétences. Par exemple, depuis peu, tout le monde est à l’aise avec map et reduce : on va donc pouvoir supprimer pas mal de boucles pendant les refactoring.

Et, finalement, le code que l’équipe trouvait complexe au départ est devenu notre standard de simplicité.

Ca nous a obligé à construire tout un set de formations, katas, etc. pour former les nouveaux venus sur nos concepts de dev et pratiques craft et Agile. Ce qui a profité à tout la boîte et fait monter en compétence pas mal d’autres devs (et donc nous a permis de prendre des sujets plus complexes).

Aujourd’hui, mon objectif à atteindre, c’est le Continuous Delivery : réussir à pousser en permanence (plusieurs fois par jour) de la valeur et garantir que ce sera toujours vrai dans plusieurs mois/années

Du côté des tests, je suis toujours impressionné de la méthode TDD que tu sembles si bien maîtriser maintenant. J’ai toujours du mal, malgré tes billets précédents sur le sujet.

Encore une fois, merci pour cet article, félicitations, et bonne chance pour la suite.

QuentinC

Petite précision : le TDD n’est pas une méthode de tests mais de design. Les tests sont une conséquence du TDD :)

Et mine de rien, ça change pas mal de choses (notamment si tu veux apprendre à écrire des tests, s’essayer au TDD n’est pas le meilleur choix)

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte