Overblog Tous les blogs
Editer l'article Suivre ce blog Administration + Créer mon blog
MENU
Laurent COCAULT
Publicité

Rust, à votre service

Pour ce quatrième article consacré au langage Rust, nous allons explorer les possibilités offertes par son écosystème pour développer un Web Service REST. Pour démarrer, créons un nouveau paquetage qui viendra compléter les paquetages déjà créés lors des précédents articles de la série. Reprenons la commande de création d'un paquetage, en spécifiant que nous souhaitons, cette fois, produire un binaire :

PS> cargo new server --bin
Created binary (application) `server` package

Tout comme pour nos premiers modules, le nouveau paquetage propose un fichier "cargo.toml" avec lequel nous pourrons spécifier à la fois nos dépendances internes et nos dépendances technologiques. Mais à la différence de nos précédents paquetages, le dossier "src" comprend ici un fichier "main.rs" dans lequel nous allons déclarer notre fonction principale. Comme à notre habitude, commençons par écrire le test d'accès à un paramètre de configuration via une requête HTTP GET sur l'URL http://localhost:3030/config/parameter/PARAM1

Publicité
Rust, à votre service

Ce test s'appuie sur les paquetages "reqwest" et "tokio" pour récupérer le contenu de la ressource correspondant au paramètre PARAM1 qu'on assimilera dans un premier temps à une chaîne de caractère confirmant que la ressource existe.

  • "reqwest" est un paquetage proposant les fonctions d'un client HTTP,
  • "tokio" est un paquetage permettant de travailler avec des interactions asynchrones.

Le paquetage "reqwest" propose les moyens de manipuler les informations du protocole HTTP, en particulier de décoder le code retour de la requête. Dans le cas nominal, on vérifie ainsi que le statut répond positivement sur "is_success". On peut compléter ce test avec un cas dégradé consistant à vérifier que l'absence d'une ressource retourne bien un code 404.

Rust, à votre service
Publicité

Pour assurer le passage de ces tests, il est possible de proposer une implémentation naïve d'un serveur HTTP s'appuyant sur le paquetage "warp" (à noter qu'il existe d'autres frameworks Rust permettant d'implémenter des services Web, tels que Rocket).

Rust, à votre service

Dans cette implémentation naïve, la réponse du serveur Web est codée en dur ; les choses sérieuses commencent lorsqu'on souhaite proposer un comportement plus réaliste en intégrant l'un des ParameterRepository développés précédemment. La difficulté tient au caractère asynchrone et concurrent des sollicitations d'un serveur Web. Comme indiqué dans le premier article de cette série, le langage Rust propose une syntaxe permettant de sécuriser les accès concurrents ; et cette sécurisation passe par des contrôles qui s'appliquent dès la phase de compilation.

Déclarer "simplement" une instance statique du ParameterRepository pour l'utiliser depuis les appels successifs au point d'entrée GET du serveur ne suffit pas. On pourra donc s'orienter vers l'utilisation d'un sémaphore, ou Mutex défini dans le paquetage std::sync comme suit: "A mutual exclusion primitive useful for protecting shared data". Un Mutex propose en effet une fonction "lock" qui permet d'obtenir un accès exclusif à la ressource protégée. La ressource protégée en question étant une instance de ParameterRepository allouée dynamiquement, on utilisera la macro "lazy_static" permettant de différer l'allocation de la donnée statique à l'exécution.

Rust, à votre service
Publicité

Il est ensuite assez simple de lier la route GET vers un accès à la fonction "get_parameter" de l'instance unique de ParameterRepository proposant un contenu persisté dans un fichier local. 

Rust, à votre service

Mais avant de pouvoir effectivement compiler le contenu de ce fichier "main", quelques conditions supplémentaires doivent être remplies par le trait ParameterRepository dont les accès concurrents sont gérés par un Mutex : il doit être Thread-Safe, ce qui n'est pas possible avec l'implémentation historique des ParameterRepository qui gèrent les paramètres dans une HashMap. Un changement d'implémentation vers une DashMap est l'une des options possibles.

Rust, à votre service

Une telle implémentation implique un changement de signature du trait ParameterRepository : plutôt que de retourner une référence, il devient nécessaire de retourner une copie de la valeur accédée, sans quoi il deviendrait possible de modifier l'information extraite sans passer par le point d'entrée de ce qu'on pourrait appeler un agrégat. Rust propose des traits Copy et Clone qui permettent de produire des copies d'objet; mais dans notre cas, le caractère "copiable" doit être déclaré sur la Quantity encapsulée dans le Parameter, et dériver le trait Clone n'est pas Object Safe, ce qui interdirait de déclarer une Box<dyn Quantity>.

Rust, à votre service

En l'état actuel, la valeur retournée par la méthode GET correspond à une sérialisation de la valeur du paramètre. Dans notre cas, la cible est plutôt de proposer un contenu JSON. Il convient donc de proposer un formatage au niveau du Web Service. Pour ce faire, on peut importer le paquetage "serde_json" qui assurera la prise en charge des spécifications du format JSON pour nous. Il ne reste plus ensuite qu'à "mapper" notre paramètre dans un contenu sérialisable JSON.

Rust, à votre service

La route GET étant définie et fonctionnelle, l'étape suivante consiste à implémenter la fonction de mise à jour d'un paramètre existant avec une route PUT. Considérons trois tests à ajouter pour ce nouveau service :

  • Le cas nominal d'un paramètre mis à jour avec une valeur correcte pour lequel on attend un code retour 200 et suite auquel on s'attend à récupérer la valeur en question sur une méthode GET.
  • Le cas dégradé d'un paramètre inexistant pour lequel on attend un retour 404.
  • Le cas dégradé d'un contenu invalide pour lequel on attend un retour 400.

Ci-dessous un exemple de test correspondant au cas nominal.

Rust, à votre service

On notera que l'exécution de ce test induit un changement transitoire de la valeur du paramètre PARAM1 dont on prend soin de restaurer la valeur initiale en fin de test. Pendant l'exécution du test, la valeur du paramètre est donc changée. Si on exécute la suite de test sans configuration particulière, on constate un problème de reproductibilité des tests. En effet, Rust exécute par défaut les tests en parallèle. Si le test vérifiant la valeur du PARAM1 avec un appel GET s'exécute en même temps que le test mettant en œuvre la modification du paramètre, il est probable que le premier échoue. On demandera donc à Rust d'exécuter les tests séquentiellement avec l'option "--test-threads=1".

Passons maintenant à l'implémentation du service PUT en complétant les routes du serveur de la manière suivante :

Rust, à votre service

Quelques points à noter sur cette implémentation:

  1. L'instance du serveur de données est récupérée sous forme mutable avec le mot clé "mut". Cette précision est nécessaire pour spécifier que l'on souhaite appeler une opération qui va modifier l'état du gestionnaire de paramètres, contrairement à l'accès en lecture seule qui avait été utilisé pour la route GET.
  2. L'enchaînement des points de branchement "match" permet de distinguer trois points de sortie pour le service, correspondant aux trois cas de figure spécifiés par les tests. Précisons que l'accumulation des points de branchement va créer un malaise chez les "never nester" et qu'il serait tout à fait envisageable d'encapsuler les étapes de traitement de la requête pour proposer une implémentation plus linéaire.
  3. La combinaison des routes se fait avec l'opération "or" proposée par le Filter de warp.

Avant de déployer notre serveur, il reste à ajouter les opérations POST et DELETE, ce qui suppose par ailleurs de compléter le ParameterRepository avec une fonction de suppression d'un paramètre. Ces compléments sont disponibles à l'adresse suivante : https://bitbucket.org/lcocault/rust-config/src/article-3/

Le déploiement expose à des risques de dysfonctionnement qu'il est plus difficile d'analyser et de corriger, notamment parce que les cycles de correction sont allongés par la nécessité de recompiler et redéployer l'application. On cherchera donc à améliorer la traçabilité du comportement du serveur et à le rendre plus configurable.

En matière de journalisation, warp propose un mécanisme qu'il est assez simple d'activer en initialisant un logger avec "env_logger::init();" et en spécifiant le logger à associer aux routes créés:

let log = warp::log("parameters");
let routes = get_parameter.or(...).with(log);

Il ne restera qu'à spécifier la variable d'environnement RUST_LOG au lancement du serveur pour indiquer le niveau des logs à tracer.

Une fois le serveur prêt pour un déploiement, il faut encore passer par une étape de compilation vers une version portable du serveur. Par défaut, les compilations donnent lieu à un exécutable à édition de lien dynamique, ce qui peut poser souci dans le cas d'un changement de système d'exploitation. Dans mon cas, développant sous Ubuntu et déployant sous Debian, on peut s'attendre à quelques écarts, même si les deux distributions sont parentes. Il est donc préférable de passer par une édition de lien statique, ce qui suppose d'utiliser une cible "musl". En exécutant la commande "rustup target list" on verra notamment apparaître une cible "x86_64-unknown-linux-musl" qu'il sera possible d'installer dans l'environnement de développement avec la commande "rustup target add x86_64-unknown-linux-musl". Dans la plupart des cas, cette étape suffit à produire un exécutable statique pour une cible Linux, via la commande "cargo build --release --target x86_64-unknown-linux-musl". Mais dans notre cas, cette commande échoue sur l'intégration du module OpenSSL requis par nos dépendances Web ; en effet, OpenSSL exige une librairie système qu'il va donc être nécessaire de compiler à la volée. On installera ainsi les outils de compilation musl avec la commande "sudo apt-get install musl-tools" et on précisera dans le cargo.toml que le module OpenSSL est recompilé.

Une fois le serveur compilé statiquement (ce qui peut être vérifié avec la commande "ldd"), il ne reste plus qu'à le déployer sur un serveur Web. Dans mon cas, j'utilise une instance gratuite AlwaysData qui expose le service à l'adresse suivante : "https://cocault.alwaysdata.net/config/parameter". On pourra par exemple accéder à la version actuelle du paramètre PARAM1 via l'URL "https://cocault.alwaysdata.net/config/parameter/PARAM1".

A l'occasion du transfert entre l'environnement de développement et l'environnement de production, on notera la taille du serveur : moins de 8,5 Mo. Le premier article de la série évoquait le potentiel Green du langage Rust en s'appuyant sur un classement des langages sur leur consommation de ressources. La taille de l'exécutable obtenu pour exposer un service REST complet va dans le sens de cette sobriété.

Avec cet article, nous avons vu comment exposer un servie REST avec Rust. Nous avons ainsi appris :

  • à produire un exécutable,
  • à mettre en œuvre les paquetages reqwest, tokio et warp,
  • à gérer les accès concurrents aux données partagées,
  • à sérialiser et désérialiser des données avec serde,
  • à implémenter des services GET, PUT, POST et DELETE,
  • à déployer un serveur REST.

Crédit couverture: Cindy Cornett Seigle sur Flickr sous licence CC BY-NC-SA 2.0.

Partager cet article
Repost0
Pour être informé des derniers articles, inscrivez vous :
Commenter cet article