MessagePackで独自バイナリファイル形式(PIM)をバージョン管理対応で設計する
2026-04-15
はじめに
PICOMというチップチューン向け音楽作成Webアプリを開発する中で、「楽譜データをどのフォーマットで保存するか」を決める必要がありました。最終的に選んだのがMessagePackで、これに.pimという独自拡張子を付けて「PIMフォーマット」と呼んでいます。
この記事では、JSONではなくMessagePackを選んだ理由と、将来のフォーマット変更に備えたバージョン管理の設計、そして実装で気をつけた点を書きます。「自作アプリのセーブデータをどう設計するか」で悩んでいる方の参考になれば嬉しいです。
PICOMのセーブデータ形式「PIM」はMessagePackベースで、バイナリの軽量さとフォーマット拡張への耐性を両立させています。モデルクラスにVersionプロパティを埋め込むことで将来のフォーマット変更に備え、[Key]番号の管理ルールやReadResult enumでデシリアライズ失敗を型で表現する設計も紹介します。
なぜJSONではなくMessagePackを選んだのか
一番最初はJSONで実装するつもりでした。デバッグしやすいですし、System.Text.Jsonは.NET標準で追加の依存がいりません。ただ、PICOMで扱うデータの性質を考えた結果、MessagePackに切り替えました。
決め手を一言で言うと、バイナリフォーマットの利点が欲しかったからです。
サイズが小さい
PICOMの1曲は数十〜数百の音符と、各音符にエフェクト情報が付きます。JSONで書くとキー名("scale", "scaleNumber", "length"...)が毎音符ごとに繰り返され、中身より外側が重くなりがちです。MessagePackはキーを整数に置き換えられるので、同じデータで比較するとざっくり半分以下になります。ブラウザのIndexedDBに保存する以上、軽いに越したことはありません。
IndexedDBとの相性が良い
最終的にIndexedDBに入れるのはバイト列なので、中間でJSON文字列を経由するよりも、最初からバイナリで扱える方がシンプルになります。余計な文字列⇔バイト列の変換が挟まらない分、保存・読み込み周りのコードの見通しも良くなります。
ただし、バイナリなら何でも良かったわけではありません。独自のバイナリ形式を自力で設計すると、フィールドを1つ追加しただけで既存のセーブデータが一切読めなくなる という事態に容易に陥ります。PICOMの楽譜データには、今後も新しいフィールドを追加していく可能性が残っており、拡張に耐えるフォーマットであることは必須条件でした。
MessagePackを選んだのは、この問題への答えを持っていたからです。[Key(n)] の番号運用ルールさえ守れば、新しいフィールドを追加しても古いデータが壊れず、逆に古いコードで新しいデータを読んでも余分なフィールドが無視されるだけで済みます。どう拡張に耐えるかは後述の「Versionフィールドで将来のフォーマット変更に備える」で具体的に扱いますが、「バイナリの扱いやすさ」と「フォーマット拡張への耐性」が両立している 点こそが、PICOMが最終的にMessagePackを選んだ決定打でした。
- JSONより小さい(同じデータで半分程度)
- バイナリなのでIndexedDBへの保存時に余計な文字列変換が要らない
[Key]番号を守ればフィールド追加しても古いデータが壊れない拡張性
MessagePackへの依存が増える[Key]番号を後から変更できないPIMフォーマットのデータモデル
セーブデータのトップレベルは次のMusicDataModelです。[MessagePackObject]と[Key(n)]の組み合わせでフィールド位置を明示しています。
[MessagePackObject]
public class MusicDataModel
{
[Key(0)]
public int Version { get; set; } = 2;
[Key(1)]
public MusicMetadataModel Metadata { get; set; } = new();
[Key(2)]
public MusicSettingsModel Settings { get; set; } = new();
[Key(3)]
public TrackDataModel[] Tracks { get; set; } = [];
}
ここで一番重要なのがVersionプロパティです。後述しますが、これは書き込んだ時のフォーマットバージョンを自己申告するためのフィールドで、将来のフォーマット進化の鍵になります。
Trackの中身はもっとツリーが深く、ComponentDataModelを基底にしたNoteDataModel / RestDataModel / TieDataModel / TupletDataModelのサブクラスが入っています。MessagePackでは[Union]属性で多態を表現する方法と、サブクラスを判別するTypeフィールドを持たせる方法がありますが、PIMでは前者を使っています。
[Key]番号は追記のみ、絶対に削除しない
DBの主キーもそうですが、キーはイミュータブルな運用としています。
[Key(0)], [Key(1)], [Key(2)]... の番号は、一度付けたら絶対に変えてはいけません。番号はバイナリ内の物理的な位置そのもので、番号を変えると古いセーブデータが読み込めなくなります。
フィールドを増やすときはどうするか。次のように常に新しい番号を末尾に追加します。
// 悪い例: 既存の番号を詰め直してしまう
[Key(0)] public int Version { get; set; }
[Key(1)] public MusicSettingsModel Settings { get; set; } // Metadataを消して番号を詰めた
[Key(2)] public TrackDataModel[] Tracks { get; set; }
// 良い例: 既存番号はそのまま、新規は末尾に追加
[Key(0)] public int Version { get; set; }
[Key(1)] public MusicMetadataModel Metadata { get; set; }
[Key(2)] public MusicSettingsModel Settings { get; set; }
[Key(3)] public TrackDataModel[] Tracks { get; set; }
[Key(4)] public string? Comment { get; set; } // 新規追加
削除したいフィールドがあっても、番号は空き番として予約しておき、コメントで「deprecated」と書いておくのが安全です。
Versionフィールドで将来のフォーマット変更に備える
たとえば「トラックに色を付けたい」という要件が来たとします。単純にTrackDataModelに[Key(n)] int ColorHexを足すだけでも新しい番号なら動きますが、PICOMではモデル自体が大きく変わる将来にも備えてVersionを数値で持たせています。
public (ReadResult resultType, Music? music) Read(byte[] data, int musicId)
{
MusicDataModel model;
try
{
model = MessagePackSerializer.Deserialize<MusicDataModel>(data);
Console.WriteLine($"[PIM] Read: deserialized OK, version = {model.Version}, tracks = {model.Tracks.Length}");
}
catch (Exception ex)
{
Console.WriteLine($"[PIM] Read: deserialize error: {ex.Message}");
return (ReadResult.FormatError, null);
}
// ... model.Version に応じて分岐する余地がある
}
現状PIMはv2で統一されていますが、Versionフィールドを持たせているおかげで、いずれv3が必要になったら次のような選択肢を取れます。
MusicDataModelV2とMusicDataModelV3を別クラスで定義し、読み込み時にVersionを見て振り分けるMusicDataModelを大胆に拡張しつつ、v2データは古い部分だけを読み込むフォールバックを用意する
ここで効いてくるのが、「JSONではなくMessagePackであることの副次効果」です。MessagePackは余分なフィールドを自動で無視してくれるので、v3で追加したフィールドを持たないv2データを読んでも壊れません。逆に言うと、v3を書いたものをv2のコードで読むときは、追加フィールドが欠落した状態になります。
Versionフィールドを[Key(0)]に置くのは意図的です。一番最初に来る数値なので、バイナリの先頭数バイトを見るだけで「これはPIM v2だ」と判定できます。ツール側からの識別が楽になるので、新しく独自フォーマットを作るならVersionを[Key(0)]に置くことを強くお勧めします。
ReadResult enum でデシリアライズ失敗を型で表現する
読み込み結果は単純なMusic?ではなく、(ReadResult, Music?)のタプルで返しています。
public enum ReadResult
{
Success,
NotFound,
FormatError,
SoundError,
}
最初はtry-catchで例外を投げる設計だったのですが、 「ユーザーに見せるエラーメッセージを分岐させたい」 という要件には例外クラスよりenumの方が相性が良いと気付きました。たとえば「フォーマットが壊れている」と「音階の値が不正」では、ユーザーに見せるメッセージが違います。前者は「ファイルが壊れています」で終わりですが、後者は「PICOMのバージョンが古い可能性があります」と案内したい。enumで分岐すればswitch式で網羅チェックも効くので、例外より安全です。
C#ではTryXXというメソッドを作ってout引数で結果を返す方法が主流ですが、非同期メソッドでは使えないという問題もあります。PICOMの一部メソッドは非同期で実装されており、できる限りソースコードのパターンを揃えた方が良いという理由もあり、タプル+enumの形で統一しました。
ただ、この方法にも弱点があり、MusicのNullチェックとReadResultがSuccessかどうかの両方をチェックしないといけないという手間があります。
バックアップ機能にも対応!複数の曲をまとめて1ファイルに
PIMはもう一つ、バックアップ用のBackupDataModelを持っています。
public byte[] WriteBackup(List<Music> musics)
{
var backup = new BackupDataModel
{
Version = 1,
CreatedAt = DateTime.UtcNow,
Musics = musics.Select(ToDataModel).ToArray(),
};
return MessagePackSerializer.Serialize(backup);
}
このバックアップのVersionは曲単体のVersionとは別系統で管理しています。最初は同じ番号を使っていたのですが、「バックアップ形式だけ変えたい(曲フォーマットは据え置き)」みたいな要件が出て来る可能性を考えて別系統に分けました。
追記:実際にv3へ上げました
この記事を書いた直後に、本文で触れた「いずれv3が必要になったら」が実際に発生しました。PICOMの実装を眺めていて、楽曲IDが「IndexedDBのキー」と「メモリ上の Music.Id」の2箇所に散っており、PIMバイナリ自体はIDを持たない状態だと気付いたのがきっかけです。
そこで MusicDataModel に [Key(4)] public int Id { get; set; } を追加し、Version を 3 に上げました。
[MessagePackObject]
public class MusicDataModel
{
[Key(0)] public int Version { get; set; } = 3;
[Key(1)] public MusicMetadataModel Metadata { get; set; } = new();
[Key(2)] public MusicSettingsModel Settings { get; set; } = new();
[Key(3)] public TrackDataModel[] Tracks { get; set; } = [];
[Key(4)] public int Id { get; set; }
}
本文で強調した「[Key]番号は追記のみ」のルールに従って、既存の [Key(0)]〜[Key(3)] は一切触らず、末尾に [Key(4)] を足すだけで済みました。そして、読み込み時に model.Version < 3 かどうかで旧形式かを判定するだけで済み、バイナリ先頭を見れば版が分かる設計のありがたみを感じました。
記事本文で「Versionフィールドは最初から入れる。後付けすると必ず苦労する」と書きましたが、自分で書いた記事の主張に後から自分で救われた形になりました。バージョン管理対応の思想を最初から仕込んでおく価値は、実際に次の版を作るまで実感しづらいのですが、こうやって発揮される瞬間が必ず来ます。
まとめ
- デバッグ性よりサイズ・バイナリとしての扱いやすさ・フォーマット拡張への耐性が大事なら、JSONではなくMessagePackを選ぶ価値がある
[Key]番号は一度付けたら絶対に変えない、削除もしない- フォーマットの
Versionフィールドは最初から入れる。これを後付けすると必ず苦労する - デシリアライズ結果はenumで分岐できるようにしておくと、ユーザーへのエラーメッセージ設計が楽になる
