Introduction
À des fins d'apprentissage, je souhaite créer un microservice HTTP très simple en Rust. Par très simple j'entends : faire du CRUD sur une ressource. Je vais donc écrire ici petit à petit mes notes pour mettre en place une API de gestion de doggos. Pourquoi une API de gestion de doggos ? Well, because I can.
Le but du jeu est est d'obtenir un service web permettant au minimum d'enregistrer et lire des informations sur des doggos à l'aide des routes suivantes :
| Méthode | Chemin | Description |
|---|---|---|
| POST | /doggos | Enregistre les informations sur un doggo, passées en JSON dans le corps de la requête. |
| GET | /doggos/:doggo_id | Récupère les informations sur un doggo. |
Mise en place du projet
On commence par créer un projet Cargo. Cargo est l'outil de build et de gestion de dépendances fourni avec la chaine d'outils de Rust.
# L'argument `--bin` permet de préciser qu'on souhaite créer un binaire et non une bibliothèque
cargo init --bin doggos
cd doggos
cargo run
Le seul framework web que j'ai déjà un peu pratiqué lors de ma découverte du langage n'étant plus
maintenu, j'ai choisi d'essayer actix-web qui semble aujourd'hui
l'un des choix les plus populaires.
On ajoute donc la dépendance dans le Cargo.toml, puis on prend le code
d'exemple. du framework pour transformer notre hello world de la console en hello world over HTTP.
Voici à quoi ressemble désormais notre main :
extern crate actix_web;
use actix_web::{server, App, HttpRequest};
fn index(_req: HttpRequest) -> &'static str {
"Hello world!"
}
fn main() {
server::new(|| App::new().resource("/", |r| r.f(index)))
.bind("127.0.0.1:8088")
.unwrap()
.run();
}
Des doggos !
Nous allons manipuler des doggos, il est donc temps de définir une structure pour les représenter :
struct Doggo {
id: String,
name: String,
}
Comme discuté dans l'introduction, on souhaite disposer de deux routes différentes. L'une d'entre-elles nécessite de parser du JSON représentant un doggo. On ajoute donc en dépendance serde et serde_derive, et on dérive le trait Deserialize sur notre structure :
#[derive(Deserialize)]
struct Doggo {
// …
On peut désormais définir nos routes, et dans chacuns de leurs handlers, extraire les informations
nécessaires à leur traitement. Voici à quoi ressemble désormais notre main.rs :
extern crate actix_web;
#[macro_use] extern crate serde_derive;
use actix_web::{server, http, App, Path, Json};
#[derive(Deserialize)]
struct Doggo {
id: String,
name: String,
}
fn register_doggo(doggo: Json<Doggo>) -> String {
format!("Welcome {}! Good boy.", doggo.name)
}
fn fetch_doggo(path: Path<(String,)>) -> String {
format!("Hello again, good boy {}!", path.0)
}
fn main() {
let server = server::new(||
App::new()
.resource("/doggos", |r| r.method(http::Method::POST).with(register_doggo))
.resource("/doggos/{doggo_id}", |r| r.method(http::Method::GET).with(fetch_doggo))
);
server.bind("127.0.0.1:8088").unwrap().run();
}
On peut tester notre serveur web avec les commandes HTTPie suivantes :
# Register:
http -v POST localhost:8088/doggos <<JSON
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan"
}
JSON
# Fetch:
http -v localhost:8088/doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9
On obtient alors les flux HTTP suivants :
POST /doggos HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 79
Content-Type: application/json
Host: localhost:8088
User-Agent: HTTPie/0.9.2
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan"
}
HTTP/1.1 200 OK
content-length: 29
content-type: text/plain; charset=utf-8
date: Sun, 22 Jul 2018 07:30:31 GMT
Welcome Rantanplan! Good boy.
GET /doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8088
User-Agent: HTTPie/0.9.2
HTTP/1.1 200 OK
content-length: 59
content-type: text/plain; charset=utf-8
date: Sun, 22 Jul 2018 07:31:00 GMT
Hello again, good boy 963766e8-2b4a-4e4b-a4c6-1e09a16509c9!
Comme vous l'avez remarqué, on n'arrive pas encore vraiment à récupérer les informations de notre cher doggo. C'est parce qu'on n'a pas encore de persistance dans notre application.
Conserver l'état de l'application
Nous allons donc introduire une notion de repository :
struct InMemoryDoggoRepository {
map: HashMap<String, Doggo>,
}
impl InMemoryDoggoRepository {
pub fn new() -> InMemoryDoggoRepository {
InMemoryDoggoRepository {
map: HashMap::new(),
}
}
fn save(&mut self, doggo: Doggo) {
self.map.insert(doggo.id.to_owned(), doggo.to_owned());
}
fn find(&self, doggo_id: &String) -> Option<Doggo> {
match self.map.get(doggo_id) {
None => None,
Some(doggo) => Some(doggo.to_owned()),
}
}
}
La dernière chose dont nous avons besoin pour terminer notre application, c'est de pouvoir partager un repository entre nos différents handlers. Nous allons donc créer un état pour notre application. Cet état contiendra le repository et nous permettra de l'utiliser dans le traitement des différentes requêtes.
// On définit notre état partagé:
struct AppState {
repo: InMemoryDoggoRepository,
}
// …
// On initialise l'état partagé lors de la création de l'application:
App::with_state(AppState { repo: InMemoryDoggoRepository::new() })
Seulement, cela va malheureusement être un petit peu plus compliqué que cela. En effet, le framework traite les requêtes en parallèle dans plusieurs thread. La documentation indique d'ailleurs :
Note : le serveur HTTP requiert une factory d'application plutôt qu'une instance d'application. Il construit une instance d'application pour chaque thread, ce qui signifie que l'état de l'application doit être construit plusieurs fois. Si vous avez besoin de partager l'état entre différents thread, il faudra utiliser un objet partagé, par exemple avec le type Arc.La documentation du framework actix-web, maladroitement traduite par mes soins.
De notre côté, le type Arc ne suffira pas. Il s'agit en effet d'un type permettant
de conserver une référence vers un objet avec un système de comptage de références (le « rc » de Arc correspond à
Reference Counted) compatible avec un environnement concurrent (le « A » de Arc correspond à
Atomically).
Notre problème avec Arc est décrit dès le début de la documentation du type :
Par défaut, les références partagées sont immuables en Rust, et Arc n'y fait pas exception : vous ne pouvez pas obtenir une référence modifiable vers les données contenues par un Arc. Si vous avez besoin de modifier à travers un Arc, utilisez Mutex, RwLock, ou un des types Atomic.La documentation du type Arc, maladroitement traduite par mes soins.
Je passe sur les types atomiques, qui ne me semblent pas adaptés.
Nous avons donc le choix entre utiliser un Mutex, qui permet de n'accéder aux données qu'après avoir obtenu
un verrou, ou bien un RwLock. Nous allons choisir cette dernière solution, qui est similaire à un Mutex mais
qui permet de distinguer les verrous en lecture et en écriture. C'est pertinent dans notre cas où nous avons
un handler qui va écrire des données, et un autre qui ne fera que lire : inutile de bloquer l'accès aux
données dans les moments où nous ne faisons que de la lecture.
Toutefois, un RwLock ne suffit pas ! Il permet bel et bien de partager les données de façon sûre, mais il
doit lui-même être partagé entre plusieurs threads. Pour cela on peut en revanche utiliser un Arc. Voici
le code complet de notre main.rs une fois que c'est fait :
extern crate actix_web;
#[macro_use] extern crate serde_derive;
use actix_web::{server, http, HttpResponse, Responder, App, State, Path, Json};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Clone,Deserialize)]
struct Doggo {
id: String,
name: String,
}
struct InMemoryDoggoRepository {
map: HashMap<String, Doggo>,
}
impl InMemoryDoggoRepository {
pub fn new() -> InMemoryDoggoRepository {
InMemoryDoggoRepository {
map: HashMap::new(),
}
}
fn save(&mut self, doggo: Doggo) {
self.map.insert(doggo.id.to_owned(), doggo.to_owned());
}
fn find(&self, doggo_id: &String) -> Option<Doggo> {
match self.map.get(doggo_id) {
None => None,
Some(doggo) => Some(doggo.to_owned()),
}
}
}
struct AppState {
locked_repo: Arc<RwLock<InMemoryDoggoRepository>>,
}
fn register_doggo((state, json_doggo): (State<AppState>, Json<Doggo>)) -> impl Responder {
let mut repo = state.locked_repo.write().unwrap();
repo.save(json_doggo.into_inner().to_owned());
HttpResponse::Ok()
}
fn fetch_doggo((state, path) : (State<AppState>, Path<(String,)>)) -> impl Responder {
let repo = state.locked_repo.read().unwrap();
match repo.find(&path.0) {
None => HttpResponse::NotFound().body("oops"),
Some(doggo) => HttpResponse::Ok().body(doggo.name),
}
}
fn main() {
let locked_repo = Arc::new(RwLock::new(InMemoryDoggoRepository::new()));
let server = server::new(move ||
App::with_state(AppState { locked_repo: Arc::clone(&locked_repo) })
.resource("/doggos", |r| r.method(http::Method::POST).with(register_doggo))
.resource("/doggos/{doggo_id}", |r| r.method(http::Method::GET).with(fetch_doggo))
);
server.bind("127.0.0.1:8088").unwrap().run();
}
Finitions
En dérivant le trait Serialize en plus de
Deserialize, on peut facilement renvoyer du JSON dans les reponses.
Résultat
Le code complet est disponible sur GitHub.
On peut tester un worflow d'appels d'API :
# On essaie de récupérer un doggo qui n'existe pas:
http -v localhost:8088/doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9
# Ensuite on enregistre un doggo:
http -v POST localhost:8088/doggos <<JSON
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan"
}
JSON
# On récupère ses infos:
http -v localhost:8088/doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9
# On modifie notre doggo:
http -v POST localhost:8088/doggos <<JSON
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan, good boy"
}
JSON
# Puis on vérifie que ses infos on bien été mises à jour:
http -v localhost:8088/doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9
Voici les flux HTTP correspondant :
GET /doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8088
User-Agent: HTTPie/0.9.2
HTTP/1.1 404 Not Found
content-length: 0
date: Sun, 22 Jul 2018 10:06:54 GMT
POST /doggos HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 79
Content-Type: application/json
Host: localhost:8088
User-Agent: HTTPie/0.9.2
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan"
}
HTTP/1.1 200 OK
content-length: 65
content-type: application/json
date: Sun, 22 Jul 2018 10:07:51 GMT
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan"
}
GET /doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8088
User-Agent: HTTPie/0.9.2
HTTP/1.1 200 OK
content-length: 65
content-type: application/json
date: Sun, 22 Jul 2018 10:08:19 GMT
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan"
}
POST /doggos HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 89
Content-Type: application/json
Host: localhost:8088
User-Agent: HTTPie/0.9.2
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan, good boy"
}
HTTP/1.1 200 OK
content-length: 75
content-type: application/json
date: Sun, 22 Jul 2018 10:08:50 GMT
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan, good boy"
}
GET /doggos/963766e8-2b4a-4e4b-a4c6-1e09a16509c9 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8088
User-Agent: HTTPie/0.9.2
HTTP/1.1 200 OK
content-length: 75
content-type: application/json
date: Sun, 22 Jul 2018 10:09:06 GMT
{
"id": "963766e8-2b4a-4e4b-a4c6-1e09a16509c9",
"name": "Rantanplan, good boy"
}