Sisk

ルーティング

このページは英語から自動翻訳されています。 原文を読む

Router はサーバー構築の最初のステップです。URL とそのメソッドをサーバーが実行するアクションにマッピングするエンドポイントである Route オブジェクトを保持する役割を担います。各アクションはリクエストを受け取り、クライアントへレスポンスを返すことを担当します。

ルートはパス式(「パスパターン」)とリッスンできる HTTP メソッドの組み合わせです。サーバーにリクエストが送られると、受信したリクエストにマッチするルートを探し、そのルートのアクションを呼び出して結果のレスポンスをクライアントに返します。

Sisk ではルートの定義方法が複数あります。静的、動的、または自動スキャン、属性による定義、あるいは Router オブジェクトに直接定義できます。

C#
Router mainRouter = new Router();

// GET / を以下のアクションにマッピング
mainRouter.MapGet("/", request => {
    return new HttpResponse("Hello, world!");
});

ルートが何ができるかを理解するには、リクエストが何ができるかを理解する必要があります。HttpRequest には必要な情報がすべて含まれています。Sisk には開発を高速化する追加機能も用意されています。

サーバーが受け取った各アクションについて、RouteAction 型のデリゲートが呼び出されます。このデリゲートは、サーバーが受け取ったリクエストに関する情報をすべて保持した HttpRequest を引数に持ちます。このデリゲートの戻り値は HttpResponse か、暗黙的レスポンスタイプ を通じてマッピングできるオブジェクトでなければなりません。

ルートのマッチング #

HTTP サーバーがリクエストを受け取ると、Sisk はリクエストのパス式に合致するルートを検索します。式は常にルート側とリクエスト側のパスだけでテストされ、クエリ文字列は考慮されません。

パスとメソッドがリクエストと一致した最初のルートが処理を担当します。マッチするルートがない場合は Router.NotFoundErrorHandler のレスポンスがクライアントに返されます。パスは一致したが HTTP メソッドが不一致の場合は Router.MethodNotAllowedErrorHandler のレスポンスが返されます。ルートのテスト順序は マッチングの優先順位 に記載されています。

Sisk は静的ルート間の衝突可能性をチェックします。サーバー起動時に、パスパターンとメソッドが重複する静的ルートがないか検査し、見つかった場合は例外をスローします。同時に、2 つ以上のルートが同じ Route.Name を共有している場合も ArgumentException がスローされます。これらのチェックはデフォルトで有効な Router.CheckForRouteCollisions によって制御されます。

マッチングの優先順位 #

各ルートは 静的 か 動的 のいずれかで、Route.IsStatic によって示されます。

  • 静的ルート はパスパターンを厳密に遵守します(例: /users/<id>)。Map* メソッド、Route インスタンス、ルート属性で作成されたルートはすべて静的です。
  • 動的ルート は独自の方法でパスを解釈し、パスパターンで表現できないパスにもマッチします。RegexRoute、PrefixRoute(ファイルサーバー が使用)および カスタムマッチングルート は動的です。

サーバー起動時に、ルーターはルートを一度だけ再配置し、すべての静的ルートがすべての動的ルートより先にテストされるようにします。各グループ内では登録順が保持されます。つまり、静的ルートは動的ルートより常に優先され、定義順に関係なく次のように動作します。

C#
// 動的: "wwwroot" ディレクトリから任意の GET パスを提供
mainRouter.MapFileSystem("/", "wwwroot");

// 静的: ファイルサーバーの後に定義されても GET /api/status を処理
mainRouter.MapGet("/api/status", request => new HttpResponse("ok"));

上記の例では、ファイルサーバーは静的ルートで処理されないすべての GET リクエストのフォールバックとして機能します。

2 つのグループは検証と変換の方法でも異なります。

静的ルート動的ルート
マッチング順序最初に、登録順すべての静的ルートの後、登録順
ルート衝突チェックありなし
Router.Prefix が適用されるありなし

警告

動的ルートは衝突チェックの対象になりません。Sisk はどのパスにマッチするかを把握できないためです。2 つの動的ルートが同じリクエストにマッチした場合、先に定義された方が勝ちます。例として、/ にマッピングされたファイルサーバーが /static にマッピングされたファイルサーバーより先に定義されていると、2 番目は到達不能になります。

重要

1.7 以前のバージョンでは、ルートは登録順のみでテストされ、正規表現ルートが後に定義された静的ルートよりも優先されることがありました。正規表現ルートが静的ルートと同じパスにマッチするようなロジックに依存している場合は、静的ルートに移すか、静的パスパターンを絞り込んでください。

パスパターンでルートを作成する #

新規アプリケーションでは Map* 系メソッドの使用を推奨します。呼び出し側で HTTP メソッドが明示でき、現在の Router API にマッチします。古い SetRoute 系メソッドは互換性ラッパーとして残っていますが、例示は Map、MapGet、MapPost、MapPut、MapDelete、MapPatch、MapAny、MapOptions、MapHead を使用してください。

C#
// Map* 系メソッドはメソッド固有のルートを定義する標準的な方法です。
mainRouter.MapGet("/hey/<name>", (request) =>
{
    string name = request.RouteParameters["name"].GetString();
    return new HttpResponse($"Hello, {name}");
});

mainRouter.MapPost("/form", (request) =>
{
    var formData = request.GetFormContent();
    return new HttpResponse(); // 空の 200 OK
});

// Route インスタンスが必要なときは Map で受け取れます。
mainRouter.Map(Route.Get("/image.png", (request) =>
{
    var imageStream = File.OpenRead("image.png");
    
    return new HttpResponse()
    {
        // StreamContent の内部
        // 送信後にストリームが破棄されます
        Content = new StreamContent(imageStream)
    };
}));

// 複数パラメータ
mainRouter.MapGet("/hey/<name>/surname/<surname>", (request) =>
{
    string name = request.RouteParameters["name"].GetString();
    string surname = request.RouteParameters["surname"].GetString();

    return new HttpResponse($"Hello, {name} {surname}!");
});

RouteParameters プロパティは、受信したリクエストのパス変数に関するすべての情報を保持します。

サーバーが受け取るすべてのパスは、パスパターンテストが実行される前に正規化されます。正規化ルールは次の通りです。

  • 空のセグメントはすべて除去されます。例: ////foo//bar → /foo/bar。
  • パスのマッチングは 大文字小文字を区別 しますが、Router.MatchRoutesIgnoreCase が true に設定されている場合は例外です。

Query と RouteParameters は、各インデックスプロパティが非 null の StringValue を返す StringValueCollection オブジェクトを返します。これにより、生の値を管理オブジェクトへ変換するオプション/モナドとして利用できます。

以下の例はルートパラメータ "id" を読み取り、Guid に変換します。パラメータが有効な Guid でない場合は例外がスローされ、サーバーが Router.CallbackErrorHandler を処理していなければ 500 エラーがクライアントに返されます。

C#
mainRouter.MapGet("/user/<id>", (request) =>
{
    Guid id = request.RouteParameters["id"].GetGuid();
    return new HttpResponse($"User id: {id}");
});

注記

パスの末尾の / はリクエスト側・ルート側ともに無視されます。つまり、/index/page と定義されていても、/index/page/ でもアクセス可能です。

[HttpServerConfiguration.ForceTrailingSlash](/api/Sisk.Core.Http.HttpServerConfiguration.ForceTrailingSlash) を有効にすれば、URL が必ず / で終わるよう強制できます。

クラスインスタンスでルートを作成する #

属性 [RouteAttribute](/api/Sisk.Core.Routing.RouteAttribute) を使ってリフレクションで動的にルートを定義できます。この方法では、属性が付与されたクラスのメソッドが対象ルーターに自動的にマッピングされます。

メソッドをルートとして定義するには、[RouteAttribute](/api/Sisk.Core.Routing.RouteAttribute)(または [RouteGetAttribute](/api/Sisk.Core.Routing.RouteGetAttribute) など)でマークする必要があります。メソッドは static、インスタンス、public、private いずれでも構いません。インスタンスメソッドと static メソッドの両方をマッピングしたい場合は MapInstance を、型から static メソッドだけをマッピングしたい場合は MapType を使用します。

Controller/MyController.csC#
public class MyController
{
    // GET / にマッチ
    [RouteGet]
    HttpResponse Index(HttpRequest request)
    {
        HttpResponse res = new HttpResponse();
        res.Content = new StringContent("Index!");
        return res;
    }
    
    // static メソッドも対応
    [RouteGet("/hello")]
    static HttpResponse Hello(HttpRequest request)
    {
        HttpResponse res = new HttpResponse();
        res.Content = new StringContent("Hello world!");
        return res;
    }
}

以下のコードは MyController のインスタンスを渡すことで、Index と Hello の両方をルートとして登録します。型だけを渡した場合は static メソッドのみが対象になります。

C#
var myController = new MyController();
mainRouter.MapInstance(myController);

static メソッドだけを型からマッピングしたい場合は次のようにします。

C#
mainRouter.MapType<MyController>();

Sisk 0.16 以降では AutoScan を有効にでき、RouterModule を実装したユーザー定義クラスを自動的に検索してルーターに関連付けます。AOT コンパイルではサポートされません。

C#
mainRouter.AutoScanModules<ApiController>();

上記の指示は ApiController を実装するすべての型を検索しますが、型自体は対象外です。2 つのオプション引数は検索対象のアセンブリと検索方法を指定します。

正規表現ルート #

デフォルトの HTTP パスマッチング方式ではなく、パスを正規表現として解釈するルートを定義できます。正規表現ルートは RegexRoute クラスで実装され、動的ルート に分類されます。

C#
mainRouter.Map(new RegexRoute(RouteMethod.Get, @"\/[a-z]+\/", request =>
{
    return new HttpResponse("hello, world");
}));

正規表現パターンからキャプチャグループを取得し、HttpRequest.RouteParameters に格納することも可能です。

Controller/MyController.csC#
public class MyController
{
    [RegexRoute(RouteMethod.Get, @"/uploads/(?<filename>.*\.(jpeg|jpg|png))")]
    static HttpResponse RegexRoute(HttpRequest request)
    {
        string filename = request.RouteParameters["filename"].GetString();
        return new HttpResponse().WithContent($"Acessing file {filename}");
    }
}

注記

正規表現は自動的にアンカーされません。/uploads のようなパターンは /files/uploads/a にもマッチします。全体パスにマッチさせるには ^ と $ を使用してください。

重要

Sisk 1.7 以降、Route.UseRegex プロパティは設定できなくなりました。廃止予定であり、ルートが RegexRoute かどうかを示すだけです。[Route(RouteMethod.Get, "...", UseRegex = true)] や [RegexRoute] 属性は引き続き機能します。

Route.UseRegex を使って正規表現ルートを作成しているコードは、RegexRoute に置き換えてください。

C#
// 1.7 以前
mainRouter.Map(new Route(RouteMethod.Get, @"^/items/(?<id>\d+)$", action) { UseRegex = true });

// 1.7 以降
mainRouter.Map(new RegexRoute(RouteMethod.Get, @"^/items/(?<id>\d+)$", action));

プレフィックスルート #

PrefixRoute は自身のパスとその下位のすべてのパスにマッチし、セグメント単位で比較します。これは Router.MapFileSystem が使用するルートです。プレフィックスルートは 動的ルート に分類されます。

C#
// /docs、/docs/、/docs/any/nested/path にマッチし、/docsfoo にはマッチしません
mainRouter.Map(new PrefixRoute(RouteMethod.Get, "/docs", null, request =>
{
    return new HttpResponse($"Requested {request.Path}");
}, null));

カスタムルートマッチング #

Route を継承し、Route.Match をオーバーライドすることで独自のマッチングルールを作成できます。メソッドはリクエストパスとマッチング中のルーターを受け取り、結果とオプションで取得したルートパラメータを含む RouteMatch を返します。

ルーターは Match が成功した後に HTTP メソッドを検証するため、Match はパスだけをテストすべきです。これによりカスタムルートでも 405 Method Not Allowed と OPTIONS のレスポンスが正しく機能します。

Routing/ExtensionRoute.csC#
// 任意の拡張子で終わるパスにマッチ(例: /reports/2024.csv)
public sealed class ExtensionRoute : Route
{
    public ExtensionRoute(string extension, RouteAction action)
        : base(RouteMethod.Get, extension, action)
    {
    }

    // このルートはパスパターンに従いません
    public override bool IsStatic => false;

    // パスはパターンではないため、末尾スラッシュ形へのリダイレクトは行えません
    public override bool AllowRewrites => false;

    public override RouteMatch Match(string requestPath, Router router)
    {
        StringComparison comparison = router.MatchRoutesIgnoreCase
            ? StringComparison.OrdinalIgnoreCase
            : StringComparison.Ordinal;

        if (!requestPath.EndsWith(Path, comparison))
            return RouteMatch.NotMatched;

        var parameters = new NameValueCollection
        {
            ["file"] = requestPath[..^Path.Length]
        };
        return new RouteMatch(true, parameters);
    }
}
C#
mainRouter.Map(new ExtensionRoute(".csv", request =>
{
    string file = request.RouteParameters["file"].GetString();
    return new HttpResponse($"Exporting {file} as CSV");
}));

Match をオーバーライドする際の留意点:

  • ルートがパスパターンに厳密に従わない場合は false を返すよう Route.IsStatic をオーバーライドしてください。そうしないと、ルーターは衝突チェックや Router.Prefix の適用時に Path をパスパターンとして扱い、誤検知や無効なパスが生じます。
  • 末尾スラッシュ形へのリダイレクトが不可能な場合は false を返すよう Route.AllowRewrites をオーバーライドしてください。
  • Match はルートに到達したすべてのリクエストで呼び出され、複数スレッドから同時に実行される可能性があります。マッチしない場合はできるだけ割り当てを避け、キャッシュ状態はスレッドセーフに保ちましょう。失敗時は RouteMatch.NotMatched を返してください。
  • router パラメータを通じて Router.MatchRoutesIgnoreCase を尊重してください。

ルートのプレフィックス付与 #

クラスやモジュール全体にプレフィックスを付与したい場合は、RoutePrefix 属性を使用し、文字列としてプレフィックスを設定します。

以下は BREAD アーキテクチャ(Browse, Read, Edit, Add, Delete)を用いた例です。

Controller/Api/UsersController.csC#
[RoutePrefix("/api/users")]
public class UsersController
{
    // GET /api/users
    [RouteGet]
    public async Task<HttpResponse> Browse()
    {
        ...
    }
    
    // GET /api/users/<id>
    [RouteGet("/<id>")]
    public async Task<HttpResponse> Read()
    {
        ...
    }
    
    // PATCH /api/users/<id>
    [RoutePatch("/<id>")]
    public async Task<HttpResponse> Edit()
    {
        ...
    }
    
    // POST /api/users
    [RoutePost]
    public async Task<HttpResponse> Add()
    {
        ...
    }
    
    // DELETE /api/users/<id>
    [RouteDelete("/<id>")]
    public async Task<HttpResponse> Delete()
    {
        ...
    }
}

上記例では、HttpResponse パラメータは省略され、グローバルコンテキスト [HttpContext.Current](/api/Sisk.Core.Http.HttpContext.Current) を通じて使用されます。続くセクションで詳しく説明します。

リクエストパラメータなしのルート #

[HttpRequest](/api/Sisk.Core.Http.HttpRequest) パラメータなしでルートを定義できますが、リクエストコンテキストからリクエストやそのコンポーネントを取得可能です。以下はすべてのコントローラの基底クラスとして機能する ControllerBase の例で、Request プロパティで現在の [HttpRequest](/api/Sisk.Core.Http.HttpRequest) を取得できます。

Controller/ControllerBase.csC#
public abstract class ControllerBase
{
    // 現在のスレッドからリクエストを取得
    public HttpRequest Request { get => HttpContext.Current.Request; }
    
    // 以下の行は、現在の HTTP セッションからデータベースを取得し、存在しなければ新規作成します
    public DbContext Database { get => HttpContext.Current.RequestBag.GetOrAdd<DbContext>(); }
}

そして、すべての派生クラスでリクエストパラメータなしのルート構文を利用できます。

Controller/UsersController.csC#
[RoutePrefix("/api/users")]
public class UsersController : ControllerBase
{    
    [RoutePost]
    public async Task<HttpResponse> Create()
    {
        // 現在のリクエストから JSON データを取得
        UserCreationDto? user = await Request.GetJsonContentAsync<UserCreationDto>();
        ...
        Database.Users.Add(user);
        
        return new HttpResponse(201);
    }
}

現在のコンテキストと依存性注入の詳細は、依存性注入 チュートリアルをご参照ください。

任意メソッドルート #

パスだけでマッチさせ、HTTP メソッドは無視するルートを定義できます。これにより、ルートコールバック内でメソッド検証を行うことが可能です。

C#
// 任意の HTTP メソッドで / にマッチ
mainRouter.MapAny("/", callbackFunction);

RouteMethod.Any には GET、POST、PUT、PATCH、DELETE、QUERY、HEAD、OPTIONS が含まれます。

QUERY メソッドルート #

Sisk はリクエストボディにクエリを載せる安全かつ冪等な HTTP QUERY メソッドをサポートします。これらのルートは Router.MapQuery、Route.Query、RouteMethod.Query または RouteQuery 属性で定義します。

C#
mainRouter.MapQuery("/search", request =>
{
    string filter = request.Body;
    return new HttpResponse($"Searching for {filter}");
});
Controller/SearchController.csC#
public class SearchController
{
    [RouteQuery("/products")]
    static HttpResponse SearchProducts(HttpRequest request)
    {
        return new HttpResponse($"Searching for {request.Body}");
    }
}

QUERY リクエストで受け入れ可能なメディアタイプを示す Accept-Query ヘッダーは、HttpHeaderCollection.AcceptQuery および HttpKnownHeaderNames.AcceptQuery から取得できます。

任意パスルート #

任意パスルートは、HTTP サーバーが受け取った任意のパスに対してテストを行い、ルートメソッドが評価対象となります。RouteMethod.Any が指定され、かつパス式に Route.AnyPath が使用されている場合、このルートはサーバーへのすべてのリクエストをリッスンし、他のルートは定義できません。

C#
// 以下のルートはすべての POST リクエストにマッチ
mainRouter.Map(RouteMethod.Post, Route.AnyPath, callbackFunction);

大文字小文字無視のルートマッチング #

既定ではルートとリクエストの解釈は大文字小文字を区別します。無視させたい場合は次のオプションを有効にしてください。

C#
mainRouter.MatchRoutesIgnoreCase = true;

これにより正規表現ルートでは RegexOptions.IgnoreCase が有効になり、プレフィックスルートでも大文字小文字を区別しない比較が行われます。

Not Found (404) コールバックハンドラ #

リクエストが既知のルートにマッチしなかったときのカスタムコールバックを作成できます。

C#
mainRouter.NotFoundErrorHandler = () =>
{
    return new HttpResponse(404)
    {
        // v0.14 以降
        Content = new HtmlContent("<h1>Not found</h1>")
        // それ以前のバージョン
        Content = new StringContent("<h1>Not found</h1>", Encoding.UTF8, "text/html")
    };
};

Method not allowed (405) コールバックハンドラ #

リクエストがパスにはマッチしたがメソッドが一致しなかったときのカスタムコールバックも作成できます。

C#
mainRouter.MethodNotAllowedErrorHandler = (context) =>
{
    return new HttpResponse(405)
    {
        Content = new StringContent($"Method not allowed for this route.")
    };
};

エラーハンドリング #

リクエストライフサイクル(事前実行ハンドラ、ルーターアクション、事後実行ハンドラ、バリューハンドラ)内で例外がスローされることがあります。これらの例外は次の仕組みで管理されます。

  • [HttpServerConfiguration.ThrowExceptions](/api/Sisk.Core.Http.HttpServerConfiguration.ThrowExceptions) が true の場合、例外は通常通りスローされ、Sisk では捕捉されません。例外が捕捉されないと HTTP サーバーが中断する可能性があります。
  • [HttpServerConfiguration.ThrowExceptions](/api/Sisk.Core.Http.HttpServerConfiguration.ThrowExceptions) が false の場合、例外は捕捉され Sisk が処理します。その後、Router.CallbackErrorHandler が定義されていれば捕捉した例外とリクエストコンテキストで呼び出され、標準エラー出力へは転送されません。未定義の場合は標準エラー出力へ転送され、クライアントは HTTP 500 エラーを受け取ります。標準エラー出力が未定義の場合はエラーは黙って無視されます。

※ Router.CallbackErrorHandler 内では、エラーログ、アクセスログ、またはその両方、あるいは無効化を設定でき、デフォルトのログ書き込み動作を変更できます。

C#
router.CallbackErrorHandler = (ex, ctx) =>
{
    ctx.LogMode = LogOutput.Both; // エラーをアクセスログとエラーログの両方に記録
}

内部エラーハンドラ #

ルートコールバックがサーバー実行中にエラーをスローすることがあります。適切に処理しないと HTTP サーバー全体が停止する恐れがあります。ルーターには、ルートコールバックが失敗した際にサービス中断を防ぐコールバックが用意されています。

このメソッドは [ThrowExceptions](/api/Sisk.Core.Http.HttpServerConfiguration.ThrowExceptions) が false に設定されている場合にのみ呼び出せます。

C#
mainRouter.CallbackErrorHandler = (ex, context) =>
{
    return new HttpResponse(500)
    {
        Content = new StringContent($"Error: {ex.Message}")
    };
};

ドキュメントと API リファレンスを検索するには入力してください。