現在ログインしていません。
新規アカウント作成
ログイン

ASP.NET Core Blazor のテンプレートの構造(Preview6版)

2019年9月リリース予定の次期.NET環境 .NET Core 3.0 から新しく加わるWebアプリケーションフレームワーク ASP.NET Blazorを少しだけ試してみました。 自分用のメモをかねてわかったこと・推理したことなどをまとめます。

ASP.NET Blazorを試してみる動機

動機1.Webフォームがなくなる

私はASP.NETのWebアプリケーション開発ではASP.NET Webフォームが気に入っていて長年使ってきました。しかし、ASP.NET Webフォームは次期.NETではなくなってしまい、乗り換え先はASP.NET MVCかASP.NET Blazorのどちらかになるということです。MVCは昔から虫が合わないのでBlazorはどうなのかなと見てみたいと思います。

動機2.WebAssemblyとの統合

ASP.NET Blazorは新しいWebの標準技術であるWebAssembly(WASM)をうまく使ったフレームワークであると聞いています。WebAssemblyを使うとクライアント側ブラウザー内でC#で書いたプログラムを動かせる(はず)なので、もしかしたらWebアプリケーションに付き物の複雑性を大幅に削減し、開発の生産性を革命的に向上させるものなのではないなかと期待しています。(私が勝手に期待しているだけなので本当にそうなのかは今後わかっていくと思います。)

ただ、9月時点のリリースではこのクライアントサイドの技術(クライアントサイド Blazor)は含まれないとのことなので、今回はこの動機2に関しては実際に試すことはできませんでした。

開発環境の準備

2019年7月現在はASP.NET Blazorはまだプレビュー段階で、試すには少し準備がいります。ただ、昔はこういったプレビュー版の技術を試すのには専用の環境を作ったほうが良いなど結構面倒だったのですが、今は製品版の環境とプレビュー版の環境が別々に同居できる形になっているので環境構築はそんなに面倒では無かったです。

まず、プレビュー版の機能が試せるVisual Studio 2019 Previewをここからインストールします。私はCommunityをインストールしました。 インストール時にはもちろん「ASP.NETとWeb開発」を選択する必要があります。

https://visualstudio.microsoft.com/ja/vs/preview/

次に、ASP.NET Core 3.0 プレビュー6 の SDK をインストールします。

https://dotnet.microsoft.com/download/dotnet-core/3.0

以上です。

はじめてのBlazorアプリ作成

Visual Studio 2019 Previewを起動して(Visual Studio 2019ではないです。Previewの有無に注意)、ASP.NET Core Webアプリケーション から、Blazorサーバーアプリを選択すると、とりあえず簡単な動作を確認できるテンプレートプロジェクトが自動生成されます。

実行すると、単純なHello, World画面、ボタンを押してカウントが増加していく画面や、データの一覧を表示する画面を実行できます。ただ、私の環境ではページ遷移した後F5を押して更新しないとうまくカウントアップしないです。ページ遷移もリンククリック後自分でF5を押さないと画面が変わらないようなこともあります。

とりあえず動作がわかりやすいのはカウントアップです。ボタンを押すと画面全体のリロードを行うことなく、画面の数字が更新されていきます。JavaScriptを使えば同じ動作は実現できますが、Blazorのすごいところはプログラム側はC#しか使っていないところです。 しかも、この仕組みをC#だけで実現するためにいろいろ複雑なことを記述する必要があるわけではなく、次のような単純なプログラムがあるだけです。

@page "/counter"
<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="@IncrementCount">Click me</button>

@code {
    int currentCount = 0;

    void IncrementCount()
    {
        currentCount++;
    }
}
■リスト1:Pages/Counter.razor 全体

これはCounter.razorの抜粋ではなく全部なんです。Blazorのことを知らなくても眺めるだけで意図はわかりますが、どういう仕組みでそれが実現されているかは今の私にはよくわかりません。ともかく、クライアントサイドでこの意図通りの動作ができるというのが今までにないすばらしいところですね。

このような感じなので仕組みをしらないままとりあえず動くものを作るというのは簡単にできそうな雰囲気です。

しかし、仕組みをちゃんと把握しておかないとあとでいろいろ問題が発生したり、もっといいやり方や解決方法があることに気がつけなかったりとさまざまなデメリットがあるのはASP.NET Blazorに限った話ではありません。

まずは、このテンプレートプロジェクトの構造を調べてRazorの理解を深めていきたいと思います。

テンプレートプロジェクトの構造

テンプレートプロジェクトは次のような構造になっています。

プログラムが開始するのはProgram.csのmainメソッドからです。Program.csはWebアプリケーションのホストの起動が仕事であり、アプリケーションの内容自体にはかかわっていないようです。おそらくここにメスを入れることはほとんどないでしょう。

Startup.csではアプリケーションの基本的な設定を行っています。ASP.NET Webフォームでいうところの globa.asax のような役割のようです。 テンプレートのプロジェクトでは、Blazorを使う、エラーページは開発者用のページを使う、静的コンテンツへのアクセスを有効にするなど各種設定を行っています。

URLルーティング

URLのルーティングは各ファイルの先頭の @page で行っています。 たとえば、Counter.razor の先頭には @page "/counter" と記述されているので、/counter のURLにアクセスするとこのCounter.razorが意図するものが表示されます。

このようなルーティングが実行されるための重要な前提が Startup.cs に記述されている次の内容のようです。

endpoints.MapFallbackToPage("/_Host");
■リスト2:Startup.cs 抜粋

この MapFallbackToPageメソッド は、リクエストされたURLがファイル名を示していない場合で、他のどのルーティングの条件にもマッチしない場合に、リクエストを処理するRazorページを指定するようです[1]

テンプレートではこれに "/_Host" が指定されています。上記のプロジェクト全体像にはまさしくPages/_Host.cshtmlというファイルが含まれており、/counter のURLにアクセスするとこのリクエストを最初に処理するのはこの _Host.cshtml であることがわかります。

そして、_Host.cshtml 内には次のように記述されています。

<body>
    <app>@(await Html.RenderComponentAsync<App>())</app>

    <script src="_framework/blazor.server.js"></script>
</body>
■リスト3:Pages/_Host.cshtml 抜粋

おそらく、このappタグ内の RenderComponentAsyncメソッドが URLの /counter や /fetchdata を判断してこの部分にHTMLをレンダリングするコンポーネントを呼び出しているものだと思います。

簡単に確認しているみると、どのページをリクエストしてもbodyの下の方に必ず<script src="_framework/blazor.server.js"></script>が含まれています。これは _Host.cshtml が必ず実行されているという証拠になるでしょう。

どのページにも適用したいHTMLがあればここに記述すればよいということですね。

HTMLのレンダリング

ここまで見たように_Host.cshtmlの内容を基本とし、_Host.cshtml内でappタグで記載されている部分が各コンポーネントのレンダリングに置き換わるというのが標準的なHTMLの生成方法のようです。

テンプレートプロジェクトでは、さらにメニューを共通のレイアウトとしてレンダリングするようになっておりもう一段階複雑です。

ASP.NET Core Blazorではメニューやヘッダー・フッターなどの共通の要素を表現する「レイアウト」という種類のコンポーネントが用意されています[2]

これは LayoutComponentBase を継承したコンポーネントで、テンプレートでは MainLayout.razor が該当します。

@inherits LayoutComponentBase

<div class="sidebar">
    <NavMenu />
</div>

<div class="main">
    <div class="top-row px-4">
        <a href="https://docs.microsoft.com/en-us/aspnet/" target="_blank">About</a>
    </div>

    <div class="content px-4">
        @Body
    </div>
</div>
■リスト4:Shared/MainLayout.razor 全体

見るとわかるようにサイドバーとmainの領域を分けて定義しています。siderbarには別途定義してあるRazorコンポーネントNavmenuを指定しており、これは同じくテンプレートのNavManu.razorに該当します。

main領域の方には具体的なコンテンツはありませんが、@Body というところが重要で、この位置に具体的なコンテンツがレンダリングされます。

つまり、このレイアウトによる@bodyと、_Host.cshtmlによる appタグ はその位置に具体的なコンテンツをレンダリングするという意味で共通しています。@bodyとappタグのHTMLレンダリング時における位置関係は少しややこしいです。ASP.NET Coreがうまい具合にマージしてくれるのであまり気にしなくていいのかもしれません。

ところで、レイアウトに関していうと、レイアウトはページ側で @layout MainLayout のように@layoutを使って指定することで適用されます。すべてのページにこれを記述することは面倒なので、一箇所に記述すればそのフォルダー以下に自動的に適用されるという便利な _Imports.razor の仕組みがテンプレートでは利用されています。

Pages/_Imports.razor のなかに @layout の記述があります。

1つ上の階層にも _Imports.razor というファイルがもう1つあり、こちらには @using が定義されています。このような仕組みなので @using と @layout はそれぞれ配下のすべてのページに自動的に適用されます。

HTTPリクエストからHTMLが生成されるフロー

ここまでをまとめると、たとえば、/counter というリクエストを受けてからHTMLを生成するまでの流れは次のようになります。

復習もかねて私の理解をまとめます。(間違っているかもしれません)

1./counter であれ、/fetchdata であれ、 /xxxx であれ、基本的にはすべてのリクエストは _Host.cshtml に着信します。これはStartup.csに endpoints.MapFallbackToPage("/_Host"); と記述されているからです。

2._Host.cshtmlは、URLを見てappタグの位置に該当するrazorコンポーネントをレンダリングします。URLが /counter の場合は、Counter.razorが該当します。URLとrazorコンポーネントは各razorファイル内の@Pageの指定によって結び付けられています。この結びつけとレンダリングは _Host.cshtml内の <app>@(await Html.RenderComponentAsync<App>())</app> で行われます。

3._Imports.razor に @layout MainLayout という記述があるため、同じフォルダー内にある _Host.cshtml に MainLayout.razor によるレイアウトが適用されます。上の図には記載していませんが、MailLayout.razor内にはNavMenuタグがあるため、NavManu.razorもレンダリングされます。

以上の3つの流れをマージして最終的なHTMLが生成されます。

依存性注入

テンプレートには依存性注入(DI, Dependency Injection)の例も含まれています。ASP.NET Blazorは仕組みとして依存性注入をサポートします。

念のために補足すると依存性注入とは、実行時に実装を差し替えることができる機能です。プログラム時点ではインターフェース定義だけに基づいて呼び出しやフローを記述します。その呼び出しの結果、何が呼び出されるのかは外部から別途指定できるという手法です。ソフトウェアにおけるプラグイン、CDプレーヤーにおけるCDなどがこれに近い発想です。(CDの再生ボタンを押したときに何が再生されるかは、CD機器を作る人ではなく、CDを再生する人が後から挿入するCDによって決まる)

FetchData.razorはWeatherForecastService.csで定義されているWeatherForecastServiceクラスから天気予報の一覧を取得して表示します。表示する部分はforeachによる繰り返しでHTMLのtr要素を生成しており、一目見れば何をやっているかわかります。

@foreach (var forecast in forecasts)
{
    <tr>
        <td>@forecast.Date.ToShortDateString()</td>
        <td>@forecast.TemperatureC</td>
        <td>@forecast.TemperatureF</td>
        <td>@forecast.Summary</td>
    </tr>
}
■リスト5:Pages/FetchData.razor 抜粋

注目すべきはは列挙する値の forecasts をどうやって取得しているかです。 下の方を見るとforecastsを取得しているプログラムがあります。

protected override async Task OnInitAsync()
{
    forecasts = await ForecastService.GetForecastAsync(DateTime.Now);
}
■リスト6:Pages/FetchData.razor 抜粋

ForecastSerivceから取得していることがわかります。

そして、ここが最大のポイントです。このForecastServiceの定義は冒頭で次のように記述されています。

@inject WeatherForecastService ForecastService
■リスト7:Pages/FetchData.razor 抜粋

少し例としてわかりにくい気がするのですが、これはWeatherForecastService型のインスタンス ForecastService を依存性注入機能から取得するという意味です。 これは単純にWeatherForecastService型を new してインスタンス化したものかもしれませんし、WeatherForecastService型を継承した何か別のものかもしれません。ForecastServiceの実体が何であるかはここでは定義されていないのです。

・・・という趣旨があきからになるように、ここの@injectではクラス名ではなく、インターフェースを指定したほうがわかりやすいのではないかと私は思います。実際インターフェースでも動作します。

型にたいする実体の一覧はASP.NET Razorのサービスコレクションから取得されます。

@inject WeatherForecastService ForecastService

と記述すると、ランタイムはサービスコレクションのリストに WeatherForecastService型 が登録されているか探しに行きます。登録されていればその登録されているオブジェクトをForecastSericeに割り当てます。

サービスコレクションの作成は Startup.cs の ConfigureServices メソッド内で行われています。ここでまさしくWeatherForecastService型が登録されていることがわかります。

services.AddSingleton<WeatherForecastService>();
■リスト8:Startup.cs 抜粋

ここでは型しか指定されていません。私の推測ですがこの場合、単純にこの型を new したものがオブジェクトとして登録されるのではないかと思います。だからこのような簡略的な書き方の場合、この型には引数なしで呼び出せるコンストラクタが必要です。

コンストラクタが複雑な場合はインスタンスの生成方法をラムダ式の引数に指定することもできるようです。

また、次のようにして自分で型とオブジェクトの実装を指定することもできます。

services.AddSingleton<IWeatherForecastService>(new MyWeatherForecastService());
■リスト9:

感想

以上で、設定ファイルや静的コンテンツ以外はテンプレートに含まれるすべてのファイルについてどのような役割でどのように動作しているか見られたと思います。手探りで推測している部分も多々ありますが、だいたいあっているのではないかなと思います。

だた、これらのファイルをこの仕組みで動作させるもう一歩深いレベルがまだよくわからないです。たとえば、_Host.cshtml内の Html.RenderComponentAsync<App>() で App を右クリックして、「定義へ移動」すると、ソリューションには含まれていない App.razor.g.cs というファイルにジャンプします。

このファイルはobjフォルダーにあるので自動生成されたもののようですが、正規のコードから定義へ移動でobjフォルダー内に移動するなど聞いたことがありません。(まだプレビュー版だからかもしれません)

このファイルの近くには同じように razor.g.cs というファイルがあるので、どうもプロジェクト内のrazorファイルはいったん razor.g.cs ファイルに変換されるように思えます。(ちなみにこのファイルの中を見てみると他のファイルとマージされた状態になっているので、どのようにマージされているのかなと考えるヒントにもなるかもしれません。)

各 razor.g.cs ファイルはプロジェクトに含まれている razor ファイルと1対1で対応しているようなので App.razor.g.cs ファイルの生成もとになっているのは、App.razor のようです。実際 App.razor.g.cs の冒頭には #pragma checksum で App.razor のフルパスが埋め込まれています。

しかし、App.razorをVisual Studioで開くと意味のある内容はありません。このファイルがどのようにして App.razor.g.cs となり、_Host.cshtmlファイル内でRenderComponentAsyncできる対象になるのか全然わからないです。一応、App.razorのカスタムツールに MSBuild:RazorGenerateComponentDeclarationDesignTime が指定されているのはプロパティウィンドウを見ればわかります。ただし、このカスタムツールは他のrazorファイルでも指定されています。このカスタムツールの力でAppという特別な名前のrazorに対して特別な処理を行っているということなのかもしれません。

後は、深堀りしていくのではなく、razorの楽しい機能を仕組みを気にしないで使ってみるというのもやってみたいです。

情報源

出典

  1. ^ メタデータのコメント MapFallbackToPage(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder,System.String) is intended to handle cases where URL path of the request does not contain a file name, and no other endpoint has matched.
  2. ^ https://docs.microsoft.com/ja-jp/aspnet/core/blazor/layouts?view=aspnetcore-3.0