ルーター

ルーティングとは

Router は、HTTPリクエストをどのミドルウェアと Handler で処理するかを定義します。これはSalvoの中で最も基本的かつ中核的な機能です。

Router の内部は実際には一連のフィルターで構成されています。リクエストが到着すると、ルーターは追加された順序で、上から下へと自身とその子孫がリクエストにマッチするかどうかを順次テストします。マッチが成功した場合、ルーターとその子孫ルーターによって形成されるチェーン上のミドルウェアを順次実行します。処理中に Response の状態がエラー(4XX、5XX)またはリダイレクト(3XX)に設定された場合、後続のミドルウェアと Handler はスキップされます。また、手動で ctrl.skip_rest() を呼び出して後続のミドルウェアと Handler をスキップすることもできます。

マッチングの過程では、URLパス情報が存在します。これは、マッチング過程で完全にFilterによって消費される必要があるオブジェクトと考えることができます。あるRouterですべてのFilterがマッチングに成功し、かつこのURLパス情報が完全に消費された場合、「マッチング成功」と見なされます。

例:

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

これは実際には以下と同等です:

Router::new()
    // PathFilterはリクエストパスをフィルタリングし、リクエストパスにarticlesセグメントが含まれている場合のみマッチング成功、
    // それ以外はマッチング失敗。例:/articles/123 はマッチング成功、/articles_list/123
    // はarticlesを含んでいますが、後に _list があるため、マッチング成功しません。
    .filter(PathFilter::new("articles"))

    // rootがマッチング成功した場合、リクエストのmethodがgetであれば、内部の子ルーターがマッチング成功し、
    // list_articlesがリクエストを処理します。
    .push(Router::new().filter(filters::get()).handle(list_articles))

    // rootがマッチング成功した場合、リクエストのmethodがpostであれば、内部の子ルーターがマッチング成功し、
    // create_articleがリクエストを処理します。
    .push(Router::new().filter(filters::post()).handle(create_article));

GET /articles/ にアクセスするとマッチング成功と見なされ、list_articles が実行されます。しかし、GET /articles/123 にアクセスするとルーティングのマッチングに失敗し、404エラーが返されます。なぜなら、Router::with_path("articles") はURLパス情報の /articles のみを消費し、/123 部分が消費されていないため、マッチング失敗と見なされるからです。マッチングを成功させるには、ルーターを次のように変更できます:

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

ここでの {**} は任意の余分なパスにマッチするため、GET /articles/123 にマッチングし、list_articles を実行できます。

フラット定義

ルーティングをフラットスタイルで定義できます:

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

ツリー構造定義

ルーティングをツリー構造で定義することもでき、これは推奨される定義方法です:

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

この形式の定義は、複雑なプロジェクトにおいて、Routerの定義を階層的に明確かつシンプルにすることができます。

Router には、メソッド呼び出し後に自身(Self)を返すものが多くあり、チェーン形式でのコード記述が容易になっています。場合によっては、特定の条件に基づいてルーティング方法を決定する必要があり、ルーティングシステムは then 関数も提供しており、簡単に使用できます:

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

この例は、サーバーが admin_mode の場合のみ、記事の作成、編集、削除などのルートを追加することを表しています。

ルートからパラメータを取得

上記のコードでは、{id} がパラメータを定義しています。Request インスタンスを通じてその値を取得できます:

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

{id} はパス内の1つのセグメントにマッチします。通常、記事の id は単なる数字です。この場合、正規表現を使用して id のマッチングルールを制限できます:r"{id|\d+}"

このような数値型の場合、もっと簡単な方法は <id:num> を使用することです。具体的な書き方は以下の通りです:

  • {id:num}:任意の数の数字文字にマッチ;
  • {id:num[10]}:特定の数の数字文字のみにマッチ。ここでの10は、ちょうど10個の数字文字にマッチすることを意味します;
  • {id:num(..10)}:1から9個の数字文字にマッチ;
  • {id:num(3..10)}:3から9個の数字文字にマッチ;
  • {id:num(..=10)}:1から10個の数字文字にマッチ;
  • {id:num(3..=10)}:3から10個の数字文字にマッチ;
  • {id:num(10..)}:少なくとも10個の数字文字にマッチ。

また、{**}, {*+}, または {*?} を使用して残りのすべてのパスセグメントにマッチさせることもできます。コードの可読性を高めるために、適切な名前を追加してパスの意味をより明確にすることもできます。例:{**file_path}

  • {**}:ワイルドカードがマッチする部分は空文字列でもよいことを表します。例:パスが /files/{**rest_path} の場合、/files/files/abc.txt/files/dir/abc.txt にマッチします;
  • {*+}:ワイルドカードがマッチする部分が存在しなければならず、空文字列にはマッチしないことを表します。例:パスが /files/{*+rest_path} の場合、/files にはマッチしませんが、/files/abc.txt/files/dir/abc.txt にはマッチします;
  • {*?}:ワイルドカードがマッチする部分は空文字列でもよいが、1つのパスセグメントのみを含むことができることを表します。例:パスが /files/{*?rest_path} の場合、/files/dir/abc.txt にはマッチしませんが、/files/files/abc.txt にはマッチします;

複数の式を組み合わせて同じパスセグメントにマッチさせることもできます。例:/articles/article_{id:num}//images/{name}.{ext}

ミドルウェアの追加

ルーターの hoop 関数を通じてミドルウェアを追加できます:

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

この例では、ルートルーターは check_authed を使用して現在のユーザーがすでにログインしているかどうかをチェックします。すべての子孫ルーターはこのミドルウェアの影響を受けます。

ユーザーが単に writer の情報や記事を閲覧するだけであれば、ログインなしで閲覧できるようにしたい場合があります。ルーティングを次のように定義できます:

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

2つのルーターが同じパス定義 path("articles") を持っていても、同じ親ルーターに追加することができます。

フィルター

Router の内部では、すべてフィルターを通じてルーティングがマッチするかどうかを決定します。フィルターは or または and を使用した基本的な論理演算をサポートしています。1つのルーターは複数のフィルターを含むことができ、すべてのフィルターがマッチングに成功した場合、ルーティングのマッチングが成功します。

ウェブサイトのパス情報はツリー構造ですが、このツリー構造はルーティングを組織するツリー構造と同じではありません。ウェブサイトの1つのパスは、複数のルーターノードに対応する場合があります。例えば、articles/ パス下の一部のコンテンツはログインが必要で、一部はログインが不要です。ログイン確認が必要なサブパスを、ログイン確認ミドルウェアを含むルーターの下に組織し、ログイン確認が不要なものを別のログイン確認なしのルーターの下に組織できます:

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

ルーティングは、フィルターを使用してリクエストをフィルタリングし、対応するミドルウェアと Handler に送信して処理します。

pathmethod は2つの最も一般的に使用されるフィルターです。path はパス情報のマッチングに使用され、method はリクエストのMethod(例:GET、POST、PATCHなど)のマッチングに使用されます。

ルーターのフィルターを andor で接続できます:

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

パスフィルター

リクエストパスに基づくフィルターが最も頻繁に使用されます。パスフィルターではパラメータを定義できます。例:

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

Handler では、Request オブジェクトの get_param 関数を通じて取得できます:

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

メソッドフィルター

HTTP リクエストの Method に基づいてリクエストをフィルタリングします。例:

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

ここでの getpatchdelete はすべてMethodフィルターです。実際には以下と同等です:

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

頻繁に出現するマッチング式については、PathFilter::register_wisp_regex または PathFilter::register_wisp_builder を使用して短い名前を付けることができます。例えば、GUID形式はパスで頻繁に出現します。通常の書き方は、マッチングが必要なたびに次のように書くことです:

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

毎回この複雑な正規表現を書くのはエラーが発生しやすく、コードも美しくありません。次のようにすることができます:

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

一度登録するだけで、今後は {id:guid} のような簡単な書き方でGUIDにマッチングでき、コードの記述が簡素化されます。

以前に学んだControllerクラスのWebフレームワークからRouterをどう理解するか?

ルーティング設計のWebフレームワーク(Salvoなど)と従来のMVCまたはController設計フレームワークの主な違いは以下の通りです:

  • 柔軟性:ルーティング設計では、リクエスト処理フローをより柔軟に定義でき、各パスの処理ロジックを正確に制御できます。例えば、Salvoでは特定のパスの処理関数を直接定義できます:

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

    一方、Controller設計では通常、まずコントローラークラスを定義し、クラス内で複数のメソッドを定義して異なるリクエストを処理する必要があります:

    @Controller
    public class ArticleController {
        @GetMapping("/articles")
        public List<Article> listArticles() { /* ... */ }
        
        @PostMapping("/articles")
        public Article createArticle(@RequestBody Article article) { /* ... */ }
    }
  • ミドルウェア統合:ルーティングフレームワークは通常、より簡潔なミドルウェア統合方法を提供し、特定のルートにミドルウェアを適用できます。Salvoのミドルウェアは特定のルートに正確に適用できます:

    Router::new()
        .push(
            Router::with_path("admin/articles")
                .hoop(admin_auth_middleware)  // 管理者ルートにのみ認証ミドルウェアを適用
                .get(list_all_articles)
                .post(create_article),
        )
        .push(
            Router::with_path("articles")  // 公開ルートは認証不要
                .get(list_public_articles),
        );
  • コード組織:ルーティング設計は、MVCのモデル-ビュー-コントローラー階層ではなく、機能やAPIエンドポイントに基づいてコード