😺

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

2023/09/20に公開2

はじめに

.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

wswakkywswakky

いつも勉強させてもらっています。
作成中の Blazorアプリ で Reload すると ERROR(Could not find 'cultureInfo.get')となり困っています。

親から子へ遷移しこのときカスケードと URLパラメータで情報を渡し、この情報で HttpClient.GetAsync
を利用し json を読取り子画面の初期表示を行っています。この表示までは問題ないのですがブラウザで
Reload を行うと ERROR(Could not find 'cultureInfo.get') なり Shift+f5 も効かない状態になります。

初期表示までのライフサイクルのトレースは以下です。
ID:20 2024/07/26 18:06:22 - diploma - SetParametersAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnInitialized CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnInitializedAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSet CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSetAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - Dispose CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - SetParametersAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnInitialized CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnInitializedAsync CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSet CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSetAsync CascadingParameter:C3
ID:22 2024/07/26 18:06:22 - diploma - OnAfterRender CascadingParameter:C3
ID:22 2024/07/26 18:06:22 - diploma - OnAfterRenderAsync CascadingParameter:C3
ID:10 2024/07/26 18:06:22 - diploma - ShouldRender CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnAfterRender CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnAfterRenderAsync CascadingParameter:C3
ID:7 2024/07/26 18:06:22 - diploma - ShouldRender CascadingParameter:C3

CascadingParameter: の後ろの "C3" は親から渡されたカスケードパラメータです。
8件目の OnInitialized で初めて表示されます。ちなみにコレより前でデータセットを行うと
Reload 同様の ERROR(Could not find 'cultureInfo.get') が発生します。
8件目の OnInitialized 以降でデータをセットするととりあえず表示は問題ありませんが
ブラウザで Reload すると ERROR(Could not find 'cultureInfo.get') が発生します。
Reload後のトレースは以下です。

ID:9  2024/07/26 18:06:37  DEBUG
ID:9  2024/07/26 18:06:37   - diploma - SetParametersAsync   CascadingParameter:
ID:9  2024/07/26 18:06:37  SetParametersAsync() The value of 'para_eventid' is 20240707_02.
ID:9  2024/07/26 18:06:37  SetParametersAsync() The value of 'para_subevid' is 1.
ID:9  2024/07/26 18:06:37  SetParametersAsync() The value of 'para_bib' is 144.
ID:9  2024/07/26 18:06:37   - diploma - OnInitialized   CascadingParameter:
ID:9  2024/07/26 18:06:37   - diploma - OnInitializedAsync   CascadingParameter:
ID:9  2024/07/26 18:06:37   - diploma - OnParametersSet   CascadingParameter:
ID:9  2024/07/26 18:06:37   - diploma - OnParametersSetAsync   CascadingParameter:
ID:9  2024/07/26 18:06:37   - diploma - Dispose   CascadingParameter:
ID:9  2024/07/26 18:06:37   - diploma - Dispose   CascadingParameter:C3
CSS Hot Reload ignoring https://localhost:7270/bootstrap/bootstrap.min.css because it was inaccessible or had more than 5000 rules.
CSS Hot Reload ignoring https://localhost:7270/_content/Syncfusion.Blazor.Themes/bootstrap5.css because it was inaccessible or had more than 5000 rules.
[0m[48;5;127m[38;5;231mdotnet[0m[1m Loaded 11.37 MB resources[0m
This application was built with linking (tree shaking) disabled. 
Published applications will be significantly smaller if you install wasm-tools workload. 
See also https://aka.ms/dotnet-wasm-features[0m
Loaded 11.37 MB resources from cache
Debugging hotkey: Shift+Alt+D (when application has focus)
Error: One or more errors occurred. (Could not find 'cultureInfo.get' ('cultureInfo' was undefined).
Error: Could not find 'cultureInfo.get' ('cultureInfo' was undefined).
    at :1:537
    at Array.forEach (<anonymous>)
    at l.findFunction (:1:505)
    at b (:1:5248)
    at :1:3041
    at new Promise (<anonymous>)
    at y.beginInvokeJSFromDotNet (:1:3004)
    at Object.ri [as invokeJSJson] (:1:164916)
    at d:\Main_Folder\Timing\TimingSoft\Blazor\BlazorAppDiploma\BlazorAppDiploma\wwwroot\_framework\https:\raw.githubusercontent.com\dotnet\runtime\2aade6beb02ea367fd97c4070a4198802fe61c03\src\mono\wasm\runtime\invoke-js.ts:233:31
    at Tl (https://localhost:7270/_framework/dotnet.runtime.8.0.7.fty75nlz2b.js:3:179198))
    at Jn (d:\Main_Folder\Timing\TimingSoft\Blazor\BlazorAppDiploma\BlazorAppDiploma\wwwroot\_framework\https:\raw.githubusercontent.com\dotnet\runtime\2aade6beb02ea367fd97c4070a4198802fe61c03\src\mono\wasm\runtime\marshal-to-js.ts:349:18)
    at Tl (d:\Main_Folder\Timing\TimingSoft\Blazor\BlazorAppDiploma\BlazorAppDiploma\wwwroot\_framework\https:\raw.githubusercontent.com\dotnet\runtime\2aade6beb02ea367fd97c4070a4198802fe61c03\src\mono\wasm\runtime\marshal-to-js.ts:306:28)
    at wasm://wasm/00b21cf6:wasm-function[349]:0x1fad7
    at wasm://wasm/00b21cf6:wasm-function[245]:0x1bf9f
    at wasm://wasm/00b21cf6:wasm-function[238]:0xf16c
    at wasm://wasm/00b21cf6:wasm-function[306]:0x1e7f1
    at wasm://wasm/00b21cf6:wasm-function[327]:0x1efe7
    at wasm://wasm/00b21cf6:wasm-function[217]:0xcfbc
    at wasm://wasm/00b21cf6:wasm-function[772]:0x44213
    at e.<computed> (https://localhost:7270/_framework/dotnet.runtime.8.0.7.fty75nlz2b.js:3:215551) {superStack: {…}, stack: <accessor>, message: 'One or more errors occurred. (Could not find…otnet.runtime.8.0.7.fty75nlz2b.js:3:179198))', Symbol(wasm js_owned_gc_handle): 38}

その他
・プレレンダリングを App.razor で OFF にしましたが、同じ画面が上下に表示されてしまいました(親も子も)。
・StreamRendering を ON/OFF にしても変わりませんでした。
・子の レンダーモードは InteractiveAuto です。親は InteractiveServer です。

質問
・プレレンダリングを OFF で上下に表示される時点でなんらかの間違いがあると思うのですがこの情報だけでなにかわかりますか。
・子で Reload するときの ERROR はなにが原因でしょうか。
・初期のデータをセットするのはどこで行うのが正しいのでしょうか。現在 OnInitializedAsync で行っていますが 途中まで一度
表示されその後最後までが表示される残念な状態です。

以上、よろしくお願い致します。

//// プロジェクト環境 /////
Blazor Web App
Interactive render mode: Auto (Server and WebAssembly)
Interactive location: Per page/component

//// 開発環境 /////
Microsoft Visual Studio Community 2022 (64 ビット) - Current
Version 17.10.4

エディション Windows 10 Pro
バージョン 22H2
インストール日 2021/04/28
OS ビルド 19045.4651
エクスペリエンス Windows Feature Experience Pack 1000.19060.1000.0

サポート環境ではないので無視頂いても問題ありません。今後も楽しみにしています。