Como preservar referências e manipular ou ignorar referências circulares em System.Text.Json

Este artigo mostra como preservar referências e manipular ou ignorar referências circulares ao usar System.Text.Json para serializar e desserializar JSON no .NET

Preservar referências e lidar com referências circulares

Para preservar referências e lidar com referências circulares, defina ReferenceHandler como Preserve. Essa configuração causa o seguinte comportamento:

  • Ao serializar:

    Ao escrever tipos complexos, o serializador também grava propriedades de metadados ($id, $values e $ref).

  • Ao desserializar:

    Metadados são esperados (embora não obrigatórios) e o desserializador tenta entendê-los.

O código a seguir ilustra o uso da configuração 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

Esse recurso não pode ser usado para preservar tipos de valor ou tipos imutáveis. Na desserialização, a instância de um tipo imutável é criada depois que toda a carga útil é lida. Portanto, seria impossível desserializar a mesma instância se uma referência a ela aparecesse na carga JSON.

Para tipos de valor, tipos imutáveis e matrizes, nenhum metadados de referência é serializado. Na desserialização, uma exceção é lançada se $ref ou $id for encontrado. No entanto, os tipos de valor ignoram $id (e $values no caso de coleções) para possibilitar desserializar cargas que foram serializadas usando Newtonsoft.Json, que serializa metadados para tais tipos.

Para determinar se os objetos são iguais, System.Text.Json usa ReferenceEqualityComparer.Instance, que usa igualdade de referência (Object.ReferenceEquals(Object, Object)) em vez de igualdade de valor (Object.Equals(Object)) ao comparar duas instâncias de objeto.

Para obter mais informações sobre como as referências são serializadas e desserializadas, consulte ReferenceHandler.Preserve.

A classe ReferenceResolver define o comportamento de preservação de referências na serialização e desserialização. Crie uma classe derivada para especificar o comportamento personalizado. Para obter um exemplo, consulte GuidReferenceResolver.

Persistir metadados de referência em várias chamadas de serialização e desserialização

Por padrão, os dados de referência só são armazenados em cache para cada chamada a Serialize ou Deserialize. Para persistir referências de uma chamada Serialize ou Deserialize para outra, tenha como raiz a instância ReferenceResolver no site de chamada de Serialize/Deserialize. O seguinte código mostra um exemplo para esse cenário:

  • Você tem uma lista de objetos Employee e precisa serializar cada um individualmente.
  • Você deseja aproveitar as referências salvas no resolvedor para o ReferenceHandler.

Aqui está a classe Employee :

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

Uma classe derivada de ReferenceResolver armazena as referências em um dicionário:

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

Uma classe derivada de ReferenceHandler contém uma instância e MyReferenceResolver cria uma nova instância somente quando necessário (em um método chamado Reset neste exemplo):

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

Quando o código de exemplo chama o serializador, ele usa uma instância JsonSerializerOptions na qual a propriedade ReferenceHandler é definida como uma instância de MyReferenceHandler. Ao seguir esse padrão, redefina o dicionário ReferenceResolver ao terminar de serializar para evitar que ele continue crescendo para sempre.

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

Ignorar referências circulares

Em vez de manipular referências circulares, você pode ignorá-las. Ignorar referências circulares, defina ReferenceHandler como IgnoreCycles. O serializador define as propriedades de referência circular como null, conforme mostrado no exemplo a seguir:

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

No exemplo anterior, Manager em Adrian King é serializado como null para evitar a referência circular. Esse comportamento tem as seguintes vantagens sobre ReferenceHandler.Preserve:

  • Diminui o tamanho da carga útil.
  • Ele cria JSON que é compreensível para serializadores diferentes de System.Text.Json e Newtonsoft.Json.

Esse comportamento tem as seguintes desvantagens:

  • Perda silenciosa de dados.
  • Os dados não podem fazer uma viagem de ida e volta do JSON de volta ao objeto de origem.

Confira também