既存アプリで 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)に変換。
全体像(読み取りの流れ)
- NRBF ペイロードをストリーム化し、
NrbfDecoderでルートのクラスレコードを得る。 - 対象の辞書オブジェクトに対応するクラスレコードを見つける(型名・フィールド名・パスから探索)。
- 内部表現の差異に応じて値を取り出す:
- 方式A:
Keys配列 とValues配列 - 方式B:
KeyValuePairs配列(各要素がkey/valueを持つレコード)
- 方式A:
- 不正形(配列長不一致・循環参照・破損)の検査と例外化、ログ記録。
- 安全な形式に再構成し、必要なら
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/Values | Keys: TKey[] と Values: TValue[] | Dictionary<string,int>など | 配列長一致が前提。型が参照型か値型かで null 可否が異なる。 |
| B:KeyValuePairs | KeyValuePairs: 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 廃止と資産データの延命を同時に達成できます。

コメント