Blazor と MVC の混成サイトでのトラブルを再現できる?
Blazor Advent Calendar 2020 の 16 日目は、@masanori_msl さんの寄稿で、「Blazor と ASP.NET Core MVC の混成サイトで Blazor の分離 CSS ファイル要求まで MVC コントローラーに処理されてしまった」エピソードを紹介いただきました。
「混成サイトならではのエピソードだなぁ、なるほど」と思って興味深く読ませていただきました。
しかし、ふと、「あれ、でも、Blazor の分離 CSS ファイルって、結局のところ、ただの静的ファイルだよね...?」と思い至り、ちょっと自分でも再現するか、試してみました。
まずは叩き台を作る
当方、以前の投稿に書きましたとおり、ASP.NET Core MVC の View の書き方 (とくに _ViewStart.cshtml
と _Layout.cshtml
とか) をすっかり忘れてしまっていましたので、少々手こずりましたが、まずは、Blazor Server と MVC との混成サイトの叩き台を作り上げました。
@masanori_msl さんの記事の内容をなるべくなぞるように作り込みします。
Home コントローラーが返すビュー (Views/Index.cshtml
) 内にて ViewData["Title"]
にページタイトルを設定するところ、その ViewData が Views/Shared/_Layout.cshtml
にて <title>
要素にレンダリングされるところも、同じように構築しました。
実行してみて、ちゃんと「Counter」や「Fetch Data」のページにもナビゲートすることができることを確認します。
ただし、ここまでの実装だと、元記事にもありますとおり、 (Blazor によるナビゲーションではなく) https://.../fetchdata
といった URL でブラウザのリロードを実行すると、そのような URL をハンドルするサーバー側の定義がないので、"HTTP 404 Page Not Found" になってしまいます。
分離 CSS が当たらなくなる現象を再現できた!
ということで、元記事に沿い、Home コントローラーのルート定義に "{page}" を追加します。
public class HomeController : Controller
{
[Route("/")]
[Route("{*page}")] // 👈 (1) これを追加
public ActionResult Index()
{
...
これでブラウザで再読込を実行すると、ちゃんと 404 Not Found は解消されて、Home、Counter、Fetch Data、いずれのページであっても、HomeController の Index アクションが実行され、Blazor のブートスラップを含むページが期待どおり返されるようになりました... が...
たしかに元記事の記載のとおり、Blazor の分離 CSS が当たらなくなってしまいました (上図)。
開発者ツールで確認してみると、たしかに、{プロジェクト名}.styles.css
ファイルの要求に対し、HomeController の Index アクションの結果 HTML が返ってきてしまっていました。
何かがおかしい...
元記事では、この問題を回避するために、Blazor ページの URL を "Pages/~" 以下に変更されていました。
しかしところで、冒頭にも書きましたが、{プロジェクト名}.styles.css
ファイルは、ビルド時に生成される、実在する、素の静的ファイルです。
それなのに、MVC コントローラーへのルーティングに押し負けるのは妙です。
あ、と気づいて、Startup.cs
を見てみると...
...
public class Startup
{
...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting(); // 👈 静的ファイル応答より前に、ルーティング処理がある!
app.UseStaticFiles();
...
静的ファイルのミドルウェアよりも、ルーティングのミドルウェアのほうが先に、HTTP 要求を処理するようになっていました。
たぶん、これが原因ではないでしょうか。
もちろん、静的ファイルの処理よりも、意図して、ルーティング処理を先にする必要があるのであれば、それはそれで間違いではありません。
とはいえ、一般的なケースでは、静的ファイルがあればそれを優先して HTTP 応答に返すのが期待される動作であることでしょう。
ということで、この Startup.cs
中におけるミドルウェアの登録順、すなわち、HTTP 要求の処理順を入れ替えてみました。
...
public class Startup
{
...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseStaticFiles(); // 👈 (2) 静的ファイルミドルウェアを先に、順序を入れ替え
app.UseRouting();
...
これでブラウザで再読込してみると、無事、Blazor の分離 CSS も正しく読み込まれつつ、Home, Counter, Fetch Data、いずれの URL でもブラウザの再読み込みに正しくページを返すようになりました (下図)。
これで万事解決?
さてところで、ここまでの実装だけでも、"今の要件" には対応できた訳ですが、もう少し別のシナリオを見てみましょう。
例えば、「Counter」 ページのルート URL を、"/counter" から "/foo/counter" へ、複数セグメントを含む URL に変更したらどうなるでしょうか?
@page "/foo/counter"
<!-- 👆 (3) "カウンタ" ページの URL を、複数セグメントを持つ URL に変更 -->
...
...
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<ul class="nav flex-column">
...
<li class="nav-item px-3">
<!-- 👇 (3) "カウンタ" ページの URL を、複数セグメントを持つ URL に変更 -->
<NavLink class="nav-link" href="foo/counter">
<span class="oi oi-plus" aria-hidden="true"></span> Counter
...
このように改造しても、もちろん、まずは Home ページの URL からブラウザ再読み込みを開始して、Blazor として動作中に Counter ページに遷移してもちゃんと動作しますが (下図)、
しかしここでブラウザを再読み込みすると、再び HTTP 404 Page Not Found が発生してしまいます。
これは、SPA としてのブートスラップドキュメントページを返す、Home コントローラーのアクションメソッドのルート指定が、"{page}" というように、単一セグメントの URL にマッチするように定義されていたからですね。
なので、複数セグメントの URL に対する HTTP 要求に対しては、いずれのルート定義にもマッチせず、それで HTTP 404 が返されてしまうわけです。
対策の一案としては、MVC コントローラーのルート定義に、ワイルドカード指定を使う案が思い浮かびました。
実際にやってみます。
...
public class HomeController : Controller
{
[Route("{*page}")] // 👈 (4) ワイルドカード化し、すべての URL を捕捉してみる
public ActionResult Index()
{
...
これでブラウザで再読み込みしてみると...
ちゃんと表示されました!
この実装なら、URL セグメントの構成に依らず、他にその URL に応答を返すリソースが見つからない場合に、最終的にはこの Home コントローラーの Index アクションに要求が振られ、SPA のブートストラップドキュメントページが返されるようになります。
なんか、それ専用の機能があった気がする!
ところで、自分、ASP.NET Core + Angular SPA な Web アプリとか実装していた経験上、この、
「他に当該 URL に応答するものがいなければ、SPA ブートストラップドキュメントとして index.html を返す」
という "フォールバック" パターンをよく見ていました。
そのときは、Startup.cs
内で、endpoints.MapFallbackToFile("index.html")
と実装していました。
ということで、IDE のインテリセンス機能で見てみると...
MapFallbackToController
という、いかにも今回のシナリオで使えそうな拡張メソッドがあるではないですか。
試しに MapFallbackToController
を使った実装に変えてみます。
...
public class HomeController : Controller
{
// 👇 (5) ルート定義は削除
public ActionResult Index()
{
...
...
public class Startup
{
...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseEndpoints(endpoints =>
{
...
// 👇 (5) 代わりに MVC コントローラへのフォールバック指定を追加
// ※他に応答するものがいなかった URL への要求は、
// このコントローラー/アクションで処理する、という指定。
endpoints.MapFallbackToController(
action: "Index",
controller: "Home");
});
...
以上のとおりに実装を変えてみても、ちゃんと期待どおり、正常動作することが確認できました。
まとめ
ここまでの試行錯誤の流れを、下記 GitHub リポジトリ上に公開しておきました。
今回のシナリオを通して、ASP.NET Core における Startup.cs
でのミドルウェアの登録順が、自分の期待・意図したものであるかどうか、注意が必要そうだな、と思いました。
また、SPA 実装における "フォールバック" ドキュメントの仕組みとして、ASP.NET Core MVC のコントローラーを使う場合ですが、その実装方法として
- アクションメソッドに
[Route("{*page}")]
属性を付け、そのアクションメソッドがすべての URL パターンにマッチするよう構築するか、 - あるいは
Startup.cs
内での構成時に、MapFallbackToController()
拡張メソッドでフォールバック先の MVC コントローラーおよびアクションを指定するか
のいずれかの手法があることもわかりました。
なお、上記ふたつの手法のうちいずれがよいのか、はたまた、それぞれの手法のメリット・デメリット等については、今のところよくはわかっていません。
何か情報やご意見等あれば、コメントもらえるとありがたいです!
Learn, Practice, Share!