12. novembre 2020 de Kilian Krause
RUST POUR LES DÉVELOPPEURS JAVA- 2E PARTIE
Dans la première partie de cette série, nous avons défini et implémenté notre API REST. À cette fin, nous avons appris à connaître les fonctions élémentaires du framework, par exemple les gestionnaires de requêtes (abrégés en GR dans le reste du présent article), les chemins, les paramètres d’URL dynamiques ainsi que la sérialisation et la désérialisation avec JSON. Dans cette partie de la série, j’aimerais aborder deux autres aspects importants du framework et du développement logiciel en général: les tests automatisés et le traitement des erreurs.
Les prérequis
Une connaissance de base du langage de programmation Rust est recommandée pour lire cet article. Le livre The Rust Programming Language est un bon support de départ. Je m’appuierai également sur des structures qui ont été expliquées et implémentées dans la première partie de cette série. Si vous ne l’avez pas encore fait, je vous invite à lire le premier article de blog sur le framework web Actix.
Communication avec le système de fichiers
Avant d’aborder la gestion des erreurs, nous devons mettre en place une couche de persistance. Admettons que les données sont sauvegardées au format JSON.
Nous créons un fichier data.json
dans le répertoire racine de notre projet. Nous y sauvegarderons nos instances de personnes. Nous implémentons deux méthodes avec lesquelles nous pouvons lire depuis et écrire dans le fichier (nous pouvons encapsuler ces méthodes dans un nouveau fichier, par exemple person_repository.rs
)
fn read_values_from_file() -> Vec<Person>
let data = fs::read_to_string(FILE_NAME).unwrap();
let persons: Vec<Person> = serde_json::from_str(&data).unwrap();
persons
}
fn write_values_to_file(persons: Vec<Person>) {
let data = serde_json::to_string(&persons).unwrap();
fs::write(FILE_NAME, data).unwrap();
}
FILE NAME
est défini comme suit :
const FILE_NAME: &str = "data.json";
Nous devons également spécifier une autre dépendance serde_json
:
[dependencies]
actix-web = "1.0.8"
serde = "1.0.101"
serde_json = "1.0.41"
Laissons de côté le traitement d’éventuelles erreurs de sérialisation et d’accès aux fichiers afin de nous concentrer sur la gestion des erreurs d’une requête GET.
Nous appelons donc simplement la méthode unwrap ()
, qui fonctionne de manière similaire à la méthode Optional#get.
Voici une fonction qui lit une personne avec l’ID approprié dans le fichier :
pub fn get_person_by_id(id: u32) -> Option<Person> {
let persons = read_values_from_file();
persons.into_iter().find(|p| p.id() == id)
}
persons.into_iter().find(|p| p.id() == id)
itère sur le vecteur de personnes et fournit la personne avec l’ID indiqué. Les itérateurs peuvent être comparés aux Streams de Java. Si aucune personne correspondant à l’ID ne figure dans le fichier, None
est retourné. Voilà pour la préparation, passons à présent au traitement des erreurs !
Traitement des erreurs
Jusqu’à présent, nos GR ne retournaient que la représentation JSON des personnes ou des chaînes de caractères. Il faut l’éviter dans une application réelle, car nous avons besoin d’une gestion cohérente et continue des erreurs. Dans cette section, nous allons donc étendre un peu nos gestionnaires de requêtes.
Gestion des erreurs avec Result
La gestion classique des erreurs dans Rust s’effectue généralement à l’aide des deux enums Result
et Option
. Nous nous rappelons que le type de valeur de retour d’un gestionnaire de requêtes doit implémenter le trait Responder
. Actix nous propose ici une implémentation par défaut du trait Responder
pour le type Result
. Nous étendons donc nos GR de manière à ce qu’ils retournent des valeurs de type Result
. En cas d’erreur, nous pouvons ainsi fournir à l’utilisateur un message d’erreur en ce sens ou un code d’état HTTP.
À cette fin, nous modifions notre gestionnaire de requêtes pour la requête GET
comme suit :
#[get("/persons/{id}")]
pub fn get(id: Path<u32>) -> Result<Json<Person>, String> {
match person_repository::get_person_by_id(*id) {
Some(found) => Ok(Json(found)),
None => {
let err_msg = format!("person with id {} not found", id);
Err(err_msg)
}
}
}
Si une personne avec l’ID existe, nous tombons sur le premier cas de l’expression match
: Some(found)
. Dans ce cas, cette personne est retournée en tant que JSON : Ok(Json(found))
. Dans le cas contraire,
None => {
let err_msg = format!("person with id {} not found", id);
Err(err_msg)
}
retourne un message d’erreur correspondant à l’utilisateur.
Mais pourquoi notre code ne compile-t-il pas ici ? Pour répondre à cette question, nous devons examiner le mode de traitement des erreurs dans Actix-Web.
L’erreur Actix-Web
Si nos gestionnaires de requêtes retournent des valeurs de type Result
, les types retournés en cas d’erreur doivent implémenter le trait ResponseError
. Or, il n’y a pas d’implémentation de ce trait pour le type de données String
. Conséquence : notre code ne compile pas. Heureusement, Actix-Web fournit le type Error
pour la gestion des erreurs. Cette structure a une référence interne à un objet de type ResponseError
.
Nous pouvons donc spécifier dans nos GR qu’en cas d’erreur, un objet de type Error
est retourné. Celui-ci peut être converti par le framework en une réponse HTTP et envoyée au client. Nous pouvons alors modifier notre signature comme suit - au lieu de Json<Person>
, nous retournons ici une HttpResponse
dont le corps est une personne en tant qu’objet JSON :
#[get("/persons/{id}")]
pub fn get(id: Path<u32>) -> Result<HttpResponse, Error> {
// ...
}
Idéalement, nous voulons envoyer au client un code d’état HTTP et un message d’erreur correspondant. Là aussi, le framework peut offrir également quelques possibilités. Il existe plusieurs fonctions auxiliaires qui retournent une valeur du type Error
. Citons par exemple la fonction ErrorNotFound
, qui accepte n’importe quel type et crée une erreur Actix à partir de celui-ci. Celle-ci est ensuite convertie par le framework en réponse HTTP avec le code d’erreur correspondant et son contenu. Pour notre gestionnaire de requêtes, voici à quoi cela ressemble :
#[get("/persons/{id}")]
pub fn get(id: Path<u32>) -> Result<HttpResponse, Error> {
let person = person_repository::get_person_by_id(*id);
match person {
Some(found) => Ok(HttpResponse::Ok().json(found)),
None => {
let err_msg = format!("Person with id {} does not exist.", id);
let json_err = json!({ "error": err_msg });
Err(error::ErrorNotFound(json_err))
}
}
}
S’il existe une personne possédant cet ID, celle-ci est retournée au client en tant que JSON avec le code d’état 200 (OK). Toutefois, si la personne n’existe pas, une Http-Response est générée avec le code de statut 404 (Not Found). Le corps de la réponse contient le message d’erreur correspondant. Cela ressemble à ce qui suit (l’ID est sélectionné de manière arbitraire) :
Actix offre des fonctions correspondantes pour les codes d’erreur les plus courants. Vous trouverez une liste détaillée ici.
Définir ses propres erreurs
Si les fonctions fournies par le framework pour la gestion des erreurs ne sont pas suffisantes, il est bien sûr possible de définir ses propres types d’erreur. Cependant, je préfère ne pas entrer dans les détails à ce stade et vous renvoie à la documentation officielle.
Tester
Les applications fiables doivent faire l’objet de tests solides. Rust offre une bonne intégration « out of the box » pour les tests. Si vous ne connaissez pas bien ces procédures, vous pouvez les consulter ici. Actix-Web facilite également la rédaction des tests unitaires et d’intégration. Je voudrais présenter ici plus en détail les possibilités des tests d’intégration.
Environnement de test
Encore quelques mots avant de commencer les tests : Jusqu’à présent, nous avons enregistré les personnes dans un fichier data.json
, lui-même sauvegardé dans le code. Dans ce qui suit - c’est-à-dire lors de la rédaction des tests - nous pouvons supposer, par souci de simplicité, que ce fichier est un fichier test. Nous n’avons donc pas besoin de modifier le code avant de pouvoir commencer les tests.
Par exemple, nous pourrions initialiser le fichier comme suit pour y exécuter les tests :
[
{
"id": 1,
"name": "Alice",
"age": 42
},
{
"id": 2,
"name": "Bob",
"age": 24
}
]
Tests d’intégration
Dans cette section, nous allons écrire deux tests d’intégration.
Préparatifs généraux
Commençons par créer un module de test dans notre fichier main.rs :
#[cfg(test)]
mod tests {
// ...
}
Nous pouvons ensuite écrire nos tests. Pour cela, nous importons les dépendances nécessaires :
use actix_web::dev::Service;
use actix_web::{http, test, App};
actix_web::test
est le module de test dont nous avons besoin pour nos tests. Le module actix_web::http
contient les codes http utilisés. Nous avons besoin de actix_web::App
pour créer notre application de test. actix_web::dev::Service
est nécessaire pour envoyer la requête à l’application et nous permettre de générer une réponse.
Test pour la gestion correcte des erreurs
Nous écrivons un test pour lequel nous incluons dans la requête un ID qui n’existe pas dans le fichier. Nous attendons du serveur qu’il nous retourne une réponse HTTP 404. Le test complet se présente comme suit :
#[test]
fn test_returns_error() {
let mut app = test::init_service(App::new().service(get));
let req = test::TestRequest::get().uri("/persons/5").to_request();
let resp = test::block_on(app.call(req)).unwrap();
assert_eq!(resp.status(), http::StatusCode::NOT_FOUND);
}
Dans le test, nous créons d’abord notre application de test et enregistrons le gestionnaire de requêtes :
#[test]
fn test_returns_error() {
let mut app = test::init_service(App::new().service(get));
}
En outre, nous devons encore définir notre requête de test :
let req = test::TestRequest::get().uri("/persons/5").to_request();
Nous définissons une requête GET
avec l’URL /persons/id}
en spécifiant 5 comme ID. Comme il n’existe aucune personne avec cet ID, nous nous attendons à ce que le serveur nous retourne une erreur.
Nous exécutons la requête correspondante dans l’application de test et nous sauvegardons la réponse transmise par le serveur dans une variable afin de pouvoir la tester ultérieurement.
let resp = test::block_on(app.call(req)).unwrap();
Le gestionnaire de requêtes correspondant pour le chemin /persons/id}
est appelé sur le serveur. Celui-ci appelle alors le repository qui recherche une personne ayant l’ID 5 dans le fichier. Comme cette personne n’existe pas, la réponse None
est retournée. Le gestionnaire de requêtes retourne une erreur Actix avec le code d’erreur 404 (Not Found). Nous pouvons alors tester ce code d’erreur comme suit :
assert_eq!(resp.status(), http::StatusCode::NOT_FOUND);
Test de réussite de l’appel REST
À présent, nous cherchons à vérifier l’existence d’une personne avec cet ID. Notre test se présente comme suit :
#[test]
fn test_returns_success() {
let mut app = test::init_service(App::new().service(get));
let req = test::TestRequest::get().uri("/persons/2").to_request();
let result: Person = test::read_response_json(&mut app, req);
assert_eq!(result.id(), 2);
assert_eq!(result.age(), 24);
assert_eq!(result.name(), "Bob");
}
Pour ce faire, nous recréons notre application de test et la requête avec un ID existant dans notre fichier de test.
#[test]
fn test_returns_success() {
let mut app = test::init_service(App::new().service(get));
let req = test::TestRequest::get().uri("/persons/2").to_request();
}
Le module de test d’Actix-Web contient une fonction read_response_json
qui fournit l’objet désérialisé de notre requête. Voici comment appeler cette fonction :
let result: Person = test::read_response_json(&mut app, req);
La variable result
contient à présent la personne Bob à laquelle est associé l’ID 2 dans le fichier. Enfin, nous vérifions les attributs individuels :
assert_eq!(result.id(), 2);
assert_eq!(result.age(), 24);
assert_eq!(result.name(), "Bob");
Pour en savoir plus sur les autres possibilités de test d’Actix-Web, je vous invite à cliquer ici.
Conclusion
Dans cette partie de notre série d’articles, nous avons abordé les possibilités qu’offre Actix-Web en matière de gestion des erreurs et de tests automatiques. Il va de soi je n’ai pas fait de présentation exhaustive des fonctions du framework. Pour approfondir vos connaissances du framework, je vous invite à consulter la documentation officielle. Vous y trouverez également la documentation complète de l’API.
Pour consulter un exemple d’application complète avec Actix-Web, cliquez par exemple ici.
Le code développé dans cet article est disponible sur Github. Le code a été optimisé ou modifié à certains endroits.
Actix-Web est-il un candidat pour le développement d’un véritable service web ?
À mon avis, la création de l’API REST avec Actix-Web est très intuitive. J’aime beaucoup ce framework. Toutefois, d’autres facteurs interviennent également dans la décision. C’est par exemple le cas d’ORM ou de la communication avec d’autres systèmes. Dans ces domaines, Java & Spring sont plus sophistiqués que Rust, qui reste encore très jeune après tout. Il sera certainement intéressant de suivre le développement du framework et son utilisation dans la programmation web.