※Blazor Serverについて知らなかった人に興味を持ってもらいやすくする為、タイトルを「Blazor Serverの非同期処理がめちゃくちゃ直感的に書けて感激した件」から変更しました。
今更ですがBlazor Serverをちょっと試してみて、そのあまりのすごさに驚いたので記事にしてみます。C#があれば、ReactもVueもSvelteも要らんかったんや…(言い過ぎ)。
Blazor Serverは比較的新しいフレームワークで、「サーバ側もクライアント側も全てC#で記述しよう」という野心的なフレームワークです。2020年頃に正式リリースされ、.NET 3.1以降で使えるので、.NET6がLTSで既に.NET8がリリースされようとしている現在では、割と安定した技術と言えるでしょう。
同じBlazorブランドで展開している似た技術に「Blazor WebAssembly」というものもあり、これはWebAssemblyを用いてブラウザ上でC#からコンパイルしたバイナリコードを実行するというCSR(Client Side Rendering)技術ですが、Blazor Serverはこれとは異なり、バイナリコードが実行されるのはサーバ側のみとなります。
Blazorは当初、クライアントサイド技術であるBlazor WebAssemblyの方がクローズアップされていた為、当時の記憶から「Blazor Serverは、Blazor WebAssemblyが使えなかった場合のおまけ的な機能なのかな」と思っていたのですが、試してみるとむしろBlazor Serverの方が革新的で、本命に感じました(もちろんそれらは使用目的が異なるので単純にどちらが本命とは言えませんけども)。動的なウェブサイトの記述方法が根底から変わるイメージです。
※(2023.09.21追記) .NET8ではBlazorテクノロジーが集約され、1つのWebアプリ内で「Blazor SSR」「Blazor Server」「Blazor WebAssembly」を同居させたり、自動的に切り替えることができるようになります。この記事は.NET7以前を対象としています。
シンプルなサンプル(Hello.razor)
最初に、Blazor Serverのコードの一部を紹介します。
@page "/hello"
<p>Hello, @userName !</p>
@code {
string userName = "guys";
}
/hello
にアクセスすると、上記のHello.razor
が実行され、以下のHTMLが描画されます。
<p>Hello, guys !</p>
これだけなら、既存のrazor構文を用いたcshtmlと何ら違いはありません。
非同期通信が入る処理でもこんなに簡単に書ける
次に、このuserNameを外部サービスから非同期に読み込んで表示するようにしてみましょう。
読み込みが完了するまでは「Loading...」という表示にしておきます。
@page "/hello"
@inject LoginUserService _loginUser
@if (userName == null)
{
<p>Loading...</p>
}
else
{
<p>Hello, @userName !</p>
}
@code {
string? userName;
protected override async Task OnInitializedAsync()
{
var user = await _loginUser.GetUserAsync(); // 1秒程度かかる
userName = user?.Name ?? "[Load Error]";
}
}
/hello
にアクセスすると、上記のHello.razor
が実行され、以下のHTMLが描画されます。
<p>Loading...</p>
そして1秒後、表示が自動的に次のように変わります。
<p>Hello, YourName !</p>
尚、これらの再描画は非同期に行われ、ブラウザのlocation.href
がリロードされるわけではありません。
また、GetUserAsyncの呼び出しは(当然ながら)サーバ側で実行され、ブラウザはその間、何もしません。
同じことをJavaScriptでやろうとしたら…?
これをもし、HTMLとJavaScriptによる非同期通信で実現しようとしたらどうでしょうか。
-
非同期通信ライブラリはaxiosをつかおうか、それともfetchを直接呼び出そうか。
-
外部APIの呼び出しURLはどこに定義しようか。
-
そもそもレンダリングはVueをつかおうか、Reactをつかおうか、それともSvelteを使おうか。
-
C#とTypeScriptの型の定義をどう共有しようか。
-
ブラウザからGetUserAsyncの呼び出しに何らかの秘匿情報が必要だった場合はどうでしょうか。
-
その秘匿情報はどこから取得し、どのように扱えば安全でしょうか。
-
GetUserAsyncで取得できるUser情報の中にはクライアントに開示したくない情報が含まれていたとしたらどうでしょうか。
-
その場合には、ブラウザから直接GetUserAsyncを呼び出すのは諦めて、独自のGetUserNameAsyncメソッドを定義しなければいけませんね。
そのような心配事が、Blazor Serverならば全て解決します。だって、このあたりの処理は全部サーバ側で行われることですから。
クライアントとサーバ間の通信は、全てフレームワークに任せることができますから、画面にデータを返す為だけのAPIの定義からは解放されます。
型を共有する必要すらありませんし、画面には「表示結果」しか送られませんから、コード上に現れる秘匿情報は、画面に表示しない限り秘匿情報のままです。
それでいて、画面の動的なレンダリングが実現できます。
なんということでしょう。夢のようです。
Blazor Serverの仕組みと対象範囲
/hello というURLへの画面遷移は非同期に処理されます。ルーティング設定に従って/helloというurlに対応する画面コンポーネントがサーバ側で実行され、そのレンダリング結果がブラウザに戻されます。
そして、その画面コンポーネント上で起こるボタンクリックやタブの切り替えといったイベントは、ブラウザ上ではなくサーバ側で処理され、変化したDOMの差分のみがブラウザに返され、統合されて描画されます。
ここまで聞いて感づいた方もいらっしゃると思いますが、Blazor Serverにはサーバとクライアントの常時接続環境が必要です。
つまり、サーバとクライアントは紐づいており、サーバは状態を持ちます。負荷分散の際にはスティッキーセッションによるサーバとクライアントの紐づけが必要です。
ですので、Blazor Serverが向いているのは、特定のユーザーのみがアクセスする管理画面や、社内システムのような限られたユーザーが操作するウェブアプリケーション、ショッピングサイトの注文画面部分、のようなものとなります。不特定多数がアクセスするような閲覧専用のサイトの場合には、多くのケースでオーバースペックとなるでしょうから、閲覧専用のページ(商品検索ページなど)はcshtmlで書き、カート部分はBlazor Serverを用いる、のような選択が大事になりそうです。
場合によっては、Blazor WebAssemblyとの併用(Blazor Unite)も可能です。
進捗状況のリアルタイム表示
サーバ側で実行したい一連の処理の進捗状況を画面に表示したい場合、次のように直感的に書くことができます。
@page "/lingtimeproc"
@inject LongTimeProcService1 _longTimeProcService1
@inject LongTimeProcService2 _longTimeProcService2
@inject LongTimeProcService3 _longTimeProcService3
@if (isProcessing)
{
<p>実行中...(@progress %)</p>
}
else
{
<button type="button" class="btn" @onclick="DoLongTimeProc">長い時間かかる処理を実行</button>
@if (message != null)
{
<p>@message</p>
}
}
@code {
bool isProcessing = false;
int progress = 0;
string? message = null;
private async Task DoLongTimeProc()
{
try
{
isProcessing = true;
progress = 0;
StateHasChanged();
// 時間のかかる処理1
var id = await _longTimeProcService1.GetId();
progress += 33;
StateHasChanged();
// 時間のかかる処理2
var info = await _longTimeProcService2.GetInfo(id);
progress += 33;
StateHasChanged();
// 時間のかかる処理3
message = await _longTimeProcService3.GetMessage(info.msgid);
progress = 100;
StateHasChanged();
}
catch(Exception ex)
{
message = ex.Message;
}
finally
{
isProcessing = false;
StateHasChanged();
}
}
}
ボタンを押下するとisProcessing
がtrue
になり、ボタンは非表示になり、代わりに進捗状態がリアルタイムに表示されるようになります。
時間のかかる処理が終わる度に、progress
を加算します。変更されたprogress
はStateHasChanged()
呼び出しによって直ちに画面に反映されます。
まるでJavaScriptで書いているかのようにシンプルに書けていますが、これらは相変わらずサーバ上で実行されます。
もし、LongTimeService1~3がブラウザ側から直接アクセスできない場所にあったとしたら、これをJavaScriptで同じように書くには、それぞれのサービスの呼び出し用のファサードAPIを別途定義しなければならなかったでしょう。
念のため、上記のサンプルは動きを把握するためのもので、実際の業務で同じようなことをする場合には、処理の途中でコンポーネントが破棄された時(つまり、画面が閉じられたり、別の画面に遷移してしまったりした場合)のキャンセル処理をちゃんと書く必要があるでしょう。むしろ、このあたりを「一連の処理」としてアトミックに記述できる点はメリットと言えます。
確認ダイアログによる処理の継続
さて、ここまでくると、あれをやりたくなります。
そう、「処理を継続しますか?」の確認ダイアログによる処理の継続です。
あれを、callback関数やOnCloseイベントハンドラなどを使わず、awaitで直感的に記述できるでしょうか。
結論から言うと、できました。
確認ダイアログ呼び出し側のコードをこんな感じで書けます。
@page "/confirmproc"
@inject Proc1Service _proc1Service
@if (!isProcessing)
{
<button type="button" class="btn" @onclick="DoProc">更新</button>
@if (message != null)
{
<p>@message</p>
}
}
<ConfirmDialog @ref="myDialog"></ConfirmDialog>
@code {
ConfirmDialog myDialog;
bool isProcessing = false;
string? message = null;
private async Task DoProc()
{
try
{
isProcessing = true;
StateHasChanged();
// 処理実行の確認
if (!await myDialog.ConfirmAsync("更新を行いますか?"))
{
message = "キャンセルされました";
StateHasChanged();
return;
}
// 更新処理
message = await _proc1Service.Update("アップデートする内容");
StateHasChanged();
}
catch(Exception ex)
{
message = ex.Message;
}
finally
{
isProcessing = false;
StateHasChanged();
}
}
}
どうでしょうか。とても直感的に書けていると思います。
ここから発展して、処理中の間画面を制御不可にするようなものも簡単に作れそうですね。
<ConfirmDialog @ref="myDialog"></ConfirmDialog>
上記の @ref
は、このDOM要素のインスタンスをmyDialogという変数に格納する属性です。
下記のように、変数として宣言しておきます。このDOM要素はConfirmDialog.razorコンポーネントなので、その型で宣言します。
ConfirmDialog myDialog;
こうすることで、myDialog.ShowAsync("メッセージ")
をコード上から呼び出して制御することができるようになります。
便利!
こちらが、確認ダイアログを表示する為のRazorコンポーネントConfirmDialog.razor
の詳細になります。
@if (isShow)
{
<div class="modal fade show" id="myModal"
style="display:block; background-color:rgba(10,10,10,.8);"
aria-modal="true" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
<p>@Message</p>
</div>
<div class="modal-footer">
<button type="button" class="btn" @onclick="Cancel">キャンセル</button>
<button type="button" class="btn btn-primary" @onclick="Ok">OK</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter]
public string Message { get; set; } = "";
TaskCompletionSource<bool>? tcs = null;
bool isShow = false;
void Show()
{
isShow = true;
StateHasChanged();
}
void Hide()
{
isShow = false;
StateHasChanged();
}
void Cancel()
{
Hide();
tcs?.SetResult(false);
tcs = null;
}
void Ok()
{
Hide();
tcs?.SetResult(true);
tcs = null;
}
public Task<bool> ConfirmAsync(string? message = null)
{
if (message != null)
{
this.Message = message;
}
tcs = new();
Show();
return tcs.Task;
}
}
内容的にはそれほど難しいことはしておらず、ConfirmAsync
メソッド内でTaskCompletionSource<bool>
を生成してtcs.Task
を返し、モーダルダイアログを表示(レンダリング)します。そして、OKボタンが押されたらtcs.SetResult(true)
、キャンセルボタンを押されたらtcs.SetResult(false)
を返し、ダイアログを非表示にする、というだけです。
TaskCompletionSource<bool>
はスレッドを生成せずユーザーコードでタスクの完了を制御できるクラスです。JavaScriptのPromise
と同じような事ができるやつですね。
モーダルダイアログの表示自体にはBootstrapを使用しています(これは、BlazorAppテンプレートに標準でインクルードされています)。
注意
この記事は、Blazor Serverの画面描画の概要をお伝えするのが目的で、細部については間違っていることもあるかもしれません。
基本的には公式サイトのサンプルなどを参考にしていますが、サンプルを触った程度の知識ですので、正しい方法で記述されていない箇所もあるかもしれません。
また、確認ダイアログの処理のやり方(つまり、awaitでUIスレッドに継続するような方法)はBlazor Serverのお作法から外れている可能性もあります。
実際に業務に使用する際には、そのあたりも十分にご注意ください。
注意2(追記)
await周りの挙動に注意が必要な箇所があったのでここに書き残しておきます。.NET6, .NET7, .NET8.0.0-rc.1で確認しました。
以下のように、UIスレッドに対してawaitを連続で行うと、途中から処理が中断してしまうようです。この例だと、「確認してください(2)」のダイアログが表示されたあと、OKを押してもmessageが変更されず、Step1. passed のままになり、しかも次の「確認してください(3)」のダイアログは表示されません。
message = "Start";
StateHasChanged();
await myDialog.ConfirmAsync("確認してください(1)");
message = "Step.1 passed";
StateHasChanged();
await myDialog.ConfirmAsync("確認してください(2)");
// ★これ以降が実行されていない?
message = "Step.2 passed";
StateHasChanged();
await myDialog.ConfirmAsync("確認してください(3)");
message = "Step.3 passed";
StateHasChanged();
以下のように、1msのDelayを間に挟み、一旦UI処理スレッドに戻してやると、正しく動作するようになります。
message = "Start";
StateHasChanged();
await myDialog.ConfirmAsync("確認してください(1)");
message = "Step.1 passed";
StateHasChanged();
+ await Task.Delay(1);
await myDialog.ConfirmAsync("確認してください(2)");
message = "Step.2 passed";
StateHasChanged();
+ await Task.Delay(1);
await myDialog.ConfirmAsync("確認してください(3)");
message = "Step.3 passed";
- StateHasChanged();
+ await Task.Delay(1);
なんか、このあたり.NET8でDelay(1)入れなくてもいいように修正されたみたいな話を聞いたことがあるような気がするんですが、探しても見つかりませんでした。
Blazor Serverを試すには
.NET 7 SDKを用いてコマンドラインで試す方法はこちらです。VSCodeを用いるのも良いと思います。
https://dotnet.microsoft.com/ja-jp/learn/aspnet/blazor-cli-tutorial/intro
Visual Studio 2022を使う場合はこちらが良いでしょう。
https://dotnet.microsoft.com/ja-jp/learn/aspnet/blazor-tutorial/intro
.NET MAUI + Blazor Hybrid
なんと、この記事で説明したBlazor Serverで作成したWebアプリを、丸ごとデスクトップアプリケーションやモバイルアプリケーションとして実行できてしまう仕組みです。
外部のBlazor Server(localも、remoteも)は不要です。具体的には.NET MAUIのBlazorWebViewというコンポーネントを用いて、そのアプリのプロセス内でBlazor(Server や WebAssembly)を動かし、結果をWebViewに描画します。
言い換えると、XAMLではなくBlazorでUIを記述し、操作できるということです。
こちらの記事がわかりやすいです。
個人的には、「XAMLではなくHTMLでUIを書けるようにならないか」とずっと思っていたので、これはかなり感動的です。
MicrosoftはMAUIで様々なパターンに対応できるようにしているようですが、個人的にはこの、.NET MAUI + Blazor Hybridが本命です。
もはや C# は無敵なんじゃないでしょうか。
参考
(追記2023/9/20)
.NET 8で、Blazor ServerのようなSSRとSPAの中間技術ではなく完全なSSRである「SSRモード」がBlazorに追加されるそうです。こうなると、Blazor ServerをSSRだと説明するのはよくないので、記事からSSRという説明を削除しました。
詳しくはこちらの記事をどうぞ:
https://zenn.dev/okazuki/articles/streamrendering-blazor
SSRモードは、ページを完全なHTMLとしてブラウザに返す機能のようです。「そんなもん、これまでのRazor PagesとかMVCでもできるやん」と思われるかもしれませんが、同じことを.razorコンポーネントを使ってできるということは、Blazor ServerやBlazor WebAssemblyのページと.razorコンポーネントを共有できるということです。
これは、Microsoftさん、Blazorをマジで中核技術として推してきていますね…!
楽しみになってきました!