C# 側から JavaScript 側へ大きなオブジェクト集合を渡す
Blazor Server で C# 側から JavaScript 側へ大きなオブジェクト集合を渡す必要が発生したとしましょう。ChartJS など JavaScript 製のライブラリを Blazor から利用したく、その際に、描画するデータを渡す、みたいなシナリオです。JSON 文字列化したときに、5~6 MB くらいになる規模を想定します。
普通に実装すれば、シンプルに、以下のようにオブジェクト集合を JavaScript 関数にそのまま引数に渡すだけです。
@code {
private IEnumerable<Data> _dataSet = ...;
private async Task OnXHandler()
{
await using var helper = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./helper.js");
await helper.InvokeVoidAsync("doSomething", _dataSet);
}
...
受け取る JavaScript 側では、単純に引数に JavaScript オブジェクトとして受け取るだけです。
export function doSomething(dataSet) {
// dataSet には JavaScript オブジェクトの配列が格納されています
console.log(dataSet);
...
}
これでちゃんと機能はします。ですが、実はもう少し処理速度と消費メモリの点で効率良く行なう方法があります。Stream の利用と GZip 圧縮です。
Stream と GZip 圧縮の使用
まずは Blazor Server 側です。
オブジェクト集合を JavaScript 相互運用機能に任せてシリアル化して JavaScript 側に送り込むのではなく、自前で JSON にシリアル化した Stream にして JavaScript 側に送り込むようにします。さらにその際に、その Stream を GZip 圧縮します。実装コードは以下のようになります。
@code {
private IEnumerable<Data> _dataSet = ...;
private async Task OnXHandler()
{
// JSON シリアル化の結果を Gzip 圧縮しながら書き込む Pipe Stream と、
// その書き込まれた内容を読み取る Pipe Stream を用意してつなぎます。
await using var pipeStream = new AnonymousPipeServerStream(PipeDirection.Out);
await using var clientStream = new AnonymousPipeClientStream(PipeDirection.In, pipeStream.ClientSafePipeHandle);
// JSON シリアル化を書き込む処理を別のスレッドで走らせます。
_ = Task.Run(async () =>
{
// 書き込み用の Pipe Stream に GZip Stream から書き込むようにします。
await using (var gzipStream = new GZipStream(pipeStream, CompressionLevel.SmallestSize, leaveOpen: true))
{
// GZip Stream へ JSON シリアル化を書き込みます。
// すると GZip 圧縮されたバイナリが書き込み用 Pipe Stream に書き込まれ、
// それは接続された読み取り用の Pipe Stream から順次読み取り可能になります。
await JsonSerializer.SerializeAsync(gzipStream, _dataSet, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
// JSON シリアル化が済んだら、書き込み用 Pipe Stream は閉じます。
// 閉じることで、読み取り用 Pipe Stream が終端を認識できるようになります。
pipeStream.Close();
});
// 読み取り用 Pipe Stream を参照用オブジェクトにアタッチし、
using var streamRef = new DotNetStreamReference(clientStream, leaveOpen: true);
// その Stream 参照を JavaScript 関数へ渡します。
await using var helper = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./helper.js");
await helper.InvokeVoidAsync("doSomething", streamRef);
}
...
Pipe Stream を使うことで、JSON シリアル化の処理を逐次 GZip 圧縮しながら JavaScript 側に渡すことができます。Stream のチャンクごとに GZip 圧縮結果が生成されるので、メモリ効率よく処理できます。
さて、これを受け取る JavaScript 側ですが、以下のように実装します。
export function doSomething(streamRef) {
// 関数の引数には Blazor の JavaScript 相互運用の参照オブジェクトが渡されるので、
// そこから ReadableStream<Uint8Array> を取得します。
const stream = await streamRef.stream();
// pipeThrough を使って、GZip 展開の処理を組み込みます。
const decompressed = stream.pipeThrough(new DecompressionStream('gzip'));
// その Stream から JSON からの逆シリアル化を実行することで、
// GZip 圧縮を解きながら、オブジェクトの配列を復元します。
const response = new Response(decompressed);
const dataSet = await response.json();
console.log(dataSet);
...
}
以上で、オブジェクト集合を GZip 圧縮された Stream 経由で C# 側から JavaScript 側へ渡すことができます。見た目上の動作は変わりませんが、さて、処理時間とメモリ消費量はどうなったでしょうか。
結果比較
この Blazor Serer プログラムを US Weast リージョンの Azure App Services 上に配置し、転送にかかる時間、サーバー側の消費メモリ、ブラウザ側の消費メモリを比べてみました。
| | オリジナル実装 | Stream + GZip の使用 |
|---|---|---|
| 転送時間 | 約 6 秒前後 | 約 2 秒前後 |
| サーバー側消費メモリ | 約 220 MB | 約 150 MB |
| ブラウザ側消費メモリ | 約 25 MB | 約 18 MB |
ご覧のとおり、Stream + GZip 圧縮を使用したほうが、処理時間の速さも消費メモリの少なさも、いずれも勝っていました。転送サイズはオリジナル実装で 6 MB 程、Stream + GZip は 2 MB 程でした。Stream + GZip 圧縮方式の転送時間には、GZip 圧縮にかかる時間が含まれているのですが、それでもなお上記のように、オリジナル実装の転送時間の3分の1程度で済んでしまっていました。
さらにもう一点、動作に違いがありました。
オリジナル実装ですと、数 MB のサイズのチャンクをひとかたまりで WebSocket 通信に載せてくるため、そのチャンクをダウンロードし終わるまで、一切のインタラクションが機能しませんでした。つまり、上記のとおり転送時間が約 6 秒前後かかっているのですが、この約 6 秒間の間、その Blazor Server は何のユーザー操作にも反応できなかったのです。
一方で Stream + GZip の場合は、そもそも Stream が数 KB ごとの小さなチャンクにわけて細切れにダウンロードされるため、転送処理中でもユーザー操作への反応は継続されました。転送効率的にはバッチで1回のチャンクで送った方がよいはずですが、そもそも GZip 圧縮してあることによる転送時間の節約のほうが大幅に上回り、むしろ小分けのチャンクで送信処理が行なわれることによって、その小分けのチャンクの合間を縫ってユーザー操作に対する応答の通信も混ぜ込めるため、アプリケーションの対話性能が落ちない、という結果になっていました。
まとめ
以上、大きなサイズのオブジェクト集合を Blazor Server から JavaScript 側に送り込む際は、そのオブジェクト集合をただ単に JavaScript 関数呼び出しの引数に渡すよりは、GZip 圧縮された Stream 化すると、処理速度も速くなり、メモリ消費も抑えられる、ということがわかりました。
どの程度のサイズから Stream + GZip 方式を採用したほうがいいのか、自分にはその閾値が今ひとつはっきりとは掴めていませんが、場合によっては Stream GZip 方式を採用することで、その Blazor Server アプリケーションがより軽量に動作するようになりますので、知っておいて損はない技巧かと思います。