Comment écrire des convertisseurs personnalisés pour la sérialisation JSON (marshaling) dans .NET

Cet article explique comment créer des convertisseurs personnalisés pour les classes de sérialisation JSON fournies dans l’espace de noms System.Text.Json. Pour une présentation de System.Text.Json, consultez Comment sérialiser et désérialiser du JSON dans .NET.

Un convertisseur est une classe qui convertit un objet ou une valeur vers et à partir de JSON. L’espace de noms System.Text.Json a des convertisseurs intégrés pour la plupart des types primitifs mappés aux primitives JavaScript. Vous pouvez écrire des convertisseurs personnalisés pour remplacer le comportement par défaut d’un convertisseur intégré. Par exemple :

  • Il est possible que vous souhaitiez que les valeurs DateTime soient représentées au format jj/mm/aaaa. Par défaut, ISO 8601-1:2019 est pris en charge, y compris le profil RFC 3339. Pour plus d’informations, consultez Prise en charge de DateTime et DateTimeOffset dans System.Text.Json.
  • Il est possible que vous souhaitiez sérialiser un objet CLR (OCT) traditionnel en chaîne JSON, par exemple, avec un type PhoneNumber.

Vous pouvez également écrire des convertisseurs personnalisés pour personnaliser ou étendre System.Text.Json avec des nouvelles fonctionnalités. Les scénarios suivants sont abordés plus loin dans cet article :

Visual Basic ne peut pas être utilisé pour écrire des convertisseurs personnalisés, mais peut appeler des convertisseurs implémentés dans des bibliothèques C#. Pour plus d’informations, consultez le Support Visual Basic.

Modèles de convertisseur personnalisés

Il existe deux modèles pour créer un convertisseur personnalisé : le modèle de base et le modèle de fabrique. Le modèle de fabrique est destiné aux convertisseurs qui gèrent le type Enum ou des génériques ouverts. Le modèle de base est destiné aux types génériques fermés et non génériques. Par exemple, les convertisseurs pour les types suivants nécessitent le modèle de fabrique :

Voici quelques exemples de types qui peuvent être gérés par le modèle de base :

  • Dictionary<int, string>
  • WeekdaysEnum
  • List<DateTimeOffset>
  • DateTime
  • Int32

Le modèle de base crée une classe qui peut gérer un type. Le modèle de fabrique crée une classe qui détermine, au moment de l’exécution, quel type spécifique est requis, et crée dynamiquement le convertisseur approprié.

Exemple de convertisseur de base

L’exemple suivant est un convertisseur qui remplace la sérialisation par défaut pour un type de données existant. Le convertisseur utilise le format mm/jj/aaaa pour les propriétés DateTimeOffset.

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

namespace SystemTextJsonSamples
{
    public class DateTimeOffsetJsonConverter : JsonConverter<DateTimeOffset>
    {
        public override DateTimeOffset Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
                DateTimeOffset.ParseExact(reader.GetString()!,
                    "MM/dd/yyyy", CultureInfo.InvariantCulture);

        public override void Write(
            Utf8JsonWriter writer,
            DateTimeOffset dateTimeValue,
            JsonSerializerOptions options) =>
                writer.WriteStringValue(dateTimeValue.ToString(
                    "MM/dd/yyyy", CultureInfo.InvariantCulture));
    }
}

Exemple de convertisseur de modèle de fabrique

Le code suivant montre un convertisseur personnalisé qui fonctionne avec Dictionary<Enum,TValue>. Le code suit le modèle de fabrique, car le premier paramètre de type générique est Enum et le second est ouvert. La méthode CanConvert retourne true uniquement pour un Dictionary avec deux paramètres génériques, dont le premier est un type Enum. Le convertisseur interne obtient un convertisseur existant pour gérer le type fourni au moment de l’exécution pour TValue.

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

namespace SystemTextJsonSamples
{
    public class DictionaryTKeyEnumTValueConverter : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
        {
            if (!typeToConvert.IsGenericType)
            {
                return false;
            }

            if (typeToConvert.GetGenericTypeDefinition() != typeof(Dictionary<,>))
            {
                return false;
            }

            return typeToConvert.GetGenericArguments()[0].IsEnum;
        }

        public override JsonConverter CreateConverter(
            Type type,
            JsonSerializerOptions options)
        {
            Type[] typeArguments = type.GetGenericArguments();
            Type keyType = typeArguments[0];
            Type valueType = typeArguments[1];

            JsonConverter converter = (JsonConverter)Activator.CreateInstance(
                typeof(DictionaryEnumConverterInner<,>).MakeGenericType(
                    [keyType, valueType]),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: [options],
                culture: null)!;

            return converter;
        }

        private class DictionaryEnumConverterInner<TKey, TValue> :
            JsonConverter<Dictionary<TKey, TValue>> where TKey : struct, Enum
        {
            private readonly JsonConverter<TValue> _valueConverter;
            private readonly Type _keyType;
            private readonly Type _valueType;

            public DictionaryEnumConverterInner(JsonSerializerOptions options)
            {
                // For performance, use the existing converter.
                _valueConverter = (JsonConverter<TValue>)options
                    .GetConverter(typeof(TValue));

                // Cache the key and value types.
                _keyType = typeof(TKey);
                _valueType = typeof(TValue);
            }

            public override Dictionary<TKey, TValue> Read(
                ref Utf8JsonReader reader,
                Type typeToConvert,
                JsonSerializerOptions options)
            {
                if (reader.TokenType != JsonTokenType.StartObject)
                {
                    throw new JsonException();
                }

                var dictionary = new Dictionary<TKey, TValue>();

                while (reader.Read())
                {
                    if (reader.TokenType == JsonTokenType.EndObject)
                    {
                        return dictionary;
                    }

                    // Get the key.
                    if (reader.TokenType != JsonTokenType.PropertyName)
                    {
                        throw new JsonException();
                    }

                    string? propertyName = reader.GetString();

                    // For performance, parse with ignoreCase:false first.
                    if (!Enum.TryParse(propertyName, ignoreCase: false, out TKey key) &&
                        !Enum.TryParse(propertyName, ignoreCase: true, out key))
                    {
                        throw new JsonException(
                            $"Unable to convert \"{propertyName}\" to Enum \"{_keyType}\".");
                    }

                    // Get the value.
                    reader.Read();
                    TValue value = _valueConverter.Read(ref reader, _valueType, options)!;

                    // Add to dictionary.
                    dictionary.Add(key, value);
                }

                throw new JsonException();
            }

            public override void Write(
                Utf8JsonWriter writer,
                Dictionary<TKey, TValue> dictionary,
                JsonSerializerOptions options)
            {
                writer.WriteStartObject();

                foreach ((TKey key, TValue value) in dictionary)
                {
                    string propertyName = key.ToString();
                    writer.WritePropertyName
                        (options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);

                    _valueConverter.Write(writer, value, options);
                }

                writer.WriteEndObject();
            }
        }
    }
}

Étapes pour suivre le modèle de base

Les étapes suivantes expliquent comment créer un convertisseur en suivant le modèle de base :

  • Créez une classe qui dérive de JsonConverter<T>, où T est le type à sérialiser et désérialiser.
  • Remplacez la méthode Read pour désérialiser le JSON entrant et le convertir en type T. Utilisez le Utf8JsonReader qui est passé à la méthode pour lire le JSON. Vous n’avez pas à vous soucier de la gestion des données partielles, car le sérialiseur transmet toutes les données pour l’étendue JSON actuelle. Il n’est donc pas nécessaire d’appeler Skip ou TrySkip ou de valider que Read retourne true.
  • Remplacez la méthode Write pour sérialiser l’objet entrant de type T. Utilisez le Utf8JsonWriter qui est passé à la méthode pour écrire le JSON.
  • Remplacez la méthode CanConvert uniquement si nécessaire. L’implémentation par défaut retourne true lorsque le type à convertir est de type T. Par conséquent, les convertisseurs qui prennent uniquement en charge le type T n’ont pas besoin de remplacer cette méthode. Pour obtenir un exemple de convertisseur qui doit remplacer cette méthode, consultez la section désérialisation polymorphe plus loin dans cet article.

Vous pouvez faire référence au code source des convertisseurs intégrés en tant qu’implémentations de référence pour l’écriture de convertisseurs personnalisés.

Étapes à suivre pour suivre le modèle de fabrique

Les étapes suivantes expliquent comment créer un convertisseur en suivant le modèle de fabrique :

  • Créez une classe qui dérive de JsonConverterFactory.
  • Remplacez la méthode CanConvert pour renvoyer true quand le type à convertir peut être géré par le convertisseur. Par exemple, si le convertisseur concerne List<T>, il peut uniquement gérer List<int>, List<string> et List<DateTime>.
  • Remplacez la méthode CreateConverter pour retourner une instance d’une classe de convertisseur qui gérera le type à convertir fourni au moment de l’exécution.
  • Créez la classe de convertisseur que la méthode CreateConverter instancie.

Le modèle de fabrique est requis pour les génériques ouverts, car le code permettant de convertir un objet vers et à partir d’une chaîne n’est pas le même pour tous les types. Un convertisseur pour un type générique ouvert (List<T>, par exemple) doit créer un convertisseur pour un type générique fermé (List<DateTime>, par exemple) en arrière-plan. Le code doit être écrit pour gérer chaque type générique fermé que le convertisseur peut gérer.

Le type Enum est similaire à un type générique ouvert : un convertisseur pour Enum doit créer un convertisseur pour un Enum spécifique (WeekdaysEnum, par exemple) en arrière-plan.

Utilisation de Utf8JsonReader dans la méthode Read

Si votre convertisseur convertit un objet JSON, le Utf8JsonReader est positionné sur le jeton d’objet de début lorsque la méthode Read commence. Vous devez ensuite lire tous les jetons de cet objet et quitter la méthode avec le lecteur positionné sur le jeton d’objet de fin correspondant. Si vous lisez au-delà de la fin de l’objet, ou si vous arrêtez avant d’atteindre le jeton de fin correspondant, vous obtenez une exception JsonException indiquant que :

Le convertisseur ’ConverterName’ lit trop ou pas assez.

Pour obtenir un exemple, consultez l’exemple de convertisseur de modèle de fabrique précédent. La méthode Read commence par vérifier que le lecteur est positionné sur un jeton d’objet de démarrage. Il lit jusqu’à ce qu’il détecte qu’il est positionné sur le jeton d’objet de fin suivant. Il s’arrête sur le jeton d’objet de fin suivant, car il n’existe aucun jeton d’objet de début intermédiaire qui indiquerait un objet dans l’objet. La même règle sur le jeton de début et le jeton de fin s’applique si vous convertissez un tableau. Pour obtenir un exemple, voir l’exemple de convertisseur Stack<T> plus loin dans cet article.

Gestion des erreurs

Le sérialiseur fournit une gestion spéciale pour les types d’exceptions JsonException et NotSupportedException.

JsonException

Si vous levez un JsonException sans message, le sérialiseur crée un message qui inclut le chemin d’accès à la partie du JSON à l’origine de l’erreur. Par exemple, l’instruction throw new JsonException() génère un message d’erreur semblable à l’exemple suivant :

Unhandled exception. System.Text.Json.JsonException:
The JSON value could not be converted to System.Object.
Path: $.Date | LineNumber: 1 | BytePositionInLine: 37.

Si vous fournissez un message (par exemple, throw new JsonException("Error occurred")), le sérialiseur définit toujours les propriétés Path, LineNumber et BytePositionInLine.

NotSupportedException

Si vous levez un NotSupportedException, vous obtenez toujours les informations de chemin dans le message. Si vous fournissez un message, les informations de chemin d’accès y sont ajoutées. Par exemple, l’instruction throw new NotSupportedException("Error occurred.") génère un message d’erreur semblable à l’exemple suivant :

Error occurred. The unsupported member type is located on type
'System.Collections.Generic.Dictionary`2[Samples.SummaryWords,System.Int32]'.
Path: $.TemperatureRanges | LineNumber: 4 | BytePositionInLine: 24

Quand lever quel type d’exception

Lorsque la charge utile JSON contient des jetons qui ne sont pas valides pour le type en cours de désérialisation, levez un JsonException.

Lorsque vous souhaitez interdire certains types, levez un NotSupportedException. Cette exception est ce que le sérialiseur lève automatiquement pour les types qui ne sont pas pris en charge. Par exemple, System.Type n’étant pas pris en charge pour des raisons de sécurité, une tentative de désérialisation entraîne un NotSupportedException.

Vous pouvez lever d’autres exceptions si nécessaire, mais elles n’incluent pas automatiquement des informations de chemin JSON.

Inscrire un convertisseur personnalisé

Inscrivez un convertisseur personnalisé pour que les méthodes Serialize et Deserialize l’utilisent. Choisissez l’une des approches suivantes :

  • Ajoutez une instance de la classe de convertisseur à la collection JsonSerializerOptions.Converters.
  • Appliquez l’attribut [JsonConverter] aux propriétés qui nécessitent le convertisseur personnalisé.
  • Appliquez l’attribut [JsonConverter] à une classe ou à un struct qui représente un type de valeur personnalisé.

Exemple d’inscription - Collection de convertisseurs

Voici un exemple qui fait de DateTimeOffsetJsonConverter la valeur par défaut pour les propriétés de type DateTimeOffset :

var serializeOptions = new JsonSerializerOptions
{
    WriteIndented = true,
    Converters =
    {
        new DateTimeOffsetJsonConverter()
    }
};

jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

Supposons que vous sérialisiez une instance du type suivant :

public class WeatherForecast
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

Voici un exemple de sortie JSON qui montre que le convertisseur personnalisé a été utilisé :

{
  "Date": "08/01/2019",
  "TemperatureCelsius": 25,
  "Summary": "Hot"
}

Le code suivant utilise la même approche pour désérialiser à l’aide du convertisseur personnalisé DateTimeOffset :

var deserializeOptions = new JsonSerializerOptions();
deserializeOptions.Converters.Add(new DateTimeOffsetJsonConverter());
weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString, deserializeOptions)!;

Exemple d’inscription - [JsonConverter] sur une propriété

Le code suivant sélectionne un convertisseur personnalisé pour la propriété Date :

public class WeatherForecastWithConverterAttribute
{
    [JsonConverter(typeof(DateTimeOffsetJsonConverter))]
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

Le code à sérialiser WeatherForecastWithConverterAttribute ne nécessite pas l’utilisation de JsonSerializeOptions.Converters :

var serializeOptions = new JsonSerializerOptions
{
    WriteIndented = true
};
jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

Le code à désérialiser ne nécessite pas non plus l’utilisation de Converters :

weatherForecast = JsonSerializer.Deserialize<WeatherForecastWithConverterAttribute>(jsonString)!;

Exemple d’inscription - [JsonConverter] sur un type

Voici le code qui crée un struct et lui applique l’attribut [JsonConverter] :

using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    [JsonConverter(typeof(TemperatureConverter))]
    public struct Temperature
    {
        public Temperature(int degrees, bool celsius)
        {
            Degrees = degrees;
            IsCelsius = celsius;
        }

        public int Degrees { get; }
        public bool IsCelsius { get; }
        public bool IsFahrenheit => !IsCelsius;

        public override string ToString() =>
            $"{Degrees}{(IsCelsius ? "C" : "F")}";

        public static Temperature Parse(string input)
        {
            int degrees = int.Parse(input.Substring(0, input.Length - 1));
            bool celsius = input.Substring(input.Length - 1) == "C";

            return new Temperature(degrees, celsius);
        }
    }
}

Voici le convertisseur personnalisé pour le struct précédent :

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

namespace SystemTextJsonSamples
{
    public class TemperatureConverter : JsonConverter<Temperature>
    {
        public override Temperature Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
                Temperature.Parse(reader.GetString()!);

        public override void Write(
            Utf8JsonWriter writer,
            Temperature temperature,
            JsonSerializerOptions options) =>
                writer.WriteStringValue(temperature.ToString());
    }
}

L’attribut [JsonConverter] sur le struct inscrit le convertisseur personnalisé comme valeur par défaut pour les propriétés de type Temperature. Le convertisseur est automatiquement utilisé sur la propriété TemperatureCelsius du type suivant lorsque vous la sérialisez ou désérialisez :

public class WeatherForecastWithTemperatureStruct
{
    public DateTimeOffset Date { get; set; }
    public Temperature TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

Priorité d’inscription du convertisseur

Pendant la sérialisation ou désérialisation, un convertisseur est choisi pour chaque élément JSON dans l’ordre suivant, de la priorité la plus élevée à la plus basse :

  • [JsonConverter] appliqué à une propriété.
  • Convertisseur ajouté à la collection Converters.
  • [JsonConverter] appliqué à un type de valeur personnalisé ou OCT.

Si plusieurs convertisseurs personnalisés pour un type sont inscrits dans la collection Converters, le premier convertisseur qui renvoie true pour CanConvert est utilisé.

Un convertisseur intégré est choisi uniquement si aucun convertisseur personnalisé applicable n’est inscrit.

Exemples de convertisseurs pour les scénarios courants

Les sections suivantes fournissent des exemples de convertisseurs qui traitent de certains scénarios courants que les fonctionnalités intégrées ne gèrent pas.

Pour obtenir un exemple de convertisseur DataTable, consultez Types de collection pris en charge.

Désérialiser les types déduits en propriétés d’objet

Lors de la désérialisation vers une propriété de type object, un objet JsonElement est créé. La raison est que le désérialiseur ne sait pas quel type CLR créer, et il n’essaie pas de le deviner. Par exemple, si une propriété JSON a « true », le désérialiseur ne déduit pas que la valeur est un Boolean, et si un élément a « 01/01/2019 », le désérialiseur ne déduit pas qu’il s’agit d’un DateTime.

L’inférence de type peut être inexacte. Si le désérialiseur analyse un nombre JSON qui n’a pas de virgule décimale en tant que long, cela peut entraîner des problèmes de dépassement de limite si la valeur a été sérialisée à l’origine en tant que ulong ou BigInteger. L’analyse d’un nombre qui a un point décimal en tant que double peut perdre en précision si le nombre a été sérialisé à l’origine en tant que decimal.

Pour les scénarios qui nécessitent une inférence de type, le code suivant montre un convertisseur personnalisé pour les propriétés object. Le code convertit :

  • true et false en Boolean
  • Les nombres sans décimale en long
  • Les nombres avec une décimale en double
  • Les dates en DateTime
  • Les chaînes en string
  • Tout le reste en JsonElement
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CustomConverterInferredTypesToObject
{
    public class ObjectToInferredTypesConverter : JsonConverter<object>
    {
        public override object Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) => reader.TokenType switch
            {
                JsonTokenType.True => true,
                JsonTokenType.False => false,
                JsonTokenType.Number when reader.TryGetInt64(out long l) => l,
                JsonTokenType.Number => reader.GetDouble(),
                JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime) => datetime,
                JsonTokenType.String => reader.GetString()!,
                _ => JsonDocument.ParseValue(ref reader).RootElement.Clone()
            };

        public override void Write(
            Utf8JsonWriter writer,
            object objectToWrite,
            JsonSerializerOptions options) =>
            JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options);
    }

    public class WeatherForecast
    {
        public object? Date { get; set; }
        public object? TemperatureCelsius { get; set; }
        public object? Summary { get; set; }
    }

    public class Program
    {
        public static void Main()
        {
            string jsonString = """
                {
                  "Date": "2019-08-01T00:00:00-07:00",
                  "TemperatureCelsius": 25,
                  "Summary": "Hot"
                }
                """;

            WeatherForecast weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString)!;
            Console.WriteLine($"Type of Date property   no converter = {weatherForecast.Date!.GetType()}");

            var options = new JsonSerializerOptions();
            options.WriteIndented = true;
            options.Converters.Add(new ObjectToInferredTypesConverter());
            weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString, options)!;
            Console.WriteLine($"Type of Date property with converter = {weatherForecast.Date!.GetType()}");

            Console.WriteLine(JsonSerializer.Serialize(weatherForecast, options));
        }
    }
}

// Produces output like the following example:
//
//Type of Date property   no converter = System.Text.Json.JsonElement
//Type of Date property with converter = System.DateTime
//{
//  "Date": "2019-08-01T00:00:00-07:00",
//  "TemperatureCelsius": 25,
//  "Summary": "Hot"
//}

L’exemple montre le code du convertisseur et une classe WeatherForecast avec des propriétés object. La méthode Main désérialise une chaîne JSON dans une instance WeatherForecast, d’abord sans utiliser le convertisseur, puis en l’utilisant. La sortie de la console indique que, sans le convertisseur, le type d’exécution de la propriété Date est JsonElement ; avec le convertisseur, le type d’exécution est DateTime.

Le dossier des tests unitaires dans l’espace de noms System.Text.Json.Serialization contient d’autres exemples de convertisseurs personnalisés qui gèrent la désérialisation en propriétés object.

Prise en charge de la désérialisation polymorphe

.NET 7 prend en charge à la fois la sérialisation et la désérialisation polymorphe. Toutefois, dans les versions précédentes de .NET, la prise en charge de la sérialisation polymorphe était limitée, et il n’y avait aucune prise en charge de la désérialisation. Si vous utilisez .NET 6 ou une version antérieure, la désérialisation nécessite un convertisseur personnalisé.

Supposons, par exemple, que vous disposez d’une classe de base Person abstraite, avec des classes dérivées Employee et Customer. La désérialisation polymorphe signifie qu’au moment de la conception, vous pouvez spécifier Person comme cible de désérialisation, et les objets Customer et Employee dans le JSON sont correctement désérialisés au moment de l’exécution. Pendant la désérialisation, vous devez trouver des indices qui identifient le type requis dans le JSON. Les types d’indices disponibles varient selon le scénario. Par exemple, une propriété de discriminateur peut être disponible, ou vous devrez peut-être vous appuyer sur la présence ou l’absence d’une propriété particulière. La version actuelle de ne fournit pas d’attributs System.Text.Json permettant de spécifier comment gérer les scénarios de désérialisation polymorphe. Des convertisseurs personnalisés sont donc nécessaires.

Le code suivant montre une classe de base, deux classes dérivées et un convertisseur personnalisé pour celles-ci. Le convertisseur utilise une propriété de discriminateur pour effectuer une désérialisation polymorphe. Le discriminateur de type n’est pas dans les définitions de classe, mais est créé pendant la sérialisation et est lu pendant la désérialisation.

Important

L’exemple de code nécessite que les paires nom/valeur d’objet JSON restent dans l’ordre, ce qui n’est pas une exigence standard de JSON.

public class Person
{
    public string? Name { get; set; }
}

public class Customer : Person
{
    public decimal CreditLimit { get; set; }
}

public class Employee : Person
{
    public string? OfficeNumber { get; set; }
}
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class PersonConverterWithTypeDiscriminator : JsonConverter<Person>
    {
        enum TypeDiscriminator
        {
            Customer = 1,
            Employee = 2
        }

        public override bool CanConvert(Type typeToConvert) =>
            typeof(Person).IsAssignableFrom(typeToConvert);

        public override Person Read(
            ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.StartObject)
            {
                throw new JsonException();
            }

            reader.Read();
            if (reader.TokenType != JsonTokenType.PropertyName)
            {
                throw new JsonException();
            }

            string? propertyName = reader.GetString();
            if (propertyName != "TypeDiscriminator")
            {
                throw new JsonException();
            }

            reader.Read();
            if (reader.TokenType != JsonTokenType.Number)
            {
                throw new JsonException();
            }

            TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
            Person person = typeDiscriminator switch
            {
                TypeDiscriminator.Customer => new Customer(),
                TypeDiscriminator.Employee => new Employee(),
                _ => throw new JsonException()
            };

            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.EndObject)
                {
                    return person;
                }

                if (reader.TokenType == JsonTokenType.PropertyName)
                {
                    propertyName = reader.GetString();
                    reader.Read();
                    switch (propertyName)
                    {
                        case "CreditLimit":
                            decimal creditLimit = reader.GetDecimal();
                            ((Customer)person).CreditLimit = creditLimit;
                            break;
                        case "OfficeNumber":
                            string? officeNumber = reader.GetString();
                            ((Employee)person).OfficeNumber = officeNumber;
                            break;
                        case "Name":
                            string? name = reader.GetString();
                            person.Name = name;
                            break;
                    }
                }
            }

            throw new JsonException();
        }

        public override void Write(
            Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
        {
            writer.WriteStartObject();

            if (person is Customer customer)
            {
                writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Customer);
                writer.WriteNumber("CreditLimit", customer.CreditLimit);
            }
            else if (person is Employee employee)
            {
                writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Employee);
                writer.WriteString("OfficeNumber", employee.OfficeNumber);
            }

            writer.WriteString("Name", person.Name);

            writer.WriteEndObject();
        }
    }
}

Le code suivant inscrit le convertisseur :

var serializeOptions = new JsonSerializerOptions();
serializeOptions.Converters.Add(new PersonConverterWithTypeDiscriminator());

Le convertisseur peut désérialiser le JSON créé à l’aide du même convertisseur pour sérialiser, par exemple :

[
  {
    "TypeDiscriminator": 1,
    "CreditLimit": 10000,
    "Name": "John"
  },
  {
    "TypeDiscriminator": 2,
    "OfficeNumber": "555-1234",
    "Name": "Nancy"
  }
]

Le code du convertisseur dans l’exemple précédent lit et écrit manuellement chaque propriété. Une alternative consiste à appeler Deserialize ou Serialize pour effectuer une partie du travail. Pour obtenir un exemple, consultez ce billet StackOverflow.

Une autre façon d’effectuer une désérialisation polymorphe

Vous pouvez appeler Deserialize dans la méthode Read :

  • Créez un clone de l’instance Utf8JsonReader. Étant donné que Utf8JsonReader est un struct, cela nécessite simplement une instruction d’affectation.
  • Utilisez le clone pour lire les jetons du discriminateur.
  • Appelez Deserialize à l’aide de l’instance d’origine Reader une fois que vous connaissez le type dont vous avez besoin. Vous pouvez appeler Deserialize, car l’instance d’origine Reader est toujours positionnée pour lire le jeton d’objet de début.

L’inconvénient de cette méthode est que vous ne pouvez pas passer l’instance d’options d’origine qui inscrit le convertisseur dans Deserialize. Cela entraînerait un dépassement de capacité de la pile, comme expliqué dans Propriétés requises. L’exemple suivant montre une méthode Read qui utilise cette alternative :

public override Person Read(
    ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
    Utf8JsonReader readerClone = reader;

    if (readerClone.TokenType != JsonTokenType.StartObject)
    {
        throw new JsonException();
    }

    readerClone.Read();
    if (readerClone.TokenType != JsonTokenType.PropertyName)
    {
        throw new JsonException();
    }

    string? propertyName = readerClone.GetString();
    if (propertyName != "TypeDiscriminator")
    {
        throw new JsonException();
    }

    readerClone.Read();
    if (readerClone.TokenType != JsonTokenType.Number)
    {
        throw new JsonException();
    }

    TypeDiscriminator typeDiscriminator = (TypeDiscriminator)readerClone.GetInt32();
    Person person = typeDiscriminator switch
    {
        TypeDiscriminator.Customer => JsonSerializer.Deserialize<Customer>(ref reader)!,
        TypeDiscriminator.Employee => JsonSerializer.Deserialize<Employee>(ref reader)!,
        _ => throw new JsonException()
    };
    return person;
}

Prise en charge l’aller-retour pour les types Stack

Si vous désérialisez une chaîne JSON dans un objet Stack, puis sérialisez cet objet, le contenu de la pile est dans l’ordre inverse. Ce comportement s’applique aux interfaces et aux types suivants, ainsi qu’aux types définis par l’utilisateur qui en dérivent :

Pour prendre en charge la sérialisation et la désérialisation tout en conservant l’ordre d’origine dans la pile, un convertisseur personnalisé est requis.

Le code suivant montre un convertisseur personnalisé qui permet l’aller-retour vers et à partir d’objets Stack<T> :

using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class JsonConverterFactoryForStackOfT : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
            => typeToConvert.IsGenericType
            && typeToConvert.GetGenericTypeDefinition() == typeof(Stack<>);

        public override JsonConverter CreateConverter(
            Type typeToConvert, JsonSerializerOptions options)
        {
            Debug.Assert(typeToConvert.IsGenericType &&
                typeToConvert.GetGenericTypeDefinition() == typeof(Stack<>));

            Type elementType = typeToConvert.GetGenericArguments()[0];

            JsonConverter converter = (JsonConverter)Activator.CreateInstance(
                typeof(JsonConverterForStackOfT<>)
                    .MakeGenericType(new Type[] { elementType }),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: null,
                culture: null)!;

            return converter;
        }
    }

    public class JsonConverterForStackOfT<T> : JsonConverter<Stack<T>>
    {
        public override Stack<T> Read(
            ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.StartArray)
            {
                throw new JsonException();
            }
            reader.Read();

            var elements = new Stack<T>();

            while (reader.TokenType != JsonTokenType.EndArray)
            {
                elements.Push(JsonSerializer.Deserialize<T>(ref reader, options)!);

                reader.Read();
            }

            return elements;
        }

        public override void Write(
            Utf8JsonWriter writer, Stack<T> value, JsonSerializerOptions options)
        {
            writer.WriteStartArray();

            var reversed = new Stack<T>(value);

            foreach (T item in reversed)
            {
                JsonSerializer.Serialize(writer, item, options);
            }

            writer.WriteEndArray();
        }
    }
}

Le code suivant inscrit le convertisseur :

var options = new JsonSerializerOptions
{
    Converters = { new JsonConverterFactoryForStackOfT() },
};

Stratégies d’affectation de noms pour la désérialisation des chaînes d’énumération

Par défaut, le JsonStringEnumConverter intégré peut sérialiser et désérialiser des valeurs de chaîne pour les énumérations. Il fonctionne sans stratégie d’affectation de noms spécifiée ou avec la stratégie d’affectation de noms CamelCase. Il ne prend pas en charge d’autres stratégies de nommage, comme l’utilisation de tirets. Pour plus d’informations sur le code de convertisseur personnalisé qui peut prendre en charge l’aller-retour vers et depuis des valeurs de chaîne d’énumération lors de l’utilisation d’une stratégie d’affectation de noms avec des tirets, consultez le problème GitHub dotnet/runtime #31619. Vous pouvez également effectuer une mise à niveau vers .NET 7 ou une version ultérieure qui fournit une prise en charge intégrée de l’application de stratégies d’affectation de noms lors de l’aller-retour vers et depuis des valeurs de la chaîne d’énumération.

Utiliser le convertisseur système par défaut

Dans certains scénarios, vous pouvez utiliser le convertisseur système par défaut dans un convertisseur personnalisé. Pour ce faire, récupérez le convertisseur système à partir de la propriété JsonSerializerOptions.Default, comme illustré dans l’exemple suivant :

public class MyCustomConverter : JsonConverter<int>
{
    private readonly static JsonConverter<int> s_defaultConverter = 
        (JsonConverter<int>)JsonSerializerOptions.Default.GetConverter(typeof(int));

    // Custom serialization logic
    public override void Write(
        Utf8JsonWriter writer, int value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }

    // Fall back to default deserialization logic
    public override int Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return s_defaultConverter.Read(ref reader, typeToConvert, options);
    }
}

Traiter les valeurs Null

Par défaut, le sérialiseur gère les valeurs null comme suit :

  • Pour les types de référence et les types Nullable<T> :

    • Il ne passe pas null aux convertisseurs personnalisés lors de la sérialisation.
    • Il ne passe pas JsonTokenType.Null aux convertisseurs personnalisés lors de la désérialisation.
    • Elle retourne une instance null lors de la désérialisation.
    • Il écrit null directement avec l’enregistreur lors de la sérialisation.
  • Pour les types de valeurs non nullables :

    • Il passe JsonTokenType.Null aux convertisseurs personnalisés lors de la désérialisation. (Si aucun convertisseur personnalisé n’est disponible, une exception JsonException est levée par le convertisseur interne pour le type.)

Ce comportement de gestion des valeurs null vise principalement à optimiser les performances en ignorant un appel supplémentaire au convertisseur. En outre, il évite de forcer les convertisseurs pour les types nullables à rechercher null au début de chaque remplacement de méthode Read et Write.

Pour permettre à un convertisseur personnalisé de gérer null pour un type référence ou valeur, remplacez JsonConverter<T>.HandleNull pour retourner true, comme illustré dans l’exemple suivant :

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

namespace CustomConverterHandleNull
{
    public class Point
    {
        public int X { get; set; }
        public int Y { get; set; }

        [JsonConverter(typeof(DescriptionConverter))]
        public string? Description { get; set; }
    }

    public class DescriptionConverter : JsonConverter<string>
    {
        public override bool HandleNull => true;

        public override string Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
            reader.GetString() ?? "No description provided.";

        public override void Write(
            Utf8JsonWriter writer,
            string value,
            JsonSerializerOptions options) =>
            writer.WriteStringValue(value);
    }

    public class Program
    {
        public static void Main()
        {
            string json = @"{""x"":1,""y"":2,""Description"":null}";

            Point point = JsonSerializer.Deserialize<Point>(json)!;
            Console.WriteLine($"Description: {point.Description}");
        }
    }
}

// Produces output like the following example:
//
//Description: No description provided.

Préserver les références

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 Serialize/Deserialize à un autre, rootez l’instance de ReferenceResolver dans le site d’appel de Serialize/Deserialize. Le code suivant présente un exemple pour ce scénario :

  • Vous écrivez un convertisseur personnalisé pour le type Company.
  • Vous ne souhaitez pas sérialiser manuellement la propriété Supervisor, qui est un Employee. Vous souhaitez déléguer cela au sérialiseur et souhaitez également conserver les références que vous avez déjà enregistrées.

Voici les classes Employee et Company :

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

public class Company
{
    public string? Name { get; set; }
    public Employee? Supervisor { get; set; }
}

Le convertisseur se présente comme suit :

class CompanyConverter : JsonConverter<Company>
{
    public override Company Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, Company value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        writer.WriteString("Name", value.Name);

        writer.WritePropertyName("Supervisor");
        JsonSerializer.Serialize(writer, value.Supervisor, options);

        writer.WriteEndObject();
    }
}

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

options.Converters.Add(new CompanyConverter());
var myReferenceHandler = new MyReferenceHandler();
options.ReferenceHandler = myReferenceHandler;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.WriteIndented = true;

string str = JsonSerializer.Serialize(tyler, options);

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

L’exemple précédent ne fait que la sérialisation, mais une approche similaire peut être adoptée pour la désérialisation.

Autres exemples de convertisseurs personnalisés

L’article Migrer de Newtonsoft.Json à System.Text.Json contient des exemples supplémentaires de convertisseurs personnalisés.

Le dossier tests unitaires dans le code source System.Text.Json.Serialization inclut d’autres exemples de convertisseur personnalisés, notamment les suivants :

Si vous devez créer un convertisseur qui modifie le comportement d’un convertisseur intégré existant, vous pouvez obtenir le code source du convertisseur existant pour servir de point de départ pour la personnalisation.

Ressources complémentaires