53 votes

La désérialisation polymorphe est-elle possible dans System.Text.Json ?

J'essaie de migrer de Newtonsoft.Json vers System.Text.Json. Je veux désérialiser une classe abstraite. Newtonsoft.Json a TypeNameHandling pour cela. Existe-t-il un moyen de désérialiser une classe abstraite via System.Text.Json en .net core 3.0 ?

52voto

ahsonkhan Points 1256

La désérialisation polymorphe est-elle possible dans System.Text.Json ?

La réponse est oui y non, cela dépend de ce que vous entendez par "possible" .

Il y a pas de désérialisation polymorphe (équivalente à celle de Newtonsoft.Json). TypeNameHandling ) support intégré a System.Text.Json . En effet, la lecture du nom de type .NET spécifié en tant que chaîne de caractères dans les données utiles JSON (telles que $type propriété des métadonnées) pour créer vos objets est non recommandé car il introduit des problèmes de sécurité potentiels (voir https://github.com/dotnet/corefx/issues/41347#issuecomment-535779492 pour plus d'informations).

Permettre à la charge utile de spécifier ses propres informations de type est une source commune de vulnérabilités dans les applications web.

Cependant, il y a est un moyen d'ajouter votre propre support pour la désérialisation polymorphe en créant un fichier JsonConverter<T> donc dans ce sens, c'est possible.

La documentation montre un exemple de la façon de procéder en utilisant un fichier de type discriminateur de type propriété : https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to#support-polymorphic-deserialization

Prenons un exemple.

Disons que vous avez une classe de base et quelques classes dérivées :

public class BaseClass
{
    public int Int { get; set; }
}
public class DerivedA : BaseClass
{
    public string Str { get; set; }
}
public class DerivedB : BaseClass
{
    public bool Bool { get; set; }
}

Vous pouvez créer les éléments suivants JsonConverter<BaseClass> qui écrit le discriminateur de type pendant la sérialisation et le lit pour déterminer le type à désérialiser. Vous pouvez enregistrer ce convertisseur sur la page JsonSerializerOptions .

public class BaseClassConverter : JsonConverter<BaseClass>
{
    private enum TypeDiscriminator
    {
        BaseClass = 0,
        DerivedA = 1,
        DerivedB = 2
    }

    public override bool CanConvert(Type type)
    {
        return typeof(BaseClass).IsAssignableFrom(type);
    }

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

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

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

        BaseClass baseClass;
        TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
        switch (typeDiscriminator)
        {
            case TypeDiscriminator.DerivedA:
                if (!reader.Read() || reader.GetString() != "TypeValue")
                {
                    throw new JsonException();
                }
                if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
                {
                    throw new JsonException();
                }
                baseClass = (DerivedA)JsonSerializer.Deserialize(ref reader, typeof(DerivedA));
                break;
            case TypeDiscriminator.DerivedB:
                if (!reader.Read() || reader.GetString() != "TypeValue")
                {
                    throw new JsonException();
                }
                if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
                {
                    throw new JsonException();
                }
                baseClass = (DerivedB)JsonSerializer.Deserialize(ref reader, typeof(DerivedB));
                break;
            default:
                throw new NotSupportedException();
        }

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

        return baseClass;
    }

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

        if (value is DerivedA derivedA)
        {
            writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.DerivedA);
            writer.WritePropertyName("TypeValue");
            JsonSerializer.Serialize(writer, derivedA);
        }
        else if (value is DerivedB derivedB)
        {
            writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.DerivedB);
            writer.WritePropertyName("TypeValue");
            JsonSerializer.Serialize(writer, derivedB);
        }
        else
        {
            throw new NotSupportedException();
        }

        writer.WriteEndObject();
    }
}

Voici à quoi ressembleraient la sérialisation et la désérialisation (y compris la comparaison avec Newtonsoft.Json) :

private static void PolymorphicSupportComparison()
{
    var objects = new List<BaseClass> { new DerivedA(), new DerivedB() };

    // Using: System.Text.Json
    var options = new JsonSerializerOptions
    {
        Converters = { new BaseClassConverter() },
        WriteIndented = true
    };

    string jsonString = JsonSerializer.Serialize(objects, options);
    Console.WriteLine(jsonString);
    /*
     [
      {
        "TypeDiscriminator": 1,
        "TypeValue": {
            "Str": null,
            "Int": 0
        }
      },
      {
        "TypeDiscriminator": 2,
        "TypeValue": {
            "Bool": false,
            "Int": 0
        }
      }
     ]
    */

    var roundTrip = JsonSerializer.Deserialize<List<BaseClass>>(jsonString, options);

    // Using: Newtonsoft.Json
    var settings = new Newtonsoft.Json.JsonSerializerSettings
    {
        TypeNameHandling = Newtonsoft.Json.TypeNameHandling.Objects,
        Formatting = Newtonsoft.Json.Formatting.Indented
    };

    jsonString = Newtonsoft.Json.JsonConvert.SerializeObject(objects, settings);
    Console.WriteLine(jsonString);
    /*
     [
      {
        "$type": "PolymorphicSerialization.DerivedA, PolymorphicSerialization",
        "Str": null,
        "Int": 0
      },
      {
        "$type": "PolymorphicSerialization.DerivedB, PolymorphicSerialization",
        "Bool": false,
        "Int": 0
      }
     ]
    */

    var originalList = JsonConvert.DeserializeObject<List<BaseClass>>(jsonString, settings);

    Debug.Assert(originalList[0].GetType() == roundTrip[0].GetType());
}

Voici une autre question de StackOverflow qui montre comment prendre en charge la désérialisation polymorphe avec des interfaces (plutôt que des classes abstraites), mais une solution similaire s'appliquerait pour tout polymorphisme : Existe-t-il un moyen simple de sérialiser/désérialiser manuellement les objets enfants dans un convertisseur personnalisé dans System.Text.Json ?

10voto

J'ai fini par adopter cette solution. C'est léger et assez générique pour moi.

Le convertisseur de discrimination de type

public class TypeDiscriminatorConverter<T> : JsonConverter<T> where T : ITypeDiscriminator
{
    private readonly IEnumerable<Type> _types;

    public TypeDiscriminatorConverter()
    {
        var type = typeof(T);
        _types = AppDomain.CurrentDomain.GetAssemblies()
            .SelectMany(s => s.GetTypes())
            .Where(p => type.IsAssignableFrom(p) && p.IsClass && !p.IsAbstract)
            .ToList();
    }

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

        using (var jsonDocument = JsonDocument.ParseValue(ref reader))
        {
            if (!jsonDocument.RootElement.TryGetProperty(nameof(ITypeDiscriminator.TypeDiscriminator), out var typeProperty))
            {
                throw new JsonException();
            }

            var type = _types.FirstOrDefault(x => x.Name == typeProperty.GetString());
            if (type == null)
            {
                throw new JsonException();
            }

            var jsonObject = jsonDocument.RootElement.GetRawText();
            var result = (T) JsonSerializer.Deserialize(jsonObject, type);

            return result;
        }
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        JsonSerializer.Serialize(writer, (object)value, options);
    }
}

L'interface

public interface ITypeDiscriminator
{
    string TypeDiscriminator { get; }
}

Et les modèles d'exemple

public interface ISurveyStepResult : ITypeDiscriminator
{
    string Id { get; set; }
}

public class BoolStepResult : ISurveyStepResult
{
    public string Id { get; set; }
    public string TypeDiscriminator => nameof(BoolStepResult);

    public bool Value { get; set; }
}

public class TextStepResult : ISurveyStepResult
{
    public string Id { get; set; }
    public string TypeDiscriminator => nameof(TextStepResult);

    public string Value { get; set; }
}

public class StarsStepResult : ISurveyStepResult
{
    public string Id { get; set; }
    public string TypeDiscriminator => nameof(StarsStepResult);

    public int Value { get; set; }
}

Et voici la méthode d'essai

public void SerializeAndDeserializeTest()
    {
        var surveyResult = new SurveyResultModel()
        {
            Id = "id",
            SurveyId = "surveyId",
            Steps = new List<ISurveyStepResult>()
            {
                new BoolStepResult(){ Id = "1", Value = true},
                new TextStepResult(){ Id = "2", Value = "some text"},
                new StarsStepResult(){ Id = "3", Value = 5},
            }
        };

        var jsonSerializerOptions = new JsonSerializerOptions()
        {
            Converters = { new TypeDiscriminatorConverter<ISurveyStepResult>()},
            WriteIndented = true
        };
        var result = JsonSerializer.Serialize(surveyResult, jsonSerializerOptions);

        var back = JsonSerializer.Deserialize<SurveyResultModel>(result, jsonSerializerOptions);

        var result2 = JsonSerializer.Serialize(back, jsonSerializerOptions);

        Assert.IsTrue(back.Steps.Count == 3 
                      && back.Steps.Any(x => x is BoolStepResult)
                      && back.Steps.Any(x => x is TextStepResult)
                      && back.Steps.Any(x => x is StarsStepResult)
                      );
        Assert.AreEqual(result2, result);
    }

6voto

Veuillez essayer cette bibliothèque que j'ai écrite comme une extension de System.Text.Json pour offrir le polymorphisme : https://github.com/dahomey-technologies/Dahomey.Json

Si le type réel d'une instance de référence diffère du type déclaré, la propriété discriminante sera automatiquement ajoutée au json de sortie :

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

public class WeatherForecastDerived : WeatherForecast
{
    public int WindSpeed { get; set; }
}

Les classes héritées doivent être enregistrées manuellement dans le registre des conventions de discrimination afin de permettre au framework de connaître la correspondance entre une valeur de discrimination et un type :

JsonSerializerOptions options = new JsonSerializerOptions();
options.SetupExtensions();
DiscriminatorConventionRegistry registry = options.GetDiscriminatorConventionRegistry();
registry.RegisterType<WeatherForecastDerived>();

string json = JsonSerializer.Serialize<WeatherForecast>(weatherForecastDerived, options);

Résultat :

{
  "$type": "Tests.WeatherForecastDerived, Tests",
  "Date": "2019-08-01T00:00:00-07:00",
  "TemperatureCelsius": 25,
  "Summary": "Hot",
  "WindSpeed": 35
}

3voto

Charles d'Avernas Points 490

J'ai vraiment aimé la réponse de Demetrius mais je pense que l'on peut aller encore plus loin en termes de réutilisation. J'ai trouvé la solution suivante :

La JsonConverterFactory :

/// <summary>
/// Represents the <see cref="JsonConverterFactory"/> used to create <see cref="AbstractClassConverter{T}"/>
/// </summary>
public class AbstractClassConverterFactory
    : JsonConverterFactory
{

    /// <summary>
    /// Gets a <see cref="Dictionary{TKey, TValue}"/> containing the mappings of types to their respective <see cref="JsonConverter"/>
    /// </summary>
    protected static Dictionary<Type, JsonConverter> Converters = new Dictionary<Type, JsonConverter>();

    /// <summary>
    /// Initializes a new <see cref="AbstractClassConverterFactory"/>
    /// </summary>
    /// <param name="namingPolicy">The current <see cref="JsonNamingPolicy"/></param>
    public AbstractClassConverterFactory(JsonNamingPolicy namingPolicy)
    {
        this.NamingPolicy = namingPolicy;
    }

    /// <summary>
    /// Gets the current <see cref="JsonNamingPolicy"/>
    /// </summary>
    protected JsonNamingPolicy NamingPolicy { get; }

    /// <inheritdoc/>
    public override bool CanConvert(Type typeToConvert)
    {
        return typeToConvert.IsClass && typeToConvert.IsAbstract && typeToConvert.IsDefined(typeof(DiscriminatorAttribute));
    }

    /// <inheritdoc/>
    public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        if(!Converters.TryGetValue(typeToConvert, out JsonConverter converter))
        {
            Type converterType = typeof(AbstractClassConverter<>).MakeGenericType(typeToConvert);
            converter = (JsonConverter)Activator.CreateInstance(converterType, this.NamingPolicy);
            Converters.Add(typeToConvert, converter);
        }
        return converter;
    }

}

Le convertisseur JsonConverter :

/// <summary>
/// Represents the <see cref="JsonConverter"/> used to convert to/from an abstract class
/// </summary>
/// <typeparam name="T">The type of the abstract class to convert to/from</typeparam>
public class AbstractClassConverter<T>
    : JsonConverter<T>
{

    /// <summary>
    /// Initializes a new <see cref="AbstractClassConverter{T}"/>
    /// </summary>
    /// <param name="namingPolicy">The current <see cref="JsonNamingPolicy"/></param>
    public AbstractClassConverter(JsonNamingPolicy namingPolicy)
    {
        this.NamingPolicy = namingPolicy;
        DiscriminatorAttribute discriminatorAttribute = typeof(T).GetCustomAttribute<DiscriminatorAttribute>();
        if (discriminatorAttribute == null)
            throw new NullReferenceException($"Failed to find the required '{nameof(DiscriminatorAttribute)}'");
        this.DiscriminatorProperty = typeof(T).GetProperty(discriminatorAttribute.Property, BindingFlags.Default | BindingFlags.Public | BindingFlags.Instance);
        if (this.DiscriminatorProperty == null)
            throw new NullReferenceException($"Failed to find the specified discriminator property '{discriminatorAttribute.Property}' in type '{typeof(T).Name}'");
        this.TypeMappings = new Dictionary<string, Type>();
        foreach (Type derivedType in TypeCacheUtil.FindFilteredTypes($"nposm:json-polymorph:{typeof(T).Name}", 
            (t) => t.IsClass && !t.IsAbstract && t.BaseType == typeof(T)))
        {
            DiscriminatorValueAttribute discriminatorValueAttribute = derivedType.GetCustomAttribute<DiscriminatorValueAttribute>();
            if (discriminatorValueAttribute == null)
                continue;
            string discriminatorValue = null;
            if (discriminatorValueAttribute.Value.GetType().IsEnum)
                discriminatorValue = EnumHelper.Stringify(discriminatorValueAttribute.Value, this.DiscriminatorProperty.PropertyType);
            else
                discriminatorValue = discriminatorValueAttribute.Value.ToString();
            this.TypeMappings.Add(discriminatorValue, derivedType);
        }
    }

    /// <summary>
    /// Gets the current <see cref="JsonNamingPolicy"/>
    /// </summary>
    protected JsonNamingPolicy NamingPolicy { get; }

    /// <summary>
    /// Gets the discriminator <see cref="PropertyInfo"/> of the abstract type to convert
    /// </summary>
    protected PropertyInfo DiscriminatorProperty { get; }

    /// <summary>
    /// Gets an <see cref="Dictionary{TKey, TValue}"/> containing the mappings of the converted type's derived types
    /// </summary>
    protected Dictionary<string, Type> TypeMappings { get; }

    /// <inheritdoc/>
    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
            throw new JsonException("Start object token type expected");
        using (JsonDocument jsonDocument = JsonDocument.ParseValue(ref reader))
        {
            string discriminatorPropertyName = this.NamingPolicy?.ConvertName(this.DiscriminatorProperty.Name);
            if (!jsonDocument.RootElement.TryGetProperty(discriminatorPropertyName, out JsonElement discriminatorProperty))
                throw new JsonException($"Failed to find the required '{this.DiscriminatorProperty.Name}' discriminator property");
            string discriminatorValue = discriminatorProperty.GetString();
            if (!this.TypeMappings.TryGetValue(discriminatorValue, out Type derivedType))
                throw new JsonException($"Failed to find the derived type with the specified discriminator value '{discriminatorValue}'");
            string json = jsonDocument.RootElement.GetRawText();
            return (T)JsonSerializer.Deserialize(json, derivedType);
        }
    }

    /// <inheritdoc/>
    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        JsonSerializer.Serialize(writer, (object)value, options);
    }

}

L'attribut DiscriminatorAttribute :

/// <summary>
/// Represents the <see cref="Attribute"/> used to indicate the property used to discriminate derived types of the marked class
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class DiscriminatorAttribute
    : Attribute
{

    /// <summary>
    /// Initializes a new <see cref="DiscriminatorAttribute"/>
    /// </summary>
    /// <param name="property">The name of the property used to discriminate derived types of the class marked by the <see cref="DiscriminatorAttribute"/></param>
    public DiscriminatorAttribute(string property)
    {
        this.Property = property;
    }

    /// <summary>
    /// Gets the name of the property used to discriminate derived types of the class marked by the <see cref="DiscriminatorAttribute"/>
    /// </summary>
    public string Property { get; }

}

L'attribut DiscriminatorValueAttribute :

 /// <summary>
/// Represents the <see cref="Attribute"/> used to indicate the discriminator value of a derived type
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class DiscriminatorValueAttribute
    : Attribute
{

    /// <summary>
    /// Initializes a new <see cref="DiscriminatorValueAttribute"/>
    /// </summary>
    /// <param name="value">The value used to discriminate the derived type marked by the <see cref="DiscriminatorValueAttribute"/></param>
    public DiscriminatorValueAttribute(object value)
    {
        this.Value = value;
    }

    /// <summary>
    /// Gets the value used to discriminate the derived type marked by the <see cref="DiscriminatorValueAttribute"/>
    /// </summary>
    public object Value { get; }

}

Et enfin, un exemple d'utilisation sur les classes :

[Discriminator(nameof(Type))]
public abstract class Identity
{

    public virtual IdentityType Type { get; protected set; }

}

[DiscriminatorValue(IdentityType.Person)]
public class Person
   : Identity
{

}

Et... Voilà !

Il ne reste plus qu'à enregistrer l'usine :

 this.Services.AddControllersWithViews()
            .AddJsonOptions(options => 
            {
                options.JsonSerializerOptions.Converters.Add(new AbstractClassConverterFactory(options.JsonSerializerOptions.PropertyNamingPolicy));
            });

2voto

EOLeary Points 129

Je vous propose cette option : Utiliser un générateur de code source pour générer automatiquement un JsonConverter pour les objets dont une propriété est marquée d'un attribut spécial.

Vous pouvez l'essayer avec ce paquet, mais il nécessite .net5

https://github.com/wivuu/Wivuu.JsonPolymorphism

Le générateur examine le type de la propriété marquée par un attribut discriminant, puis recherche les types héritant du type contenant le discriminant pour correspondre à chaque cas de l'enum.

Source ici : https://github.com/wivuu/Wivuu.JsonPolymorphism/blob/master/Wivuu.JsonPolymorphism/JsonConverterGenerator.cs

enum AnimalType
{
    Insect,
    Mammal,
    Reptile,
    Bird // <- This causes an easy to understand build error if it's missing a corresponding inherited type!
}

// My base type is 'Animal'
abstract partial record Animal( [JsonDiscriminator] AnimalType type, string Name );

// Animals with type = 'Insect' will automatically deserialize as `Insect`
record Insect(int NumLegs = 6, int NumEyes=4) : Animal(AnimalType.Insect, "Insectoid");

record Mammal(int NumNipples = 2) : Animal(AnimalType.Mammal, "Mammalian");

record Reptile(bool ColdBlooded = true) : Animal(AnimalType.Reptile, "Reptilian");

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X