Serialization of concrete classes
Let’s start with a simple one that can (de)serialize a concrete class Category
. In our example we (de)serialize the property Name
only.
public class Category
{
public string Name { get; }
public Category(string name)
{
Name = name;
}
}
To implement a custom JSON converter we have to derive from the generic class JsonConverter<T>
and to implement 2 methods: Read
and Write
.
public class CategoryJsonConverter : JsonConverter
{
public override Category Read(ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
var name = reader.GetString();
return new Category(name);
}
public override void Write(Utf8JsonWriter writer,
Category value,
JsonSerializerOptions options)
{
writer.WriteStringValue(value.Name);
}
}
The method Read
is using the Utf8JsonReader
to fetch a string
, i.e. the name, and the method Write
is writing a string
using an instance of Utf8JsonWriter
.
In both cases (i.e. during serialization and deserialization) the converter is not being called if the value is null
so I skipped the null checks. The .NET team doesn’t do null checks
either, see JsonKeyValuePairConverter<TKey, TValue>.
Let’s test the new JSON converter. For that we create an instance of JsonSerializerOptions
and add our CategoryJsonConverter
to the Converters
collection. Next, we use the static class JsonSerializer
to serialize and to deserialize an instance of Category
.
Category category = new Category("my category");
var serializerOptions = new JsonSerializerOptions
{
Converters = { new CategoryJsonConverter() }
};
// json = "my category"
var json = JsonSerializer.Serialize(category, serializerOptions);
// deserializedCategory.Name = "my category"
var deserializedCategory = JsonSerializer.Deserialize(json, serializerOptions);
Serialization of generic classes
The next example is slightly more complex. The property we are serializing is a generic type argument, i.e. we can’t use methods like reader.GetString()
or writer.WriteStringValue(name)
because we don’t know the type at compile time.
In this example I’ve changed the class Category
to a generic type and renamed the property Name
to Key
:
public class Category
{
public T Key { get; }
public Category(T key)
{
Key = key;
}
}
For serialization of the generic property Key
we need to fetch a JsonSerializer<T>
using the instance of JsonSerializerOptions
.
public class CategoryJsonConverter : JsonConverter>
{
public override Category Read(ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
var converter = GetKeyConverter(options);
var key = converter.Read(ref reader, typeToConvert, options);
return new Category(key);
}
public override void Write(Utf8JsonWriter writer,
Category value,
JsonSerializerOptions options)
{
var converter = GetKeyConverter(options);
converter.Write(writer, value.Key, options);
}
private static JsonConverter GetKeyConverter(JsonSerializerOptions options)
{
var converter = options.GetConverter(typeof(T)) as JsonConverter;
if (converter is null)
throw new JsonException("...");
return converter;
}
}
The behavior of the generic JSON converter is the same as before especially if the Key
is of type string
.
Deciding the concrete JSON converter at runtime
Having several categories with different key types, say, string
and int
, we need to register them all with the JsonSerializerOptions
.
var serializerOptions = new JsonSerializerOptions
{
Converters =
{
new CategoryJsonConverter(),
new CategoryJsonConverter()
}
};
If the number of required CategoryJsonConverter
s grows to big or the concrete types of the Key
are not known at compile time then this approach is not an option. To make this decision at runtime we need to implement a JsonConverterFactory
. The factory has 2 method: CanConvert(type)
that returns true
if the factory is responsible for the serialization of the provided type; and CreateConverter(type, options)
that should return an instance of type JsonConverter
.
public class CategoryJsonConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
if (!typeToConvert.IsGenericType)
return false;
var type = typeToConvert;
if (!type.IsGenericTypeDefinition)
type = type.GetGenericTypeDefinition();
return type == typeof(Category<>);
}
public override JsonConverter CreateConverter(Type typeToConvert,
JsonSerializerOptions options)
{
var keyType = typeToConvert.GenericTypeArguments[0];
var converterType = typeof(CategoryJsonConverter<>).MakeGenericType(keyType);
return (JsonConverter)Activator.CreateInstance(converterType);
}
}
Now, we can remove all registrations of the CategoryJsonConverter<T>
from the options and add the newly implemented factory.
Category category = new Category(42);
var serializerOptions = new JsonSerializerOptions
{
Converters = { new CategoryJsonConverterFactory() }
};
// json = 42
var json = JsonSerializer.Serialize(category, serializerOptions);
// deserialized.Key = 42
var deserialized = JsonSerializer.Deserialize>(json, serializerOptions);
In the end the implementation of a custom converter for System.Text.Json
is very similar to the one for Newtonsoft.Json
. The biggest difference here is the non-existence of a non-generic JsonConverter
but for that we’ve got the JsonConverterFactory
.
Actually, there is a non-generic JsonConverter
which is the base class of the JsonConverter<T>
and the JsonConverterFactory
but we cannot (and should not) use this class directly because its constructor is internal.