Tous les articles

Tech6 min de lecture

Structurer un gros projet Rust: les règles d'Orion

Dix-sept crates, plus de 850 fichiers, environ 3 400 tests: comment j'organise Orion pour qu'il reste lisible. Des couches strictes, un module par fichier, des types qui refusent les erreurs, et trois places pour les tests.

Ancien plan d'architecte bleu d'une façade d'immeuble

Je travaille sur Orion, mon moteur de trading, depuis plus de six mois. C'est aujourd'hui un workspace de dix-sept crates, plus de 850 fichiers Rust et environ 3 400 tests. À cette taille, le code ne reste pas lisible par hasard. Il faut des règles, et surtout des règles que le compilateur vérifie.

Le principe qui guide tout le reste est écrit en tête de la documentation du projet:

Une règle qu'on peut violer par inattention n'est pas une règle, c'est un vœu.

Un workspace, des couches#

Chaque crate a une responsabilité, et les dépendances ne vont que dans un sens. En bas, kernel porte le vocabulaire commun (unités, identifiants, types d'événements, traits) et aucune logique métier. Au-dessus viennent le stockage, la lecture du flux de marché, le carnet d'ordres, les indicateurs, les stratégies, l'exécution. Tout en haut, le binaire orion assemble les pièces, et rien d'autre.

Les crates principaux, du bas vers le haut
  • crates/
    • kernel/vocabulaire, aucune logique
    • storage/écritures atomiques sur disque
    • venue/ce qui est propre à une bourse (CME)
    • feed/décodage et rejeu du flux de marché
    • book/reconstruction du carnet d'ordres
    • features/mesures continues, jamais de seuil
    • strategies/les seuils et les décisions
    • execution/cycle de vie des ordres, trait Broker
    • orion/le binaire: assemble, ne décide pas

Trois règles décident où va chaque chose:

  • Un type vit dans le crate le plus bas dont tous ses consommateurs dépendent. Pas plus haut (il serait inaccessible), pas plus bas (il polluerait des crates qui n'en ont pas besoin).
  • Un dossier de données, un seul propriétaire. Les données de marché appartiennent à datatree, les résultats d'exécution à runtree, et tout octet écrit passe par storage.
  • Les crates qui parlent au réseau sont des feuilles. Le runtime asynchrone et le client HTTP restent hors du chemin critique.

Une règle me plaît particulièrement: strategies ne dépend pas de book. Une stratégie ne voit que des indicateurs déjà calculés, jamais le carnet brut. Regarder dans le futur n'est donc pas interdit par une convention: c'est inexprimable, le code ne compilerait pas.

Pour le binaire, j'utilise un test simple: si retirer le binaire faisait disparaître une décision, elle était au mauvais endroit.

Des règles vérifiées par l'outil#

Les conventions qui comptent sont des lints, déclarés une fois pour tout le workspace:

[workspace.lints.clippy]
# Un module est un fichier, ou un dossier avec mod.rs,
# jamais foo.rs à côté de foo/.
self_named_module_files = "deny"
# Un chemin à rallonge au milieu d'une fonction cache
# d'où vient un nom : les imports vont en haut du fichier.
absolute_paths = "deny"

clippy.toml va plus loin: il interdit par exemple d'ouvrir un fichier en ajout directement, pour forcer le passage par la fonction de storage qui garantit qu'un fichier créé l'est durablement. Et la règle d'équipe (même quand l'équipe, c'est moi) est simple: zéro warning, et on ne désactive jamais un lint pour faire passer une vérification.

Même le profil de compilation est justifié par écrit. Passer à lto = "thin" et codegen-units = 1 fait gagner environ 12 % quand le moteur rejoue un mois complet d'historique de marché, avec une sortie identique à l'octet près. La contrepartie est mesurée aussi: une compilation complète passe de 9 à 26 secondes.

Un module, un fichier#

À l'intérieur d'un crate, la règle est stricte: un module est un fichier. Un dossier n'apparaît que lorsqu'un concept déborde d'un fichier, jamais pour ranger des choses « par catégorie ». Il n'y a pas de dossier types/, models/, utils/ ou common/ dans tout le projet.

La raison est que le module est la frontière de visibilité en Rust. Un dossier utils/ regroupe des choses qui n'ont rien à voir entre elles, et qui finissent toutes publiques pour pouvoir se parler.

Pour savoir si un dossier se justifie, je me pose une question: peut-on décrire ce dossier en une phrase, sans le mot « et »? Si non, ce n'est pas un concept, c'est un tiroir.

Les noms de fichiers suivent la même logique: des noms du domaine (sweeping.rs, restating/, admits.rs) plutôt que des noms techniques. Et quand un dossier existe, son mod.rs sert d'index: documentation, déclarations de modules, réexports, et le type principal, que les fichiers voisins étendent avec leurs propres blocs impl.

Des types qui refusent les erreurs#

Dès qu'une valeur pourrait être passée à la place d'une autre et produire un résultat faux sans bruit, elle a son propre type. Un prix n'est pas une quantité, une quantité n'est pas un montant, et même un prix n'est pas un écart de prix:

/// Un prix absolu, en entier à virgule fixe.
/// C'est une position sur un axe, pas un déplacement: la différence
/// entre deux prix est un `Gap`, pas un `Px`.
pub struct Px(i64);

Prix, quantités et montants sont tous des entiers à virgule fixe, à la même échelle, pour que « prix × quantité » tombe exactement sur un montant, sans arrondi flottant. Les flottants ne servent qu'aux grandeurs statistiques.

Les erreurs suivent la même idée. Orion distingue deux familles qui ne se convertissent jamais l'une dans l'autre:

/// Un refus et un défaut sont deux types distincts, sans conversion entre eux.
/// On peut élargir l'un ou l'autre en `Fault`, jamais l'inverse:
/// un bug signalé comme un refus est un bug que personne ne cherche.
pub enum Fault {
  #[error(transparent)]
  Refused(#[from] Refusal),
  #[error("the code contradicted itself")]
  Defective(#[from] Defect),
  #[error(transparent)]
  Io(#[from] IoFailure),
}

Un refus, c'est une entrée invalide: un fichier manquant, une commande mal formée. Un défaut, c'est le code qui se contredit lui-même: un bug. Le binaire en tire son code de sortie: 1 pour un refus, 2 pour un défaut. On sait tout de suite si c'est l'utilisateur ou le programme qui a tort.

Trois places pour les tests#

Avec 3 400 tests, la question « où mettre ce test? » doit avoir une seule réponse. Elle dépend de ce dont le test a besoin.

Le test a besoin…Il vit…
des éléments privés d'un seul fichierdans le fichier, puis dans x/tests.rs, puis dans x/tests/ quand il grossit
de plusieurs fichiers d'un même moduledans le dossier tests/ de ce module
de l'API publique seulementdans le dossier tests/ à la racine du crate

Le carnet d'ordres montre les trois à la fois:

crates/book
  • src/
    • level/un concept, ses tests à côté
      • mod.rs
      • tests.rs
    • book/
      • mod.rs
      • tests/plusieurs fichiers du même module
        • events.rs
        • orders.rs
        • snapshots.rs
    • tests/tests différentiels et aléatoires
      • exhaustive.rs
      • randomized.rs
    • test_support/les aides partagées du crate
  • tests/l'API publique, sur de vraies données
    • ten_levels.rs
    • queue_priority.rs
  • proptest-regressions/les cas qui ont déjà cassé

Les fonctions d'aide suivent la même règle que les types: elles vivent au plus petit endroit que tous leurs utilisateurs voient. Celles qui servent à plusieurs crates sont dans kernel, derrière une feature test-support qui n'apparaît que dans les [dev-dependencies], et n'atteint donc jamais un binaire de production.

Des tests qui cherchent vraiment les bugs#

Ce sont les tests du carnet d'ordres dont je suis le plus content.

  • Un test différentiel. Le vrai carnet (optimisé, avec des structures complexes) est comparé à un carnet de référence volontairement naïf, un simple vecteur. Deux implémentations qui n'ont rien en commun ont peu de chances de se tromper de la même façon.
  • Des tests aléatoires avec réduction. proptest génère des séquences d'événements, et quand une séquence casse le carnet, il la réduit au plus petit cas qui casse encore. Une séquence de soixante événements ne dit rien; une séquence de trois raconte le bug. Les cas trouvés sont enregistrés dans le dépôt pour ne jamais revenir.
  • Une énumération exhaustive de toutes les petites séquences possibles, qui trouve le plus court exemple qui casse quelque chose.
  • La conformité à un carnet publié. Le carnet reconstruit à partir du flux ordre par ordre est comparé, sur de vraies journées de marché, à un carnet à dix niveaux publié indépendamment: accord sur 100 % des fins d'événement (le détail est dans l'article sur le carnet).

Et au niveau du programme entier, un test vérifie le déterminisme: rejouer la même séance doit produire les mêmes résultats, octet pour octet. Une décision ne doit dépendre que du flux de données, de rien d'autre.

Ce que j'en retiens#

  • Les règles utiles sont celles que l'outil vérifie. Lints, graphe de dépendances, types: tout ce qui est structurel ne s'oublie pas.
  • Rendre l'erreur impossible plutôt qu'interdite. Une stratégie qui ne voit pas le carnet ne peut pas tricher avec.
  • Un emplacement évident pour chaque chose, code comme tests. À 850 fichiers, on ne cherche plus, on sait où regarder.
  • Écrire le pourquoi. Chaque dépendance, chaque lint, chaque option de compilation a sa justification à côté. Dans six mois, c'est elle qui m'empêchera de défaire une bonne décision.
  • rust
  • architecture
  • tests