Given the design of Json.Net, when you implement the contract, you are responsible for implementing the serialization and deserialization of the whole graph derived by traversing the type(s) handled by the contract itself.
This is hard, since we have to mimic the behavior of json.net on our own.
The accepted answer gives you the idea, for serializing. I put my 2c with the solution we have in production that implements both writing and reading, trying to be as transparent to the developer as possible.
Disclaimer
The following code solves our purposes (and my constraints) and is far for perfect. The converter must:
- handle polymorphic types (with deep hierarchies)
- be driven by attributes
- serialize cyclic graphs
- preserve the references
- preserve references across other contracts we have
- avoid to reinvent the wheel
For this purpose, we needed to shamefully bend and ruin the design of the entities to make the whole machinery work. Therefore:
- all the entities have a public or internal empty constructor used for this
- collections and dictionaries are mutable (e.g. no IReadOnlyList ...)
- read only properties have an internal setter for serialization purposes
- nullable values are defaulted to an ugly
default!
in the internal constructor, therefore protecting the developer for making mistakes, but opening a huge whole when deserializing a malformed or manually edited json, since nulls slip through.
Serialization
The key point for serialization is being able to do it, without recursively being called. This could be achieved (as suggested by other answers) by using the contract already defined in the serializer provided in WriteJson
.
private JObject ManuallySerializeParent(object value, JsonSerializer serializer)
{
var o = new JObject();
foreach (var item in _contract.Properties)
{
// (1)
if (item.Ignored || item.ShouldSerialize?.Invoke(value) == false || item.ValueProvider == null || item.PropertyName == null)
continue;
var propertyValue = item.ValueProvider.GetValue(value);
if (propertyValue != null)
o.Add(item.PropertyName, JToken.FromObject(propertyValue, serializer));
}
return o;
}
The condition in (1)
can be improved since it does not cover all the possible cases, but for us it is sufficient.
Deserialization
The deserialization process is nearly specular (with a catch, more below):
var contract = serializer.ContractResolver.ResolveContract(type);
var newReader = o.CreateReader();
// here we cannot use ToObject since it would recurse
var ret = ManuallyCreateObject(newReader, contract);
serializer.Populate(newReader, ret);
The catch is that, apparently, there is no public way to ask Json.Net to initialize an instance following it's knowledge. The ManuallyCreateObject
is an ugly helper method that access the private implementation detail JsonSerializerInternalReader
and invoke the appropriate method. Since this is a bad thing, you need to keep your code aligned to the library, since it can change at any time.
private static readonly Lazy<PrivateSerializerAccessor> _privateSerializerAccessor = new(LazyThreadSafetyMode.PublicationOnly);
private sealed class PrivateSerializerAccessor
{
public PrivateSerializerAccessor()
{
var type = typeof(JsonSerializer).Assembly.DefinedTypes.Single(x => x.Name == "JsonSerializerInternalReader") ?? throw new InvalidOperationException("Persistent bug: cannot access to the private serializer.");
var method = type.GetMethod("CreateNewObject", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(JsonReader), typeof(JsonObjectContract), typeof(JsonProperty), typeof(JsonProperty), typeof(string), typeof(bool).MakeByRefType() }) ?? throw new InvalidOperationException("Persistent bug: cannot get the method.");
StolenSerializedType = type;
StolenCreateNewObjectMethod = method;
}
public MethodInfo StolenCreateNewObjectMethod { get; }
public Type StolenSerializedType { get; }
public object CreateStolenSerializer()
{
return Activator.CreateInstance(StolenSerializedType, new JsonSerializer()) ?? throw new InvalidOperationException("Cannot create the private serializer.");
}
}
private static object ManuallyCreateObject(JsonReader reader, JsonContract contract)
{
var accessor = _privateSerializerAccessor.Value;
var o = accessor.CreateStolenSerializer();
var newObject = accessor.StolenCreateNewObjectMethod.Invoke(o, new object?[] { reader, contract, null, null, null, null }) ?? throw new InvalidOperationException("Cannot create the object.");
return newObject;
}
Full Code
Here is the full vetted and redacted code, including helper classes.
[System.AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class PolymorphicAttribute : Attribute
{
public PolymorphicAttribute(string contractTypeName)
{
if (string.IsNullOrWhiteSpace(contractTypeName))
throw new ArgumentNullException(nameof(contractTypeName));
ContractTypeName = contractTypeName;
}
public string ContractTypeName { get; }
}
public sealed class ModelJsonConverter : JsonConverter
{
private const string ContractName = "contractName";
private const string IdentityMetadataName = "$id";
private const string InvalidContractName = "@@invalid@@";
private const string ReferenceMetadataName = "$ref";
private static readonly Lazy<PrivateSerializerAccessor> _privateSerializerAccessor = new(LazyThreadSafetyMode.PublicationOnly);
private readonly JsonObjectContract _contract;
private readonly JsonObjectTracker _tracker;
public ModelJsonConverter(JsonObjectTracker tracker, JsonObjectContract contract)
{
ArgumentNullException.ThrowIfNull(tracker);
ArgumentNullException.ThrowIfNull(contract);
_tracker = tracker;
_contract = contract;
}
public override bool CanRead => true;
public override bool CanWrite => true;
internal static KnownConverterTypes KnownTypes { get; } = InitializeKnownTypes();
public override bool CanConvert(Type objectType)
{
return KnownTypes.TypeToId.ContainsKey(objectType);
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(serializer);
var o = JObject.Load(reader);
if (o == null)
return null;
var objectIdReference = o.GetValue(ReferenceMetadataName, StringComparison.Ordinal)?.ToString();
if (objectIdReference != null && _tracker.TryGetTrackedReference(objectIdReference, out var r))
return r;
var id = o.GetValue(ContractName, StringComparison.Ordinal)?.ToString();
var objectId = o.GetValue(IdentityMetadataName, StringComparison.Ordinal)?.ToString();
if (string.IsNullOrEmpty(id))
return null;
if (!KnownTypes.IdToType.TryGetValue(id, out var type))
throw new InvalidOperationException($@"Invalid contract ""{id}"".");
var contract = serializer.ContractResolver.ResolveContract(type);
var newReader = o.CreateReader();
var ret = ManuallyCreateObject(newReader, contract);
serializer.Populate(newReader, ret);
if (objectId != null && ret != null)
_tracker.TrackReference(objectId, ret);
return ret;
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(serializer);
if (value == null)
return;
if (_tracker.TryGetTrackedInstance(value, out var id))
{
var o = new JObject
{
{ ReferenceMetadataName, id }
};
o.WriteTo(writer);
}
else
{
// we generate the ids in such a way they can formally blend with json net but they do not overlap.
id = "y" + (_tracker.GenerateNewId()).ToString(CultureInfo.InvariantCulture);
_tracker.TrackInstance(value, id);
var o = ManuallySerializeParent(value, serializer);
// note one huge detail: we conform to the metadata syntax $id, but we write it purposefully at the end
// this way, json.net would simply ignore it by default and would not try to locate the reference.
o.Add(ContractName, new JValue(KnownTypes.TypeToId[value.GetType()]));
o.Add(IdentityMetadataName, new JValue(id));
o.WriteTo(writer);
}
}
private static KnownConverterTypes InitializeKnownTypes()
{
var assembly = typeof(ModelJsonConverter).Assembly;
var polimorphicTypes = assembly.GetTypes()
.Where(x => x.IsPublic && x.IsClass && x.GetCustomAttribute<PolymorphicAttribute>(false) != null)
.Select(x => (Type: x, x.GetCustomAttribute<PolymorphicAttribute>(false)!.ContractTypeName));
var idToType = new Dictionary<string, Type>(StringComparer.Ordinal);
var typeToId = new Dictionary<Type, string>();
foreach (var item in polimorphicTypes)
{
idToType.Add(item.ContractTypeName, item.Type);
typeToId.Add(item.Type, item.ContractTypeName);
// we also need to add all the abstract ancestors, so that the serializer knowns we are responsible for those types too.
// however, note that we do not allow deserialization of those types. Also, it is safe to use a placeholder for the type
// identifier, since we recover the type information from the runtime object.
var candidate = item.Type.BaseType;
while (candidate != null && candidate != typeof(object))
{
if (candidate.IsPublic && candidate.IsAbstract)
typeToId.TryAdd(candidate, InvalidContractName);
candidate = candidate.BaseType;
}
}
return new(idToType, typeToId);
}
private static object ManuallyCreateObject(JsonReader reader, JsonContract contract)
{
var accessor = _privateSerializerAccessor.Value;
var o = accessor.CreateStolenSerializer();
var newObject = accessor.StolenCreateNewObjectMethod.Invoke(o, new object?[] { reader, contract, null, null, null, null }) ?? throw new InvalidOperationException("Cannot create the object.");
return newObject;
}
private JObject ManuallySerializeParent(object value, JsonSerializer serializer)
{
var o = new JObject();
foreach (var item in _contract.Properties)
{
if (item.Ignored || item.ShouldSerialize?.Invoke(value) == false || item.ValueProvider == null || item.PropertyName == null)
continue;
var propertyValue = item.ValueProvider.GetValue(value);
if (propertyValue != null)
{
o.Add(item.PropertyName, JToken.FromObject(propertyValue, serializer));
}
}
return o;
}
private sealed class PrivateSerializerAccessor
{
public PrivateSerializerAccessor()
{
var type = typeof(JsonSerializer).Assembly.DefinedTypes.Single(x => x.Name == "JsonSerializerInternalReader") ?? throw new InvalidOperationException("Persistent bug: cannot access to the private serializer.");
var method = type.GetMethod("CreateNewObject", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(JsonReader), typeof(JsonObjectContract), typeof(JsonProperty), typeof(JsonProperty), typeof(string), typeof(bool).MakeByRefType() }) ?? throw new InvalidOperationException("Persistent bug: cannot get the method.");
StolenSerializedType = type;
StolenCreateNewObjectMethod = method;
}
public MethodInfo StolenCreateNewObjectMethod { get; }
public Type StolenSerializedType { get; }
public object CreateStolenSerializer()
{
return Activator.CreateInstance(StolenSerializedType, new JsonSerializer()) ?? throw new InvalidOperationException("Cannot create the private serializer.");
}
}
}
}
// this class is not thread safe.
public sealed class JsonObjectTracker
{
private readonly Dictionary<object, string> _ids = new();
private readonly Dictionary<string, object> _instances = new();
private long _id;
public long GenerateNewId() => Interlocked.Increment(ref _id);
public void TrackInstance(object instance, string objectId)
{
ArgumentNullException.ThrowIfNull(instance);
ArgumentNullException.ThrowIfNull(objectId);
_ids.TryAdd(instance, objectId);
}
public void TrackReference(string objectId, object instance)
{
ArgumentNullException.ThrowIfNull(objectId);
ArgumentNullException.ThrowIfNull(instance);
_instances.TryAdd(objectId, instance);
}
public bool TryGetTrackedInstance(object instance, [MaybeNullWhen(false)] out string objectId)
{
ArgumentNullException.ThrowIfNull(instance);
return _ids.TryGetValue(instance, out objectId);
}
public bool TryGetTrackedReference(string reference, [MaybeNullWhen(false)] out object instance)
{
return _instances.TryGetValue(reference, out instance);
}
}