CodeGym /コース /C# SELF /バイナリ形式の深堀りとその問題点

バイナリ形式の深堀りとその問題点

C# SELF
レベル 43 , レッスン 3
使用可能

1. はじめに

簡単に言うと:シリアライゼーションのバイナリ形式は、オブジェクトをその構造と値をできるだけコンパクトに符号化したバイト列に変換します。JSONやXMLのように言葉でオブジェクトを説明するのではなく、メモリに格納されている通りにビット単位で書き出すイメージです。

テキスト形式のデータは友達への手紙(ロシア語で書かれたものを人が読めるように)に似ています。バイナリ形式はむしろモールス信号のようで、点とダッシュが最小限に記録されていて、人間が「手で」読むのはほぼ不可能です。

比較図:フォーマットの比較

フォーマット 人間が読めるか ファイルサイズ 速度(書き込み/読み取り) 互換性
XML/JSON はい 大きい 遅め 良い
バイナリ いいえ 小さい 非常に高速 制限される

.NETでのバイナリシリアライゼーションの仕組みは?

.NETエコシステムでは歴史的に主要なツールがBinaryFormatterでした。しかしプラットフォームの進化とともに安全でないと判断され、.NET 9から除外されました。現在の標準的なアプローチは別の方法になっています:BinaryWriter/BinaryReader、複雑なオブジェクトにはサードパーティのライブラリ(例えば protobuf-net)などです。

昔話(歴史的背景)

BinaryFormatterは[Serializable]([Serializable])属性が付いた任意のクラスをバイト列に変換し、デシリアライズ時にオブジェクト構造を復元できました。魔法みたいに聞こえますが、そこには多くの問題が潜んでいました(以下で説明します)。

現代の手段

プリミティブ型やシンプルな構造体にはBinaryWriterBinaryReaderが便利です。複雑なオブジェクトにはサードパーティライブラリ(例:protobuf-net、MessagePack-CSharpなど)を使いましょう。

2. BinaryWriterでのプリミティブのシリアライゼーション

学習用のアプリをさらに整備してみましょう。例えば、ユーザー設定(ユーザー名、スコア、ログイン時間)をバイナリファイルに書き込みたいとします。

public class UserProfile
{
    public string Name { get; set; }
    public int Score { get; set; }
    public DateTime LoginTime { get; set; }
}

public static Task SaveUserProfile(UserProfile profile, string filePath)
{
    // ファイルを開いて書き込み
    using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
    using var writer = new BinaryWriter(stream);

    // データを部分ごとに書き込む。まず文字列、次に数値、次に日付
    writer.Write(profile.Name ?? string.Empty); // 文字列
    writer.Write(profile.Score);                // 整数
    writer.Write(profile.LoginTime.ToBinary()); // 日付を "long" に変換
}

次に読み込み例:

public static Task<UserProfile> LoadUserProfile(string filePath)
{
    using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
    using var reader = new BinaryReader(stream);

    string name = reader.ReadString();
    int score = reader.ReadInt32();
    long dateData = reader.ReadInt64();
    DateTime loginTime = DateTime.FromBinary(dateData);

    return new UserProfile { Name = name, Score = score, LoginTime = loginTime };
}

プリミティブなバイナリシリアライゼーションの特徴

BinaryWriterを使うと各プロパティを個別にシリアライズします。これは信頼できて予測可能な方法です:データ構造を変更した場合、それはコードに明示的に現れます。

3. 古典的なバイナリシリアライゼーションの問題点

では裏側を見てみましょう。なぜMicrosoftはBinaryFormatterを強く非推奨にし、使用を禁じたのでしょうか?

フォーマットの脆弱性(Schema Evolution Hell)

バイナリデータはクラス構造に強く結び付いています。クラスを変更した(フィールド名を変えた、フィールドを追加した、削除した)場合、古いバイナリファイルは読み込めなくなります。プロパティの順序を変えただけでも問題になります。

例:

// 昨日
public class Profile
{
    public string Name;
    public int Score;
}

// 今日
public class Profile
{
    public string Name;
    public double Rating; // 新しいフィールドが追加された
    public int Score;
}

古いファイルを読むとエラーが出るか、フィールドが「混ざった」データとして読まれる可能性があります。JSONやXMLのように欠けている要素を無視できるわけではなく、バイナリ形式は固いコンクリート道路のようです:少しでもズレると「転倒」します。

デシリアライズの脆弱性

最大の問題はBinaryFormatterの潜在的な脆弱性です。信頼できないソース(例えばインターネット経由のユーザー)から受け取ったバイナリデータをデシリアライズすると、攻撃者が悪意ある「オブジェクト」を差し込める可能性があります。過去にはこれがリモートで任意コード実行に至ることもありました。

クロスプラットフォーム性と互換性

バイナリシリアライザは内部表現や実行環境のバージョン、コンパイラ、アーキテクチャ(x64/ARMなど)に強く依存します。WindowsでシリアライズしてLinuxでデシリアライズすると予期せぬ問題が起きることがあります。.NETのバージョン間でも非互換が発生し得ます。

診断の不便さ

テキスト形式ならファイルを開いて何が問題か推測できますが、バイナリファイルは謎の塊です。目に見えるのは意味のないバイト列だけで、解析は好き者向けの作業になります。

4. 複雑なオブジェクトのバイナリシリアライゼーション

参照(References)

BinaryFormatterはオブジェクト間の同一参照(例えば2つのプロパティが同じインスタンスを参照している場合)を保持できましたが、BinaryWriterや多くのサードパーティライブラリにはそのような魔法はありません。通常は「オブジェクトをネストして順に書き出す」方式になります。

循環参照(Cyclic references)

親がChildプロパティを持ち、子がParentプロパティで親を参照するような循環参照を持つオブジェクトをシリアライズしようとすると、エラーになるか無限ループに陥ります。

例:

public class Node
{
    public Node? Next { get; set; }
    public Node? Prev { get; set; }
}

このオブジェクトを「素直に」シリアライズするとループします。

5. バイナリシリアライゼーションと移植性

特に自作のバイナリフォーマットは「自分たちだけの」フォーマットになりがちです。もし他のプログラムとデータを交換したり、長期保存する予定があるなら、オープンな標準を選んでください:JSON、XML、またはProtoBufなど。

いつバイナリシリアライゼーションが妥当か?

  • データが単一アプリケーション内で短期間だけ使われる場合。
  • 速度とコンパクトさが重要な場合(大量ログや同一エコシステム内のサービス間通信など)。
  • シリアライズ/デシリアライズの両側を厳密にコントロールできる場合。

代替:protobuf、MessagePackなど

  • protobuf-net:Google Protocol Buffersの.NET向け実装で、クロスプラットフォームな通信や互換性に適しています。
  • MessagePack-CSharp:.NET向けの高速なMessagePack実装です。

「生の」BinaryWriterと比べて、これらのライブラリはスキーマを提供し、フォーマットの進化をサポートし、移植性や安全性を向上させます。他システムとの互換性を考えるなら使うべきです。

6. 「手動」のバイナリシリアライゼーション

それでもバイナリデータを書きたい場合(例えばパフォーマンスが重要なアプリケーション)、BinaryWriter/BinaryReaderを使い、データの順序と型を常に明示的にエンコードしてください。

アドバイス:

  • 読み書きは常に同じ順序で行うこと。
  • フォーマットを変更する場合はバージョン番号を保持する、または「マジックヘッダー」(Magic Header)を書くこと。
  • 文字列や配列は長さを先に書いてからデータ本体を書くこと。
  • ファイル構造をドキュメント化すること:そうしないと1年後に自分でフォーマットが分からなくなります。

例:バージョニング

// ファイル先頭にフォーマットバージョンを書き込む
writer.Write((byte)1); // バージョン1

writer.Write(profile.Name ?? "");
writer.Write(profile.Score);
writer.Write(profile.LoginTime.ToBinary());

/*
将来フォーマットを変更したときに読み取り条件を追加できるようにする
*/

7. バイナリシリアライゼーションでよくあるミス

書き込んだフィールドの順序と読み込み時の順序が違う。その結果、値が「ズレ」ます:文字列がintとして読まれたり、intが日付として読まれたりします。

10 個のオブジェクトを書いたのに、読み取り側では11個読もうとする。ストリームの整合性が崩れ、EOFに達したという例外が発生します。

クラス構造を変更したが、古いバイナリファイルが読めなくなった — データの履歴が失われます。

重要なファイルの読み込みで例外処理を忘れる — ディスクの最初の障害でアプリが落ちます(例えば、EndOfStreamException)。

明確なフォーマットなしに異なるプログラミング言語間でバイナリファイルを交換しようとする — ほとんどのケースで問題になります(99%の確率で痛い目に遭います)。

ネットワーク経由で知らないユーザーから受け取ったデータをデシリアライズする — 脆弱性に直行です!決してBinaryFormatterを使わないでください。入力を検証し、安全なフォーマットを使用しましょう。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION