C#オブジェクトがIndexedDBに着くまで|Blazor WASM永続化のデータ形式の旅
2026-04-20
はじめに
PICOMはオフラインで動く楽譜エディタなので、楽曲データはブラウザ内のIndexedDBに保存しています。C#の Music オブジェクトをそこまで落とすには、データは何段階かの形式変換を経る必要があります。
この記事では、書き込み経路を追いながら、データがどういう姿で各レイヤーを通過し、C#/JS境界で誰が何に責任を持っているかを解説します。
C#の Music は「MessagePackモデル → byte[] → (C#/JS境界) → Uint8Array → IndexedDB」のレイヤーを経て永続化されます。各境界の責務分担、keyPathなしでストアを設計した理由などを解説します。
データの旅の全体像
書き込み時、楽曲データが通過する形式の変化を示します。
- C# Music (ドメイン)
- C# MusicDataModel (MessagePack用のオブジェクト)
- C# byte
- JS Uint8Array
- IndexedDB musics ストア
読み込みはこの逆順です。以降、各レイヤーで何が起きているかを順に追います。
レイヤー1 ドメインオブジェクト → MessagePackモデル
出発点の Music クラスは ObservableObject を継承していて、プロパティ変更通知やイベント、他のドメインオブジェクトへの参照を持ちます。このまま直列化するとイベント購読の内部状態まで引きずってしまうので、シリアライズ専用のDTOに詰め直します。それが MusicDataModel です。
private static MusicDataModel ToDataModel(Music music)
{
return new MusicDataModel
{
Version = 3,
Id = music.Id,
Metadata = new MusicMetadataModel
{
Title = music.Metadata.Title,
LastModified = DateTime.UtcNow,
},
Settings = new MusicSettingsModel
{
Tempo = music.Settings.Tempo,
Numerator = music.Settings.Numerator,
Denominator = music.Settings.Denominator,
},
Tracks = music.Tracks.Select(TrackToV2).ToArray(),
};
}
MusicDataModel は [MessagePackObject] 属性を持ち、各フィールドが [Key(n)] で番号付けされます。ドメイン側の Music は永続化の都合を一切知らず、PIMRepository という永続化層がこの変換の責任だけを負います。
なぜドメインモデルを直接永続化しないか
ドメインモデルの変化のサイクルと保存形式のデータ構造変化のサイクルは異なるからです。 例えば、保存に適したデータ構造へ変更したい場合を考えます。分離していないとドメインモデルを直接改良する必要がありますが、分離していれば DTO と詰め替え処理の改善だけで済みます。 こうすることで、インフラレイヤの変更がドメインレイヤへ波及することを防ぐことができます。
レイヤー2 MessagePackモデル → byte
ここは一行で終わります。
public byte[] Write(Music music)
{
var model = ToDataModel(music);
var bin = MessagePackSerializer.Serialize(model);
return bin;
}
MessagePackSerializer.Serialize が MusicDataModel を純粋な byte[] に落とします。ここまでは完全にC#内で完結する話で、interopの世界にはまだ触れていません。なぜJSONではなくMessagePackを選んだのかや、バージョニング戦略は、別記事で詳しく書いているのでそちらを参照してください。
MessagePackで独自バイナリフォーマットをバージョン管理する話はこちら⬇️

レイヤー3 C#/JS境界を越える — byteを直接渡す
ここが記事の中心です。PICOMは.NET 10のBlazor WebAssemblyで動いているので、IJSRuntime.InvokeVoidAsync の引数に byte[] を渡すと、JSON経由ではなく Uint8Array として直接渡されます。これは.NET 6からの機能で、Base64で文字列化する必要はありません。
境界を越えるコードは MusicsDataBase クラスにあります。
public class MusicsDataBase(IJSRuntime js)
{
private readonly IJSRuntime _js = js;
private IJSObjectReference? _db;
private async Task<IJSObjectReference> EnsureDbAsync()
{
if (_db is null)
{
var dbModule = await _js.InvokeAsync<IJSObjectReference>("import", "/db.js");
_db = await dbModule.InvokeAsync<IJSObjectReference>("useDatabase");
}
return _db;
}
public async Task SetAsync(int musicId, byte[] pimData)
{
var db = await EnsureDbAsync();
await db.InvokeVoidAsync("setItem", musicId, pimData);
}
}
3つの IJSObjectReference 階層に分かれています。import('/db.js') で取得したモジュールと、そのモジュールの useDatabase() が返すDBハンドル、それから各メソッド呼び出し。という流れになります。
InvokeVoidAsync("setItem", musicId, pimData) の pimData は byte[]。これが IJSRuntime の層で Uint8Array に変換され、JS側の setItem 関数の引数として届きます。
レイヤー4 JS側はUint8Arrayを受け取ってそのまま使える
JS側の db.js では、C#から渡ってきた Uint8Array をそのまま IndexedDB に渡せます。atob や Uint8Array.from による変換コードは一切ありません。
コードを見る前に、あとで繰り返し登場する runTransaction というヘルパーを先に定義しておきます。IndexedDB のトランザクションは「open → objectStore 取得 → リクエスト発行 → onsuccess/onerror を受ける」というボイラープレートが毎回必要なので、一度 Promise にラップしてあります。
const runTransaction = (mode, fn) =>
new Promise((resolve, reject) => {
const tx = db.transaction(MUSIC_STORE_NAME, mode);
const store = tx.objectStore(MUSIC_STORE_NAME);
const request = fn(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
これを使うと setItem は次の一行で済みます。
async function setItem(musicId, pimData) {
// pimData は C# 側から Uint8Array として届く
await runTransaction("readwrite", (store) => store.put(pimData, musicId));
}
たったこれだけです。実装の薄さが interop の真価だと感じる箇所です。
レイヤー5 IndexedDBに書き込む
最後のレイヤー、store.put(pimData, musicId) が実際にIndexedDBへ書き込みます。ここで少し変わったのは、keyPathなしのobjectStoreを使っている点です。
keyPathなしのストア設計
createObjectStore を呼ぶ際、keyPath を指定しない形で作っています。
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(MUSIC_STORE_NAME)) {
db.createObjectStore(MUSIC_STORE_NAME);
}
};
keyPath を指定すると、value に格納するオブジェクトの特定フィールドが自動でキーとして抽出される仕組みになります。多くの入門サンプルはこの方式で、{ musicId: 1, pimData: ... } のようなラッパーオブジェクトを value に入れます。
PICOMはkeyPathなしにしました。こうすると store.put(value, key) の第2引数でキーを外から明示的に渡すアウトオブラインキー方式になり、value には何でも置けます。PICOMの場合は生の Uint8Array を直接置いています。
- valueが任意の型でよい(
Uint8Arrayそのまま置ける) - ラッパーオブジェクト
{ musicId, pimData }のような構造を挟まずに済む - PIMバイナリ内にIDが埋まっている現在の設計と相性が良い(二重にIDを持たない)
store.createIndex('byTitle', 'title') のようなことができない)store.put(value) だけでなく必ず store.put(value, key) のようにキーを渡す必要があるセカンダリインデックスが必要になる場面、たとえば「タイトルで検索」のような機能を今後入れるなら、別ストアを並立させて軽量なメタデータだけを置くのがよいと考えています。現状は楽曲数が個人利用レベルなので、全件取得してC#側でフィルタで十分と判断しました。
Storage Persistence API
IndexedDBは容量が逼迫するとブラウザが勝手にデータを消す仕様なので、ストアを開くタイミングで永続化をリクエストしています。
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
const granted = await navigator.storage.persist();
console.log(`[Picom] Storage persistence ${granted ? 'granted' : 'denied'}`);
}
}
この処理を加えることで、容量逼迫時でも自動削除されなくなります。
ユーザーが明示的にサイトデータを削除すれば当然消えます。クラウド同期など別系統のバックアップは別途検討する価値があります。
読み込み側は逆順をたどる
読み込みは、書き込みで通った5段階をそのまま逆向きに降りるだけです。
JS側の getAllItems は store.getAll() で値(Uint8Array)の配列を一括取得します。PIM v3 からはIDがバイナリ内に埋め込まれているので、外側のキーを一緒に返す必要はありません。cursor で1件ずつ走査する必要もなく、一行で済みます。
async function getAllItems() {
// PIM v3 からは ID がバイナリ内に埋め込まれているため、外側のキーは不要。
// store.getAll() で値(Uint8Array)配列を一括取得する。
return await runTransaction("readonly", (store) => store.getAll());
}
C#側は List<byte[]> で受け取り、各バイナリを直接 MessagePackSerializer.Deserialize<MusicDataModel> に渡します。
public async Task<List<Music>> ReadAllFromLocalStorageAsync()
{
var stored = await _storage.GetAllAsync();
var musics = new List<Music>(stored.Count);
foreach (var bin in stored)
{
var (result, music) = Read(bin);
if (result is ReadResult.Success && music is not null)
{
musics.Add(music);
}
}
return musics;
}
まとめ
- .NET 6以降の
IJSRuntimeはbyte[]をUint8Arrayに直接変換できるため、Base64で文字列化する必要はない - IndexedDBは
keyPathなしで生のバイナリを直接入れる形が、自己完結したバイナリフォーマット(PIM)との相性が良い navigator.storage.persist()を忘れないこと


