Json.net - 如何在填充字典时保留字典值引用?

Json.net - How to preserve dictionary value references when populating a dictionary?

我想从 JSON 文件填充字典中包含的对象,同时保留对象引用本身。

Json.net 关于 PreserveReferencesHandling 的文档明确指出,如果类型实现 System.Runtime.Serialization.ISerializable:

,它将不起作用

Specifies reference handling options for the Newtonsoft.Json.JsonSerializer. Note that references cannot be preserved when a value is set via a non-default constructor such as types that implement System.Runtime.Serialization.ISerializable.

这是我失败的代码:

class Model
{
   public int Val { get; set; } = 123;
}

...

    var model = new Model();
    var to_serialize = new Dictionary<int, Model> { { 0, model } }; // works ok with list<Model>

    // serialize
    var jsonString = JsonConvert.SerializeObject(to_serialize, Formatting.Indented);

    var jsonSerializerSettings = new JsonSerializerSettings();
    jsonSerializerSettings.MissingMemberHandling = MissingMemberHandling.Ignore;
    jsonSerializerSettings.PreserveReferencesHandling = PreserveReferencesHandling.All; // does not work for ISerializable
    
    Assert.AreSame(to_serialize[0], model); // ok!

    JsonConvert.PopulateObject(
        value: jsonString,
        target: to_serialize,
        settings: jsonSerializerSettings
    );

    Assert.AreSame(to_serialize[0], model); // not ok... works ok with list<Model>

我的主要要求是在调用 PopulateObject() 时,不会调用 Model 的构造函数 class。相反,只有它的内部字段会用 JSON 中的值更新。 在我的真实情况下,模型 class 包含其他不在 JSON 中但我不想丢失的值:

[JsonObject(MemberSerialization.OptIn)]
class Model
{
   [JsonProperty(PropertyName = "val_prop")]
   public int Val { get; set; } = 123;

   // not in the json file, would like this field to maintain the value
   // it had prior to PopulateObject()
   public int OtherVal { get; set; } = 456;
}

有没有办法让它工作?

您的问题类似于 : you would like to populate a preexisting collection, specifically a Dictionary<int, T> for some T, and populate the preexisting values. Unfortunately, in the case of a dictionary, Json.NET will replace the values rather than populate them, as can be seen in JsonSerializerInternalReader.PopulateDictionary() 中的问题,它只是将值反序列化为适当的类型,并将其设置为字典。

要解决此限制,您可以为 Dictionary<TKey, TValue> 创建一个 custom JsonConverter,当 TKey 是原始类型而 TValue 是合并传入的复杂类型时JSON key/value 对到预先存在的字典中。以下转换器可以解决问题:

public class DictionaryMergeConverter : JsonConverter
{
    static readonly IContractResolver defaultResolver = JsonSerializer.CreateDefault().ContractResolver;
    readonly IContractResolver resolver = defaultResolver;

    public override bool CanConvert(Type objectType)
    {
        var keyValueTypes = objectType.GetDictionaryKeyValueType();
        if (keyValueTypes == null)
            return false;
        var keyContract = resolver.ResolveContract(keyValueTypes[0]);
        if (!(keyContract is JsonPrimitiveContract))
            return false;
        var contract = resolver.ResolveContract(keyValueTypes[1]);
        return contract is JsonContainerContract;
        // Also possibly check whether keyValueTypes[1] is a read-only collection or dictionary.
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
            return null;
        if (reader.TokenType != JsonToken.StartObject)
            throw new JsonSerializationException(string.Format("Unexpected token {0}", reader.TokenType));
        IDictionary dictionary = existingValue as IDictionary ?? (IDictionary)serializer.ContractResolver.ResolveContract(objectType).DefaultCreator();
        var keyValueTypes = objectType.GetDictionaryKeyValueType();
        while (reader.ReadToContentAndAssert().TokenType != JsonToken.EndObject)
        {
            switch (reader.TokenType)
            {
                case JsonToken.PropertyName:
                    var name = (string)reader.Value;
                    reader.ReadToContentAndAssert();

                    // TODO: DateTime keys and enums with overridden names.
                    var key = (keyValueTypes[0] == typeof(string) ? (object)name : Convert.ChangeType(name, keyValueTypes[0], serializer.Culture));
                    var value = dictionary.Contains(key) ? dictionary[key] : null;

                    // TODO:
                    //  - JsonConverter active for valueType, either in contract or in serializer.Converters
                    //  - NullValueHandling, ObjectCreationHandling, PreserveReferencesHandling, 

                    if (value == null)
                    {
                        value = serializer.Deserialize(reader, keyValueTypes[1]);
                    }
                    else
                    {
                        serializer.Populate(reader, value);
                    }
                    dictionary[key] = value;
                    break;

                default:
                    throw new JsonSerializationException(string.Format("Unexpected token {0}", reader.TokenType));
            }
        }

        return dictionary;
    }

    public override bool CanWrite { get { return false; } }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { throw new NotImplementedException(); }
}

public static partial class JsonExtensions
{
    public static JsonReader ReadToContentAndAssert(this JsonReader reader)
    {
        return reader.ReadAndAssert().MoveToContentAndAssert();
    }

    public static JsonReader MoveToContentAndAssert(this JsonReader reader)
    {
        if (reader == null)
            throw new ArgumentNullException();
        if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
            reader.ReadAndAssert();
        while (reader.TokenType == JsonToken.Comment) // Skip past comments.
            reader.ReadAndAssert();
        return reader;
    }

    public static JsonReader ReadAndAssert(this JsonReader reader)
    {
        if (reader == null)
            throw new ArgumentNullException();
        if (!reader.Read())
            throw new JsonReaderException("Unexpected end of JSON stream.");
        return reader;
    }
}

public static class TypeExtensions
{
    public static IEnumerable<Type> BaseTypesAndSelf(this Type type)
    {
        while (type != null)
        {
            yield return type;
            type = type.BaseType;
        }
    }

    public static Type[] GetDictionaryKeyValueType(this Type type)
    {
        return type.BaseTypesAndSelf().Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>)).Select(t => t.GetGenericArguments()).FirstOrDefault();
    }
}

这样做之后,您会遇到一个次要问题:Json.NET 永远不会使用自定义转换器来填充 根对象 。要解决此问题,您需要通过一些实用方法直接调用 JsonConverter.ReadJson()

public static partial class JsonExtensions
{
    public static void PopulateObjectWithConverter(string value, object target, JsonSerializerSettings settings)
    {
        if (target == null || value == null)
            throw new ArgumentNullException();
        var serializer = JsonSerializer.CreateDefault(settings);
        var converter = serializer.Converters.Where(c => c.CanConvert(target.GetType()) && c.CanRead).FirstOrDefault() ?? serializer.ContractResolver.ResolveContract(target.GetType()).Converter;
        using (var jsonReader = new JsonTextReader(new StringReader(value)))
        {
            if (converter == null)
                serializer.Populate(jsonReader, target);
            else
            {
                jsonReader.MoveToContentAndAssert();
                var newtarget = converter.ReadJson(jsonReader, target.GetType(), target, serializer);
                if (newtarget != target)
                    throw new JsonException(string.Format("Converter {0} allocated a new object rather than populating the existing object {1}.", converter, value));
            }
        }
    }
}

您现在可以按如下方式填充您的词典:

var jsonString = JsonConvert.SerializeObject(to_serialize, Formatting.Indented);

var settings = new JsonSerializerSettings
{
    Converters = { new DictionaryMergeConverter() },
};
JsonExtensions.PopulateObjectWithConverter(jsonString, to_serialize, settings);

备注:

  • PreserveReferencesHandling 对是否填充或替换字典值没有影响。相反,此设置控制具有对同一对象的多个引用的序列化图在往返时是否会保持其引用拓扑。

  • 在你的问题中你写了// works ok with list<Model>但实际上这是不正确的。当 List<T> 被填充时,新值被 追加 到列表中,因此 Assert.AreSame(to_serialize[0], model); 完全靠运气。如果您另外断言 Assert.AreSame(1, to_serialize.Count) 它将失败。

  • 虽然转换器适用于 stringint 等原始键,但可能不适用于需要 JSON 特定转换的键类型,例如enumDateTime.

  • 转换器目前仅针对 Dictionary<TKey, TValue> 实现,并利用了此类型实现非通用 IDictionary 接口这一事实。如果需要,它可以扩展到其他字典类型,例如 SortedDictionary<TKey,TValue>

演示 fiddle here.