Background Image
INGÉNIERIE DES PLATES-FORMES

Ce que ce développeur senior a appris de son premier grand projet Rust

Construire un MVP Rust IoT pendant les fêtes de fin d'année

January 17, 2024 | 6 Lecture minute

Voici quelques informations sur moi

  • selon l'organigramme de mon entreprise sur Workday, mon titre actuel est "Consultant senior"

  • J'écris du code à plein temps dans diverses fonctions depuis plus de dix ans et je développe des logiciels de manière professionnelle depuis environ cinq ans.

  • pendant mes études supérieures, j'ai fait de l'analyse et de la visualisation de données presque entièrement en C++

  • depuis quatre ans, mon principal langage de développement est Scala

Ce mélange de développement "proche du métal" en C++ et de développement de style FP en Scala m'a conduit à Rust, que je considère comme un juste milieu entre les deux. Au cours de l'année écoulée, j'ai donc appris Rust en construisant de petits projets et en animant des clubs de lecture hebdomadaires.

Pendant les vacances, j'ai décidé d'aller plus loin et de construire mon premier "gros" projet Rust. Voici comment cela s'est passé...

Le projet

L'idée que j'avais était de construire un petit système d'Internet des Objets (IoT). Les objectifs explicites du projet étaient :

- Construire quelques services qui utilisent très peu de ressources, capables de fonctionner dans des environnements où la taille de l'environnement d'exécution Java (JRE) rendrait impossible le développement de Scala ou de Java.

- Les services doivent fonctionner sur des nœuds séparés et se découvrir d'une manière ou d'une autre sur le réseau, sans qu'il soit nécessaire d'utiliser des adresses IP codées en dur.

- Les services doivent pouvoir s'envoyer des messages (et en recevoir).

- Le système doit contenir des données simulées qui peuvent être visualisées (ou, au moins, exportées vers une feuille de calcul à des fins de visualisation).

En outre, je travaille pour une société de conseil, et le client avec lequel nous sommes engagés a été OOO à Noël. Un autre objectif de ce projet était donc de terminer tout cela, à partir de zéro, en seulement cinq jours ouvrables.

J'ai réussi à recruter deux autres développeurs* qui m'ont aidé à construire certaines des fondations du projet au cours de ces cinq premiers jours ; au cours des deux semaines qui ont suivi, j'ai construit le reste du projet tout seul. En général, je considère que l'effort est un succès, mais j'espère que ceux qui liront ces lignes seront en mesure de laisser des commentaires précieux qui pourraient améliorer les futurs efforts de ce genre.

* Huge shout-out to boniface et à davidleacock!

Planification

Les deux autres développeurs et moi-même avons passé la semaine avant Noël à planifier et à discuter du projet, mais pas à coder. Nous espérions qu'une "équipe de trois développeurs construisant un MVP Rust IoT en seulement cinq jours" serait un outil de vente efficace pour nous et notre entreprise. C'était très ambitieux, et le travail a rapidement débordé sur environ quatre semaines-personnes au total (ce qui n'est pas mal, si vous voulez mon avis).

Je me suis préparé en rédigeant quelques esquissescomme je les appelais. Il s'agissait de petits projets qui (je l'espérais) deviendraient les éléments constitutifs de notre MVP. Ces esquisses comprenaient

J'ai également créé une image de conteneur personnalisée en Rust pour le projet CD, qui qui inclut les bibliothèques nécessaires au projet CIcomme rustfmt pour le formatage, clippy pour le linting, et grcov pour les rapports de couverture de code.

Alors que j'avais initialement pensé à conteneuriser ces applications, à les exécuter dans Kubernetes (K8s) et à laisser K8s se charger de la découverte des services, j'ai réalisé que cette approche ne correspondrait pas à la "vraie vie", où les services devraient d'une manière ou d'une autre se découvrir les uns les autres sur un LAN. mDNS semblait être le meilleur choix pour émuler la découverte de services dans la vie réelle sur un réseau.

Enfin, nous avons dû planifier le domaine lui-même. Nous sommes arrivés à quelque chose d'assez similaire à à cet exemple de Bridgera.

Graphic 1 - What This Senior Developer Learned From His First Big Rust Project

1. A capteur recueille des données de l l'environnementet communique d'une manière ou d'une autre ces données à...

2. a contrôleurqui évalue ces données et envoie (éventuellement) une commande à...

3. un actionneurqui a un effet sur...

4. l'environnement l'environnementqui, dans notre exemple, génère de fausses données et possède un état interne qui peut être modifié par l'intermédiaire d'un l'actionneur commandes

Ces quatre types de dispositifs -- capteurss, actionneurset le contrôleur et L'environnementsont les services dans ce système. Ils se connectent l'un à l'autre via mDNS.

Comme nous manquions de temps et de ressources, tout cela devait être réalisé par logiciel, sans interaction réelle avec des capteurs ou des actionneurs matériels. Pour cette raison, nous avions besoin d'un environnementsimulé, capable de générer de fausses données pour nos capteurss.

(Dès le départ, nous avons réalisé qu'il était important de disposer d'un langage omniprésent autour de ces concepts. Nous nous sommes efforcés d'affiner et de documenter notre compréhension du domaine, et de garder notre modèle aussi clair et aussi petit que possible. Rien d'inutile ou de déroutant ne doit se faufiler).

Mise en œuvre

Quoi qu'il en soit, passons aux choses sérieuses.

L'espace de travail Cargo

Ce projet est structuré comme un espace de travail espace de travail Cargooù il y a plusieurs crates dans un seul repo. L'idée derrière cela est que, dans un scénario réel, nous voudrions publier des bibliothèques distinctes pour les éléments suivants actionneurss, les capteurset ainsi de suite. Si vous êtes un développeur tiers qui crée un logiciel pour (par exemple) une ampoule intelligente, vous ne vous souciez peut-être pas de la bibliothèque Capteur ne vous intéresse pas. Votre appareil n'a qu'un effet effet sur l'environnement, il ne le sonde en aucune façon.

La mise en place d'un projet en tant qu'espace de travail Cargo est simple et vous permet d'extraire le code "commun" dans une ou plusieurs caisses séparées, qui adhèrent à la méthode DRY et rend généralement l'ensemble du projet plus facile à développer et à maintenir.

Dépendances

Dans l'intérêt de garder les binaires et les conteneurs résultants aussi petits que possible, j'ai éloigné ce projet des grands frameworks (tokio, actixetc.), optant pour "rouler nos propres chaque fois que nous le pouvions. Actuellement, le projet n'a que huit dépendances.

1. mdns-sd pour le réseau mDNS

2. chrono pour les horodatages UTC et la dé/sérialisation des horodatages

3. rand pour la génération de nombres aléatoires

4. local-ip-address pour la découverte de l'adresse IP locale

5. phf pour les cartes statiques à la compilation statique au moment de la compilations

6. journal le rust-lang cadre officiel de journalisation

7. env_logger une implémentation minimale de la journalisation

8. plotly pour la représentation graphique des données dans l'interface Web

Même certains de ces éléments ne sont pas strictement nécessaires. Nous pourrions...

- Supprimer chrono en utilisant notre propre dé/sérialisation d'horodatage

- Supprimer phf en créant une seule carte statique Map au moment de l'exécution

- Suppression de log et env_logger en revenant à l'utilisation de println !() partout

mdns-sd et local-ip-address sont essentiels ; ils garantissent que le périphériquessur le réseau peuvent se connecter les uns aux autres. rand est essentiel pour l environnementet n'apparaît que dans les dépendances de cette caisse. plotly est critique pour l'interface Web, hébergée par la classe Contrôleurqui (à l'heure où j'écris ces lignes) ne montre qu'un graphique en direct et rien d'autre.

Graphic 2 - What This Senior Developer Learned From His First Big Rust Project

Enfin, pour la conteneurisation des services, nous avons utilisé rust:alpine dans le cadre d'une construction en plusieurs étapes. Seule une dépendance unique a dû être installée lors de l'étape initiale, musl-devqui est requise par l'option adresse-ip locale .

Les tailles finales des quatre binaires produits (pour le contrôleur, l'environnementet une implémentation de chacun des capteur et actionneur ) varient de 3,6 MO à 4,8 MOsoit un ordre de grandeur inférieur à celui du JRE, qui tourne autour de 50 à 100 Mo. autour de 50-100 Moselon la configuration.

Les conteneurs sont un peu plus volumineux, puisqu'ils pèsent environ 13,5 MO à 13,7 MO. C'est encore peu comparé aux tailles d'images de conteneurs auxquelles je suis habitué pour les projets basés sur Scala - je trouve que les images de conteneurs Scala sont typiquement de l'ordre de 100 Mo, donc < 15 Mo est une bouffée d'air frais.

Découverte de services et messagerie

Comme le montre ce croquis montreil est en fait très simple de faire en sorte que deux services se découvrent l'un l'autre via mDNS avec l'option mdns-sd . Une fois que les services se connaissent, ils peuvent communiquer.

Le moyen le plus simple que je connaisse pour que deux services sur un réseau communiquent l'un avec l'autre est HTTP. Donc, dans ce projet...

- Le service A découvre le service B via mDNS, en récupérant son ServiceInfo

- Le service A ouvre un TcpStream en se connectant au service B à l'aide de l'adresse extraite de son ServiceInfo

- chaque service (y compris le service B) ouvre un TcpListener à sa propre adresse, à l'écoute des connexions TCP entrantes.

- Le service A envoie un message au service B par l'intermédiaire de son TcpStreamle Service B le reçoit sur son TcpListenerle traite et envoie une réponse au Service A, en fermant le socket.

Ces messagesn'ont pas nécessairement besoin d'être des messages au format HTTP, mais il est plus facile d'interagir avec eux "de l'extérieur" (par l'intermédiaire de curl) s'ils le sont.

De même, les points de données (appelés Datumdans ce projet) envoyés via HTTP n'ont pas besoin d'être sérialisés en JSON, mais ils le sont car cela facilite l'interaction avec ces données dans un navigateur ou sur la ligne de commande.

La construction des messages formatés HTTP et la dé/sérialisation de JSON ont toutes été faites à la main dans ce repo, pour éviter d'apporter des dépendances inutiles.

ASTUCE : L'un des problèmes que j'ai rencontrés en écrivant le code de découverte des services est que chaque service a besoin de son propre mDNS ServiceDaemon. Dans la démo originale, un seul démon était instancié et clone()et les clones étaient transmis à chaque service. Mais alors, seul l Actuator (ou seulement le capteur) verrait, par exemple, le service environnement se mettre en ligne. Il consommera l'événement ServiceEvent annonçant la découverte de ce périphérique sur le réseau, et le service suivant ne serait pas en mesure de le voir se connecter. Donc, attention : créez un démon séparé pour chaque service qui a besoin d'écouter les événements.

Modèles communs et observations

Avec la structure de base du projet en place, et avec les services capables de communiquer, j'ai remarqué quelques schémas récurrents au fur et à mesure que le projet prenait vie.

Arc&lt;Mutex<Everything>&gt;

Dans ce projet, le service Deviceont un état qui est souvent mis à jour à travers les threads. Par exemple, le contrôleur utilise un thread pour rechercher en permanence de nouveaux capteurset des actionneurssur le réseau, et ajoute ceux qu'il trouve à sa mémoire.

Pour mettre à jour en toute sécurité les données partagées par plusieurs threads, je me suis retrouvé à envelopper beaucoup de choses dans des objets Arc&lt;Mutex<...>&gt; en suivant l'exemple suivant l'exemple suivant de Le Livre.

J'aimerais savoir s'il existe une meilleure façon, plus ergonomique ou plus idiomatique, de procéder.

Clonage avant déplacer-dans un nouveau fil de discussion

Un autre modèle qui apparaît plusieurs fois dans ce repo est quelque chose comme...

fn my_method(&self) {
    let my_field = self.field.clone();
    std::thread::spawn(move || {
        // do something with my_field
    })
}

Nous ne pouvons pas réarranger ceci en...

fn my_method(&self) {
    std::thread::spawn(move || {
        let my_field = self.field; // will not compile
        // do something with my_field
    })
}

parce que "Self ne peut pas être partagé entre les threads en toute sécurité" (E0277). De même, tout ce qui est enveloppé dans un Arc<...> doit être cloned'être cloné.

fn my_other_method(&self) {
    let my_arc = Arc::clone(&self.arc);
    std::thread::spawn(move || {
        // do something with my_arc
    })
}

Je me suis retrouvé avec quelques fil::spawn avec de gros blocs de cloned juste au-dessus d'eux.

Il y a un RFC pour ce problème, qui est ouvert depuis 2018. Il semble qu'il y ait eu des progrès dernièrement, mais il pourrait se passer un certain temps avant que nous n'ayons plus besoin de cloner tout ce qui obtient déplacerdans un fil de discussion.

Il est trop facile de faire .unwrap()

Ce projet n'est pas très grand - il s'agit d'environ 5000 lignes de code Rust, d'après mes estimations. Mais dans ces 5000 lignes, .unwrap() apparaît plus de 100 fois.

Lorsque l'on développe quelque chose de nouveau, il est plus facile (et plus amusant) de se concentrer sur le "chemin heureux". "chemin heureux" et de laisser la gestion des exceptions pour plus tard. En Rust, c'est assez facile : en cas de succès, appelez .unwrap() sur votre Option ou Résultatet continuer ; il est très facile de contourner la gestion des erreurs. Mais c'est une véritable plaie que de l'ajouter plus tard (imaginez que l'on doive ajouter une gestion d'erreur pour plus de 100 de ces fonctions .unwrap() ).

Il serait préférable, à mon avis, de garder un œil sur ces sites .unwrap()s au fur et à mesure de leur apparition.

Vers la fin de ce MVP, alors que je comptais tous ces sites dont la gestion des erreurs était manquante, je me suis pris à rêver d'une solution de type clippy qui interdirait tout .unwrap()...

Il s'avère que il existe déjà des unwrap_used et expect_used qui peuvent être utilisés pour provoquer une erreur si l'une ou l'autre de ces méthodes est appelée. Je vais certainement activer ces lints sur mes projets personnels à l'avenir, et j'espère qu'ils deviendront par la suite les lints par défaut.

Analyse syntaxique

J'ai écrit beaucoup de code de parsing personnalisé.

Un modèle commun que j'ai suivi était impl Display pour un certain type, puis d'ajouter une fonction pub fn parse() pour transformer la version sérialisée en un type approprié.

Ce n'est probablement pas la meilleure façon de procéder - les chaînes conviviales pour l'affichage sont différentes des représentations sérialisées compactes pour le passage de messages et la persistance. Si je devais refaire cela, j'utiliserais probablement une caisse comme serde pour la de/sérialisation, et sauvegarder impl Display pour une représentation conviviale des chaînes de caractères.

De plus, j'ai "roulé mon propre" routage. Lorsqu'une requête HTTP était trouvée sur un TcpStreamje vérifiais manuellement la ligne ligne_début (quelque chose comme POST /command HTTP/1.1) afin d'acheminer l'information vers le point d'accès approprié. A l'avenir, je pourrais laisser cette tâche à un crate externe... peut-être quelque chose comme hyper.

structure pubdevraient implémenter PartialEq lorsque c'est possible

Je pense qu'il s'agit là d'une bonne règle de base pour toute structure pub : implémenter le PartialEq lorsque c'est nécessaire, afin que les consommateurs de votre caisse puissent tester l'égalité. Le ServiceInfo dans mdns-sd n'est pas dérive pas PartialEq. Cela signifie que je ne pourrais pas facilement tester l'égalité de deux ServiceInfodans les tests.

Au lieu de cela, j'ai vérifié que chaque pub sur deux instances renvoyait les mêmes valeurs. C'était un peu pénible, avec pour résultat de gros blocs de...

assert_eq!(actual.foo(), expected.foo());
assert_eq!(actual.bar(), expected.bar());
assert_eq!(actual.baz(), expected.baz());
// ...

Il aurait été intéressant d'écrire simplement

assert_eq!(actual, expected)

à la place, traitsimplémentant d'autres traitspeuvent rapidement devenir problématiques.

Dans ce projet, il y a un trait Device avec une méthode abstraite appelée get_handler()

// examples in this section are abridged for clarity
pub trait Device {
    fn get_handler(&self) -> Handler;
}

Le trait Capteur et Actuator sont tous deuxmettent tous deux en œuvre les traits Dispositifet fournissent des implémentations par défaut de get_handler()

pub trait Sensor: Device {
    fn get_handler(&self) -> Handler {
        // some default implementation here for all `Sensor`s
    }
}
pub trait Actuator: Device {
    fn get_handler(&self) -> Handler {
        // some default implementation here for all `Actuator`s
    }
}

Mais il y a aussi les implémentations concrètes de Capteur et de Actuator

pub struct TemperatureSensor {
  // ...
}

impl Sensor for TemperatureSensor {}

impl Device for TemperatureSensor {
    fn get_handler(&self) -> Handler {
        Sensor::get_handler(self)
    }
}
pub struct TemperatureActuator {
  // ...
}

impl Actuator for TemperatureActuator {}

impl Device for TemperatureActuator {
    fn get_handler(&self) -> Handler {
        Actuator::get_handler(self)
    }
}

Il existe déjà une implémentation concrète de get_handler() dans Capteur / Actuatornous n'avons donc pas besoin de quoi que ce soit dans l'implémentation impl Capteur / impl Actuator (à moins qu'il n'y ait d'autres méthodes abstraites), mais nous avons avons avons besoin de cette maladroite impl Device dans chaque cas.

En ce qui concerne les Device "sait", TemperatureActuator n'a pas implémenté sa méthode abstraite. Mais nous savons que Actuator l'a fait, et que TemperatureActuator met en œuvre Actuator. Il semble qu'il manque des informations que le compilateur pourrait théoriquement compléter, mais qu'il ne le fait pas actuellement.

Rust pourrait utiliser une méthode plus robuste .join() plus robuste sur les tranches

D'autres langages vous permettent de spécifier un début et fin lors de l'assemblage d'un tableau de chaînes de caractères, vous pouvez donc facilement faire quelque chose comme

["apple", "banana", "cherry"].join("My favourite fruits are: ", ", ", ". How about yours?")
//                                 |--------- start ---------| |sep|  |------- end -------|

ce qui donnerait une chaîne comme "Mes fruits préférés sont : la pomme, la banane et la cerise. Et les vôtres ?"mais Rust n'a pas encore cette fonctionnalité. Ce serait un excellent ajout à la qualité de vie du type primitif slice.

Tous mes Résultat sont des Chaînes

C'est certainement le moyen le plus simple de construire rapidement quelque chose en ignorant principalement les échecs, mais à un moment donné, je devrais revenir en arrière et remplacer ces types d'erreurs par des types d'erreurs appropriés. Les clients devraient pouvoir se baser sur le type d'erreur, plutôt que d'avoir à analyser le message pour comprendre ce qui a échoué.

N'importe quel Résultat qui fuient vers le monde extérieur (vers les clients) devraient probablement avoir des types d'erreur Err et pas seulement des variantes de type Chaîne et pas seulement des messages de type "String". C'est une autre chose que j'aimerais que clippy ait un lint pour : pas de &amp;str ou Chaîne Err types.

S : Dans<String> au lieu de &amp;str

Rust va automatiquement contraindre &amp;Stringen &amp;strset la sagesse traditionnelle veut donc que les arguments des fonctions soient de type &amp;strafin que l'utilisateur n'ait pas à construire une nouvelle chaîne pour la passer à une fonction qui prend un argument de type chaîne. Si vous avez déjà une chaînevous pouvez simplement appeler as_ref() pour obtenir une chaîne &amp;str.

Mais Rust n'effectue qu'une seule coercion implicite à la fois. Nous ne pouvons donc pas convertir un type T : Into<String> en un type Chaîne puis en &amp;str. C'est pourquoi j'ai opté pour S : Into<String> au lieu de &amp;str à certains endroits. &amp;str met en œuvre Into<String> et il en va de même pour tout type qui implémente Into<String> (ou Affichage).

C'est certainement moins performant, puisque nous copions des données sur le tas, mais aussi un peu plus ergonomique, puisque nous n'avons pas besoin de passer à t.to_string().as_ref() (quand t : T et T : Into<String>) à la fonction, mais seulement t lui-même.

Apparemment, je ne suis pas la première personne à avoir découvert ce schéma : Into<String> renvoie à 176 000 résultats sur GitHub.

Conclusion

J'ai beaucoup appris en construisant ce projet : sur le réseau mDNS, sur les détails des formats de messages HTTP, et sur l'écriture de projets plus importants en Rust. Pour résumer les points que j'ai soulevés ci-dessus...

Les choses que je sais devoir améliorer :

- Je ne devrais pas utiliser Display pour la sérialisation. A l'avenir, j'envisagerai d'utiliser une caisse comme serde à la place.

- Je ne devrais pas utiliser chaîne pour tous mes Err pour toutes mes variantes d'Err. Les clients des bibliothèques que je produis devraient être capables de gérer une erreur sans avoir à analyser un message sous forme de chaîne de caractères. À l'avenir, je construirai des erreurs enumdès que je commencerai à produire des erreurs.

Les choses que j'attends avec impatience de la part de la communauté Rust :

- Clonage explicite clone-avant un move est une plaie. Je suis en train de suivre ce problème GitHub dans l'espoir que cela devienne plus ergonomique à l'avenir.

- A clippy pour les String / &amp;str Err serait également une bonne chose.

- Rust pourrait utiliser une fonction plus robuste .join() plus robuste sur les tranches de chaînes, avec start et fin avec des paramètres de début et de fin. Pour autant que je sache, ce problème n'est pas encore suivi. Après la publication de cet article, j'espère ouvrir un RFC pour cette petite fonctionnalité.

- J'espère qu'un jour, le compilateur sera assez intelligent pour savoir quand B : A et que C : BA définit une méthode abstraite et B met en œuvre cette méthode abstraite, que c : C a déjà implémenté cette méthode, sans avoir à informer explicitement le compilateur de cette implémentation. Mais ce n'est peut-être pas pour tout de suite.

J'ai encore des questions à poser :

- Est-ce que Arc&lt;Mutex<Everything>&gt; est-il vraiment le meilleur moyen de muter des données entre plusieurs threads ? Ou existe-t-il un moyen plus idiomatique (ou plus sûr) de le faire ?

Les choses que je recommanderais aux autres développeurs Rust :

- S'il vous plaît impl PartialEq sur n'importe quel pub publié par votre caisse, dans la mesure du possible. Vos clients vous remercieront (espérons-le).

- N'ayez pas peur d'utiliser S : Into<String> au lieu de &amp;str. C'est peut-être moins performant, mais c'est aussi plus ergonomique, et vous n'êtes certainement pas le premier à le faire.

- Autoriser clippy's unwrap_used et expect_used pour vous forcer à aborder les scénarios d'erreur de front, au lieu de les mettre de côté pour les traiter plus tard.

Si vous avez des commentaires sur l'article ci-dessus, veuillez les adresser à l'adresse électronique figurant sur mon mon CV. Ce fut une expérience d'apprentissage fantastique et je suis impatient de me lancer dans un développement plus sérieux de Rust dans un avenir proche.

Vous souhaitez en savoir plus ? Contactez Improving pour tout ce qui concerne Rust.

Ingénierie des plates-formes
Développement de logiciels
Technologie

Dernières réflexions

Explorez nos articles de blog et laissez-vous inspirer par les leaders d'opinion de nos entreprises.
Asset - Image 1 Data Storage in a Concurrent World 
DONNÉES

Data Storage in a Concurrent World 

Data storage and event ordering in concurrent systems can spark challenges, but there are ways to be prepared.