Pourquoi j’utilise Go
Je profite de la publication de ce petit article , un de plus comme il en existe des centaines pour partager un peu mon expérience avec ce langage.
En analysant mes repos, je peux affirmer que j’utilise Go professionnellement depuis environs 3 ans.
C’était à l’époque chez LibMed, une startup ayant développé une plate-forme de gestion des remplacements paramédicaux.
Comme toute plate-forme qui se respecte, LibMed est constitué d’une multitude de microservices qui collaborent entre eux pour délivrer les fonctionnalités proposées aux utilisateurs (front web, gestion d’un graph de donnés, génération de contrats de travail, signature électronique, système de notification intelligent, etc.).
L’avantage des architectures microservices est, entre autres, qu’elles laissent la liberté du choix des armes (le langage de développement) pour chaque service pour peu que ce dernier supporte le protocole d’API choisi pour la communication entre les services (en l’occurrence dans l’exemple gRPC).
Beaucoup de microservices chez LibMed ont été développés dans le langage probablement le plus éloigné de la philosophie de Go, j’ai nommé : Scala.
Ce choix avait été fait pour la puissance et l’expressivité que peut apporter un langage fonctionnel comme Scala.
Lorsqu’un service doit implémenter de nombreuses et complexes règles métier, l’expressivité d’un langage est un aspect important, Scala est très bon dans ce domaine, tout comme le serait Kotlin (inexistant à l’époque de la création de LibMed) voir même Rust dans une moindre mesure.
Mais Scala a deux problèmes qu’il hérite des languages hyperexpréssifs :
- La puissance qu’il tire de son expressivité se paye au prix d’un code qui devient rapidement incompréhensible pour qui n‘y consacre pas huit heures par jour. Cela pose deux problèmes en génie logiciel : le coût d’un nouvel entrant dans l’équipe et le coût de la maintenance. Outre une courbe d’apprentissage très longue (plusieurs années pour véritablement maîtriser les subtilités du langage, ce qui rend les candidats plutôt rares sur le marché), les nouveaux arrivants devront investir plusieurs semaines avant de s’approprier parfaitement le code base. Il en va de même pour le pauvre développeur qui devra reprendre du code écrit il y a plusieurs mois ou plusieurs années par quelqu’un d’autre (ou par lui même).
- Son temps de compilation est trèèèèès long (malgré les progrès accomplis au fil des versions), cela peut paraître anecdotique à l’époque où le moindre ordinateur portable dispose de 4 voir 8 coeurs et 16Go de RAM, ou encore à l’époque où l’on délègue de toute façon le build à une chaîne CI-CD prévue à cet effet, mais c’est en fait une source de frustration, de perte de temps et donc de productivité pour le développeur (car rares sont les développeurs qui codent pendant 8 heures sans jamais tester sur leur poste)
Cela étant dit, je reste convaincu que les langages hyperexpressifs tels que Scala, Kotlin ou même Rust (qui a d’autres avantages) restent un excellent choix dès lors qu’il s’agit d’implémenter des règles métiers, elles-mêmes complexes.
Mais, car il y a un mais, une plate-forme n’est pas constituée que de composants devant gérer des règles métier complexe (en fait ils sont plutôt rarissimes).
Dans l’immense majorité des cas, les problèmes que doit résoudre un développeur sont simplement compliqués, et non complexes.
Les propriétés que l’on va chercher dans l’outil (le langage) pour les résoudre sont donc différentes.
L’hyperexpréssivité (qui se paye cher je le rappelle) n’est plus indispensable, au contraire :
- d’une productivité maximum au niveau du cycle de développement : donc une compilation rapide et une librairie standard bien fournie
- d’un coût d’entrée (investissement cognitif) minimum pour le nouveau développeur : donc un langage simple, lisible et sans ambiguïté
- d’un coût de maintenance minimum : on reprend les propriétés précédentes en y ajoutant une stabilité du langage au fil des versions ainsi qu’une compatibilité ascendante totale.
Ces composants (qui gèrent les problèmes compliqués et non complexes) ou microservices représentant la grande majorité de la plate-forme, la performance globale de cette dernière et son coût d’exploitation dépendent par conséquent surtout d’eux.
On ajoute alors les propriétés suivantes à notre liste de souhaits :
- un niveau de performance (à l’exécution) élevé
- une consommation des ressources (CPU, mémoire) minimum (c’est ce qui va déterminer le montant de la facture de votre “Cloud Provider” à la fin du mois)
- un démarrage rapide (c’est devenu une nécessité avec les fonctions d’autoscalling des orchestrateurs d’infrastructure, Kubernetes par exemple, j’y reviendrai)
- un système de packaging / livraison simplifiant au maximum le déploiement et les mises à jour (on va éviter les systèmes d’appuyant sur une multitude de librairies qu’il faut individuellement maintenir à jour ou encore les systèmes nécessitant un interpréteur (Python, JS, Perl, …) ou une machine virtuelle (JVM, Erlang / Elixir VM, …) pour les mêmes raisons)
Il y a trois ans, en analysant cette liste, un candidat est sorti du lot : Go.
Je me plis régulièrement (au moins une fois par an) à l’exercice qui consiste à re-challanger ce choix à la lumière des évolutions des autres langages ou des nouveaux arrivants (je garde par exemple un oeil sur V).
Jusqu’à aujourd’hui Go sort systématiquement vainqueur de l’exercice, par rapport à cette liste et pour ce type d’usage, et voici pourquoi.
Productivité
J’ai dû écrire ces trois dernières années pas loin d’une centaine de programmes en Go, du petit outil CLI au système de notification différée implémentant du CQRS , de l’ event sourcing et des FSM en passant par des applications très orientées production (imagerie médicale, GIS) ou plus destinées à M. tout le monde.
Je ne suis tout simplement pas certain que j’aurais pu en faire autant avec un autre langage.
Il est bien sûr possible d’écrire très rapidement un programme en Java, en Kotlin, en Perl ou en Ruby, mais serait-il de qualité “production” ?
J’entends pas là :
- Suffisamment modulaire pour pouvoir évoluer rapidement
- Suffisamment robuste (avec la gestion d’erreur que cela implique) pour pouvoir tourner des mois voire des années sans intervention humaine en production
- Suffisamment performant pour ne jamais représenter un bottleneck dans la globalité du système
- Suffisamment sécurisé (memory safety) pour ne pas présenter un risque important
De ma modeste expérience : non.
Go est un langage récent (il vient tout juste de fêter ses 11 ans), ce n’est pas forcement une qualité, mais cela présente un avantage : sa librairie standard répond aux besoins d’aujourd’hui.
- Besoin d’un serveur HTTP ? Boom! en 10 lignes de code vous en avez un multithreadé (plutôt multi-Go-routines, mais simplifions) ultra robuste et avec le même niveau de performances qu’un Apache ou un NGinx.
- Besoin de parser ou d’écrire du JSON ? Boom, c’est géré nativement dans les structures de données avec un peu d’annotations
- Besoin de dialoguer avec une base de données ? Boom, ça aussi c’est dans la librairie standard
- Besoin de “zipper” / “dézipper” des fichiers ? pareil
- Besoin d’envoyer / parser des emails ? idem
Si vous voulez faire du Corba ou du SOAP, il faudra certainement passer par des librairies externes, mais pour implémenter un service REST ou gRPC, vous n’avez besoin quasiment de rien d’autre que la librairie standard… et de très peu de lignes de code.
Qu’en est-il de la rapidité de compilation ?
L’un de mes projets le plus longs a compiler est un “Storage Provider” pour Kubernetes (pour faire simple, c’est un genre de driver qu’utilise Kubernetes pour provisionner de l’espace de stockage).
Mon code n’est pas très long en soit, mais au build, Go dois aussi compiler les dépendances de Kubernetes (il n’y a pas de système de librairie précompilée en Go, j’y reviendrais), c’est a dire une partie non négligeable de ce dernier (environs 3.3 millions de lignes de codes d’après scc).
Voici ce que cela donne sur mon modeste MacBook 13" :
- Première compilation (pas de cache, il faut tout builder) :
go build . 86.94s user 15.95s system 542% cpu 18.978 total
- Deuxième compilation (il vérifie simplement l’exactitude du cache et compile ce qui a changé) :
go build . 1.25s user 1.09s system 524% cpu 0.446 total
Go a donc compilé environ 3.3 millions de lignes en moins de 19 secondes sur un simple laptop.
Difficile à battre. L’impact au quotidien pour le développeur n’est pas négligeable.
Coût à l’entrée
Go est un langage volontairement simple.
C’est depuis le premier jour de sa conception (qui d’après la légende, à eu lieu en attendant la fin d’un build C++ chez Google), une priorité absolue pour ses créateurs.
Résumons :
- Ce n’est pas un langage-objet (même s’il permet d’implémenter des “méthodes” sur des sutrcutures de données) : pas de classes, de superclasses, pas de polymorphisme, d’héritage, de “protected”, de types abstraits ou de tout ce qui rend l’objet rapidement complexe pour ne pas dire inutilisable
- Ce n’est pas un langage fonctionnel (même s’il sait gérer les lambda, les fonctions d’ordre supérieur et la composition) : pas d’immutabilité, pas d’interdiction d’effet de bord, pas de foncteur, pas de monade ou d’autres vulgarités qui font fuir la plupart des nouveaux arrivants.
Il embarque le minimum (et pas beaucoup plus) :
- 7 types de données et rien d’autre : Integers (Signed and UnSigned), Floats, Complex Numbers, Byte, Rune, String, Booleans.
- Des fonctions et des “méthodes” (des fonctions applicables à une structure de donnée)
- Des interfaces pour apporter un peu de modularité
- Des packages pour organiser son code
- Et une gestion élégante et incroyablement efficace de la programmation concurrente qui s’appuie le concept CSP.
Voilà, c’est à peu près tout.
La totalité de la spécification du langage est disponible ici.
Une personne normalement équipée (avec ses 90–100 milliards de neurones) se forme au langage en une journée ou deux et commence à être productive en une semaine.
Cette simplification volontaire a ses détracteurs : la gestion des erreurs est répétitive (il n’y a pas de notion d’exception, il faut vérifier le retour de chaque appel de fonction), il n’y a pas de génériques (mais c’est un chantier en cours), ça manque d’expressivité, etc…
Mais elle a aussi et surtout d’énormes avantages :
- Je l’ai indiqué, un nouvel entrant dans une équipe est très rapidement opérationnel, même s’il ne connaissait pas le langage a son arrivée
- Le code est explicite et d’une clarté cristalline : ouvrez le code source de n’importe quel projet écrit en Go (même le volumineux Kubernetes) et vous comprendrez tout de suite (ou presque) comment il fonctionne.
- La gestion d’erreur est peut-être répétitive, mais elle est tellement explicite qu’elle ne laisse aucune place à l’oubli et il n’y a pas de rupture dans le flux d’exécution (comme cela peut être le cas avec les exceptions)
- Comme le langage est simple, la compilation est rapide (il n’y a pas de secret…)
Coût de maintenance
Un code explicite et facilement lisible est un code plus facilement maintenable (même s’il faut le double de lignes d’un code ultra-expressif / compressé et donc illisible).
Un développeur passe en moyenne 90% de son temps à lire du code et seulement 10% à en écrire.
La facilité de lecture du code devrait par conséquent être une priorité, et tant pis s’il doit taper une ligne de plus lorsqu’il en écrit, il est gagnant au final (son employeur / client aussi).
L’autre particularité relativement surprenante de Go, eu égard à son jeune âge, est sa stabilité.
Il est d’ores et déjà ultra-présent dans les infrastructures de Cloud computing, citons par exemple :
Des services qui doivent fonctionner en 24/7/365 sans interruption et avec un minimum de maintenance humaine.
Une combinaison de facteurs explique ce phénomène :
- Son aspect “memory safety by design”, qui élimine une très grande source de bugs potentiels
- Sa gestion explicite des erreurs qui ne laisse pas de place au “on part du principe que ça devrait marcher”
- Un binaire Go est autonome, il ne dépend d’aucune librairie externe (même pas de la libc), d’aucune JVM ou autre VM: les risques d’incompatibilité entre plusieurs produits sont donc nuls et le risque d’erreur lors du processus de mise à jour est réduit à son strict minimum (on remplace un fichier par un autre)
- La stabilité globale du langage : en 11 ans d’évolution, les nouveautés (au niveau du langage lui-même) sont presque inexistantes. Il suffit de lire les notes de version pour s’en convaincre. Certains trouvent cela un peu ennuyeux (j’en fais partie), mais il faut reconnaître que reprendre du code écrit il y a 10 ans et le compiler avec la version actuelle du langage n’est pas un gros défi.
Performance
C’est LE sujet qui obsède souvent les amoureux de la programmation, oubliant au passage que la performance finale n’est pas tant déterminée par le langage et le compilateur que par le talent algorithmique du développeur.
Pour faire simple, Go est rapide, le bon terme serait : suffisamment rapide.
Plus rapide que Java , un peu moins que Rust, un peu moins que C++ et beaucoup plus que Python.
Et le plus beau, c’est qu’il atteint ce niveau de performance facilement, sans nécessiter des années d’expérience pour en maîtriser les subtilités, sans avoir recours à la magie noire des 20 lignes d’options de compilation ou du fine tunning de garbage collector.
La facilité induite par le langage à faire de la programmation concurrente incite naturellement le développeur à utiliser réellement tous les coeurs disponibles sur son (ou ses) CPU(s), ce qui est beaucoup moins fréquent qu’on pourrait le penser.
La consommation des ressources
Go est plutôt économe, surtout vis-à-vis de la mémoire.
Il m’est arrivé de réécrire en Go des composants initialement développés en Java, Scala voir PHP, le bénéfice en termes de consommation mémoire est flagrant, d’un facteur de 10 souvent observé à un facteur de 100 dans certains cas extrêmes.
Lorsque l’on sait que la mémoire est la dimension la plus déterminante (parce que la plus coûteuse) dans la facturation des Cloud providers, on se demande pourquoi si peu d’entreprises y prêtent attention.
Enfin, et même si c’est moins significatif, un binaire (ou disons un container) Go pèsera lui aussi facilement 20 à 100 fois moins lourd que son équivalent Java, Kotlin ou Scala.
Ce dernier n’ayant besoin d’aucune librairie ou VM pour s’exécuter, la fabrication d’un container docker est par exemple réduite à sa plus simple expression :
FROM scratch
COPY hello /
CMD ["/hello"]
scratch
étant une image vide, hello
étant le binaire issue de votre compilation.
Pas besoin de se poser la question du choix d’une distribution Linux de base, d’une distribution de JVM, de sa version, de la fréquence à laquelle il va falloir mettre tout celà à jour pour en combler les nouvelles vulnérabilités…
Démarage rapide ?
Les architectures orientées microservices plébiscitent le découpage d’une problématique métier en sous domaines fonctionnels.
Par exemple, une boutique en ligne peut être découpée en modules tels que :
- Un module d’enregistrement des nouveaux comptes
- Un module de gestion des identités
- Un module de gestion de stock
- Un module de présentation des données (pour le front)
- Un module de calcul du montant du panier (gérant les remises et autres promos conditionnelles)
- Un module de gestion des paiements
- Un module de gestion des expéditions / livraison
- Un module de gestion des retours
- Un module de gestion de la communication (marketing ciblé) aux clients
Chaque module peut être sollicité avec une intensité différente au cours du temps, certains pourraient même être lancés de façon événementielle (par exemple le calcul des modalités logistiques inhérent à la préparation d’une commande).
Si l’on considère que l’entreprise souhaite piloter sa facture IT au plus juste, elle a intérêt a ce que cette architecture logicielle se comporte de façon élastique, c’est-à-dire que le nombre d’instances d’un module donné augmente en cas de pics d’utilisation et qu’il baisse rapidement (potentiellement jusqu’à 0) lorsqu’il n’y plus ou peu de trafic.
Le véritable “Pay as you Grow” est à ce prix.
Cela implique qu’en cas d’augmentation rapide du trafic sur la boutique en ligne, le démarrage d’une nouvelle instance d’un module ne peut prendre plus de 2 ou 3 secondes.
La JVM sur laquelle s’appuient les langages tels que Scala, Java ou Kotlin n’a pas été conçue pour cela.
Elle est née à une époque ou l’on concevait plutôt de grosses applications monolithiques qui pouvaient mettre plusieurs minutes à démarrer, car elles n’étaient pratiquement jamais arrêtées. Et pour cause, elles étaient exécutées sur des machines que l’entreprise avait achetées et dont elle finançait la totalité de l’amortissement qu’elles soient utilisées à 5, 10 ou 100% de leur capacité.
Le paradigme du Cloud computing et des orchestrateurs tel que Kubernetes, réellement capable de piloter efficacement l’élasticité (le nombre d’instance(s)) d’un programme en fonction d’une demande mesurable, ont tout changé.
Des initiatives sont apparues pour tenter de corriger cette inadéquation avec les besoins actuels, tels que l’excellent Quarkus, mais les efforts à déployer pour adataper des applications “legacy” sont tels que la question de leurs redéveloppements complets dans une technologie nativement armée pour ces nouvelles contraintes est parfois pertinente.
La grande majorité des applications Go démarrent en moins d’une seconde, même celles embarquant un serveur Web et/ou devant se connecter à une base de données.
Le packaging / déploiement
Nous l’avons vu, Go produit des binaires autonome, les dépendances sont “linkées” de façon statique.
En effet, et bien que ce soit prévu par le compilateur quasiment personne ne produit de librairies précompilées.
Lorsque vous avez une dépendance à une libraire externe, il vous suffit d’indiquer l’URL du repository de cette dernière, par exemple :
import (
"github.com/sirupsen/logrus"
)
Le système de gestion des dépendances (go modules) va alors récupérer la dernière release (vous pouvez malgré tout choisir une version précise) de cette dernière, en télécharger les sources qui seront ensuite compilées avec les vôtres lors du build.
Il s’agit dans l’exemple d’un repo Git, mais Go en supporte plusieurs types :
Bazaar .bzr
Fossil .fossil
Git .git
Mercurial .hg
Subversion .svn
Ce système à la fois simple et élégant a deux conséquences heureuses :
- Plus besoin de déployer ou d’utiliser un “artifact repository” tel que Maven, Nuget, CPAN, npm, RubyGems, etc… pour publier une librairie (un package en Go), vous n’avez qu’a “commiter” , “pousser” et versionner. Difficile de faire plus simple.
- Pas besoin de “builder” votre librairie, ce sera fait lors de son utilisation. Et c’est heureux, car Go étant multi-plates-formes et multiarchitectures, il faudrait la compiler et la stocker dans toutes les combinaisons possibles, ce serait fastidieux et coûteux.
Vous pourriez objecter que cela va se payer lors du “build” du binaire final (puisqu’il doit tout compiler) mais rappelez-vous des performances du compilateur (3 millions de lignes en moins de 19s dans mon exemple précédent).
Dans la grande majorité du temps, il n’y a qu’un fichier à déployer en production ou à injecter dans une image Docker.
Il peut toutefois arriver que vous ayez besoin d’y ajouter des ressources, pour les applications Web notamment : images, styles CSS, scripts JS, …
Il existe trois façons de gérer cela :
- Votre format cible est une image Docker et vous pouvez tout simplement copier vos ressources dans cette dernière.
- Votre format cible est un binaire unique : vous pouvez y injecter vos ressources, soit avec des librairies tels que packr ou omeid mais aussi bientôt simplement avec la librairie standard.
- Votre format cible est un binaire et ses ressources : et bien vous livrez votre binaire et ses ressources (dans un zip ou dans n’importe quel autre format).
Personnellement, je déploie le plus souvent sous forme d’image Docker (les cibles étant le plus souvent des clusters K8s).
Avec un minimum de rigueur dans la stratégie de versionning (mais là encore le langage à tout prévu), la gestion des déploiements et des montées de versions est un jeu d’enfant.
Ce système de gestion de dépendances et de format cible est tellement simple et efficace que l’on se demande pourquoi les autres languages ne l’ont pas adopté (tous ceux qui ont eu à faire un npm build
récemment comprendront).
Conclusion
Go n’est n’est pas un langage académique, il n’a pas été conçu dans l’optique de faire avancer la science des langages ou explorer de nouveaux paradigmes.
Go a été conçu par des Ingénieurs pour des Ingénieurs, c’est un outil, pas un sujet d’étude.
On le ressent à l’utilisation, il est pragmatique, efficace, simple, parfois basique, mais il permet de résoudre rapidement et de façon fiable 99% de mes problèmes quotidiens et c’est tout ce que je lui demande.
Il ne prétend pas pour autant être un langage universel, il est par exemple très peu utilisé dans le développement mobile ou pour les UIs desktop (auquel je suis relativement peu exposé chez mes clients).
On l’a pendant longtemps considéré comme “le langage du Cloud”, de l’infrastructure, mais il s’est ouvert à bien d’autres types d’applications ces 11 dernières années.
Que vous soyez développeur, “Tech lead”, CTO, ou d’une manière plus générique, décideur c’est une technologie qui mérite votre attention, car elle peut vous simplifier la vie, vous rendre plus productif et conduire à des économies et une meilleure efficience pour votre entreprise et vos clients.
Originally published at https://www.solutions.im on November 14, 2020.