こんにちは、サイオステクノロジーの佐藤 陽です。
今回は、ASP.NET Core入門シリーズ第三弾として、ミドルウェアとリクエストパイプラインについて書いていきたいと思います。
- コントローラーじゃないところでリクエストに共通処理を追加したい!
- ミドルウェアって何?
- リクエストパイプラインって何?
という方はぜひ最後までご覧ください。
また前回の記事から繰り返しになりますが
まだ自分も勉強中の身なので、記事の内容に誤りなどありましたらコメントにて指摘いただけると幸いです。
はじめに
今回のテーマはミドルウェアとリクエストパイプラインです。
「コントローラー等のアプリケーションコードはガシガシ書くけど、パイプライン周りは触ったことない」という方も多いのではないでしょうか?
前回の記事の中にもMiddlewarePipelineというワードが何回か出てきました。
リクエストを処理するにあたって非常に重要な仕組みであるため、このあたりもしっかり追っていきたいと思います。
ミドルウェアとリクエストパイプラインとは
ミドルウェアとは、ソフトウェアコンポーネントです。
そして、それらのミドルウェアを連ねたものがリクエストパイプラインと呼ばれています。
前回の記事でも紹介したインプロセスホスティングの場合のASP.NET Coreの動作の図を例にみると
ASP.NET Core のアプリケーションに対するリクエストは、コントローラ(Application Code)で処理される前に、リクエストパイプライン(Middleware Pipeline)にて順々に処理されます。
※表記揺れがあって申し訳ないですが、コントローラとApplicationCode, リクエストパイプラインとMiddleware Pipelineは同義として扱います。
リクエストパイプラインの内部をもう少し詳細に見てみると、以下のような形となります。
このリクエストパイプラインの仕組みを利用することで、コントローラでの処理の前処理および、後処理を実装することが可能となります。
パイプラインの構築方法
ではこのリクエストパイプラインが、どのように実装されているかを見ていきたいと思います。
扱うプロジェクトとしては、前回の記事でも作成したMVCのプロジェクトとしたいと思います。
リクエストパイプラインを構成するソースコードとしては、Program.csファイルの中に書かれています。
(汎用なホストを利用している場合は、Startup.cs の Configureメソッド内に書かれます)
プロジェクトのProgram.cs の中身を抜粋します。
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
この時、app変数はWebApplication クラスのインスタンスです。
前回の記事の復習になりますが、このWebApplicationクラスの役割としては以下2つのものがあります。
- リクエストパイプラインの構築
- ホストの開始
このWebApplicationのインスタンスと、Use, Run, Mapといったような拡張メソッドを利用してパイプラインを構築していきます。
なお、ミドルウェアの定義に関しては①インラインで定義する方法と、②再利用可能なクラスとして定義する方法の2種類があるため、それぞれについて見ていきたいと思います。
インライン定義での追加
まずはインラインで定義したミドルウェアの追加方法を紹介します。
拡張メソッドであるUseExtensions.Useを利用して以下のように書きます。
await next.Invoke()
を境として、前処理なのか後処理なのかを区別することができます。
app.Use(async (context, next) =>
{
//ここにコントローラで処理される前に行う処理を書く
//次のミドルウェアへと遷移する
await next.Invoke();
//ここにコントローラでの処理が行われた後に行う処理を書く
});
余談ですが、Use拡張メソッドの引数はIApplicationBuilder型となっています。
public static IApplicationBuilder Use(this IApplicationBuilder app, Func<HttpContext, RequestDelegate, Task> middleware)
「Program.csにおいてappはWebApplicationクラスだから型が違うのでは?」
と思われる方もいるかもしれませんが、
WebApplicationはIApplicationBuilderのInterfaceを実装しているので、ここは問題なしです。
再利用可能なクラスによる追加
インライン定義での追加はサクッとできてお手軽なのですが
やはり可読性や再利用性などを考えた場合、事前に再利用な可能なクラスにまとめて使いまわす方が得策とされています。
本章では事前に定義したミドルウェアを追加していく方法を見ていきたいと思います。
なお事前に定義したミドルウェアとしては「組み込みミドルウェア」と「カスタムミドルウェア」があるため、それぞれについて見ていきます。
組み込みミドルウェア
ASP.NET Coreにおいては組み込みミドルウェア コンポーネントが豊富に提供されています。
先ほど見たProgram.csにおいて書かれているミドルウェア群も、組み込みミドルウェアの一例になります。
ここでは実際にUseHttpsRedirectionのメソッドを例にしてソースコードを見てみたいと思います。
UseHttpsRedirectionのソースコードを見ると、Summaryとして
Adds middleware for redirecting HTTP Requests to HTTPS.
と記載されていることから、ミドルウェアに追加するためのメソッドであることがわかります。
またこちらのソースコードの中身を見てみると、Useメソッドと同じような形で拡張メソッドとして定義されていることがわかります。
public static IApplicationBuilder UseHttpsRedirection(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var serverAddressFeature = app.ServerFeatures.Get<IServerAddressesFeature>();
if (serverAddressFeature != null)
{
app.UseMiddleware<HttpsRedirectionMiddleware>(serverAddressFeature);
}
else
{
app.UseMiddleware<HttpsRedirectionMiddleware>();
}
return app;
}
この時、コード内において
app.UseMiddleware<HttpsRedirectionMiddleware>(serverAddressFeature);
と実行している場所があります。
ここで書かれているHTTPsRedirectionMiddlewareというのが事前定義されたミドルウェア本体であり、UseMiddlware<TMiddleware>メソッドを利用して追加していることが分かります。
他のUseStaticsFiles()メソッドなども同様の形で拡張メソッドとして定義されており
これらのメソッドを通して、組み込みミドルウェアをリクエストパイプラインへと追加しています。
カスタムミドルウェア
先ほどはASP.NET Core側で用意されている組み込みミドルウェアに言及しましたが
もちろん自分でミドルウェアを作成したいようなケースも往々にしてあります。
その際、先ほどのHTTPsRedirectionMiddlewareのようなクラスを自分で定義することが可能です。
ただし、どんなものでもいいわけではなく、作成に当たっていくつかルールが存在します。
公式ドキュメントによると、ミドルウェアは以下のようなものを持つ必要があるとのことです。
- RequestDelegate 型のパラメーターを持つパブリック コンストラクター。
- Invoke または InvokeAsync という名前のパブリック メソッド。 このメソッドでは次のことが必要です。
- Task を返します。
- HttpContext 型の最初のパラメーターを受け取ります。
先ほどのHTTPsRedirectionMiddlewareのソースコードを見ても、これらが含まれていることが確認できます。
また必須ではないようですが、組み込みミドルウェアのように拡張メソッドを定義してあげることも一般的のようです。
定義してもしなくても性能的な部分では変わりませんが、可読性の向上を目的としているようです。
その際、組み込みミドルウェアと同様の形として
- ***Extensitonsという名称のstaticクラスで定義する
- Use***という名称の拡張メソッドを定義する
といった形にするかとよいかと思われます。
追加する順番について
これまでリクエストパイプラインにミドルウェアを追加する方法について説明してきましたが
このミドルウェアを追加する順番というのが、実はとても重要です!!
これに関しては公式ドキュメントにも以下のように言及されています。
Program.cs ファイルでミドルウェア コンポーネントを追加する順序は、要求でミドルウェア コンポーネントが呼び出される順序および応答での逆の順序を定義します。 この順序は、セキュリティ、パフォーマンス、および機能にとって重要です。
つまりUseメソッドを呼び出した順にミドルウェアが追加され、その順番に処理も行われます。
上記のProgram.csのコードでリクエストパイプラインの構築を行う場合
ExceptionHandlerのミドルウェアでの前処理を行う ↓ HstsのMidlewareでの前処理を行う ↓ EndpointRoutingミドルウェアでの前処理を行う ↓ ... (略) コントローラでの処理が行われる (略) ... ↓ EndpointRoutingミドルウェアでの後処理を行う ↓ HstsのMidlewareでの後処理を行う ↓ ExceptionHandlerのミドルウェアでの後処理を行う
といった順番で処理が行われます。
順番が大切な理由
「順番を誤ると何かまずい事が起こるの?」という問いに対する例を一つ挙げたいと思います。
例えば一番最初に呼ばれているExceptionHandlerのミドルウェアを、リクエストパイプラインの後半で実行するよう変更するとします。
このExceptionHandlerのミドルウェアの役割としては
例外をキャッチし、それらをログに記録し、代替パイプラインで要求を再実行するミドルウェアをパイプラインに追加します。
との記載があります。
つまり、このミドルウェアがあるからこそ例外がキャッチされログが記録されるわけです。
逆に言えば、このミドルウェアが実行される前のソースコードにおいて例外が発生しても、それはログに残りません。
そうするとエラーの解析も難しくなり、障害が起きた時の復旧が困難になってしまいます。
このため、このExceptionHandlerのミドルウェアは一番最初に持ってくる事が大切となります。
他のミドルウェアに関しても同様に、追加する順番には理由があるものが多いです。
そのため
- 既存のリクエストパイプラインの順番はむやみに変えない
- 新規にミドルウェアを追加する場合は順番に気を付ける
といった心がけが重要になるかと思います。
ターミナルミドルウェア
先ほどまではUseメソッドを使ってミドルウェアにパイプラインを追加しました。
もう一つ、追加する方法としてRunメソッドを利用する方法があります。
注意点として、Program.csファイルの最後で利用されているapp.Run()とは別モノです。
Program.csの最後で利用されているのはApplicationをスタートさせるためのRun()であり、
今回紹介するのはMiddlewareを追加するためのRunメソッド(拡張メソッド)です。
詳細はリンク先の定義を確認してみてください。
このRunメソッドで追加したミドルウェアはターミナルミドルウェア(もしくは終端ミドルウェア)と呼ばれるものであり、ターミナルミドルウェアが呼ばれた時点でリクエストパイプラインは終了する形となります。
実際にやってみましょう。
以下のようにターミナルミドルウェアを2つ追加します。
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
//1つ目のターミナルミドルウェアの追加
app.Run(async context =>
{
await context.Response.WriteAsync("1st Hello world.");
});
//2つ目のターミナルミドルウェアの追加
app.Run(async context =>
{
await context.Response.WriteAsync("2nd Hello world.");
});
app.Run();
この状態でアプリを起動し、以下のURLに遷移します。
Http://localhost:{port}/Home/Index
すると、”1st Hello world.”という文字が表示されました。
1つ目のターミナルミドルウェアによってレスポンスが返されていることがわかります。
ただここでふと思ったのですが
今回のサンプルプロジェクトにはControllerコンポーネントに、HomeControllerクラスとIndexメソッドは定義されています。
ターミナルミドルウェアより先にMapControllerRouteによってルーティングが定義されているため
リクエストはControllerへとルーティングされ、以下のようなViewが返されるかと思ったのですが、ターミナルミドルウェアの処理が優先されました。
ここの処理がいまいち納得がいっておらず、少し考察してみたいと思います。
考察
※ここからの記載は真偽の定かではない独自の考察になります
まず、上記のProgram.csにおいてMapControllerRouteメソッドによってルーティングが設定されています。
ターミナルミドルウェアであるRun()が存在しない場合、このメソッド自体は正しく機能していることは確認できました。
そのため、ルーティングの設定自体は正しく行えているものとします。
ここで、公式ドキュメントのルーティングに関する記載に以下のような文章がありました。
(翻訳がわかりづらかったので原文で載せます。)
Note: Routes added directly to the WebApplication execute at the end of the pipeline.
「WebApplicationインスタンスで直接加えられたルートはPipelineの最後に追加されます」と書かれています。
今回は、この「WebApplicationインスタンスで直接加えられたルート」のケースに該当すると考えます。
また、Runに関する公式ドキュメントには以下のような記載があります。
Run デリゲートでは、next パラメーターは受け取られません。 最初の Run デリゲートが常に終点となり、パイプラインが終了されます。
そのため、以下の2点から
- ルーティングはPipelineの最後に追加される
- Runが呼ばれた時点でPipelineが終了してしまう
ルーティングのMiddlewareに到達する前に、RunによってPipelineが終了してしまったのではないかなと考察しました。
回避策
ちなみに以下のような実装をすると、
- ルートが存在する場合はControllerの処理が走る
- ルートが存在しない場合はRunの処理が実行される
といった処理を実現できました。
app.UseRouting();
app.UseAuthorization();
//UseEndpointsのミドルウェアを追加
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
});
//ターミナルミドルウェアの追加
app.Run(async context =>
{
await context.Response.WriteAsync("Hello world.");
});
app.Run();
ちなみに、これは少し前のASP.NET Coreのバージョンでの書き方であり
ASP.NET Core 8.0では、UseEndpointsおよびUseRoutingをわざわざ書かなくていいとされています。(明示的に書く必要がある場合を除き)
実際、UseEndpointsの部分にASP0014の警告文も表示されます。
参考:ASP.NET Core でのコントローラー アクションへのルーティング
なお、UseEndpointsを利用するとルーティングが正しく行われる理由としては
UseEndpointsの仕様として、以下の通りであるためです。
- 一致が見つかると、UseEndpoints ミドルウェアはターミナルです。 ターミナル ミドルウェアについては、この記事で後ほど定義します。
- UseEndpoints の後のミドルウェアは、一致が検出されなかった場合にのみ実行されます。
これにより、
- UseEndpointsにおいてルートが見つかった場合は、そこでパイプラインが終了しRun()が実行されない
- UseEndpointsにおいてルートが見つからなった場合は、後続のMiddleware の処理が行われRun()が実行される
という処理となると考えられます。
ということで一応回避策はありましたが、MSが推奨する方法ではないですし、あまり納得もいっていないです。
ASP.NET Core2.2時代などはUseMVCの拡張メソッドを利用してミドルウェアを追加した場合
パイプラインの構造が変更され、ターミナルミドルウェアが定義されていても、ルートが一致した場合は無視される動きがありました。
このあたりがASP.NET Core 8.0のMapControllerRouteでは挙動が違うのかなとも予想されます。
MapControllerRouteの挙動などはRoutingの調査の時に改めて行いたいと思います。
今後ASP.NET CoreのRoutingを題材とした記事を書こうと思っているので、その時まで少々お待ちください。
このあたり詳しいよ!わかるよ!という方はぜひぜひコメントお待ちしています。
パイプラインの分岐
RunとUseのほかにMapという拡張メソッドも存在します。
こちらは何かというと、パイプラインの処理をパスによって分岐することが可能となります。
実際にソースコードを修正してみましょう。
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
//分岐1
app.Map("/sios1", HandleMapTest1);
//分岐2
app.Map("/sios2", HandleMapTest2);
//分岐3(パスに該当しなかった場合)
app.Run(async context =>
{
await context.Response.WriteAsync("Hello I am non-Map sios.");
});
app.Run();
//分岐1用の処理
static void HandleMapTest1(IApplicationBuilder app)
{
app.Run(async context =>
{
await context.Response.WriteAsync("Hi, I am sios1");
});
}
//分岐2用の処理
static void HandleMapTest2(IApplicationBuilder app)
{
app.Run(async context =>
{
await context.Response.WriteAsync("Hello, I am sios2");
});
}
こちら実行すると、それぞれ分岐した結果のレスポンスが得られました。
分岐なし
Http://localhost:{port}
分岐1
Http://localhost:{port}/sios1
分岐2
Http://localhost:{port}/sios2
ただ個人的に「Program.csの可読性が一気に落ちるなぁ」という印象を持ちました。
このMapの拡張メソッドを使用しないと実現できないユースケースを除いては、あまり利用しない方がよいのかなと感じました。
ただ、使いこなせてないだけかもしれないので、気にはかけていこうとは思います。
まとめ
今回はミドルウェアとリクエストパイプラインの概要を説明しました。
ミドルウェアを定義し、それをリクエストパイプラインに追加することでコントローラでの処理の前処理・後処理を実現できることがわかりました。
その際、ミドルウェアを追加する順序も非常に重要であることも気を付けたいポイントです。
ルーティングのところで宿題事項が残ってしまいましたし、パイプラインもまだまだ奥が深いような気がするので、新たな発見があれば追加で記事にしていきたいと思います!
ではまた!