Router

Cos'è il Routing

Router definisce quale middleware e quale Handler elaboreranno una richiesta HTTP. Questa è la funzionalità più fondamentale e centrale in Salvo.

Internamente, un Router è essenzialmente composto da una serie di filtri. Quando arriva una richiesta, il router testa se stesso e i suoi discendenti in ordine, dall'alto verso il basso, per vedere se possono corrispondere alla richiesta. Se una corrispondenza ha successo, il middleware sull'intera catena formata dal router e dai suoi router discendenti viene eseguito in sequenza. Se durante l'elaborazione, lo stato della Response viene impostato su un errore (4XX, 5XX) o un reindirizzamento (3XX), il middleware successivo e l'Handler verranno saltati. Puoi anche chiamare manualmente ctrl.skip_rest() per saltare il middleware e l'Handler rimanenti.

Durante il processo di corrispondenza, esiste un oggetto informativo del percorso URL, che può essere considerato come un oggetto che deve essere completamente consumato dai filtri durante la corrispondenza. Se tutti i filtri in un determinato Router corrispondono con successo e queste informazioni sul percorso URL sono state completamente consumate, viene considerata una "corrispondenza riuscita".

Ad esempio:

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

È effettivamente equivalente a:

Router::new()
    // PathFilter può filtrare i percorsi delle richieste. Corrisponde con successo solo se il percorso della richiesta contiene il segmento "articles".
    // Altrimenti, la corrispondenza fallisce. Ad esempio: /articles/123 corrisponde con successo, mentre /articles_list/123
    // contiene "articles" ma non corrisponde a causa del suffisso "_list".
    .filter(PathFilter::new("articles"))

    // Se la radice corrisponde con successo e il metodo della richiesta è GET, il router figlio interno può corrispondere con successo,
    // e la richiesta viene gestita da list_articles.
    .push(Router::new().filter(filters::get()).handle(list_articles))

    // Se la radice corrisponde con successo e il metodo della richiesta è POST, il router figlio interno può corrispondere con successo,
    // e la richiesta viene gestita da create_article.
    .push(Router::new().filter(filters::post()).handle(create_article));

Se si accede a GET /articles/, viene considerata una corrispondenza riuscita e viene eseguito list_articles. Tuttavia, se si accede a GET /articles/123, la corrispondenza della rotta fallisce e restituisce un errore 404 perché Router::with_path("articles") consuma solo la parte /articles delle informazioni sul percorso URL, lasciando la parte /123 non consumata, quindi la corrispondenza viene considerata fallita. Per ottenere una corrispondenza riuscita, la rotta può essere modificata in:

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

Qui, {**} corrisponde a qualsiasi percorso rimanente, quindi può corrispondere a GET /articles/123 ed eseguire list_articles.

Definizione Piatta

Possiamo definire le rotte in uno stile piatto:

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);

Definizione ad Albero

Possiamo anche definire le rotte in una struttura ad albero, che è l'approccio consigliato:

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)),
    );

Questa forma di definizione rende le definizioni del Router gerarchiche, chiare e semplici per progetti complessi.

Molti metodi in Router restituiscono Self dopo essere stati chiamati, facilitando la scrittura di codice concatenato. A volte, è necessario decidere come instradare in base a determinate condizioni. Il sistema di routing fornisce anche la funzione then, facile da usare:

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
                }
            }),
    );

Questo esempio significa che le rotte per la creazione, modifica ed eliminazione di articoli vengono aggiunte solo quando il server è in modalità admin_mode.

Recupero dei Parametri dalle Rotta

Nel codice sopra, {id} definisce un parametro. Possiamo recuperarne il valore attraverso l'istanza Request:

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

{id} corrisponde a un segmento nel percorso. Normalmente, l'id di un articolo è solo un numero. In questo caso, possiamo usare un'espressione regolare per limitare la regola di corrispondenza per id, come r"{id|\d+}".

Per i tipi numerici, c'è un metodo ancora più semplice usando <id:num>. Le notazioni specifiche sono:

  • {id:num} corrisponde a qualsiasi numero di caratteri cifra.
  • {id:num[10]} corrisponde esattamente a un numero specifico di caratteri cifra; qui, 10 significa che corrisponde esattamente a 10 cifre.
  • {id:num(..10)} corrisponde da 1 a 9 caratteri cifra.
  • {id:num(3..10)} corrisponde da 3 a 9 caratteri cifra.
  • {id:num(..=10)} corrisponde da 1 a 10 caratteri cifra.
  • {id:num(3..=10)} corrisponde da 3 a 10 caratteri cifra.
  • {id:num(10..)} corrisponde ad almeno 10 caratteri cifra.

Puoi anche corrispondere a tutti i segmenti di percorso rimanenti usando {**}, {*+} o {*?}. Per una migliore leggibilità del codice, puoi aggiungere nomi appropriati per rendere la semantica del percorso più chiara, ad esempio {**file_path}.

  • {**}: Rappresenta una corrispondenza con caratteri jolly dove la parte corrispondente può essere una stringa vuota. Ad esempio, il percorso /files/{**rest_path} corrisponde a /files, /files/abc.txt, /files/dir/abc.txt.
  • {*+}: Rappresenta una corrispondenza con caratteri jolly dove la parte corrispondente deve esistere e non può essere una stringa vuota. Ad esempio, il percorso /files/{*+rest_path} NON corrisponde a /files ma corrisponde a /files/abc.txt, /files/dir/abc.txt.
  • {*?}: Rappresenta una corrispondenza con caratteri jolly dove la parte corrispondente può essere una stringa vuota ma può contenere solo un segmento di percorso. Ad esempio, il percorso /files/{*?rest_path} NON corrisponde a /files/dir/abc.txt ma corrisponde a /files, /files/abc.txt.

Multiple espressioni possono essere combinate per corrispondere allo stesso segmento di percorso, ad esempio /articles/article_{id:num}/, /images/{name}.{ext}.

Aggiunta di Middleware

Il middleware può essere aggiunto tramite la funzione hoop su un router:

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)),
    );

In questo esempio, il router radice utilizza check_authed per verificare se l'utente corrente ha effettuato l'accesso. Tutti i router discendenti sono influenzati da questo middleware.

Se gli utenti stanno solo navigando tra le informazioni degli writer e gli articoli, potremmo preferire che possano navigare senza effettuare l'accesso. Possiamo definire le rotte in questo modo:

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)),
        ),
    );

Anche se due router hanno la stessa definizione di percorso path("articles"), possono comunque essere aggiunti allo stesso router padre.

Filtri

Router utilizza internamente i filtri per determinare se una rotta corrisponde. I filtri supportano operazioni logiche di base utilizzando or o and. Un router può contenere più filtri; quando tutti i filtri corrispondono con successo, la rotta corrisponde con successo.

Le informazioni sul percorso del sito web sono una struttura ad albero, ma questa struttura ad albero non è equivalente alla struttura ad albero che organizza i router. Un percorso del sito web può corrispondere a più nodi del router. Ad esempio, alcuni contenuti sotto il percorso articles/ potrebbero richiedere l'accesso per essere visualizzati, mentre altri no. Possiamo organizzare i sottopercorsi che richiedono la verifica dell'accesso sotto un router contenente il middleware di verifica dell'accesso, e quelli che non richiedono la verifica sotto un altro router senza di essa:

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)),
    );

I router utilizzano i filtri per filtrare le richieste e inviarle al middleware e all'Handler corrispondenti per l'elaborazione.

path e method sono due dei filtri più comunemente usati. path viene utilizzato per corrispondere alle informazioni sul percorso; method viene utilizzato per corrispondere al Metodo della richiesta, ad esempio GET, POST, PATCH, ecc.

Possiamo collegare i filtri del router utilizzando and, or:

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

Filtro Percorso

I filtri basati sui percorsi delle richieste sono i più frequentemente utilizzati. I filtri del percorso possono definire parametri, ad esempio:

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

In un Handler, possono essere recuperati tramite la funzione get_param dell'oggetto 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");
}

Filtro Metodo

Filtra le richieste in base al Method della richiesta HTTP, ad esempio:

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

Qui, get, patch, delete sono tutti filtri di Metodo. Questo è effettivamente equivalente a:

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 Personalizzato

Per determinate espressioni di corrispondenza che si verificano frequentemente, possiamo assegnare un nome breve tramite PathFilter::register_wisp_regex o PathFilter::register_wisp_builder. Ad esempio, il formato GUID appare spesso nei percorsi. Il modo normale è scriverlo così ogni volta che è necessaria la corrispondenza:

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}/>");

Scrivere questa espressione regolare complessa ogni volta è soggetto a errori e rende il codice meno leggibile. Puoi invece fare così:

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));
}

Devi registrarlo solo una volta. Successivamente, puoi utilizzare direttamente una notazione semplice come {id:guid} per corrispondere ai GUID, semplificando la scrittura del codice.

Provenendo da un Web Framework basato su Controller, Come Comprendere il Router?

Le principali differenze tra i framework web progettati per il routing (come Salvo) e i framework tradizionali MVC o progettati per i Controller sono:

  • Flessibilità: La progettazione del routing consente una definizione più flessibile dei flussi di elaborazione delle richieste, consentendo un controllo preciso sulla logica per ogni percorso. Ad esempio, in Salvo puoi definire direttamente funzioni handler per percorsi specifici:

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

    Nella progettazione dei Controller, in genere è necessario definire prima una classe controller, quindi definire più metodi all'interno della classe per gestire diverse richieste:

    @Controller
    public class ArticleController {
        @GetMapping("/articles")
        public List<Article> listArticles() { /* ... */ }
        
        @PostMapping("/articles")
        public Article createArticle(@RequestBody Article article) { /* ... */ }
    }
  • Integrazione del Middleware: I framework di routing di solito forniscono un modo più conciso per integrare il middleware, consentendo al middleware di essere applicato a rotte specifiche. Il middleware di Salvo può essere applicato con precisione a rotte particolari:

    Router::new()
        .push(
            Router::with_path("admin/articles")
                .hoop(admin_auth_middleware)  // Applica il middleware di autenticazione solo alle rotte di amministrazione
                .get(list_all_articles)
                .post(create_article),
        )
        .push(
            Router::with_path("articles")  // Le rotte pubbliche non necessitano di autenticazione
                .get(list_public_articles),
        );
  • Organizzazione del Codice: La progettazione del routing tende a organizzare il codice in base alla funzionalità o agli endpoint API, piuttosto che alla stratificazione Model-View-Controller dell'MVC. La progettazione del routing incoraggia l'organizzazione del codice in base alla funzionalità dell'endpoint API:

    // user_routes.rs - Rotta e logica di gestione relative agli utenti
    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 - Rotta e logica di gest