BinaryFormatter互換NRBFのDictionary/ListDictionaryを安全に読み取る実装ガイド(C#・NrbfDecoder)

既存アプリで BinaryFormatter を廃止したい――しかし運用中の過去データ(NRBF)をどう読み解くか。この記事は、安全に「読むだけ」を目的に、NrbfDecoder を使って Dictionary<TKey,TValue> と System.Collections.Specialized.ListDictionary を復元する実装を、実例・落とし穴・テスト戦略まで一気通貫で解説します。将来的な再保存(JSON 等)を前提に、移行の実務に即した手順を提示します。

目次

前提とゴール

  • 対象データ:.NET の NRBF(.NET Remoting Binary Format) で BinaryFormatter により生成された既存バイナリ。
  • 新アプリ:BinaryFormatter は使用しない(非推奨・危険)。独自のデコーダ(NrbfDecoder)で安全に読み出すのみ。
  • 対応コレクション:Dictionary<TKey,TValue>、ListDictionary。
  • 出力:アプリ内部の安全な表現(通常の Dictionary<,>/POCO/JSON)に変換。

全体像(読み取りの流れ)

  1. NRBF ペイロードをストリーム化し、NrbfDecoder でルートのクラスレコードを得る。
  2. 対象の辞書オブジェクトに対応するクラスレコードを見つける(型名・フィールド名・パスから探索)。
  3. 内部表現の差異に応じて値を取り出す:
    • 方式A:Keys 配列 と Values 配列
    • 方式B:KeyValuePairs 配列(各要素が key/value を持つレコード)
  4. 不正形(配列長不一致・循環参照・破損)の検査と例外化、ログ記録。
  5. 安全な形式に再構成し、必要なら System.Text.Json 等で再シリアライズ。

使用 API の要点(NrbfDecoder 仮想インターフェイス)

本稿のコード片は、以下のような API を想定しています(名称は仮。実装やプレビュー版に合わせて読み替えてください)。

メソッド/プロパティ役割補足
DecodeClassRecord(Stream)ルートの ClassRecord を得る静的メソッド
ClassRecord.GetClassRecord(name)子のクラスレコードを名前で取得存在しない場合は例外/null
ClassRecord.GetArrayRecord(name)子の配列レコードを取得プリミティブ/レコード配列を包含
ArrayRecord.GetArray<T>()プリミティブ配列を T[] として取り出すT が string/int 等の場合
ArrayRecord.GetArray(Type)ランタイム型指定で配列を取り出すKeyValuePair[] など
ClassRecord.GetInt32(name), GetString(name) などフィールドを型付きで取得存在しない/型不一致で例外
ClassRecord.Get(name)フィールドを object として取得プリミティブ/レコード/null

BinaryFormatter 互換ペイロードに含まれる Dictionary<TKey, TValue> の読み取り

ルートレコードの取得

using var stream = new MemoryStream(payload);      // payload = 過去のバイナリ
var root = NrbfDecoder.DecodeClassRecord(stream);  // 静的メソッド

辞書レコードの特定

型名やフィールド名、あるいは複合パス(例:Customer.Profile.Settings.MyDictionary)で探索します。

ClassRecord dictRec = root.GetClassRecord("MyDictionary"); // 実データの構造に合わせて名称を指定

内部表現の差異(Keys/Values vs KeyValuePairs)

方式格納イメージ典型例備考
A:Keys/ValuesKeys: TKey[] と Values: TValue[]Dictionary<string,int>など配列長一致が前提。型が参照型か値型かで null 可否が異なる。
B:KeyValuePairsKeyValuePairs: KeyValuePair<TKey,TValue>[]Dictionary<int,int> など各要素が key/value フィールドを持つレコードとして表現。

Keys/Values 方式(汎用)

string[] keys  = dictRec.GetArrayRecord("Keys")   .GetArray<string>();
int[]    vals  = dictRec.GetArrayRecord("Values") .GetArray<int>();

var dict = new Dictionary(keys.Length);
for (int i = 0; i < keys.Length; i++)
{
// 異常系:長さ不一致を検知
if (i >= vals.Length) throw new FormatException("Values array is shorter than Keys.");
dict[keys[i]] = vals[i];
} 

KeyValuePairs 方式(汎用)

ArrayRecord pairsRec = dictRec.GetArrayRecord("KeyValuePairs");
// ランタイム型で配列を取得(型引数は実際のキー/値に合わせて)
var pairs = pairsRec.GetArray(typeof(KeyValuePair<int,int>[]));

var dict = new Dictionary();
foreach (ClassRecord kv in pairs.Cast())
{
int k = kv.GetInt32("key");
int v = kv.GetInt32("value");
dict[k] = v;
} 

方式を自動判別して読む(実用ユーティリティ)

NRBF は .NET バージョンやジェネリック実引数により内部表現が揺れます。候補名を複数試すアプローチが現実的です。

public static class NrbfDictionaryReader
{
    // 候補フィールド名を網羅(バージョン差・実装差吸収用)
    private static readonly string[] KeysCandidates   = { "Keys", "keys", "_keys" };
    private static readonly string[] ValuesCandidates = { "Values", "values", "_values" };
    private static readonly string[] PairsCandidates  = { "KeyValuePairs", "keyValuePairs", "pairs", "entries" };


public static Dictionary<TKey,TValue> Read<TKey,TValue>(ClassRecord dictRec)
{
    // 1) Keys/Values
    if (TryGetArray<TKey>(dictRec, KeysCandidates, out var keys) &&
        TryGetArray<TValue>(dictRec, ValuesCandidates, out var vals))
    {
        if (keys.Length != vals.Length)
            throw new FormatException($"Keys({keys.Length}) / Values({vals.Length}) length mismatch.");
        var d = new Dictionary<TKey,TValue>(keys.Length);
        for (int i = 0; i < keys.Length; i++) d[keys[i]] = vals[i];
        return d;
    }

    // 2) KeyValuePairs
    if (TryGetPairs(dictRec, PairsCandidates, out var list))
    {
        var d = new Dictionary<TKey,TValue>(list.Count);
        foreach (var (k, v) in list) d[k] = v;
        return d;
    }

    throw new NotSupportedException("Dictionary payload shape not recognized.");
}

private static bool TryGetArray<T>(ClassRecord rec, string[] candidates, out T[] result)
{
    foreach (var name in candidates)
    {
        try
        {
            var arr = rec.GetArrayRecord(name);
            result = arr.GetArray<T>();
            return true;
        }
        catch { /* 試行を継続 */ }
    }
    result = Array.Empty<T>();
    return false;
}

private static bool TryGetPairs<TKey,TValue>(ClassRecord rec, string[] candidates, out List<(TKey,TValue)> pairs)
{
    foreach (var name in candidates)
    {
        try
        {
            var arr = rec.GetArrayRecord(name);
            var list = new List<(TKey,TValue)>();
            foreach (ClassRecord kv in arr.GetArray(typeof(object[])).Cast<ClassRecord>())
            {
                var k = (TKey)kv.Get("key");
                var v = (TValue)kv.Get("value");
                list.Add((k, v));
            }
            pairs = list;
            return true;
        }
        catch { /* 試行を継続 */ }
    }
    pairs = new();
    return false;
}


} 

注: 上記は API 名や戻り値の形を単純化した例です。お使いの NrbfDecoder に合わせて GetArray・Cast 部分を調整してください。

実践テクニック(例外処理・型チェック・診断)

  • 長さ不一致は早期例外にする(不整合データのサイレントな混入を防ぐ)。
  • キー重複は最後勝ちにするか、検出してログに残す(移行ポリシーに合わせる)。
  • 未知フィールド名(entries など)が出たら警告ログにフィールド一覧を出力しておく。
  • 辞書の順序に意味を持たせない(BinaryFormatter は順序保証を目的に設計されていない)。
  • 取り出した要素数と count フィールド(存在する場合)を照合する。

最小実装(質問のコードからの発展)

質問で提示されているシンプルな読み出しは、以下のようにまとめられます。

// 方式A:Keys / Values
string[] keys  = dictRec.GetArrayRecord("Keys")   .GetArray<string>();
int[]    vals  = dictRec.GetArrayRecord("Values") .GetArray<int>();
var dictA = new Dictionary<string,int>();
for (int i = 0; i < keys.Length; i++) dictA[keys[i]] = vals[i];

// 方式B:KeyValuePairs
ArrayRecord pairsRec = dictRec.GetArrayRecord("KeyValuePairs");
var pairs = pairsRec.GetArray(typeof(KeyValuePair[]));
var dictB = new Dictionary();
foreach (ClassRecord kv in pairs.Cast())
{
int k = kv.GetInt32("key");
int v = kv.GetInt32("value");
dictB[k] = v;
} 

セキュリティと運用上の注意

  • 決して BinaryFormatter で再デシリアライズしない(任意コード実行の危険)。
  • 読み取りはサンドボックス化し、未知型の生成を禁止。NrbfDecoder は型インスタンスを作らず構造を読むだけに徹する。
  • 許可された型のアロウリストを設ける(string、int、bool、既知 POCO など)。
  • 読み取り後は安全形式へ再保存(JSON/MessagePack/自前スキーマ)。

System.Collections.Specialized.ListDictionary の読み取り

内部構造の要点

ListDictionary はハッシュではなく単方向連結リストで持ちます。NRBF でも head ノードから next をたどる形でシリアライズされるのが通例です。

フィールド意味備考
count要素数検査に使用
head先頭ノード(DictionaryNode)null の場合は空
DictionaryNode.keyキー(多くのケースで string)NRBF ではプリミティブ文字列として格納
DictionaryNode.value値(object)bool/int/string など
DictionaryNode.next次ノードへの参照末尾は null

読み取り実装(基本形)

var listRec  = NrbfDecoder.DecodeClassRecord(fs);              // fs = ストリーム
int itemCnt  = listRec.GetInt32("count");                      // 要素数(検査用)
var kvRec    = listRec.GetClassRecord("head");                 // 先頭ノード

var dict = new System.Collections.Specialized.ListDictionary();
for (int i = 0; i < itemCnt && kvRec != null; i++)
{
string key = kvRec.GetString("key");
object val = kvRec.Get("value");   // 型は後で判定
dict[key] = val;
kvRec = kvRec.GetClassRecord("next");
}

// 末尾条件や count 不一致を検査
if (dict.Count != itemCnt)
throw new FormatException($"ListDictionary count mismatch: actual={dict.Count}, header={itemCnt}"); 

値型ごとのキャスト(bool/int/string など)

static object ReadValue(ClassRecord node)
{
    // 代表的な値型を個別ハンドリング。型情報が取れる API がある場合はそれを優先。
    if (node.TryGetBoolean("value", out var b)) return b;
    if (node.TryGetInt32("value", out var i))   return i;
    if (node.TryGetString("value", out var s))  return s;


// それ以外:入れ子オブジェクトの場合は ClassRecord として再帰処理、あるいは JSON 化するなど
var any = node.Get("value");
return any; // or ConvertToSafeObject(any);


} 

堅牢化のポイント

  • 循環参照(誤って next が前ノードを指すなど)を検知するため、最大反復回数を count + α(例:+8)に制限。
  • キー重複は最後勝ちか検出例外かを方針化。移行ログに「重複キー」「最後の値」「最初の値」を残す。
  • 未知型の値(byte[]、カスタムクラス等)は JSON の object に写像するなど、アプリ要件に合わせて安全化。

移行先への再保存(JSON 例)

読み出した辞書は、System.Text.Json など安全なフォーマットで再保存することを推奨します。以後の読み書きはその安全フォーマットのみを使用します。

var safe = new
{
    Version = 1,
    WhenMigratedUtc = DateTime.UtcNow,
    Data = dict // 例:Dictionary<string,int> や ListDictionary を Dictionary<string,object> に詰め替え
};

var json = System.Text.Json.JsonSerializer.Serialize(
safe,
new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});

// ファイルへ保存
File.WriteAllText("migrated.json", json, System.Text.Encoding.UTF8); 

テスト戦略(回帰・破損検知・サンプル生成)

  • 代表データの固定化:運用で典型的な NRBF バイナリを複数セット(辞書サイズの大小、空・null、極端値)で用意。
  • 相互検証:既存アプリ(旧版)が表示していた値と、新デコーダの出力を比較。
  • 破損系テスト:配列長不一致、キー重複、未知フィールド名、循環参照をモックし、例外とログを確認。
  • 性能:10^4〜10^6 要素でメモリと時間を計測し、しきい値に到達したらチャンク処理やストリーミングに切り替え。
[Fact]
public void CanRead_Dictionary_KeysValues()
{
    var root = NrbfDecoder.DecodeClassRecord(new MemoryStream(TestData.Dictionary_KeysValues));
    var rec  = root.GetClassRecord("MyDictionary");
    var d    = NrbfDictionaryReader.Read<string,int>(rec);
    Assert.Equal(42, d["answer"]);
}

[Fact]
public void CanRead_Dictionary_KeyValuePairs()
{
var root = NrbfDecoder.DecodeClassRecord(new MemoryStream(TestData.Dictionary_KeyValuePairs));
var rec  = root.GetClassRecord("MyDictionary");
var d    = NrbfDictionaryReader.Read(rec);
Assert.Equal(7, d[3]);
}

[Fact]
public void CanRead_ListDictionary_LinkedNodes()
{
var root  = NrbfDecoder.DecodeClassRecord(new MemoryStream(TestData.ListDictionary));
var list  = root.GetClassRecord("Settings");
int count = list.GetInt32("count");
var head  = list.GetClassRecord("head");


var dict = new System.Collections.Specialized.ListDictionary();
for (int i = 0; i < count && head != null; i++)
{
    dict[(string)head.Get("key")] = head.Get("value");
    head = head.GetClassRecord("next");
}
Assert.Equal(count, dict.Count);


} 

トラブルシュート(症状別チェックリスト)

症状主な原因対処
辞書が空として復元されるフィールド名の相違(Keys/Values でなく entries 等)候補名を増やす。フィールド一覧をダンプして実物を確認。
配列長不一致の例外破損/途中切断/バージョン差による表現変更NRBF ヘッダの完全性を検査。破損ログを残し、該当データだけ手動補正。
値の型が判別できないobject にボックス化された複合型型情報(アセンブリ修飾名)を読み、アロウリストで許可し JSON 化。
読み取りが極端に遅い逐次反復で大規模辞書を処理、不要なボックス化バッファ再利用、Span<T> 相当の最適化、ログの抑制を検討。
StackOverflow / 循環参照壊れた next 参照(ListDictionary)最大反復回数ガードを入れる。訪問済み集合で検出。

移行設計の実務ポイント

  • 読み取りは一度きり:起動時またはバッチで NRBF → 安全形式(JSON)に変換し、以降は NRBF を読まない。
  • 監査証跡:変換対象ファイル名、件数、エラー詳細、欠損キー一覧を JSON ログに出力。
  • 再現性:同一入力に対して同一出力(順序差の影響を排除)。
  • ロールバック:変換前ファイルのバックアップと、バージョン付き出力を併存させる。

参考となるコード片(再利用しやすい最小ユーティリティ)

public static class NrbfSafeGet
{
    public static bool TryGetString(this ClassRecord rec, string name, out string value)
    {
        try { value = rec.GetString(name); return true; } catch { value = default!; return false; }
    }
    public static bool TryGetInt32(this ClassRecord rec, string name, out int value)
    {
        try { value = rec.GetInt32(name); return true; } catch { value = default; return false; }
    }
    public static bool TryGetBoolean(this ClassRecord rec, string name, out bool value)
    {
        try { value = rec.GetBoolean(name); return true; } catch { value = default; return false; }
    }
    public static bool TryGetArrayRecord(this ClassRecord rec, string name, out ArrayRecord arr)
    {
        try { arr = rec.GetArrayRecord(name); return true; } catch { arr = default!; return false; }
    }
}

FAQ(よくある質問)

  • Q:辞書の順序は復元されますか?
    A:保証しない前提で設計してください。必要なら別途「順序の手掛かり」を NRBF 側に持っていないか確認し、無ければ新形式でのみ順序保証を導入します。
  • Q:KeyValuePair の型引数がわかりません。
    A:辞書の型情報(例:System.Collections.Generic.Dictionary`2[[System.String,...],[System.Int32,...]])が NRBF に含まれているケースが多いので、NrbfDecoder の型メタデータ API から取り出して動的に Type を組み立てます。難しければ、まず object として受け、安全に文字列化・数値変換できるものだけを採用する方針も現実的です。
  • Q:ListDictionary で count と実ノード数がずれます。
    A:破損の可能性があります。復元はできても、そのまま採用せず「不整合あり」として別保管し、手動確認を挟むのが安全です。
  • Q:移行後、旧データは削除すべき?
    A:業務・監査要件によります。少なくとも JSON 等の安全形式が安定運用に入った後、保全アーカイブを暗号化して隔離し、通常運用からは切り離すのが無難です。

まとめ

BinaryFormatter 時代の NRBF バイナリを安全に生かすカギは、「読むだけに徹し、構造を理解して抽出し、すぐに安全形式へ移し替える」の三点です。Dictionary<TKey,TValue> は Keys/Values 方式と KeyValuePairs 方式の両面待ちで吸収し、ListDictionary は head → next の連結を丁寧にたどる。例外処理・型チェック・ログで不整合を可視化し、ユニットテストで回帰を固める――この一連の流れをテンプレート化しておけば、BinaryFormatter 廃止と資産データの延命を同時に達成できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次