Blazor Server で Scoped なサービスに状態保存してたら、.NET 8 の新しい Render Mode では危ないかもね、という話

## お題

まずは .NET 6~7 あたりのバージョンでの Blazor Server アプリケーションを、標準のプロジェクトテンプレートから生成したところから話しを始めましょう。

この標準のプロジェクトテンプレートから生成したばかりの .NET 7 (あるいは6) Blazor Serevr アプリケーションでは、"Counter" ページ上で "Click me" ボタンをクリックして増やしたカウント回数は、"Home" や "Weather" などの他のページに遷移すると、失われてしまいます。

![movie-002.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/3b4c782b-cf68-1e13-3fb3-5b9774e8d550.gif)

これはもちろん、カウント回数が、"Counter" ページコンポーネントのフィールド変数として実装されているからです。

```cs:Counter.razor
...
@code {
  // 👇 カウント回数がこのコンポーネントのフィールド変数に保存されている
  private int currentCount = 0;
  ...
```

なので、他のページへの遷移に伴い、"Counter" ページコンポーネントが破棄されれば、そのフィールド変数の値も失われます。次回、再び "Counter" ページを訪問したときには、先のとはまったく別の、新しい "Counter" ページコンポーネントがインスタンス化されますので、それでカウント回数は 0 に戻っているわけです。

これを、**"Home" や "Weather" など他のページに移動しても、再び "Counter" ページに戻ってきたら、前回カウント回数が維持されるよう**に改造してみます。

このシナリオでは、**カウント回数を、有効期間 = スコープなサービスオブジェクトに保存**する方法でやってみます。

## 実装

まずはじめに以下のように、加算された回数をカウンター変数に覚えておくクラス、`CounterService` をプロジェクトに追加します。

```cs:CounterService.cs
public class CounterService
{
    public int Count { get; private set; }

    public void Increment()
    {
        this.Count++;
    }
}
```

これを、Blazor Server アプリケーションの開始地点、`Program.cs` 内で 有効期間 = スコープな (Scoped) サービスとして DI コンテナにサービス登録します。

```cs:Program.cs
var builder = WebApplication.CreateBuilder(args);
...
// 👇 これを追加
builder.Services.AddScoped<CounterService>();
...
var app = builder.Build();
...
```

次に、"Counter" ページ (`Counter.razor`) にて、この `CounterService` を注入し、`CounterService` の持つカウンター変数 (`Count` プロパティ) をページ上に表示します。および、"Click me" ボタンのクリックイベントで、`CounterService` の加算用のメソッド `Increment` を呼び出すようにします。

```html:Counter.razor
@page "/counter"
@inject CounterService CounterService
...
<p role="status">
  Current count: @CounterService.Count
</p>

<button class="btn btn-primary" @onclick="CounterService.Increment">
  Click me
</button>
```

以上で、カウント回数はいまや、有効期間 = スコープなサービスオブジェクト、すなわち、この Blazor Server アプリに接続しているブラウザタブごとの、ある種の "グローバル変数" のように振る舞う `CounterService` オブジェクトで保存されるようになりました。その結果、"Counter" ページを離れても、カウント回数はこの `CounterService` オブジェクト内に保存されていますから、再び "Counter" ページを訪れると、前回カウント回数が表示され、その続きでカウントを増やしていけるようになりました。

![movie-001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/ffc40ead-2c59-baa4-0598-5841b6f8ee7d.gif)

## .NET 8 からの新しい Render Mode で再実装

さて 2023 年 11 月にリリースされた .NET 8 の Blazor では、サーバー側実装も強化され、SSR (サーバー側レンダリング) を基本としつつ、ページやコンポーネントの単位で細かく Server Interaction Mode を指定できるようになりました。

ということで、カウント回数を Scoped なサービスオブジェクトに覚えるようにした Blazor Server のデモアプリを、この .NET 8 の新しい Render Mode を利用したプロジェクトで再実装してみます。

まずは標準のプロジェクトテンプレートで Blazor Web App を新規作成します。このとき、プロジェクト新規作成時のオプション指定で、

- "Interactive render mode" は "Server" を、
- "Interactive location" は "Per page/component" を

選んで、前述のように、ページやコンポーネントの単位で、既定の SSR か、あるいは Blazor Server としてのインタラクション能力を持たせるか、を細やかに使い分けられる方法でプロジェクトを生成します。および、今回はカウント回数のデモンストレーションなので、"Include sample page" (サンプルのページ実装も含めて生成) のオプションも有効にして生成します。

下図は Windows 上で Visual Studio を使ってプロジェクト新規作成する際の、オプション指定のダイアログの様子です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/4ed4d5ce-5c34-119a-1151-ff64ea596cad.png)

プロジェクトが生成されたら、あとは冒頭で説明したのと同じ手順・内容で、以下の実装を適用します。

- `CounterService` クラスをプロジェクトに追加
- `Program.cs` で `CounterService` クラスを、有効期間 = スコープ で DI コンテナにサービス登録
- `Counter.razor` に `CounterService` を注入、カウント回数の表示とボタンクリック時の加算を配線

以上で出来上がりましたので、早速実行してみましょう。

​
すると...
​
​
​

![movie-000.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/123d77a2-8464-5f8d-f95e-685e7a796684.gif)

おやおや、**"Counter" ページをいちど離れて、もういちど "Counter" ページに戻ったときに、前回最終のカウント回数が失われ、0 に戻ってしまっています!**

いったいどういうことでしょうか??

## 種明かし

.NET 8 では、Blazor Server としての SignalR 接続は**必要な場合にのみ接続**され、いちど接続した後も、SSR なページに遷移するなど、**不要になったら自動で閉じられる**ようになりました。こうすることで、**サーバー側資源を節約**し、同じサーバー性能なら以前より多くのクライアントを捌けるように強化されています。

今回のサンプルプログラムでは、"Home" と "Weather" は SSR (サーバ側レンダリング) で動作し、Blazor Server として振る舞うのは "Counter" ページのみです。ですから、"Counter" ページを訪問したそのタイミングで SignalR 接続が開かれますが、いっぽうでそこから "Home" あるいは "Weather" ページに移動すると SignalR 接続は自動で閉じられます。

さて、Blazor Server の SignalR 接続がこのように不要と判断されて閉じられた場合、何がおきるでしょうか? そう、**それまで接続されていたサーバー側セッションが破棄**されるのです。このことはつまり、そのセッションに含まれていた有効期間 = スコープなサービスも一緒に破棄されることを意味します。サーバー側資源を節約しようというのですから、言われてみれば当然の仕様ですね。

そして再び "Counter" ページを訪問し、SignalR 接続が再び開かれたときには、新たなサーバー側セッションが作られてスコープが新規に作成されるわけです。

ユーザー体験的には、同一 Web アプリ内のあちこちを行き来しているだけで、一貫して同じ Web アプリ上に滞在・対話しているようにしか見えません。しかしその実、サーバー側目線では、**"Counter" ページを訪問するたびに毎回、新たに SignalR 接続を開き直しては、新しいサーバー側セッション (スコープ) を開始**して動作していたのですね。下図のように、"Counter" ページを訪問するたびに、新たな SignalR 接続 "_blazor?id=..." が開かれる様子が、ブラウザの開発者ツールの Network タブから観察できます。

![movie-003.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/17cac5db-c0cd-14b7-556b-89caae481309.gif)

これが、有効期間 = スコープなサービスに状態を保存する方法を採用していたときに、.NET 7 以前ではうまく動作していたのに、.NET 8 で細かく SSR と Interactive Server Mode とを使い分けているとうまく動作しなくなった理由・原因です。

## どうしたらよいか

現時点でのアイディアとしては、スコープなサービスに状態保存するのではなく、ブラウザのセッションストレージに最終カウント回数を保存・復元するとよいのかも、と思われました。

また、そもそも .NET 7 以前であっても、スコープなサービスへの状態保存は、サーバープロセスの再起動などでも簡単に失われるので、簡易な用途には手軽でいいかもですが、あまり積極的に採用してはいけない方式ではないか、とも思われます。

以上です!