1. はじめに
循環(またはループ)参照は、あるオブジェクトが別のオブジェクトを直接または間接的に参照し、結果的に最初のオブジェクトに戻るときに発生します。
実例
書籍ライブラリの現実的な例を作ってみましょう。クラス Book はプロパティ Author を持ち、クラス Author は自分の本を覚えておくために List<Book> 型の Books を持っているとします。
public class Author
{
public string Name { get; set; }
public int BirthYear { get; set; }
public List<Book> Books { get; set; } = new List<Book>();
}
public class Book
{
public string Title { get; set; }
public Author Author { get; set; }
}
今、ある著者と本を一つ作って関連付けると「閉じたループ」ができます:
var author = new Author { Name = "マルセル・プルースト", BirthYear = 1871 };
var book = new Book { Title = "スワンへ往け", Author = author };
author.Books.Add(book);
// これで author は book を参照し、book は author を参照しています!
なぜ問題になるのか?
このようなオブジェクトを JSON にシリアライズすると、シリアライザはプロパティを辿っていきます。著者に本があり、その中にまた著者がいて... と無限に続くことになります。
author -> books[] -> author -> books[] ...
これは、他の鏡の前に立って鏡越しに鏡を見るようなものです — 反射が果てしなく続きます。きれいな反射の代わりにスタックオーバーフロー(StackOverflowException)になります。
2. シリアライザは循環参照にどう反応するか?
シリアル化エラー
デフォルトでは System.Text.Json は循環参照を処理できません。こうした構造をシリアライズしようとすると、JsonException が発生します: "A possible object cycle was detected"。
エラーを引き起こす例:
string json = JsonSerializer.Serialize(author); // いきなり! JsonException
可視化:
graph TD;
Author --> Book;
Book --> Author;
3. 循環参照の解決方法
実際的なアプローチをいくつか見ていきます。それぞれ長所と短所があります。万能の「魔法のフラグ」は残念ながらありません。
シリアル化前にループを削除する
もっとも単純なのは、そういう参照を作らないこと。シリアル化前に参照をnullにする(または無視する)方法です。
実際のやり方:
// 一時的に著者の本への参照を外す
var authorToSerialize = new Author
{
Name = author.Name,
BirthYear = author.BirthYear,
Books = null // もしくはプロパティ自体を含めない
};
string json = JsonSerializer.Serialize(authorToSerialize);
// これで問題なくシリアライズできる!
利点: シンプルで速く、理解しやすい。
欠点: データの一部を失う(デシリアライズ後に逆参照は復元されない)。
属性 [JsonIgnore] を使う
循環に関わるプロパティにこの属性を付ければ無視されます:
public class Author
{
public string Name { get; set; }
public int BirthYear { get; set; }
[JsonIgnore]
public List<Book> Books { get; set; }
}
これで著者をシリアライズしても Books は含まれません。上の方法と似ていますが、手動で「クリーン」する必要がなく宣言的です。
利点: シンプル、参照を消し忘れるリスクが減る。
欠点: 著者の本情報が JSON に含まれなくなる。
入れ子オブジェクトの代わりに識別子を使う
両方向の関係を保持したいが循環は避けたい場合、オブジェクトではなく一意の識別子を使います:
public class Book
{
public string Title { get; set; }
public int AuthorId { get; set; } // Author の代わりに
}
public class Author
{
public int AuthorId { get; set; }
public string Name { get; set; }
// Books を保存しないか、Id のリストにする
}
JSON ではオブジェクトではなく識別子だけになります。データベースや REST API、単方向の参照が好まれるシステムで一般的なアプローチです。
利点: 循環がなく、JSON はコンパクト、Id からリンクを復元できる。
欠点: オブジェクトモデルに馴染まない場合があり、デシリアライズ時に Id で検索する必要がある。
簡易比較表:
| アプローチ | 循環問題は解決? | データの損失? | 適用シーン |
|---|---|---|---|
| [JsonIgnore] | はい | はい | 入れ子が重要でない場合 |
| 手動で参照を削除 | はい | はい | シリアル化前に素早く処理したい場合 |
| オブジェクトの代わりに Id を保存 | はい | いいえ* | REST、DB、複雑なシステム |
* データは失われていないが、すぐには利用できない(Id で検索が必要)。
4. System.Text.Json に循環参照をシリアライズさせる方法
.NET 5 から JsonSerializerOptions に参照モードが追加されました: options.ReferenceHandler = ReferenceHandler.Preserve。
このモードは再利用オブジェクトのために $id と $ref を使います。
例
var options = new JsonSerializerOptions
{
WriteIndented = true,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve
};
string json = JsonSerializer.Serialize(author, options);
Console.WriteLine(json);
生成される JSON はこんな感じ:
{
"$id": "1",
"Name": "マルセル・プルースト",
"BirthYear": 1871,
"Books": {
"$id": "2",
"$values": [
{
"$id": "3",
"Title": "スワンへ往け",
"Author": {
"$ref": "1"
}
}
]
}
}
- $id — JSON 内のオブジェクト固有の識別子
- $ref — 既にシリアライズされたオブジェクトへの参照
デシリアライズ時には、これで正しく復元されます(無限ループやスタックエラーなし)。
注意点と制約
- この JSON はフロントエンドからすると珍しい: ほとんどの JS クライアントは補助ロジックなしで $id/$ref を理解しない。
- JSON サイズが大きくなり、人間が目でデバッグしにくくなる。
- ReferenceHandler.Preserve を明示的に有効にした場合にのみ動作する。
- 値型には適用されない(値型に循環は発生しない)。
この JSON をどうデシリアライズするか?
通常のやり方と同じですが、同じ JsonSerializerOptions を使います:
var deserializedAuthor = JsonSerializer.Deserialize<Author>(json, options);
5. Newtonsoft.Json (Json.NET) はどうか?
歴史的に Newtonsoft.Json は System.Text.Json より早く循環に対応していました。属性 [JsonObject(IsReference = true)] やグローバルなシリアライズ設定が使えます。
参照用の属性
[JsonObject(IsReference = true)]
public class Author
{
public string Name { get; set; }
public List<Book> Books { get; set; }
}
[JsonObject(IsReference = true)]
public class Book
{
public string Title { get; set; }
public Author Author { get; set; }
}
次にこんな風にシリアライズします:
var settings = new JsonSerializerSettings
{
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
Formatting = Formatting.Indented
};
string json = JsonConvert.SerializeObject(author, settings);
結果として $id と $ref を含む JSON が得られ、ReferenceHandler.Preserve と似た動作になります。
短いまとめ
- .NET 間でのデータ交換であれば、参照シリアライゼーションを有効にする(ReferenceHandler.Preserve や PreserveReferencesHandling)。
- データが JavaScript や他のクライアントに渡る場合は、循環を断ち切る: [JsonIgnore]、参照のクリア、または Id に切り替える。
6. エラーと面倒ごとを避ける方法
初心者(時には経験者も)がシリアライザのクラッシュを循環のせいで経験します。コレクションやプロパティが互いを参照しているなら、まずモデル設計を見直してください。
外部交換で不要なプロパティには [JsonIgnore] を使うのをためらわないでください。
典型的な罠は「多対多」のシリアライゼーション(例: 学生 ↔ コース)。循環を切るか参照シリアライゼーションを使わないと動きません。
REST API では多くの場合「一方向のオブジェクト」を返します: 例えば Book は Author を知っているが、Author はこの契約では本の Id のみ(あるいは本について全く知らない)ようにする、という具合です。
GO TO FULL VERSION