Comment conserver des références et gérer ou ignorer les références circulaires dans System.Text.Json

Cet article explique comment conserver les références et gérer ou ignorer les références circulaires lors de l’utilisation de System.Text.Json pour sérialiser et désérialiser du JSON dans .NET

Conserver les références et gérer les références circulaires

Pour conserver les références et gérer les références circulaires, définissez ReferenceHandler sur Preserve. Ce paramètre entraîne le comportement suivant :

  • Lors de la sérialisation :

    Lors de l’écriture de types complexes, le sérialiseur écrit également les propriétés de métadonnées ($id, $values et $ref).

  • Lors de la désérialisation :

    Des métadonnées sont attendues (bien qu’elles ne soient pas obligatoires), et le désérialiseur tente de les comprendre.

Le code suivant illustre l’utilisation du paramètre 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

Cette fonctionnalité ne peut pas être utilisée pour conserver des types valeur ou des types immuables. Lors de la désérialisation, l’instance d’un type immuable est créée après la lecture de la charge utile entière. Il serait donc impossible de désérialiser la même instance si une référence à celle-ci apparaît dans la charge utile JSON.

Pour les types valeur, les types immuables et les tableaux, aucune métadonnée de référence n’est sérialisée. Lors de la désérialisation, une exception est levée si $ref ou $id est trouvé. Cependant, les types valeurs ignorent $id (et $values dans le cas des collections) afin de permettre la désérialisation de charges utiles qui ont été sérialisées en utilisant Newtonsoft.Json, lequel sérialise bien les métadonnées pour ces types.

Pour déterminer si les objets sont égaux, System.Text.Json utilise ReferenceEqualityComparer.Instance, qui utilise l’égalité de référence (Object.ReferenceEquals(Object, Object)) au lieu de l’égalité de valeur (Object.Equals(Object)) lors de la comparaison de deux instances d’objet.

Pour plus d’informations sur la façon dont les références sont sérialisées et désérialisées, consultez ReferenceHandler.Preserve.

La classe ReferenceResolver définit le comportement de la préservation des références en cas de sérialisation et désérialisation. Créez une classe dérivée pour spécifier un comportement personnalisé. Pour obtenir un exemple, consultez GuidReferenceResolver.

Conserver les métadonnées de référence sur plusieurs appels de sérialisation et de désérialisation

Par défaut, les données de référence sont uniquement mises en cache pour chaque appel à Serialize ou Deserialize. Pour conserver les références d’un appel de Serialize ou Deserialize à un autre, ancrez l’instance ReferenceResolver au site d’appel de Serialize/Deserialize. Le code suivant présente un exemple pour ce scénario :

  • Vous disposez d’une liste d’objets Employee et vous devez sérialiser chacun d’eux individuellement.
  • Vous souhaitez tirer parti des références enregistrées dans le résolveur pour le ReferenceHandler.

Voici la classe Employee :

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

Une classe qui dérive de ReferenceResolver stocke les références dans un dictionnaire :

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;
    }
}

Une classe qui dérive de ReferenceHandler contient une instance de MyReferenceResolver et crée une nouvelle instance uniquement si nécessaire (dans une méthode nommée Reset dans cet exemple) :

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

Lorsque l’exemple de code appelle le sérialiseur, il utilise une instance JsonSerializerOptions dans laquelle la propriété ReferenceHandler est définie sur une instance de MyReferenceHandler. Lorsque vous suivez ce modèle, veillez à réinitialiser le dictionnaire ReferenceResolver lorsque vous avez terminé la sérialisation, pour éviter qu’il continue à grandir.

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();

Ignorer les références circulaires

Au lieu de gérer les références circulaires, vous pouvez les ignorer. Pour ignorer les références circulaires, définissez ReferenceHandler sur IgnoreCycles. Le sérialiseur définit les propriétés de référence circulaires sur null, comme illustré dans l’exemple suivant :

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

Dans l’exemple précédent, Manager sous Adrian King est sérialisé comme null pour éviter la référence circulaire. Ce comportement présente les avantages suivants par rapport à ReferenceHandler.Preserve :

  • Cela diminue la taille de la charge utile.
  • Cela crée un JSON compréhensible pour les sérialiseurs autres que System.Text.Json et Newtonsoft.Json.

Ce comportement présente les inconvénients suivants :

  • Perte silencieuse de données.
  • Les données ne peuvent pas effectuer d’aller-retour de JSON à l’objet source.

Voir aussi