Home > OS >  Customizing serialization of System.Text.Json without converter?
Customizing serialization of System.Text.Json without converter?

Time:10-16

Let's say I have a type/class "Person" and I would like to write custom serialization for it. Typical answer to this problem would be "write a converter". The thing is, I don't want to go this route -- I would like to write serialization directly into this type "Person", so this class would know how to serialize itself.

How to do it when using System.Text.JSON?

CodePudding user response:

I'm not pretty sure whether if this is what you want. SerializationInfo is just some kinda K-V collection, so if an object implemements any enumerable like IDictionary or IDictionary<,>, the default converter will serialize it as a json object.

You can declare an interface to reduce unrelated methods that have to be implemented in the class

interface IJsonObject : IDictionary
{
    int ICollection.Count => 0;
    ICollection IDictionary.Keys => null;
    //...
}

class Person : IJsonObject
{
    // data example
    Dictionary<string, object> properties = new()
    {
        ["a"] = 100,
        ["b"] = "Big"
    };

    IDictionaryEnumerator IDictionary.GetEnumerator()
       => properties.GetEnumerator();  
}

Result:

{"a":100,"b":"Big"}

CodePudding user response:

You're fighting against the API design here; a custom JsonConverter is going to be the easiest way to take control of your type's serialization. That being said, if for some reason you refuse to write a converter, you could customize your JSON serialization by populating a JsonExtensionDataAttribute overflow dictionary with your customized JSON contents during serialization via an OnSerializing callback, then postprocess the overflow dictionary to populate your model via an OnDeserialized callback. You will also need to prevent default serialization by applying [JsonIgnore] to all properties that should not be serialized automatically.

For instance, say you have the following model:

public class Model 
{
    public List<string?>? List { get; set; }
}

And you want it to be serialized like so:

{
  "0": "value 0",
  "1": "value 1"
}

Then you could define your model as follows:

public class Model : IJsonOnDeserialized, IJsonOnSerializing, IJsonOnSerialized
{
    [JsonIgnore] // Prevent default serialization of properties that are serialized as part of the JsonExtensionData dictionary
    public List<string?>? List { get; set; }
    
    [JsonExtensionData, JsonInclude]
    public Dictionary<string, JsonElement>? Elements { get; private set; }

    protected virtual Dictionary<string, JsonElement> SerializeJsonElements(Dictionary<string, JsonElement> elements) => 
        List.EmptyIfNull().Select((value, index) => (value, index)).Aggregate(elements, (e, p) => { e.Add(p.index.ToInvariantString(), p.value.ToJsonElement()); return e; } );

    protected virtual void DeserializeJsonElements(Dictionary<string, JsonElement>? elements) => 
        List = elements?.Select(p => p.Value.Deserialize<string>()).ToList();

    void IJsonOnSerializing.OnSerializing() => Elements = SerializeJsonElements(new ());
    void IJsonOnSerialized.OnSerialized() => Elements = null; // Elements are no longer needed, so null them out.
    void IJsonOnDeserialized.OnDeserialized() 
    {
        DeserializeJsonElements(Elements);
        Elements = null;
    }
}

public static class JsonExtensions
{
    public static IEnumerable<T> EmptyIfNull<T>([System.Diagnostics.CodeAnalysis.AllowNull] this IEnumerable<T> enumerable) => enumerable ?? Enumerable.Empty<T>();
    public static JsonElement ToJsonElement<T>(this T value, JsonSerializerOptions? options = default) => JsonSerializer.SerializeToElement(value, options);
    public static string ToInvariantString<TFormattable>(this TFormattable input) where TFormattable : IFormattable => Convert.ToString(input, CultureInfo.InvariantCulture) ?? "";
}

Then it will be serialized as required. Notes:

  • The JsonExtensionData dictionary must be public, but may have a private setter if also marked with [JsonInclude].

  • The dictionary is nulled out in the OnDeserialized callback as well as in an OnSerialized callback to prevent it from permanently consuming memory. (Thus this trick requires the implementation of three interfaces: IJsonOnSerializing, IJsonOnSerialized and IJsonOnDeserialized.)

  • Derived types may add their own content to the dictionary of custom elements by overriding SerializeJsonElements() and DeserializeJsonElements().

Demo fiddle #1 here.

As an aside, I would question your assumption that writing a converter is difficult or adds complexity to your library. The converter can be a private, nested type inside your type as long as you apply it direct to the type via [JsonConverter(typeof(TMyType.TInnerConverter)). For instance, here's a private converter for the Model type above:

[JsonConverter(typeof(Model.ModelConverter))]
public class Model 
{
    public List<string?>? List { get; set; }

    private class ModelConverter : JsonConverter<Model>
    {
        public override Model? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
            new Model { List = JsonSerializer.Deserialize<Dictionary<string, string?>>(ref reader, options)?.Values.ToList() };

        public override void Write(Utf8JsonWriter writer, Model value, JsonSerializerOptions options) => 
            JsonSerializer.Serialize(writer, value.List.EmptyIfNull().Select((value, index) => (value, index)).ToDictionary(p => p.index.ToInvariantString(), p => p.value));
    }
}

public static class JsonExtensions
{
    public static IEnumerable<T> EmptyIfNull<T>([System.Diagnostics.CodeAnalysis.AllowNull] this IEnumerable<T> enumerable) => enumerable ?? Enumerable.Empty<T>();
    public static string ToInvariantString<TFormattable>(this TFormattable input) where TFormattable : IFormattable => Convert.ToString(input, CultureInfo.InvariantCulture) ?? "";
}

I think this is simpler than the JsonExtensionData workaround, and adds no complexity to the public surface of your types.

Demo fiddle #2 here.

  • Related