Un CLI météo, cinq chaînes d’outils : Rust, Go, Zig, Bun et Node.js
À quoi ressemble la même interface en ligne de commande HTTP-plus-JSON petite dans Rust, Go, Zig, Bun et Node.js, et quelles sont les implications de la taille du fichier binaire, du temps de compilation et des difficultés d’installation pour votre choix.
Les micro-benchmarks tels qu’une boucle Fibonacci ne disent pas grand-chose sur les coûts liés au développement d’une véritable outil en ligne de commande. Un test plus fiable consiste à créer une petite utilité qui communique avec le réseau, décode du JSON, effectue des calculs mathématiques et affiche un résultat bien structuré, développée de manière identique dans plusieurs langages. Cette présentation suit précisément cette expérience avec Rust, Go, Zig, Bun et Node.js, afin que vous puissiez déterminer quel ensemble d’outils convient le mieux à votre prochain outil en ligne de commande, en fonction de la taille du binaire, du temps de compilation, des coûts en temps de exécution et, surtout, du nombre d’obstacles entre un dossier vide et un binaire que vos utilisateurs peuvent exécuter.
Il convient de mentionner d’emblée les résultats principaux. Les fichiers binaires finaux variaient de 1,2 MB à 45 MB, et omettre complètement la compilation signifie demander aux utilisateurs d’installer un environnement de exécution d’environ 100 MB. Les temps de compilation propres variaient de 0,8 seconde à 28 secondes. La recommandation la plus pragmatique en fin de compte n’est pas celle du langage offrant l’exécution la plus rapide.
Outil de test et raison pour laquelle il représente une charge de travail équitable
Cet outil s’appelle wx. On lui donne le nom d’une ville ; il convertit ce nom en coordonnées à l’aide de l’API de géocodage Open-Meteo, demande les conditions météorologiques actuelles via l’API de prévisions d’Open-Meteo, puis affiche le résultat ainsi qu’une température ressentie calculée. Une invocation typique se présente comme ceci :
$ wx reykjavik
Reykjavik, Iceland
Temperature: 4.2C (feels like -0.4C)
Wind: 24 km/h NNW
Humidity: 68%
Cet outil est délibérément compact, mais il couvre les quatre domaines où l’ergonomie de la ligne de commande diffère réellement d’un écosystème à l’autre :
- Un client HTTP avec TLS. Deux requêtes HTTPS sont nécessaires, une pour la géocodage et une pour la prévision.
- Décodage JSON. Les réponses sont mappées sur des structures typées plutôt que traitées comme des objets non structurés.
- Calcul réel. La température apparente est le résultat d’une formule avec des branches, et non de la concaténation de chaînes.
- Sortie vers le terminal. Couleurs ANSI et colonnes alignées, c’est ce que voient réellement les utilisateurs.
Un langage qui ne peut pas gérer confortablement ces quatre aspects n’est pas un bon choix pour une interface en ligne de commande, peu importe la vitesse à laquelle il exécute des boucles numériques complexes.
La seule logique commune
Chaque version applique la même règle à trois branches. En dessous de 10 °C, elle utilise la formule du froid perçu de Environment Canada. Au-dessus de 27 °C, elle applique la régression de l’indice de chaleur Rothfusz de NOAA, exprimée en degrés Celsius avec l’humidité indiquée en pourcentage. Entre ces deux valeurs, la température brute est restituée telle quelle. La version en TypeScript, utilisée tant pour les builds Bun que Node.js, est présentée ici ; les autres langages implémentent la même logique arithmétique.
function feelsLike(tempC: number, windKmh: number, humidity: number): number {
if (tempC < 10) {
// Wind chill (Environment Canada formula)
const v = windKmh ** 0.16;
return 13.12 + 0.6215 * tempC - 11.37 * v + 0.3965 * tempC * v;
}
if (tempC > 27) {
// Heat index (NOAA Rothfusz regression, in Celsius)
const t = tempC;
const r = humidity;
return -8.784 + 1.611 * t + 2.338 * r - 0.146 * t * r
- 0.0123 * t * t - 0.0164 * r * r + 0.00221 * t * t * r
+ 0.000725 * t * r * r - 0.00000358 * t * t * r * r;
}
return tempC; // between 10C and 27C, raw temperature
}
Deux points méritent une attention particulière. Premièrement, les limites du régime sont ce que les versions approximatives de ce code comprennent mal, ce qui en fait un bon critère de vérification de la correction lors du comparatif des implémentations. Deuxièmement, les deux formules possèdent des plages de validité que cette version simplifiée ignore : l’équation du froid du vent est conçue pour des vitesses de vent d’environ 5 km/h et plus, tandis que la régression de Rothfusz n’est adaptée qu’aux conditions chaudes et relativement humides. Cela est acceptable pour un outil météorologique de démonstration, mais une version en production devrait limiter les calculs ou recourir à la température brute en dehors de ces plages.
Expérience de développement pour chaque implémentation
Le code complet dans les cinq langages compte environ 360 lignes, donc seuls les extraits les plus révélateurs sont présentés. Ce qui importe, c’est de savoir dans quels cas chaque écosystème a été utile et dans quels cas il a posé des problèmes.
Rust avec reqwest, serde et un analyseur d’arguments basé sur derive
La compilation en Rust utilise un crate populaire basé sur le mécanisme derive pour l’analyse des arguments, reqwest pour les requêtes HTTP et serde pour la désérialisation. Le fragment ci-dessous montre le principe qui rend Rust particulièrement adapté à ce type de tâches : les macros derive génèrent à partir de simples définitions de structures à la fois l’analyseur en ligne de commande et les décodeurs JSON, et les types des champs sont vérifiés au moment de la compilation.
#[derive(Parser)]
#[command(name = "wx", about = "Weather lookup")]
struct Cli {
city: String,
}
#[derive(Deserialize)]
struct WeatherResponse {
current: CurrentWeather,
}
#[derive(Deserialize)]
struct CurrentWeather {
temperature_2m: f64,
wind_speed_10m: f64,
relative_humidity_2m: u8,
wind_direction_10m: f64,
}
Le programme entier compte environ 95 lignes, couvrant à la fois les appels API et la logique liée à la température. Le runtime asynchrone tokio doit être activé explicitement, tandis que Node.js cache son boucle d’événements. Cette explicitité est utile pour le contrôle, mais elle augmente la taille du binaire.
Ce qui était surprenant, c’était le résultat par défaut : une simple exécution de cargo build --release produisait un binaire de 8,4 MB. En activant l’optimisation en temps de liaison et le suppression des symboles dans Cargo.toml, on est passé à 3,8 MB. Ces paramètres sont bien connus des personnes qui distribuent des outils en Rust, mais un débutant distribuerait probablement le fichier plus volumineux sans se rendre compte qu’un fichier plus petit existait, à deux lignes de là.
Se contenter de la bibliothèque standard
La compilation en Go ne nécessite absolument aucun paquet tiers. net/http, encoding/json et os.Args suffisent pour tout gérer. Le fragment montre la structure de réponse, où les balises associent les clés JSON aux noms de champs typiques en Go, ainsi que le début de la fonction main avec une vérification minimale d’utilisation.
type WeatherResponse struct {
Current struct {
Temperature float64 `json:"temperature_2m"`
WindSpeed float64 `json:"wind_speed_10m"`
Humidity int `json:"relative_humidity_2m"`
WindDir float64 `json:"wind_direction_10m"`
} `json:"current"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: wx <city>")
os.Exit(1)
}
city := os.Args[1]
// geocode, fetch weather, compute, print
}
Le programme final compte 68 lignes. Un schéma domine : la vérification if err != nil { log.Fatal(err) } apparaît cinq fois, une fois pour la demande de géocodage, son corps, sa décodification, la demande de prévision et le corps de la prévision. Cette répétition n’est pas un véritable problème, mais c’est la ligne la plus fréquente dans pratiquement tout CLI en Go.
Zig 0.16 avec std.http.Client et std.json
Zig, testé à la version 0.16, a été la version la plus éducative mais aussi celle qui a mis le plus de temps à être finalisée. Le fragment montre la structure de réponse ainsi que le début de la fonction main, où un allocateur de débogage est créé puis transmis. C’est là la caractéristique fondamentale de Zig : toute fonction pouvant allouer de la mémoire heap reçoit un argument d’allocateur, ce qui permet à la propriété de la mémoire d’être toujours visible dans la signature de la fonction.
const WeatherResponse = struct {
current: struct {
temperature_2m: f64,
wind_speed_10m: f64,
relative_humidity_2m: u8,
wind_direction_10m: f64,
},
};
pub fn main() !void {
var debug_allocator = std.heap.DebugAllocator(.{}){};
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
// Every function that might allocate takes `allocator` as a parameter.
// This is Zig's deal: you control memory, always.
}
Le programme est passé à 108 lignes, devenant ainsi le plus long des cinq. La compilation elle-même n’a duré que 0,8 seconde, ce qui représente le résultat le plus rapide dans cette comparaison, mais la construction du projet a nécessité plus d’une heure de travail réel. La cause en était TLS. std.http.Client intègre sa propre implémentation TLS, mais nécessite néanmoins des certificats racine fiables ; sur la machine de test, il n’a pas pu trouver le paquet des certificats CA du système. Le seul symptôme observé était error.TlsInitializationFailed, sans aucun contexte supplémentaire. La solution, qui consiste à charger explicitement les certificats via std.crypto.Certificate.Bundle et à transmettre ce paquet au client, n’a été proposée que dans une discussion sur un issue GitHub. Vos résultats peuvent varier en fonction de la plateforme, mais la leçon reste la même : un compilateur rapide ne peut pas compenser le temps perdu à cause d’erreurs en temps de exécution opaques.
Le passage explicite de l’allocateur est idéal pour les logiciels où le comportement de la mémoire est crucial. Pour une utilité qui alloue quelques chaînes de caractères et un buffer JSON, cela ajoute principalement de la complexité inutile. Zig dispose bien d’un gestionnaire de paquets intégré basé sur build.zig.zon, disponible depuis 0.11, mais l’écosystème reste limité ; aucune bibliothèque de couleurs de terminal maintenue n’a été trouvée, si bien qu’un outil ANSI d’environ 40 lignes a été copié depuis un gist.
Bun avec un seul fichier TypeScript
La version Bun est la plus courte et la plus facile à lire. Elle lit la ville depuis Bun.argv, quitte avec un message d’utilisation si celle-ci fait défaut, puis utilise deux fois la fonction intégrée fetch et décode chaque réponse avec .json(). L’utilisation de await au niveau le plus élevé signifie qu’il n’y a pas de fonction d’encapsulation.
const city = Bun.argv[2];
if (!city) {
console.error("usage: wx <city>");
process.exit(1);
}
// Geocode city name to coordinates
const geoRes = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
);
const geo = await geoRes.json();
const { latitude, longitude } = geo.results[0];
// Fetch weather
const wxRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,wind_speed_10m,relative_humidity_2m,wind_direction_10m`
);
const data = await wxRes.json();
Le fichier complet compte 47 lignes, y compris les deux requêtes, et son exécution a duré environ huit minutes, la majeure partie du temps étant consacrée au calcul de la formule de température. Notez cependant ce que l’extrait ne fait pas : il ne vérifie jamais res.ok, et geo.results[0] est défini comme nul lorsque le géocodeur ne trouve aucune correspondance, ce qui provoque une erreur de déstructuration au lieu d’un message explicite en cas d’erreur de frappe pour la ville. La brièveté est réelle, mais une interface en ligne de commande destinée à un usage professionnel a besoin de ces quelques lignes supplémentaires.
Le problème avec Bun réside dans sa distribution. bun build --compile génère un exécutable autonome d’environ 45 MB pour cet outil minuscule, car le résultat inclut à la fois le moteur JavaScriptCore et l’environnement de exécution Bun. Cela représente environ sept fois la taille du binaire Go et près de quarante fois celle du binaire Zig. Si vos utilisateurs ont déjà Bun installé, exécuter directement le fichier .ts évite complètement ce problème.
Node.js, qui n’a pas besoin de fiche dédiée
L’implémentation Node.js est essentiellement le code de Bun, puisque Node.js intègre depuis sa version 18 une fonction fetch native. Les différences significatives se situent au niveau du moteur d’exécution :
- Cost de démarrage. La majeure partie de la différence dans le temps d’exécution total provient du démarrage même du processus : 82 ms pour Node.js contre 24 ms pour Bun, tandis que les allers-retours réseau simulés ne ajoutent que quelques millisecondes. Pour une commande interactive, cela n’est pas perceptible ; cependant, au sein d’une boucle de shell qui exécute l’outil des centaines de fois, cela cumule.
.ts s’exécute sans tsx ni ts-node ; cette fonctionnalité était considérée comme stable dans la version LTS 24 au moment de la rédaction. Seule la syntaxe éliminable est prise en charge : les annotations disparaissent, mais les constructions qui génèrent du JavaScript, telles que les enums, les noms d’espace et les propriétés de paramètres, nécessitent toujours un transpilateur. Bun va encore plus loin sans configuration, gérant JSX, les décorateurs et les alias de chemins. Pour en savoir plus sur ce que couvre le suppression native, consultez ce que la prise en charge native de TypeScript par Node.js fait réellement et ne fait pas.Si l’on considère uniquement le point de vue d’une interface en ligne de commande entièrement nouvelle, Node.js propose le même code que Bun, mais avec un démarrage plus lent et des exigences de distribution plus importantes. Il s’agit cependant d’un jugement limité. Si votre équipe utilise déjà Node.js comme standard, compte sur ses garanties de compatibilité npm ou gère une interface en ligne de commande existante dessus, ces facteurs peuvent facilement compenser une différence de 60 ms au démarrage.
Mise en place des mesures et chiffres rapportés
Les temps de exécution ont été mesurés sur un M3 MacBook Pro doté de 18 GB de RAM et fonctionnant sous macOS 26, en utilisant hyperfine avec la commande hyperfine --warmup 3 --min-runs 50 './wx reykjavik'. Afin d’éliminer les interférences réseau, les appels API étaient dirigés vers un serveur HTTP local simulé qui renvoyait du JSON fixe. Le temps total indiqué correspond au temps écoulé depuis le démarrage jusqu’à la fin du processus, y compris le temps de démarrage. Les temps de développement sont des estimations approximatives et non des valeurs mesurées ; il convient donc de comparer les ratios plutôt que les minutes.
Les principaux résultats obtenus :
- Rust : environ 95 lignes de code, 3,8 MB après ajustements (8,4 MB par défaut), 28 secondes pour une compilation sans erreur, 4,8 ms de temps d’exécution.
- Go : 68 lignes de code, 6,2 MB, moins d’une seconde pour la compilation, 5,2 ms de temps d’exécution, avec un temps total d’environ 10 minutes pour terminer le travail.
Les tailles indiquées dépendent des paramètres de compilation. Rust nécessitait lto = true et strip = true sous [profile.release]. Go a été compilé avec go build -ldflags='-s -w' afin d’éliminer les informations de débogage. Les 1,2 MB de Zig correspondent à une compilation ReleaseSmall ; elle reste très légère même avec TLS, car le client HTTP et le code de cryptographie se trouvent dans la bibliothèque standard et sont liés statiquement sans code inutile, contrairement à l’utilisation d’outils comme OpenSSL. Une compilation ReleaseSafe, qui conserve les vérifications de sécurité en temps de exécution, atteint environ 2,1 MB.
Ce que les chiffres des benchmarks cachent
Zig gagne sur le papier mais perd en pratique
D’après les métriques, Zig a généré les binaires les plus petits et parmi les plus rapides. Cependant, l’heure passée à travailler sur 108 lignes de code raconte une histoire différente :
- Le problème lié au paquet de certificats a occupé plus de 40 minutes à cause d’une erreur sur une seule ligne.
ReleaseSmall qui génère 1,2 MB ne contient pas d’informations sur les empilements ; ReleaseSafe conserve les traces des pannes mais représente la variante de 2,1 MB.defer ont provoqué des fuites mémoire qui n’ont été détectées que lorsque le gestionnaire de mémoire en mode débogage les a signalées à la fin du programme.Pour un outil destiné à rester en service longtemps et où l’on souhaite contrôler chaque allocation ainsi que chacun des octets produits, cette précision s’avère bénéfique avec le temps. Pour quelque chose qui doit fonctionner rapidement, c’est actuellement un choix peu adapté. La conception du langage est élégante ; l’écosystème qui l’entoure est simplement plus jeune.
Go n’est jamais le meilleur dans un domaine précis, mais il gagne malgré tout globalement
Go se compile en moins d’une seconde, génère un binaire autonome raisonnable, ne nécessite que la bibliothèque standard et était opérationnel en dix minutes. Il est plus volumineux que Rust optimisé (6,2 MB contre 3,8 MB) et légèrement plus lent (5,2 ms contre 4,8 ms), mais une personne ne peut percevoir cette différence dans un outil qui s’exécute en quelques millisecondes.
La compilation croisée se fait grâce à une seule variable d’environnement, par exemple GOOS=linux go build. Rust se rapproche avec cargo build --target x86_64-unknown-linux-gnu ou l’outil cargo-zigbuild, et Zig présente sans doute la meilleure capacité de compilation croisée, car il intègre son propre lienseur et sa libc. La différence réside dans le fait que Go ne requiert ni outils supplémentaires ni configuration. C’est là le schéma récurrent : Go atteint rarement la première place dans n’importe quelle mesure, mais il présente le moindre niveau de friction global.
Bun est excellent localement mais difficile à distribuer
Bun a généré le code le plus propre en le moins de temps. Pour un script personnel stocké dans ~/bin sous forme de fichier .ts, il est difficile de trouver mieux. En revanche, 45 MB pour une recherche météorologique constituent un véritable inconvénient ; comme presque tout cet espace est occupé par le moteur intégré, il n’existe aucun moyen pratique de réduire la taille sans une version plus légère fournie par l’équipe de Bun, qui n’existait pas au moment des tests.
L’argument en faveur de Rust pour les petits outils s’est affaibli
Il y a quelques années, les avantages des CLI en Rust reposaient sur la vitesse, la sécurité et la taille réduite des binaires. Pour les outils axés sur les opérations d’entrée/sortie, cet avantage est désormais moins évident :
- Go offre une vitesse comparable à celle de Rust.
- Zig génère des binaires plus petits sans aucun ajustement nécessaire.
- Un temps de compilation de 28 secondes pour un programme de 95 lignes représente une charge importante pour des utilitaires rapides.
Rust reste brillant dans les outils qui attirent des milliers d’utilisateurs et bénéficient de maintenances sur de longues périodes, car son système de types permet de détecter continuellement des cas limites. ripgrep, fd, bat, delta et hyperfine sont tous des CLI en Rust, et tous font l’objet d’une maintenance sérieuse sur le long terme plutôt que d’avoir été écrits en une fin de semaine. Pour des projets rapides, les coûts supplémentaires ne se rentabilisent que rarement ; pour un outil largement distribué, c’est peut-être l’option la moins susceptible d’accumuler des bugs subtils.
Sélectionner une chaîne d’outils pour votre prochain CLI
La décision dépend moins de la vitesse brute que de celui qui utilisera l’outil et des moyens par lesquels il lui sera accessible :
- Préférez Go lorsque l’objectif est de minimiser le coût total, du dossier vide au binaire distribué : code fonctionnel en quelques minutes, compilations en sous-seconde, un seul fichier de 6,2 MB sans dépendances et une compilation croisée simplifiée.
Chaque implémentation est suffisamment petite pour être reconstruite à partir des éléments mentionnés ci-dessus en une après-midi, accompagnée d’un serveur fictif et d’un script hypersimple. Vos résultats concrets varieront en fonction du matériel, du système d’exploitation et des versions des outils, mais l’ordre relatif devrait rester stable : Zig est le plus petit, Bun le plus grand, et Go présente le moindre niveau de friction.
Points clés
- Mesurez l’ensemble du chemin menant au binaire final, et non seulement le temps d’exécution ; la configuration, le débogage et la distribution représentent une part importante pour les petits outils.
- Les paramètres de compilation par défaut peuvent doubler la taille du binaire, il est donc nécessaire de connaître les flags de publication propres à l’outilchain choisi.
- Les binaires basés sur le temps d’exécution fournis par Bun ou Node.js SEA incluent tout l’ensemble moteur, ce qui est bien plus important que leur temps de démarrage.
- Vérifiez les entrées et les réponses HTTP même dans les scripts courts ; l’implémentation la plus concise est souvent celle qui omet le traitement des erreurs.
- Considérez les statuts de version et les tailles indiqués comme une donnée momentanée, et vérifiez-les à nouveau par rapport aux versions actuelles avant de prendre une décision.
Lectures complémentaires
- Isolats Edge et Wasm vs. Node.js : Compromis liés au temps d’exécution et pratiques en environnement de production — Décrit comment les isolats V8 et WebAssembly surpassent Node.js basé sur des conteneurs en environnement edge, puis aborde les pratiques opérationnelles qui rendent Node.js prêt pour la production.
- Node.js, Deno et Bun comparés : Essais, compromis et stratégie de migration — Explique les véritables différences architecturales entre Node.js, Deno et Bun, ce que révèlent les essais de 2025, ainsi que la manière de déterminer s’il convient de migrer et quand le faire.