Mise en place d'un serveur HTTP avec Rust et Axum
Publié le
Changelog :
- 26-03-2024: Mise à jour de la librairie Axum
🔖 Cet article fait partie de la série "Rust Axum"
- Partie 1: Mise en place d'un serveur HTTP avec Rust et Axum (celui-ci)
Je vous partage ici mon approche pour configurer pas à pas un serveur HTTP en Rust. L’idée est d’ajouter progressivement toutes les briques nécessaires pour avoir une application en production.
Il existe plusieurs librairies (rocket, warp, etc) et j’ai choisi Axum pour sa simplicité et pour la qualité de l’équipe Tokio.rs qui est derrière ce projet.
Nous aurons l’occasion de reparler d’autres librairies faites par Tokio.rs (ex: Hyper , Tower , Tracing, etc).
📎 TLDR : Retrouvez le code de cet article sur Github
Qu’est-ce qu’une requête HTTP ?
Avant de mettre en place un serveur HTTP, il est utile de se rafraîchir la mémoire sur les requêtes HTTP.
On peut aller relire la spécification RFC 1616 (c’est bien d’en lire parfois 😃).
The HTTP protocol is a request/response protocol. A client sends a request to the server in the form of a request method, URI, and protocol version, followed by a MIME-like message containing request modifiers, client information, and possible body content over a connection with a server. The server responds with a status line, including the message’s protocol version and a success or error code, followed by a MIME-like message containing server information, entity metainformation, and possible entity-body content
Un simple diagramme de séquence :
Une commande curl dans notre terminal suffit à lancer des requêtes HTTP :
$ curl -i localhost:3000
💡 argument
-i
permet de voir les entêtes
On reçoit alors les entêtes HTTP et le corps de la réponse :
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 13
date: Tue, 28 Mar 2023 12:22:20 GMT
Hello World !%
A noter que la réponse est en HTML, pas Json
Prérequis
- Avoir un environnement Rust déjà configuré sur votre poste (Configuration de Rust )
- Avoir déjà manipulé du code Rust (voir les exemples du Rust Book ou Rust par l’exemple )
Si tout est bien configuré, vous devriez pouvoir lancer cette commande :
$ rustc -V
rustc 1.79.0-nightly (2f090c30d 2024-03-23)
Initialisation du projet
Création du nouveau projet avec Cargo :
$ cargo new oodini
💡 le projet est initialisé avec Git
Ajout d’Axum
Ajout des dépendances Axum :
$ cargo add axum
Updating crates.io index
Adding axum v0.7.5 to dependencies.
Features:
+ form
+ http1
+ json
+ matched-path
+ original-uri
+ query
+ tokio
+ tower-log
- __private_docs
- headers
- http2
- macros
- multipart
- ws
Nous ajoutons aussi Tokio avec toutes ses options (requis par Axum) :
$ cargo add tokio -F full
Vous devriez avec ce contenu dans cargo.toml
:
[package]
name = "oodini"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7."
dotenv = "0.15.0"
tokio = { version = "1.36.0", features = ["full"] }
Configuration du serveur
Nous avons besoin d’écouter les requêtes entrantes sur un port et domaine donné.
let listener = TcpListener::bind(addr).await?;
Le router est en charge de trouver les handlers à appeler en fonction des chemins et méthodes fournis.
use axum::{response::Html, routing::get, Router};
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
// build our application with a route
let app = Router::new().route("/", get(handler));
// run it
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = TcpListener::bind(addr).await?;
info!("Listening on {addr}");
axum::serve(listener, app.into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
Ajout d’un handler
Un handler est une fonction asynchrone qui prend en paramètre une requête et retourne une réponse.
Ici, nous renvoyer une réponse en Html avec le status 200 (implicite) :
async fn handler() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
Exécutons l’application :
$ cargo run
Compiling oodini v0.1.0 (/tmp/ootest/oodini)
Finished dev [unoptimized + debuginfo] target(s) in 1.39s
Running `target/debug/oodini`
listening on 127.0.0.1:3000
Le serveur écoute bien le port 3000
.
Vérifions que nous avons bien une réponse HTML sur la route /
.
$ curl -I localhost:3000
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 13
date: Tue, 07 Feb 2023 17:14:36 GMT
<h1>Hello World !</h1>
Notre application tourne sur le port 3000
.
En l’état, il est par exemple impossible de lancer deux serveurs en parallèle sur la même machine car le port est fixe.
Utilisons des variables d’environnement pour dynamiser cette configuration.
Configuration via les variables d’environnement
Nous allons utiliser un fichier .env
à la racine de notre projet pour stocker le port sur lequel écouter les connexions
PORT=5000
Ajout de la librairie pour gérer le fichier .env
:
$ cargo add dotenv
💡 Pensez à rajouter le fichier
.env
dans le.gitignore
pour ne pas versionner des valeurs sensibles
On importe les variables d’environements et on s’assure que la valeur de PORT
est bien de type u16
comme attendu :
// ...
// loads the environment variables from the ".env" file.
dotenv().ok();
// get listening port
let port = std::env::var("PORT").unwrap_or("3000".to_string());
// ensure port is valid type
let port: u16 = port.parse().expect("Port should be valid range");
// build our application with a route
let app = Router::new().route("/", get(handler));
// run it
let addr = SocketAddr::from(([127, 0, 0, 1], port));
//...
Nous avons bien le serveur qui tourne sur le port 5000
:
$ cargo run
Compiling oodini v0.1.0 (/tmp/ootest/oodini)
Finished dev [unoptimized + debuginfo] target(s) in 1.56s
Running `target/debug/oodini`
listening on 127.0.0.1:5000
Nous pouvons aussi changer l’environnement directement au lancement de la commande
$ PORT=8080 cargo run
Compiling oodini v0.1.0 (/tmp/ootest/oodini)
Finished dev [unoptimized + debuginfo] target(s) in 1.56s
Running `target/debug/oodini`
listening on 127.0.0.1:8080
Notez que nous indiquons Html
dans le tuple car nous voulons un header content-type: text/html
.
Sans cela, on aurait un header avec content-type: text/plain
.
Le type Multipurpose Internet Mail Extensions (type MIME) est un standard permettant d’indiquer la nature et le format d’un document.
Il permet de déterminer comment l’information sera traitée ou affichée.
Il est défini au sein de la RFC 6838
Monitoring avec log
Comment débugger ou toute simplement contrôler que l’application tourne bien si l’on fonctionne à l’aveugle ?
Rajouter des logs :
$ cargo add log env_logger
Le crate env_logger
permet de modifier le niveau de log via l’environnement
#[tokio::main]
async fn main() {
env_logger::init();
info!("Starting application");
// ...
let addr = SocketAddr::from(([127, 0, 0, 1], port));
info!("listening on {addr}");
// ...
Par défaut, le niveau d’erreur est error
, donc vous le verrez pas de changement au lancement de l’application.
Changeons ce niveau de log :
$ RUST_LOG=info cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/oodini`
[2023-03-29T11:37:09Z INFO oodini] Starting application
[2023-03-29T11:37:09Z INFO oodini] listening on 127.0.0.1:3000
Monitoring avec tracing
Il existe une façon plus fine de suivre l’éxécution de son code : le tracing.
Elle est composée de 3 phases :
- instrumentation : ajout du code de tracing dans le code
- tracing : l’évènement est écrit vers la ou les cibles
- analyse : visualiser et analyser les informations collectées sur une plateforme
Rust et Axum viennent avec tout l’outillage nécessaire pour implémenter le tracing dans notre application
Ajoutons les dépendances :
$ cargo add tracing tracing-subscriber
Remplaçons le code env_logger
par celui de tracing_subscriber
:
#[tokio::main]
async fn main() {
// install global collector configured based on RUST_LOG env var.
tracing_subscriber::fmt::init();
// ...
Lançons notre application à nouveau :
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/oodini`
2023-03-29T12:10:44.118792Z INFO oodini: Starting application
2023-03-29T12:10:44.118908Z INFO oodini: listening on 127.0.0.1:3000
💡 Notez que le formatage par défaut est légèrement différement de la librairie
log
Axum est basé sur la librairie Tower et Tower-http pour les services, utilities et middleware.
Ajoutons un middleware de la librairie tower_http
pour logger automatiquement les routes appelées.
Pour cela, nous avon besoin de la fonctionnalité trace
de la librairie tower_http
$ cargo add tower_http -F trace
Ajoutons le middleware :
let app = Router::new()
.route("/", get(handler))
// ...
.layer(TraceLayer::new_for_http());
Par défaut, seules les erreurs sont tracées :
$ curl -i localhost:3000
2023-03-29T12:35:10.103066Z INFO oodini: Starting application
2023-03-29T12:35:10.103210Z INFO oodini: listening on 127.0.0.1:3000
Vous pouvez changer le niveau de log à debug
via RUST_LOG
:
$ RUST_LOG=tower_http=trace cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/oodini`
$ curl -i localhost:3000
2024-03-27T20:25:20.740255Z DEBUG request{method=HEAD uri=/ version=HTTP/1.1}: tower_http::trace::on_request: started processing request
2024-03-27T20:25:20.740336Z DEBUG request{method=HEAD uri=/ version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=200
Une méthode plus pratique consiste à définir une valeur par défaut si le niveau n’est pas défini dans l’environnement.
Pour cela nous allons rajouter la fonctionnalité env-filter
de la librairie tracing_subscriber
:
$ cargo add tracing_subscriber -F env-filter
Ou directement dans le code :
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "oodini=debug".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
Refactoring
On une application qui sert du HTML sur un port configurable et on a des logs pour contrôler ce qui se passe. Cependant, tout est dans un seul fichier, cela rend le futur code peu lisible et peu maintenable.
Organisons le code de telle façon à séparer la logique dans des fichiers et modules différents.
On aura une arborescence de ce type :
./src
├── config.rs
├── lib.rs
├── main.rs
└── routes
├── html.rs
└── mod.rs
- config : toute la logique de récupération des variables d’environnement
- routes : regrouper les routes et handlers (ex: séparer html, API, GraphQL)
- main.rs : le setup du server
- lib.rs : imports des modules
💡 Dans la section qui suit, je ne partage que les changements notables pour des raisons de visibilité. Regardez le code sur github
Le fichier de config avec une fonction qui encapsule toute la logique de vérification des variables :
use dotenv::dotenv;
use std::net::Ipv4Addr;
pub fn from_env() -> (Ipv4Addr, u16) {
// loads the environment variables from the ".env" file.
dotenv().ok();
// get listening port
let port = std::env::var("PORT").unwrap_or("3000".to_string());
// ensure port is valid type
let port: u16 = port.parse().expect("Port should be valid range");
// get host
let host = std::env::var("HOST").unwrap_or("127.0.0.1".to_string());
let host: Ipv4Addr = host.parse().expect("Not a valid address");
// let host =
(host, port)
}
💡 Notez le mot clé
pub
car nous voulons importer cette fonction dansmain.rs
On récupère un tuple avec les bons types attendus par le serveur HTTP.
On définit une fonction router publique dans le fichier routes/html.rs
:
pub async fn router() -> Router {
Router::new()
.route("/", get(handler))
}
// ...
// autres fonctions handler privées
Ici, on s’appuie sur Axum qui permet de nest
les routers.
La fonction est publique via pub
, tous les handlers peuvent rester privés.
Cela permet d’améliorer la visibilité de notre fonction main
:
// build our application with a route
let app = Router::new()
.nest("/", routes::html::router().await)
.fallback(handler_404)
.layer(TraceLayer::new_for_http());
// add a fallback service for handling routes to unknown paths
let (host, port) = oodini::config::from_env();
let addr = SocketAddr::new(host.into(), port);
En résumé
Après ces différentes étapes, nous avons :
- un serveur qui écoute les requêtes HTTP sur un port et un host configurable via les variable d’environnements
- des routes avec des handlers asynchrones
- des exemples de réponses Axum pour produire du HTML
- du tracing basique afin de contrôler et monitorer les requêtes HTTP et autres erreurs rencontrées
Dans un prochain article, nous verrons comment gérer les API de type REST et l’usage de la librairie de sérialisation/désérialisation Serde
Ressources
Le code final correspondant à cet article est disponible sur Github
#rust #log #rest #tutoriel🔖 Cet article fait partie de la série "Rust Axum"
- Partie 1: Mise en place d'un serveur HTTP avec Rust et Axum (celui-ci)