Mise en place d'un serveur HTTP avec Rust et Axum

Publié le

Changelog :

  • 26-03-2024: Mise à jour de la librairie Axum
illustration de l'article

🔖 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 :

sequenceDiagram participant Client participant Server Client->>Server: GET / activate Server Server->>Client: HTTP/1.1 200 OK deactivate Server

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

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 dans main.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)

D'autres articles à lire