Sunday, April 6, 2014

Deserialize Abstract Classes with Json.NET

Here is a fun problem: how do you deserialize an array of objects with different types, but all of which inherit from the same super class?

If you are using Newtonsoft's Json.NET, then this is actually rather easy to implement!

Example

Here are three classes...

public abstract class Pet { public string Name { get; set; } }
public class Dog : Pet { public string FavoriteToy { get; set; } }
public class Cat : Pet { public bool WantsToKillYou { get; set; } }

...here is an array with instances of those objects mixed together...

new Pet[]
{
    new Cat { Name = "Sql", WantsToKillYou = true },
    new Cat { Name = "Linq", WantsToKillYou = false },
    new Dog { Name = "Taboo", FavoriteToy = "Sql" }
}

...and now let's make it serialize and deseriailze! :)

Extending the JsonConverter

This tactic is actually quite simple! You need to extend a JsonConverter for your specific super class that is able to somehow uniquely identify each child class. In this example we look for a specific property that only exists on the child class, and Newtonsoft's JObjects and JTokens make this very easy to do!

public abstract class AbstractJsonConverter<T> : JsonConverter
{
    protected abstract T Create(Type objectType, JObject jObject);
 
    public override bool CanConvert(Type objectType)
    {
        return typeof(T).IsAssignableFrom(objectType);
    }
 
    public override object ReadJson(
        JsonReader reader, 
        Type objectType, 
        object existingValue, 
        JsonSerializer serializer)
    {
        var jObject = JObject.Load(reader);
 
        T target = Create(objectType, jObject);
        serializer.Populate(jObject.CreateReader(), target);
 
        return target;
    }
 
    public override void WriteJson(
        JsonWriter writer, 
        object value, 
        JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }
 
    protected static bool FieldExists(
        JObject jObject, 
        string name, 
        JTokenType type)
    {
        JToken token;
        return jObject.TryGetValue(name, out token) && token.Type == type;
    }
}
 
public class PetConverter : AbstractJsonConverter<Pet>
{
    protected override Pet Create(Type objectType, JObject jObject)
    {
        if (FieldExists(jObject, "FavoriteToy", JTokenType.String))
            return new Dog();
 
        if (FieldExists(jObject, "WantsToKillYou", JTokenType.Boolean))
            return new Cat();
 
        throw new InvalidOperationException();
    }
}

Unit Tests

Now let's test the PetConverter against the the example array from above and see whether or not it works as expected. (Spoiler Alert: It works just fine!)

public class AbstractJsonConverterTests
{
    [Fact]
    public void PetConverter()
    {
        var originalArray = new Pet[]
        {
            new Cat { Name = "Sql", WantsToKillYou = true },
            new Cat { Name = "Linq", WantsToKillYou = false },
            new Dog { Name = "Taboo", FavoriteToy = "Sql" }
        };
 
        var json = JsonConvert.SerializeObject(originalArray);
 
        var converter = new PetConverter();
        var deserializedArray = JsonConvert.DeserializeObject<Pet[]>(
            json, 
            converter);
 
        Assert.Equal(originalArray.Length, deserializedArray.Length);
            
        for (var i = 0; i < originalArray.Length; i++)
        {
            var original = originalArray[i];
            var deserialized = deserializedArray[i];
 
            Assert.Equal(original.GetType(), deserialized.GetType());
            Assert.Equal(original.Name, deserialized.Name);
        }
    }
}

Enjoy,
Tom

3 comments:

  1. Hate to break it to you - but there is a much easier way, just use these settings:

    var settings = new JsonSerializerSettings {TypeNameHandling = TypeNameHandling.Auto};

    No converter or anything, it just works.

    - Poul

    ReplyDelete
    Replies
    1. Excellent Poul, thanks to disclose it

      Delete
    2. Yes, that can work and it is easy, but it couples you to type/namespace specific information about the objects. While the option in this article requires a bit more code on the part of the deserializer, it offers a much more flexible contract for the serializer.

      Delete

Real Time Web Analytics