Solid et Rust, le travail fait une fois pour toutes
Sébastien
Dans un champ, le réseau tient souvent à une barre de 4G, et le téléphone qui s'en sert n'est pas toujours récent. Une application qui paraît rapide au bureau peut y devenir lente, parfois inutilisable. Notre règle chez foliag est l'accessibilité, de l'interface jusqu'aux technologies choisies. Pour moi, elle veut aussi dire qu'une application doit rester utilisable sur du matériel modeste et une connexion faible.
C'est ce qui a guidé le choix de nos outils. Ce site est construit avec Solid, et MentorAg va l'adopter à son tour. Côté serveur, MentorAg et notre plateforme de comptes tournent sur Rust. Les deux technologies ont un point commun, et c'est lui qui m'a convaincu. Elles font le travail une fois, avant la mise en ligne, plutôt qu'à chaque page ouverte et à chaque requête. Le prix se paie sur nos machines, pas sur celles des gens qui s'en servent.
Ce que le téléphone n'a pas à télécharger
Avant d'afficher quoi que ce soit, un navigateur télécharge le code de la page. Sur une bonne connexion, personne ne le remarque. Sur une barre de 4G, chaque kilo-octet se sent.
Pour mesurer l'écart, j'ai construit deux fois le même formulaire de connexion, une fois avec React et une fois avec Solid. Les deux versions ont les mêmes champs, la même validation avec Formisch et Valibot, et passent par le même outil de build, Vite. Je compare leur taille une fois compressée en gzip, celle que le navigateur reçoit vraiment. La version React pèse 73 ko. La version Solid pèse 11 ko, bibliothèque de formulaires comprise (React 19.3, Solid 1.9, Formisch 1.1).
La différence ne vient pas du formulaire. Elle vient de ce que chaque bibliothèque embarque pour faire vivre la page. React envoie au navigateur un moteur qui relance les composants et compare leurs versions successives. Solid transforme les composants en instructions directes au moment du build, et n'envoie guère plus que ce dont la page a besoin.
Comment Solid met une page à jour
Pour comprendre d'où vient cette légèreté, il faut regarder ce qui se passe quand une valeur change à l'écran.
Avec React, un composant est une fonction qui décrit un morceau de page. Quand une valeur change, React relance cette fonction, et celles des composants qu'elle contient, pour obtenir une nouvelle description. Il la compare ensuite à l'ancienne et n'applique à la page que les différences. Des optimisations, comme le compilateur de React, évitent une partie de ces relances, mais le composant reste l'unité de mise à jour.
Avec Solid, un composant ne s'exécute qu'une seule fois. Il sert à brancher la page sur les valeurs dont elle dépend. Quand une valeur change ensuite, seul le morceau de page qui la lit est mis à jour, directement, sans relancer de composant ni rien comparer.
Voici un petit composant qui calcule un devis à partir d'une surface en hectares et d'un prix à l'hectare.
import { createMemo, createSignal } from "solid-js";
function Devis() {
const [surface, setSurface] = createSignal(12);
const [prix, setPrix] = createSignal(85);
const total = createMemo(() => surface() * prix());
console.log("Devis s'exécute une seule fois");
return <p>{total()} €</p>;
}createSignal crée un signal, c'est-à-dire une valeur qui retient qui la lit. createMemo crée une valeur dérivée, recalculée seulement quand l'une de ses sources change. Au moment du build, Solid transforme le JSX en un modèle de page, recopié tel quel dans le navigateur, et en une petite fonction attachée au seul texte qui dépend de total.
Quand la surface passe à 14 hectares, par exemple avec setSurface(14) depuis un champ de saisie, le signal prévient ce qui l'a lu. Le memo recalcule 14 × 85, soit 1190, puis prévient à son tour le texte de la page, qui affiche « 1190 € ». Le message de console.log n'apparaît pas une deuxième fois, parce que Devis ne se relance pas.
Ce modèle change aussi la façon d'écrire le code. Il n'y a pas de liste de dépendances à tenir à jour à la main, ni de valeurs à mettre en cache pour éviter des relances. Solid suit tout seul qui dépend de quoi, et un composant se lit comme une fonction ordinaire, exécutée une fois.
Des serveurs qui demandent peu
Le serveur de MentorAg est écrit en Rust. Il répond en moins de dix millisecondes, même aux requêtes lourdes. Au repos, son processus occupe moins de 40 Mo de mémoire, base de données à part, soit moins que la plupart des onglets d'un navigateur.
Là encore, le travail est fait avant la mise en ligne. Rust est compilé en code machine, et il sait dès la compilation à quel moment chaque morceau de mémoire peut être libéré. Il se passe donc d'un ramasse-miettes, ce programme qui, dans beaucoup d'autres langages, passe régulièrement nettoyer la mémoire et demande de la marge pour le faire. Un serveur Rust ne prend que ce qu'il utilise.
Cette sobriété compte pour nous au-delà de la facture. Nous hébergeons nos applications chez Scaleway, qui publie chaque mois l'empreinte carbone et la consommation d'eau de ce que nous utilisons, service par service. Un serveur qui demande peu nous permet de réserver des machines à sa mesure, et l'empreinte suit les ressources réservées.
Un compilateur qui relit le code, y compris celui de l'IA
L'IA a changé notre façon d'écrire du logiciel. Nous travaillons avec des agents de code, qui écrivent vite et beaucoup. La question devient alors celle de la relecture. Qui vérifie que ce code respecte les règles de l'application ?
En Rust, une grande partie de ces règles vit dans les types, et le compilateur refuse tout code qui les enfreint. Cela donne une boucle simple. L'agent écrit, le compilateur refuse en expliquant pourquoi, l'agent corrige, et ainsi de suite jusqu'à ce que le code compile. Chaque règle inscrite dans les types est une erreur que personne n'aura à repérer à la main.
Prenons l'argent. Sur MentorAg, le prix d'un programme est connu avant tout engagement, puis réparti entre des étapes de paiement. Une somme ne doit jamais être négative, ni passer par un nombre à virgule flottante, qui arrondit en silence (en JavaScript, 0,1 + 0,2 ne donne pas exactement 0,3). Dans notre code, une somme est donc un type à part entière. En voici une version simplifiée.
/// Une somme exacte, jamais négative.
pub struct Money(BigDecimal);
impl Money {
/// Le seul moyen de créer une somme.
pub fn new(value: BigDecimal) -> Result<Self, MoneyError> {
if value < 0 {
return Err(MoneyError);
}
Ok(Self(value))
}
/// La part d'une somme, exacte, sans arrondi.
pub fn share_of(&self, percent: Percent) -> Money {
let part = &self.0 * BigDecimal::from(percent.get());
Money(part / BigDecimal::from(100))
}
}
/// Un pourcentage, entre 0 et 100.
pub struct Percent(i32);Le contenu de Money est privé. En dehors de son module, la seule façon d'obtenir une somme passe par Money::new, qui refuse les montants négatifs, et la même vérification s'applique aux données qui arrivent du réseau comme à celles qui sortent de la base. Percent, de son côté, n'accepte qu'un nombre entier entre 0 et 100.
Si quelqu'un, agent ou humain, écrit prix * 0.33 pour calculer une part, le compilateur répond ceci.
error[E0369]: cannot multiply `Money` by `{float}`
--> main.rs:5:21
|
5 | let part = prix * 0.33;
| ---- ^ ---- {float}
| |
| MoneyL'erreur désigne la ligne, l'opération et le type en cause. Rien ne permet de multiplier une somme par un nombre à virgule, alors le calcul passe forcément par share_of, qui reste exact. Le mauvais calcul n'atteint jamais un vrai paiement.
Les types servent aussi à rendre certaines situations impossibles. Sur MentorAg, un agriculteur et un mentor négocient les conditions d'un programme, et leur accord crée l'abonnement. Il ne doit jamais exister de conditions acceptées sans abonnement, ni d'abonnement sans conditions acceptées.
pub enum NegotiationState {
/// Les offres vont et viennent.
Negotiating,
/// Accord trouvé, l'abonnement existe.
Accepted(Agreement),
/// Refusé. Garde l'accord, s'il y en a eu un.
Declined { accepted: Option<Agreement> },
}
/// Les conditions acceptées et l'abonnement qu'elles ont créé.
pub struct Agreement {
pub terms: SubscriptionTerms,
pub subscription_id: Uuid,
}L'état « accepté » ne peut pas exister sans un Agreement, et aucun champ ne permet de ranger des conditions acceptées pendant que la négociation continue. Le compilateur oblige aussi chaque match sur cet état à traiter tous les cas. Si nous en ajoutons un demain, chaque endroit du code qui l'oublie cesse de compiler, et l'agent comme nous savons exactement où intervenir. La plupart de nos requêtes SQL sont vérifiées de la même manière, contre le schéma de la base, au moment de la compilation.
TypeScript a aussi des types, et nous l'utilisons pour nos pages. Mais ces types disparaissent une fois le code lancé. Une donnée qui arrive du réseau n'est vérifiée que si l'on ajoute une bibliothèque de validation, comme Valibot, et qu'on pense à s'en servir partout. Un simple as suffit d'ailleurs à faire taire le compilateur. En Rust, le type lui-même ne laisse pas d'autre chemin.
Ce que ça coûte
Ce choix a un prix, et il se paie pendant le développement. Compiler du Rust demande beaucoup de calcul. Nos ordinateurs et notre intégration continue tournent plus longtemps à chaque modification, et consomment plus d'énergie pendant que nous construisons.
Je n'ai pas encore mesuré ce bilan. Mais la compilation a lieu sur nos machines, un nombre de fois que nous maîtrisons. L'économie, elle, se répète à chaque requête et sur chaque téléphone, aussi longtemps que l'application tourne.
Solid et Rust répondent à la même idée, faire le travail difficile une fois, chez nous, pour que l'application reste légère chez ceux qui s'en servent, sur un vieux téléphone comme sur une connexion faible. Si ces choix vous parlent, ou si vous voulez les discuter, écrivez-nous à hello@foli.ag.
