😺

Blazor の StreamRendering の動作を調べてみた

2023/09/20に公開

はじめに

.NET 8 で ASP.NET Core Blazor がかなり強化されます。
今までは SignalR でサーバーと繋いだ Blazor Server や WebAssembly 上で動かす Blazor WebAssembly がありましたが .NET 8 ではサーバー サイド レンダリング (SSR) に対応しました。さらにストリーム レンダリングという画面表示のためのデータの取得に対して時間がかかるようなときには、一度データとかが無い状態でレンダリングが行われて、データが取得できたらそのデータを使って再度レンダリングが行われるという機能が追加されました。

SSR を使った場合は Blazor Server や WASM のようにユーザーとの対話操作を行うことができません。
フォームを配置することで、ユーザーがデータを入力してボタンを押すことでサーバーサイドで処理をすることは出来ます。普通の古き良き Web アプリケーションと同じですね。従来通り、SPA みたいにリッチなユーザーとの対話操作をしたい場合は Blazor Server や WASM をコンポーネント単位で有効化することが出来るので、そちらを使うようなイメージです。

確認に使ったコード

Blazor Web アプリのプロジェクトで新規作成をして Components/Pages/Home.razor に以下のようなコードを書いて確認しました。
このコードの @attribute [StreamRendering(true)] の部分を書き換えてストリーム レンダリングのオンオフを切り替えて確認しています。

@page "/"
@inject ILogger<Home> Logger
@inject IHttpContextAccessor HttpContextAccessor
@implements IDisposable
@attribute [StreamRendering(true)]

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<form method="post" @formname="person" @onsubmit="OnSubmit">
    <AntiforgeryToken />
    <input type="hidden" name="Count" @bind="Count" />
    <p>Count: @Count</p>
    <input type="submit" value="インクリメント" class="btn btn-primary" />
</form>

<ul>
    @foreach (var item in _lifecycleLogs)
    {
        <li>@item</li>
    }
</ul>

@code {
    [SupplyParameterFromForm]
    public int Count { get; set; } = 0;

    private readonly List<string> _lifecycleLogs = [];

    private async Task OnSubmit()
    {
        Logger.LogInformation(nameof(OnSubmit));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("OnSubmit await 前");
        await Task.Delay(10000);
        _lifecycleLogs.Add("OnSubmit await 後");
        Count++;
    }

    protected override void OnInitialized()
    {
        Logger.LogInformation(nameof(OnInitialized));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("OnInitialized");
        base.OnInitialized();
    }

    protected override async Task OnInitializedAsync()
    {
        Logger.LogInformation(nameof(OnInitializedAsync));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("OnInitializedAsync await 前");
        await Task.Delay(10000);
        _lifecycleLogs.Add("OnInitializedAsync await 後");

        await base.OnInitializedAsync();
    }

    protected override void OnParametersSet()
    {
        Logger.LogInformation(nameof(OnParametersSet));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("OnParametersSet");
        base.OnParametersSet();
    }

    protected override async Task OnParametersSetAsync()
    {
        Logger.LogInformation(nameof(OnParametersSetAsync));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("OnParametersSetAsync");
        await base.OnParametersSetAsync();
    }

    protected override void OnAfterRender(bool firstRender)
    {
        Logger.LogInformation(nameof(OnAfterRender));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("OnAfterRender");
        base.OnAfterRender(firstRender);
    }

    protected override Task OnAfterRenderAsync(bool firstRender)
    {
        Logger.LogInformation(nameof(OnAfterRenderAsync));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("OnAfterRenderAsync");
        return base.OnAfterRenderAsync(firstRender);
    }

    public override Task SetParametersAsync(ParameterView parameters)
    {
        Logger.LogInformation(nameof(SetParametersAsync));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("SetParametersAsync");
        return base.SetParametersAsync(parameters);
    }

    protected override bool ShouldRender()
    {
        Logger.LogInformation(nameof(ShouldRender));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
        _lifecycleLogs.Add("ShouldRender");
        return base.ShouldRender();
    }

    void IDisposable.Dispose()
    {
        Logger.LogInformation(nameof(IDisposable.Dispose));
        Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
    }
}

内部で IHttpContextAccessor を使っているので Program.cs に以下の 1 行も追加しています。

Program.cs
builder.Services.AddHttpContextAccessor();

SSR/ストリーム レンダリングの時のコンポーネントのライフサイクル

そこで気になったのが SSR/ストリーム レンダリング時のコンポーネントのライフサイクルです。基本的に Blazor Server や WASM の時は以下のような順番で呼び出されます。
Async メソッドで非同期処理が呼ばれたときには、厳密には同期処理のほうのメソッドが呼ばれたりと細かなところでは違いがありますが、大まかには以下のような順番で呼ばれます。

  • SetParametersAsync
  • OnInitialized
  • OnInitializedAsync
  • OnParameterSet
  • OnParameterSetAsync
  • ShouldRender
  • OnAfterRender
  • OnAfterRenderAsync
  • Dispose/DisposeAsync

ストリーム レンダリング時のライフサイクル

SSR でストリーム レンダリングをオンにしたページで実行すると以下のような実行結果になりました。

  • SetParametersAsync
  • OnInitialized
  • OnInitializedAsync
  • OnParameterSet
  • OnParameterSetAsync
  • Dispose/DisposeAsync

OnAfterRender 系のメソッドが呼ばれないところが大きな違いになります。ストリーム レンダリングで OnInitializedAsync メソッドで非同期処理を行うと以下のような結果になります。ポイントとしては ShouldRender が増えている所になります。
これは OnInitializedAsync のなかで await が呼ばれるところまでの状態を使って一度レンダリングが行われて、その後、一連の処理が終わった後に再レンダリングが必要かどうかが ShouldRender で確認されて true を返した場合に再レンダリングが行われるという仕組みになっています。

  • SetParametersAsync
  • OnInitialized
  • OnInitializedAsync ★ここの await で待機をした時点の状態で一度レンダリングされる★
  • OnParameterSet
  • OnParameterSetAsync
  • ShouldRender
  • Dispose/DisposeAsync

因みに ShouldRender のデフォルトの挙動は true を返すので何も考えずに OnInitializedAsync で DB にアクセスしてデータを取りにいくようなコードを書くと、DB アクセス前の状態で一度画面が表示されて、DB アクセスが終わってデータが入った状態で画面が再レンダリングされるという一般的なアプリケーションにとって好ましい動きをします。

一応 SetParametersAsync, OnParameterSetAsync でも DB アクセスのような非同期処理を書くことは出来ますが本来的な使い方ではないので動きが怪しいためやらない方が良いです。

画面表示は以下のようになりました。

最初のレンダリング時

最終的なレンダリング結果

ストリームレンダリングをオフにした時のライフサイクル

オフにしたときも、基本的にライフサイクルは同じです。ただ画面に表示されるまでのタイミングが違うだけです。
オフのときは全ての処理が終わった時に画面が表示されます。面白いのは OnInitializedAsync で非同期処理を行い ShouldRenderfalse を返したケースです。
当然と言えば当然なのですが OnInitializedAsync で非同期処理を await する直前の状態でレンダリングされたものが表示されます。

個人的には、ちょっと面白かったです。

ストリームレンダリングがオンのときのフォームの POST 時のライフサイクル

次に画面に form を置いて POST したときのライフサイクルを見てみます。この時の最初のレンダリングタイミングは、OnSubmit の中で最初に await をしたところになります。そのため OnInitializedAsync で非同期処理で時間がかかっていると最初のレンダリングがとても遅くなってしまいます。

実際にこんな結果になりました。

  • SetParametersAsync
  • OnInitialized
  • OnInitializedAsync
  • OnParameterSet
  • OnParameterSetAsync
  • ShouldRender
  • OnSubmit ★ここの await で待機をした時点の状態で一度レンダリングされる★
  • ShouldRender
  • Dispose/DisposeAsync

GET リクエストの時と同じ流れを辿りつつ、最初のレンダリングタイミングは OnSubmitawait をしたところになります。そのためこのプログラムでは OnInitializedAsync で 10 秒待機をしているので画面の更新に凄く時間がかかります。

実際の画面の表示は以下のようになりました。

初回レンダリング時

この表示になるまで OnInitializedAsync の完了をまつので 10 秒かかります。

2 回目のレンダリング時

さらに OnSubmit で 10 秒待機をしているので、2 回目のレンダリングも 10 秒かかる。

ストリーム レンダリング時の処理の書き方

例えば検索画面をストリーム レンダリングで書く場合には OnInitializedAsync で初回表示のタイミングで出しておきたいデータの取得を行い、検索ボタンが押されたときのような POST の処理では OnInitializedAsync では何も行わずに OnSubmit などのフォームのイベントでデータの取得を行うようにするとストリーム レンダリングの恩恵を受けた画面を作ることが出来ると思います。

このサンプルでは IHttpContextAccessor を使っていますが、このコードのように HttpContextRequest プロパティの Method プロパティが GET のケースと POST のケースで分岐が出来ます。この生のコードを各ページに書くのは怠いので1枚くらい何かラップするクラスを作ることになると思います。

例えばこんな感じですかね。

public interface IRenderingStatus
{
    bool IsPostBack { get; }
}

public class RenderingStatus : IRenderingStatus
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public RenderingStatus(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public bool IsPostBack => _httpContextAccessor.HttpContext?.Request.Method == "POST";
}

これを Program.cs を以下のように修正して DI に登録します。

Program.cs
builder.Services.AddScoped<IRenderingStatus, RenderingStatus>();

そうすると、以下のような感じで初回のみ OnInitializedAsync でデータ取得などが出来ます。

protected override async Task OnInitializedAsync()
{
    if (RenderingStatus.IsPostBack is false)
    {
        // 初回表示のみの処理
    }
}

ストリームレンダリングを使った時のコンポーネントのライフサイクルはリクエスト単位なので、POST の時には前回取得したデータはコンポーネントに残っていないので OnSubmit などできちんと表示に必要なデータを準備する必要があるので、その点だけは注意してください。

まとめ

ということで SSR やストリーム レンダリングの時のコンポーネントのライフサイクルを見てみました。
Blazor Server や WASM とは結構違うのでびっくりですね。大きな違いとしては OnAfterRender が呼ばれないという点になります。また GET のときと POST の時ではストリームレンダリングの初回レンダリングのタイミングが違います。GET の時は OnInitializedAsync 内で await したタイミングで、POST の時は OnSubmit などのイベントハンドラーの中で await したタイミングになります。これはちょっとハマりポイントかも。

Discussion