Routeur

Qu'est-ce qu'un routeur

Router définit quels middlewares et Handler traiteront une requête HTTP. C'est la fonctionnalité la plus basique et centrale de Salvo.

En interne, un Router est en réalité composé d'une série de filtres (Filter). Lorsqu'une requête arrive, le routeur teste successivement de haut en bas, dans l'ordre d'ajout, si lui-même et ses descendants peuvent correspondre à la requête. Si une correspondance est trouvée, les middlewares de toute la chaîne formée par le routeur et ses descendants sont exécutés séquentiellement. Si pendant le traitement, l'état de la Response est défini comme une erreur (4XX, 5XX) ou une redirection (3XX), les middlewares et Handler suivants sont ignorés. Vous pouvez aussi appeler manuellement ctrl.skip_rest() pour ignorer les middlewares et Handler suivants.

Pendant le processus de correspondance, il existe une information de chemin URL qui peut être considérée comme un objet devant être entièrement consommé par le Filter lors de la correspondance. Si tous les Filtres d'un Router correspondent avec succès et que cette information de chemin URL a été entièrement consommée, alors on considère qu'il y a "correspondance réussie".

Par exemple :

Router::with_path("articles").get(list_articles).post(create_article);

Est en réalité équivalent à :

Router::new()
    // PathFilter peut filtrer les chemins de requête, ne correspondra que si le chemin contient le segment articles,
    // sinon la correspondance échouera. Par exemple : /articles/123 correspondra, mais /articles_list/123
    // bien qu'il contienne articles, ne correspondra pas à cause du _list qui suit.
    .filter(PathFilter::new("articles"))

    // Si la racine correspond, et que la méthode de la requête est GET, alors le sous-routeur interne correspondra,
    // et la requête sera traitée par list_articles.
    .push(Router::new().filter(filters::get()).handle(list_articles))

    // Si la racine correspond, et que la méthode de la requête est POST, alors le sous-routeur interne correspondra,
    // et la requête sera traitée par create_article.
    .push(Router::new().filter(filters::post()).handle(create_article));

Si on accède à GET /articles/, cela sera considéré comme une correspondance réussie, et list_articles sera exécuté. Mais si on accède à GET /articles/123, la correspondance échouera et une erreur 404 sera retournée, car Router::with_path("articles") n'a consommé que /articles dans l'information de chemin URL, laissant /123 non consommé, donc la correspondance échoue. Pour permettre la correspondance, le routeur peut être modifié ainsi :

Router::with_path("articles/{**}").get(list_articles).post(create_article);

Ici, {**} correspondra à n'importe quel chemin supplémentaire, donc il pourra correspondre à GET /articles/123 et exécuter list_articles.

Définition plate

Nous pouvons définir les routes dans un style plat :

Router::with_path("writers").get(list_writers).post(create_writer);
Router::with_path("writers/{id}").get(show_writer).patch(edit_writer).delete(delete_writer);
Router::with_path("writers/{id}/articles").get(list_writer_articles);

Définition arborescente

Nous pouvons aussi définir les routes de manière arborescente, ce qui est la méthode recommandée :

Router::with_path("writers")
    .get(list_writers)
    .post(create_writer)
    .push(
        Router::with_path("{id}")
            .get(show_writer)
            .patch(edit_writer)
            .delete(delete_writer)
            .push(Router::with_path("articles").get(list_writer_articles)),
    );

Cette forme de définition permet pour les projets complexes de garder la définition des Router claire et simple.

Dans Router, de nombreuses méthodes retournent l'instance elle-même (Self), permettant un enchaînement fluide du code. Parfois, vous devez décider comment router en fonction de certaines conditions. Le système de routage fournit aussi la fonction then, très simple à utiliser :

Router::new()
    .push(
        Router::with_path("articles")
            .get(list_articles)
            .push(Router::with_path("{id}").get(show_article))
            .then(|router|{
                if admin_mode() {
                    router.post(create_article).push(
                        Router::with_path("{id}").patch(update_article).delete(delete_writer)
                    )
                } else {
                    router
                }
            }),
    );

Cet exemple montre que les routes pour créer, éditer ou supprimer des articles ne seront ajoutées que si le serveur est en mode admin_mode.

Récupérer des paramètres depuis la route

Dans le code ci-dessus, {id} définit un paramètre. Nous pouvons récupérer sa valeur via l'instance Request :

#[handler]
async fn show_writer(req: &mut Request) {
    let id = req.param::<i64>("id").unwrap();
}

{id} correspond à un segment du chemin. Normalement, l'id d'un article est juste un nombre, donc nous pouvons utiliser une expression régulière pour limiter les règles de correspondance de id à r"{id|\d+}".

Pour ce type numérique, une méthode plus simple est d'utiliser <id:num>, avec les syntaxes suivantes :

  • {id:num}, correspond à n'importe quel nombre de chiffres ;
  • {id:num[10]}, correspond exactement à un nombre spécifique de chiffres, ici 10 ;
  • {id:num(..10)}, correspond à 1 à 9 chiffres ;
  • {id:num(3..10)}, correspond à 3 à 9 chiffres ;
  • {id:num(..=10)}, correspond à 1 à 10 chiffres ;
  • {id:num(3..=10)}, correspond à 3 à 10 chiffres ;
  • {id:num(10..)}, correspond à au moins 10 chiffres.

Il est aussi possible d'utiliser {**}, {*+} ou {*?} pour correspondre à tous les segments de chemin restants. Pour une meilleure lisibilité, vous pouvez aussi ajouter un nom approprié pour clarifier la sémantique du chemin, comme {**file_path}.

  • {**} : correspond à une partie pouvant être une chaîne vide, par exemple le chemin /files/{**rest_path} correspondra à /files, /files/abc.txt, /files/dir/abc.txt ;
  • {*+} : la partie correspondante doit exister et ne peut pas être une chaîne vide, par exemple /files/{*+rest_path} ne correspondra pas à /files mais correspondra à /files/abc.txt, /files/dir/abc.txt ;
  • {*?} : la partie correspondante peut être une chaîne vide, mais ne peut contenir qu'un seul segment de chemin, par exemple /files/{*?rest_path} ne correspondra pas à /files/dir/abc.txt mais correspondra à /files, /files/abc.txt.

Il est possible de combiner plusieurs expressions pour correspondre au même segment de chemin, comme /articles/article_{id:num}/, /images/{name}.{ext}.

Ajouter des middlewares

Vous pouvez ajouter des middlewares via la fonction hoop du routeur :

Router::new()
    .hoop(check_authed)
    .path("writers")
    .get(list_writers)
    .post(create_writer)
    .push(
        Router::with_path("{id}")
            .get(show_writer)
            .patch(edit_writer)
            .delete(delete_writer)
            .push(Router::with_path("articles").get(list_writer_articles)),
    );

Dans cet exemple, le routeur racine utilise check_authed pour vérifier si l'utilisateur actuel est connecté. Tous les routeurs descendants seront affectés par ce middleware.

Si les utilisateurs peuvent seulement consulter les informations des writers et leurs articles sans avoir besoin de se connecter, nous pouvons définir les routes comme ceci :

Router::new()
    .push(
        Router::new()
            .hoop(check_authed)
            .path("writers")
            .post(create_writer)
            .push(Router::with_path("{id}").patch(edit_writer).delete(delete_writer)),
    )
    .push(
        Router::with_path("writers").get(list_writers).push(
            Router::with_path("{id}")
                .get(show_writer)
                .push(Router::with_path("articles").get(list_writer_articles)),
        ),
    );

Bien que deux routes aient la même définition de chemin path("articles"), elles peuvent toujours être ajoutées au même routeur parent.

Filtres

Un Router détermine s'il correspond via des filtres internes. Les filtres supportent les opérations logiques de base or et and. Un routeur peut contenir plusieurs filtres, et ne correspondra que si tous les filtres correspondent avec succès.

La structure des chemins d'un site est arborescente, mais cette structure ne correspond pas nécessairement à l'organisation arborescente des routes. Un chemin de site peut correspondre à plusieurs nœuds de route. Par exemple, certains contenus sous le chemin articles/ nécessitent une connexion pour être consultés, alors que d'autres non. Nous pouvons organiser les sous-chemins nécessitant une authentification sous un routeur contenant le middleware de vérification, et les autres sous un routeur sans cette vérification :

Router::new()
    .push(
        Router::with_path("articles")
            .get(list_articles)
            .push(Router::new().path("{id}").get(show_article)),
    )
    .push(
        Router::with_path("articles")
            .hoop(auth_check)
            .post(list_articles)
            .push(Router::new().path("{id}").patch(edit_article).delete(delete_article)),
    );

Les routeurs utilisent des filtres pour filtrer les requêtes et les envoyer aux middlewares et Handler correspondants.

path et method sont deux des filtres les plus couramment utilisés. path est utilisé pour correspondre aux informations de chemin ; method est utilisé pour correspondre à la méthode de la requête, comme GET, POST, PATCH, etc.

Nous pouvons utiliser and, or pour connecter les filtres d'un routeur :

Router::with_filter(filters::path("hello").and(filters::get()));

Filtres de chemin

Les filtres basés sur le chemin de la requête sont les plus fréquemment utilisés. Les filtres de chemin peuvent définir des paramètres, comme :

Router::with_path("articles/{id}").get(show_article);
Router::with_path("files/{**rest_path}").get(serve_file)

Dans un Handler, les paramètres peuvent être récupérés via la fonction get_param de l'objet Request :

#[handler]
pub async fn show_article(req: &mut Request) {
    let article_id = req.param::<i64>("id");
}

#[handler]
pub async fn serve_file(req: &mut Request) {
    let rest_path = req.param::<i64>("rest_path");
}

Filtres de méthode

Filtre les requêtes selon la méthode HTTP, comme :

Router::new().get(show_article).patch(update_article).delete(delete_article);

Ici, get, patch, delete sont des filtres de méthode. C'est équivalent à :

use salvo::routing::filter;

let mut root_router = Router::new();
let show_router = Router::with_filter(filters::get()).handle(show_article);
let update_router = Router::with_filter(filters::patch()).handle(update_article);
let delete_router = Router::with_filter(filters::get()).handle(delete_article);
Router::new().push(show_router).push(update_router).push(delete_router);

Wisp personnalisés

Pour certaines expressions de correspondance fréquemment utilisées, nous pouvons leur donner un nom court via PathFilter::register_wisp_regex ou PathFilter::register_wisp_builder. Par exemple, le format GUID apparaît souvent dans les chemins. Normalement, nous devrions écrire à chaque fois :

Router::with_path("/articles/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");
Router::with_path("/users/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");

Écrire cette expression régulière complexe à chaque fois est sujet aux erreurs et peu esthétique. Nous pouvons plutôt faire :

use salvo::routing::filter::PathFilter;

#[tokio::main]
async fn main() {
    let guid = regex::Regex::new("[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}").unwrap();
    PathFilter::register_wisp_regex("guid", guid);
    Router::new()
        .push(Router::with_path("/articles/{id:guid}").get(show_article))
        .push(Router::with_path("/users/{id:guid}").get(show_user));
}

Il suffit de l'enregistrer une fois, et ensuite nous pouvons utiliser simplement {id:guid} pour correspondre à un GUID, simplifiant grandement l'écriture du code.

Comment comprendre Router si on a appris avec des frameworks web de type Controller ?

Les principales différences entre les frameworks web basés sur le routage (comme Salvo) et les frameworks traditionnels de type MVC ou Controller sont :

  • Flexibilité : La conception par routage permet une définition plus flexible du flux de traitement des requêtes, avec un contrôle précis de la logique de traitement pour chaque chemin. Par exemple, dans Salvo vous pouvez directement définir la fonction de traitement pour un chemin spécifique :

    Router::with_path("articles").get(list_articles).post(create_article);

    Alors qu'avec une conception Controller, vous devez généralement définir une classe contrôleur avec plusieurs méthodes pour gérer différentes requêtes :

    @Controller
    public class ArticleController {
        @GetMapping("/articles")
        public List<Article> listArticles() { /* ... */ }
        
        @PostMapping("/articles")
        public Article createArticle(@RequestBody Article article) { /* ... */ }
    }
  • Intégration des middlewares : Les frameworks de routage offrent généralement des moyens plus simples d'intégrer des middlewares, pouvant être appliqués à des routes spécifiques. Les middlewares de Salvo peuvent être appliqués précisément :

    Router::new()
        .push(
            Router::with_path("admin/articles")
                .hoop(admin_auth_middleware)  // Middleware d'authentification uniquement pour les routes admin
                .get(list_all_articles)
                .post(create_article),
        )
        .push(
            Router::with_path("articles")  // Route publique sans authentification
                .get(list_public_articles),
        );
  • Organisation du code : Le routage favorise une organisation basée sur les fonctionnalités ou les points d'API plutôt que sur la séparation modèle-vue-contrôleur du MVC. Le routage encourage à organiser le code par fonctionnalité des points d'API :

    // user_routes.rs - Routes et logique liées aux utilisateurs
    pub fn user_routes() -> Router {
        Router::with_path("users")
            .get(list_users)
            .post(create_user)
            .push(Router::with_path("{id}").get(get_user).delete(delete_user))
    }
    
    // article_routes.rs - Routes et logique liées aux articles
    pub fn article_routes() -> Router {
        Router::with_path("articles")
            .get(list_articles)
            .post(create_article)
    }
    
    // Combinaison des routes dans l'application principale
    let router = Router::new()
        .push(user_routes())
        .push(article_routes());
  • Légèreté : Généralement, le routage est plus léger, avec moins de concepts et contraintes imposés par le framework. Vous pouvez n'inclure que les composants nécessaires sans suivre une structure stricte.

Le routage rend le développement d'API plus intuitif, particulièrement adapté pour les microservices modernes et les API RESTful. Dans des frameworks comme Salvo, le routage est un concept central qui reflète directement la structure et le comportement de l'API, rendant le code plus facile à comprendre et maintenir. En comparaison, les conceptions Controller traditionnelles nécessitent souvent plus de configuration et conventions pour réaliser les mêmes fonctionnalités.

Aperçu des méthodes de la structure Router

CatégorieMéthodeDescription
**Création/Acc