System.Text.Json で参照を保持し、循環参照を処理または無視する方法

この記事では、System.Text.Json を使用して .NET で JSON をシリアル化および逆シリアル化する間に、参照を保持し、循環参照を処理または無視する方法について説明します

参照を保持し、循環参照を処理する

参照を保持し、循環参照を処理するには、ReferenceHandlerPreserve に設定します。 これを設定すると、次のような動作になります。

  • シリアル化のとき:

    複合型を書き込むとき、シリアライザーによってメタデータのプロパティ ($id$values$ref) も書き込まれます。

  • 逆シリアル化のとき:

    メタデータが想定され (必須ではありません)、逆シリアライザーによってその理解が試みられます。

次のコードでは、Preserve 設定の使用方法を示します。

using System.Text.Json;
using System.Text.Json.Serialization;

namespace PreserveReferences
{
    public class Employee
    {
        public string? Name { get; set; }
        public Employee? Manager { get; set; }
        public List<Employee>? DirectReports { get; set; }
    }

    public class Program
    {
        public static void Main()
        {
            Employee tyler = new()
            {
                Name = "Tyler Stein"
            };

            Employee adrian = new()
            {
                Name = "Adrian King"
            };

            tyler.DirectReports = [adrian];
            adrian.Manager = tyler;

            JsonSerializerOptions options = new()
            {
                ReferenceHandler = ReferenceHandler.Preserve,
                WriteIndented = true
            };

            string tylerJson = JsonSerializer.Serialize(tyler, options);
            Console.WriteLine($"Tyler serialized:\n{tylerJson}");

            Employee? tylerDeserialized =
                JsonSerializer.Deserialize<Employee>(tylerJson, options);

            Console.WriteLine(
                "Tyler is manager of Tyler's first direct report: ");
            Console.WriteLine(
                tylerDeserialized?.DirectReports?[0].Manager == tylerDeserialized);
        }
    }
}

// Produces output like the following example:
//
//Tyler serialized:
//{
//  "$id": "1",
//  "Name": "Tyler Stein",
//  "Manager": null,
//  "DirectReports": {
//    "$id": "2",
//    "$values": [
//      {
//        "$id": "3",
//        "Name": "Adrian King",
//        "Manager": {
//          "$ref": "1"
//        },
//        "DirectReports": null
//      }
//    ]
//  }
//}
//Tyler is manager of Tyler's first direct report:
//True
Imports System.Text.Json
Imports System.Text.Json.Serialization

Namespace PreserveReferences

    Public Class Employee
        Public Property Name As String
        Public Property Manager As Employee
        Public Property DirectReports As List(Of Employee)
    End Class

    Public NotInheritable Class Program

        Public Shared Sub Main()
            Dim tyler As New Employee

            Dim adrian As New Employee

            tyler.DirectReports = New List(Of Employee) From {
                adrian}
            adrian.Manager = tyler

            Dim options As New JsonSerializerOptions With {
                .ReferenceHandler = ReferenceHandler.Preserve,
                .WriteIndented = True
            }

            Dim tylerJson As String = JsonSerializer.Serialize(tyler, options)
            Console.WriteLine($"Tyler serialized:{tylerJson}")

            Dim tylerDeserialized As Employee = JsonSerializer.Deserialize(Of Employee)(tylerJson, options)

            Console.WriteLine(
                "Tyler is manager of Tyler's first direct report: ")
            Console.WriteLine(
                tylerDeserialized.DirectReports(0).Manager Is tylerDeserialized)
        End Sub

    End Class

End Namespace

' Produces output like the following example:
'
'Tyler serialized:
'{
'  "$id": "1",
'  "Name": "Tyler Stein",
'  "Manager": null,
'  "DirectReports": {
'    "$id": "2",
'    "$values": [
'      {
'        "$id": "3",
'        "Name": "Adrian King",
'        "Manager": {
'          "$ref": "1"
'        },
'        "DirectReports": null
'      }
'    ]
'  }
'}
'Tyler is manager of Tyler's first direct report:
'True

この機能を使用して、値型または不変型を保持することはできません。 逆シリアル化のとき、不変型のインスタンスは、ペイロード全体が読み取られた後で作成されます。 そのため、同じインスタンスへの参照が JSON ペイロード内に含まれている場合、それを逆シリアル化することはできません。

値型、不変型、配列の場合、参照メタデータはシリアル化されません。 逆シリアル化では、$ref または $id が検出されると例外がスローされます。 ただし、Newtonsoft.Json を使用してシリアル化されたペイロードを逆シリアル化できるようにするため、$id (および、コレクションの場合は $values) は値型で無視されます。 Newtonsoft.Json の場合は、そのような型のメタデータがシリアル化されます。

オブジェクトが等しいかどうかを判断するために、System.Text.Json によって使用される ReferenceEqualityComparer.Instance によって、2 つのオブジェクト インスタンスを比較するときに、値の等価性 (Object.Equals(Object)) ではなく、参照の等価性 (Object.ReferenceEquals(Object, Object)) が使用されます。

参照がシリアル化および逆シリアル化される方法の詳細については、ReferenceHandler.Preserve に関するページを参照してください。

シリアル化および逆シリアル化で参照を維持するための動作は、ReferenceResolver クラスによって定義されます。 カスタム動作を指定するには、派生クラスを作成します。 例については、GuidReferenceResolver に関するページを参照してください。

複数のシリアル化および逆シリアル化呼び出し間で参照メタデータを保持する

既定では、参照データは Serialize または Deserialize への呼び出しごとにキャッシュされます。 ある Serialize/Deserialize 呼び出しから別の呼び出しへの参照を保持するには、Serialize/Deserialize の呼び出しサイトで ReferenceResolver インスタンスをルート化します。 このスクリプトの例を次のコードに示します。

  • Employee オブジェクトのリストがあり、それぞれを個別にシリアル化する必要があります。
  • ReferenceHandler のリゾルバーに保存されている参照を利用したいと考えています。

ここでは Employee クラスです。

public class Employee
{
    public string? Name { get; set; }
    public Employee? Manager { get; set; }
    public List<Employee>? DirectReports { get; set; }
}

ReferenceResolver から派生するクラスは、参照をディクショナリに格納します。

class MyReferenceResolver : ReferenceResolver
{
    private uint _referenceCount;
    private readonly Dictionary<string, object> _referenceIdToObjectMap = [];
    private readonly Dictionary<object, string> _objectToReferenceIdMap = new (ReferenceEqualityComparer.Instance);

    public override void AddReference(string referenceId, object value)
    {
        if (!_referenceIdToObjectMap.TryAdd(referenceId, value))
        {
            throw new JsonException();
        }
    }

    public override string GetReference(object value, out bool alreadyExists)
    {
        if (_objectToReferenceIdMap.TryGetValue(value, out string? referenceId))
        {
            alreadyExists = true;
        }
        else
        {
            _referenceCount++;
            referenceId = _referenceCount.ToString();
            _objectToReferenceIdMap.Add(value, referenceId);
            alreadyExists = false;
        }

        return referenceId;
    }

    public override object ResolveReference(string referenceId)
    {
        if (!_referenceIdToObjectMap.TryGetValue(referenceId, out object? value))
        {
            throw new JsonException();
        }

        return value;
    }
}

ReferenceHandler から派生するクラスは、MyReferenceResolver のインスタンスを保持し、必要な場合にのみ新しいインスタンスを作成します (この例では Reset という名前のメソッド)。

class MyReferenceHandler : ReferenceHandler
{
    public MyReferenceHandler() => Reset();
    private ReferenceResolver? _rootedResolver;
    public override ReferenceResolver CreateResolver() => _rootedResolver!;
    public void Reset() => _rootedResolver = new MyReferenceResolver();
}

このサンプル コードは、シリアライザーを呼び出すときに、ReferenceHandler プロパティが MyReferenceHandler のインスタンスに設定されている JsonSerializerOptions インスタンスを使用します。 このパターンに従う場合は、シリアル化が終了したときに必ず ReferenceResolver ディクショナリをリセットして、継続的に拡大しないようにしてください。

var options = new JsonSerializerOptions
{
    WriteIndented = true
};
var myReferenceHandler = new MyReferenceHandler();
options.ReferenceHandler = myReferenceHandler;

string json;
foreach (Employee emp in employees)
{
    json = JsonSerializer.Serialize(emp, options);
    DoSomething(json);
}

// Reset after serializing to avoid out of bounds memory growth in the resolver.
myReferenceHandler.Reset();

循環参照を無視する

循環参照を処理する代わりに、それらを無視できます。 循環参照を無視するには、ReferenceHandlerIgnoreCycles に設定します。 次の例に示すように、シリアライザーによって循環参照プロパティが null に設定されます。

using System.Text.Json;
using System.Text.Json.Serialization;

namespace SerializeIgnoreCycles
{
    public class Employee
    {
        public string? Name { get; set; }
        public Employee? Manager { get; set; }
        public List<Employee>? DirectReports { get; set; }
    }

    public class Program
    {
        public static void Main()
        {
            Employee tyler = new()
            {
                Name = "Tyler Stein"
            };

            Employee adrian = new()
            {
                Name = "Adrian King"
            };

            tyler.DirectReports = new List<Employee> { adrian };
            adrian.Manager = tyler;

            JsonSerializerOptions options = new()
            {
                ReferenceHandler = ReferenceHandler.IgnoreCycles,
                WriteIndented = true
            };

            string tylerJson = JsonSerializer.Serialize(tyler, options);
            Console.WriteLine($"Tyler serialized:\n{tylerJson}");

            Employee? tylerDeserialized =
                JsonSerializer.Deserialize<Employee>(tylerJson, options);

            Console.WriteLine(
                "Tyler is manager of Tyler's first direct report: ");
            Console.WriteLine(
                tylerDeserialized?.DirectReports?[0]?.Manager == tylerDeserialized);
        }
    }
}

// Produces output like the following example:
//
//Tyler serialized:
//{
//  "Name": "Tyler Stein",
//  "Manager": null,
//  "DirectReports": [
//    {
//      "Name": "Adrian King",
//      "Manager": null,
//      "DirectReports": null
//    }
//  ]
//}
//Tyler is manager of Tyler's first direct report:
//False

前の例では、循環参照を回避するため、Adrian KingManagernull としてシリアル化されています。 この動作は、次のような点が ReferenceHandler.Preserve より優れています。

  • ペイロードのサイズが減少します。
  • System.Text.Json と Newtonsoft.Json 以外のシリアライザーで理解できる JSON が作成されます。

この動作には、次のような欠点があります。

  • データの損失に気付きません。
  • データは、JSON からソース オブジェクトにラウンドトリップできません。

関連項目