Aller au contenu principal

Pourquoi nous avons choisi Rust pour la version 3

· 14 minutes de lecture
Dave Rapin
Dave Rapin
Fondateur chez Curling IO
À propos de cet article

Cet article technique sur l’architecture provient du développement de Curling IO v3. Il s’adresse aux ingénieurs logiciels et approfondit Gleam, Rust, SQLite, l’architecture des interfaces côté client et les outils de compilation davantage que nos articles habituels sur le produit.

Pour Curling IO v3, nous savions que nous voulions quitter la pile Ruby on Rails utilisée pour la version 2. Nous voulions un langage à typage statique avec de meilleures garanties à la compilation et moins de pièges. Nous avons d’abord exploré Gleam, qui répondait à ces critères et nous donnait accès à la BEAM. Lustre offrait un cadre d’interface typé pour le serveur et le client. Une première mise en œuvre nous a permis de voir comment cette combinaison s’intégrerait à notre architecture. Nos premiers articles sur les fondations décrivent ce que nous apprenions et construisions.

Ce travail a aidé à préciser les besoins du produit, les changements souhaités par rapport à la version 2 et la conception de l’interface. Il a aussi révélé des compromis de déploiement et d’interactions côté client qui nous ont finalement amenés à choisir Rust et TypeScript pour notre architecture, avant d’aller plus loin avec Gleam et Lustre. SQLite et la conception du produit ont été conservés. Nous avons depuis porté et enrichi nos outils SQL et construit un petit environnement d’exécution TypeScript appelé Hypertea pour les interactions côté client.

Ce changement a amélioré le déploiement, nous a donné une automatisation utile à la compilation et convenait à notre façon d’examiner et de corriger les problèmes. Malheureusement, il a aussi rendu nos compilations beaucoup plus lourdes. Il y a des aspects de Gleam et Lustre que nous préférons encore.

Ce que nous avons gardé de notre première exploration​

Les besoins du produit, les changements de comportement prévus par rapport à la version 2, la conception de l’interface et le CSS ont été conservés. SQLite aussi. Gleam et Lustre nous ont également aidés à définir l’architecture et la conception voulues. Cette exploration a inspiré ce que nous avons ensuite construit avec Rust et Hypertea.

Nous avons remplacé tout le code Gleam écrit jusque-là. Il n’en reste aucun dans l’application. Utiliser le même langage sur le serveur et le client, l’environnement d’exécution BEAM et Lustre font partie de ce à quoi nous avons renoncé.

Operations, notre système de suivi des erreurs et l’infrastructure NixOS sont arrivés après le passage à Rust.

Nos premiers articles sur les fondations décrivent la conception Gleam de l’époque. Automatiser la gestion du club avec l’IA est aussi issu de ce travail initial.

Déployer Gleam avec SQLite était compliqué​

SQLite fonctionnait très bien pour nous. Préparer sa dépendance native pour la version distribuable Erlang était plus frustrant.

Notre intégration SQLite utilisait un NIF, l’interface d’Erlang vers le code natif. Construire une version distribuable exigeait de gérer ce composant natif et sa compatibilité avec l’environnement de production. Il nous fallait un environnement de compilation Linux assez proche de la production et assez puissant pour la compilation et l’édition de liens. Notre configuration exigeait aussi des composants supplémentaires sur l’hôte de production. Nous n’aimions pas entretenir tout cela simplement pour que SQLite fonctionne dans une version distribuable.

Rust a encore des dépendances natives, dont le code C de SQLite, mais nous compilons les versions Linux nativement sur notre machine de développement, les vérifions et téléversons le paquet. La production n’a besoin ni de la chaîne d’outils Rust ni du code source de l’application.

Ce parcours de compilation pour l’autre environnement nous permet aussi de choisir explicitement la cible de la version.

Nous utilisions à peine les avantages de la BEAM​

L’application avait besoin d’un état durable des tâches dans SQLite, quel que soit l’environnement qui exécutait le travail. Les tâches en attente devaient survivre à la perte d’un processus ou d’une machine. L’environnement d’exécution ne pouvait pas remplacer ce besoin de persistance.

La BEAM offrait toujours la concurrence et la gestion des processus, mais nous n’utilisions plus assez de ses capacités distinctives pour en faire le facteur décisif. Rust avec Tokio répondait aux besoins de concurrence de notre application tout en convenant au parcours de déploiement préféré.

Tokio ne reproduit ni la supervision OTP ni l’isolation des processus de la BEAM. Pour cette charge de travail, nous étions à l’aise d’y renoncer.

Rust excelle pour les outils et le débogage​

Rust et Gleam partagent plusieurs garde-fous qui facilitent la compréhension d’un code : types statiques, valeurs facultatives explicites et filtrage exhaustif. Les agents de programmation bénéficiaient de ces vérifications dans les deux langages. Deux autres éléments favorisaient Rust dans notre expérience.

À l’époque, les agents proposaient généralement des corrections Rust plus idiomatiques. Ils pouvaient produire du code fonctionnel dans les deux langages, mais le code Rust ressemblait plus souvent à celui d’un développeur qui connaît bien le langage.

Les diagnostics détaillés du compilateur Rust ont aidé les agents à vérifier leurs corrections. Les renseignements d’erreur à l’exécution dans notre pile nous ont aussi aidés à retracer les échecs. Ces diagnostics d’exécution dépendent de nos bibliothèques et du contexte d’erreur conservé. Avec les indications du compilateur, ils aident un agent à suivre un parcours inconnu et à vérifier une correction proposée. Du point de vue d’un agent de programmation, c’est peut-être la plus grande force de Rust.

Les macros de dérivation ont aussi beaucoup compté. Serde a été très fiable pour nous. Ses dérivations produisent les implémentations de sérialisation et de désérialisation à partir des types Rust sans que nous entretenions ce code répétitif à la main. Nos outils de contrats s’appuient sur ces types pour produire le côté TypeScript des interfaces d’îlots clients.

Rust Marmot produit des appels de base de données typés à partir de SQL. C’était au départ une bibliothèque Gleam. Elle nous plaisait tellement que nous l’avons portée en Rust, puis continué à l’enrichir et à l’améliorer. Les macros de dérivation Rust ont aussi amélioré Marmot. Sa dérivation FromSqlRow produit les correspondances courantes entre les lignes de requête et les types de l’application, tandis que les conversions métier restent explicites.

Rust Proute a suivi un parcours semblable : conçu en Gleam, puis porté en Rust. Il combine la génération de routes et des paramètres typés, y compris la désérialisation dérivée. La génération de routes et le décodage dérivé remplissent des rôles différents et réduisent tous deux le code de liaison à garder synchronisé.

Entretenir les générateurs​

Marmot et Proute produisent du code applicatif qu’il faudrait autrement écrire et synchroniser nous-mêmes. Marmot produit des appels typés au-dessus de rusqlite; Proute génère le routage. Garder ces rôles précis nous aide à éviter une abstraction plus large que nécessaire. Nos bancs d’essai SQLite publiés ont aussi mesuré un meilleur débit pour Rust Marmot que pour SQLx avec les formes de requêtes testées.

Le code produit est simple. La mécanique derrière ne l’est pas toujours. Marmot doit examiner le schéma de la base de données et transformer les résultats SQL en liaisons typées. Cela exige beaucoup de code, dont l’entretien nous appartient. La génération et les vérifications du compilateur repèrent rapidement de nombreuses erreurs d’interface; elles ne prouvent pas qu’une requête applique la bonne règle métier.

Nous jugeons cette charge gérable. Les générateurs sont documentés et testés, et les diagnostics Rust aident les développeurs comme les agents à y travailler. L’application a des ADR, des règles de responsabilité et des conventions vérifiées pour guider les modifications. Ces éléments, agents compris, aident à intégrer un nouveau développeur. Le jugement appartient toujours à la personne responsable de la maintenance.

SQLite était déjà rapide avec Gleam, mais Rust a presque doublé son débit​

Nous étions déjà satisfaits des performances de SQLite dans Gleam lorsque nous avons mesuré les deux intégrations.

Nos bancs d’essai publiés sur les formes de requêtes comparent directement les intégrations :

Parcours de base de donnéesGleam MarmotRust MarmotDébit Rust/Gleam
Séquence d’administration surtout en lecture8 065/s12 886/s1,60×
Courte transaction de mise à jour21 408/s45 815/s2,14×

Ces tests utilisaient des données synthétiques, des formes de requêtes tirées de la production et un serveur Ryzen 7 9700X avec 62 Gio de mémoire vive. La séquence de lecture exécute environ 26 petites requêtes indexées. La séquence d’écriture exécute une courte transaction avec WAL et synchronous=NORMAL.

Le parcours Rust Marmot utilise rusqlite. Ces mesures couvrent les intégrations complètes de base de données, y compris leurs liaisons et leur code généré. Elles n’isolent pas le coût du NIF et ne mesurent pas le débit de l’application complète. Les gains s’appliquent aux formes de requêtes testées.

Les composants serveur n’éliminaient pas le besoin d’état côté client​

Un composant serveur était pratique jusqu’à ce que l’état doive changer immédiatement sous la main de la personne.

Un panneau ou un menu déroulant géré par le serveur peut fonctionner, mais l’aller-retour n’est pas toujours agréable. Le glisser-déposer rend le problème plus évident : le pointeur et la réaction visuelle doivent rester ensemble. Envoyer chaque position au serveur et attendre une mise à jour du DOM n’était pas le modèle souhaité.

Nous utilisions des îlots clients Lustre dédiés pour les interactions importantes. Le créateur de formulaires, par exemple, avait un hôte serveur sous server/src/server/admin/product/ et son programme de navigateur sous client/src/client/form_builder/. Le paquet serveur ciblait Erlang; le paquet client ciblait JavaScript. Une seule fonctionnalité traversait deux arborescences source.

Il y avait aussi de petits comportements locaux autour des formulaires et de la mise en page. Ils s’accumulaient en fragments JavaScript. Nous aimions la structure typée de l’application et n’aimions pas en sortir pour toutes ces petites interactions.

Le modèle de composants serveur aidait l’administration, mais n’offrait pas le même avantage à notre interface MCP pour agents ou à une éventuelle application native. Cela limitait la part du produit qui pouvait en bénéficier.

Garder TEA, même pour les petits détails​

Après le passage à Rust, nous voulions garder The Elm Architecture côté client. Nous avons considéré Elm, mais voulions aussi que l’ajout d’un petit îlot coûte assez peu pour un panneau, une commande ou un comportement modeste de formulaire.

Nous avons construit Hypertea, un petit environnement d’exécution TypeScript inspiré de Hyperapp et de TEA. Nous utilisons la même structure pour les petites commandes et les grands îlots : état explicite, messages typés, fonction de mise à jour, effets et abonnements gérés. Même une petite interaction reçoit cette structure plutôt qu’un autre fragment JavaScript isolé.

TypeScript strict et l’analyse statique soutiennent ce modèle. Ils repèrent les erreurs de valeurs facultatives, imposent un traitement exhaustif là où c’est configuré et gardent les effets secondaires non gérés hors des modules applicatifs ordinaires. Ce ne sont pas toutes les garanties de Gleam ou d’Elm, et les outils sont moins élégants. Nous trouvons quand même cette façon d’écrire les comportements clients bien meilleure que l’entretien de gestionnaires d’événements dispersés dans l’interface.

Le coût des compilations Rust​

Les compilations ont été pénibles. À mesure que l’application grandissait, une petite modification Rust pouvait exiger une compilation coûteuse de la grosse crate applicative. Un changement de gabarit Maud est du code source Rust. Un changement de requête peut exiger de régénérer les liaisons et de recompiler l’application. Les dépendances en cache peuvent rester à jour, mais l’unité de compilation de l’application demeure grande.

Nous avons désactivé les renseignements de débogage et la LTO en développement. Nous utilisons la compilation incrémentale, limitons les tâches Cargo, sérialisons les vérifications de worktrees concurrents et nettoyons les sorties incrémentales périmées. Ces mesures ont réduit nos temps de compilation, mais pas beaucoup.

Lors d’un essai, un seul processus du compilateur pour un test de bibliothèque a utilisé plus de 10 Gio de mémoire physique sur un portable de 16 Gio. Même les essais de compilation des tests avec une seule tâche ont dépassé un seuil d’arrêt de 8 Gio. Un autre relevé a trouvé des répertoires de compilation de worktree entre 5,7 et 7,9 Gio. C’étaient des artefacts et des caches accumulés, pas la taille de l’exécutable déployé.

Toucher un gabarit d’administration sans changer son contenu a déclenché un cargo check de 21,38 secondes. Cela ne comprenait ni l’édition de liens du serveur, ni son redémarrage, ni l’inspection du résultat. C’était une borne inférieure favorable : le contenu source n’avait pas changé, donc le compilateur pouvait réutiliser plus de travail qu’après une vraie modification du balisage. Pour des retouches répétées de l’interface, le délai devient difficile à ignorer.

Une extraction utile​

Nos générateurs applicatifs vivaient initialement dans le même paquet Cargo que l’application. Les exécuter compilait du code applicatif dont ils n’avaient pas besoin, et une fonction de génération de contrats créait une autre identité de bibliothèque applicative.

Nous avons déplacé les générateurs dans leur propre petit paquet. Dans une comparaison d’août, les temps et l’espace disque ont changé ainsi :

Parcours de générationAvant l’extractionAprès l’extraction
Parcours à froid237,47 secondes16,25 secondes
Parcours à chaud54,28 secondes3,53 secondes
Sortie de compilation après génération4,28 Gio257 Mio

La sortie générée est restée identique. La génération est devenue beaucoup moins coûteuse, même si compiler la grosse application reste cher.

Nous avons aussi étudié le cache de compilation avec sccache. L’admissibilité au cache et sa réutilisation diffèrent entre les dépendances à froid et les modifications incrémentales de l’application. Ajouter un cache ne corrige donc pas automatiquement ces dernières. Il faut mesurer la boucle dans laquelle nous passons réellement du temps.

Avec plusieurs worktrees d’agents, chaque copie de travail accumule aussi son état de compilation. Sérialiser Cargo protège le portable contre les compilateurs simultanés, mais introduit une file d’attente. Une machine de compilation dédiée pourrait éventuellement en valoir la peine. Le coût de compilation nous pousse déjà à revoir le découpage de l’application et l’endroit où nous produisons l’interface.

Nous espérons que séparer des parties de l’application en crates Rust réduira les coûts de compilation. Nous n’avons pas encore établi cet avantage. L’extraction des générateurs a amélioré un parcours précis; l’effet d’un découpage supplémentaire sur les modifications applicatives quotidiennes reste à déterminer.

Ce qui nous manque de Gleam​

Gleam est un langage agréable à utiliser. Les valeurs facultatives explicites, le filtrage exhaustif et un langage petit et cohérent donnent des garde-fous utiles sans rendre le code applicatif ordinaire pénible à écrire. Nous le trouvons encore beaucoup plus agréable à lire et à écrire que Rust. Nous nous sommes habitués à la syntaxe Rust, mais préférons encore celle de Gleam.

Lustre convenait particulièrement bien. Il apportait The Elm Architecture à Gleam : un modèle, des messages, une fonction de mise à jour et une vue. Nous pouvions utiliser cette structure dans les applications de navigateur et les composants serveur, avec des types Gleam partout. Il y avait peu de JavaScript écrit à la main et aucun langage de gabarit distinct à apprendre.

Nous avons décrit l’administration dans Une administration interactive avec Gleam et Lustre. Un processus BEAM de longue durée conservait l’état de la page, traitait les messages et envoyait les mises à jour du DOM par WebSocket. Pour les formulaires, les tableaux et les pages de configuration, c’était une façon productive de construire une interface.

L’accès à la BEAM était un autre avantage important. Ses processus, sa supervision et ses capacités à l’exécution sont de bonnes raisons de choisir cette pile. Dans notre expérience, la boucle de compilation Gleam était aussi considérablement plus légère que celle de notre application Rust, maintenant beaucoup plus grande.

En conclusion​

Gleam et Lustre auraient probablement bien fonctionné pour nous. Nous sommes heureux d’avoir choisi Rust et TypeScript : ils conviennent mieux à notre architecture et à notre façon de travailler, malgré le coût des compilations et ce qui nous manque. La souplesse d’Hypertea a aussi porté ses fruits de façons inattendues, avec WebMCP qui s’appuie directement sur sa conception TEA existante.