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
écrire deux services conteneurisés en Rust qui communiquent entre eux à travers un réseau
écrire deux services conteneurisés qui se découvrent l'un l'autre via mDNS
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.
1. A capteur
recueille des données de l l'environnement
et communique d'une manière ou d'une autre ces données à...
2. a contrôleur
qui évalue ces données et envoie (éventuellement) une commande
à...
3. un actionneur
qui a un effet sur...
4. l'environnement l'environnement
qui, 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
commande
s
Ces quatre types de dispositif
s -- capteurs
s, actionneurs
et le contrôleur
et L'environnement
sont 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 environnement
simulé, capable de générer de fausses données pour nos capteurs
s.
(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 actionneurs
s, les capteurs
et 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 compilation
s
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ériques
sur le réseau peuvent se connecter les uns aux autres. rand
est essentiel pour l environnement
et 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ôleur
qui (à l'heure où j'écris ces lignes) ne montre qu'un graphique en direct et rien d'autre.
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-dev
qui est requise par l'option adresse-ip locale
.
Les tailles finales des quatre binaires produits (pour le contrôleur
, l'environnement
et 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 TcpStream
le Service B le reçoit sur son TcpListener
le traite et envoie une réponse au Service A, en fermant le socket.
Ces messages
n'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 Datum
dans 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<Mutex<Everything>>
Dans ce projet, le service Device
ont un état qui est souvent mis à jour à travers les threads. Par exemple, le contrôleur
utilise un thread pour rechercher en permanence de nouveaux capteurs
et des actionneurs
sur 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<Mutex<...>>
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 clone
d'ê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 clone
d 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éplacer
dans 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ésultat
et 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 TcpStream
je 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 pub
devraient 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 ServiceInfo
dans 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, traits
implémentant d'autres traits
peuvent 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 deux
mettent tous deux en œuvre les traits Dispositif
et 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
/ Actuator
nous 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îne
s
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 &str
ou Chaîne
Err
types.
S : Dans<String>
au lieu de &str
Rust va automatiquement contraindre &String
en &str
set la sagesse traditionnelle veut donc que les arguments des fonctions soient de type &str
afin 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îne
vous pouvez simplement appeler as_ref()
pour obtenir une chaîne &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 &str
. C'est pourquoi j'ai opté pour S : Into<String>
au lieu de &str
à certains endroits. &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 enum
dè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
/ &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 : B
où A
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<Mutex<Everything>>
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 &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.