【问题标题】:.Net System.Text.Json: how to deserialize to a class constructor where parameters nullability doesn't match class properties nullability.Net System.Text.Json:如何反序列化为参数可空性与类属性可空性不匹配的类构造函数
【发布时间】:2022-02-22 08:53:27
【问题描述】:

我有一堆想要反序列化为不可变类的 Json 文件。

例如像这样的 Json 文件:

{
  "Prop1": 5,
  "Prop3": {
    "NestedProp1": "something",
    "NestedProp3": 42
  }
}

应该反序列化为这样的类:

public class Outer 
{
  public int Prop1 { get; }
  public int Prop2 { get; }
  public Inner Prop3 { get; }
  public string Prop4 { get; }
  public Outer(int prop1, int? prop2, Inner prop3, string? prop4)
  {
    Prop1 = prop1;
    Prop2 = prop2 ?? GetSensibleRuntimeProp2Default();
    Prop3 = prop3;  
    Prop4 = prop4 ?? GetSensibleRuntimeProp4Default();
  }
...
}

public class Inner
{
  public string NestedProp1 { get; }
  public int NestedProp2 { get; }
  public int NestedProp3 { get; }
  public Inner(string nestedProp1, int? nestedProp2, int nestedProp3)
  {
    NestedProp1 = nestedProp1;
    NestedProp2 = nestedProp2 ?? GetSensibleRuntimeNestedProp2Default();
    NestedProp3 = nestedProp3;
  }
...
}

可以看到,一些构造函数参数是可以为空的(ref 或 value 类型),这样可以在 Json 文件中没有指定值的情况下注入一些默认值。但是类中的匹配属性不能为空。

问题是 System.Text.Json 反序列化要求构造函数参数和属性具有完全相同的类型,所以如果我尝试这样做,我会得到一个异常说明这个确实记录在案的要求。

我将如何解决这个限制?我能否以某种方式在反序列化过程中注入代码以插入我自己的反序列化对象策略(同时让默认进程处理值和数组)?

我正在处理现有的类和 Json,并且我不允许进行更改,例如向类添加可为空的属性以匹配构造函数参数。 这件事实际上使用 Newtonsoft.Json 工作,我被要求将其转换为使用 System.Text.Json。

我编写的代码使用反射来反序列化 Json 文件中的第一级对象,使用与 Json 对象属性最匹配的目标类构造函数。看起来是这样的:

public static object CreateInstance(Type targetType, JsonObject prototype, JsonSerializerOptions serializerOptions)
{
    var constructors = GetEligibleConstructors(targetType);
    var prototypePropertySet = prototype.Select(kvp => kvp.Key)
          .ToImmutableHashSet(StringComparer.CurrentCultureIgnoreCase);
    var bestMatch = FindBestConstructorMatch(prototypePropertySet, constructors);
    if (bestMatch is null)
        throw new NoSuitableConstructorFoundException($"COuld not find a suitable constructor to instanciate {targetType.FullName} from \"{prototype.ToJsonString()}\"");
    var valuedParams = GetParameterValues(bestMatch, prototype, serializerOptions);
    return bestMatch.Constructor.Invoke(bestMatch.Parameters.Select(p => valuedParams[p.Name]).ToArray());
}

(我是从 JsonObject 而非文本反序列化,但我认为这与问题无关)

所以基本上:

  • 获取目标类型的所有公共构造函数,
  • 找到与 Json 对象属性有最多共同参数的那个
  • 使用标准 System.Text.Json 反序列化从 Json 中获取参数值
  • 调用构造函数

显然这种方法将我限制在第一级对象,因为所有子属性都将由标准反序列化处理。

我希望能够通过在 JsonConverter 中使用类似代码递归地执行此操作,该代码将在反序列化 Json 对象时调用,而 Json 数组或原始值的反序列化将留给标准转换器。

【问题讨论】:

  • 忘记 System.Text.Json 的存在,越早越好。你只是在浪费你和其他人尝试做某事的时间。
  • 如果您无法更改您的课程InnerOuter,您可以选择为每种类型创建一个custom JsonConverter。或者您可以尝试使用反射编写通用转换器。 System.Text.Json 不公开其合约信息,如System.Text.Json API is there something like IContractResolver 中所述,因此您无法在运行时注入自定义构造逻辑。
  • 有一些增强 System.Text.Json 的 3rd 方包,也许它们在这里对你有用。参见例如Json.Net JsonConstructor attribute alternative for System.Text.Json
  • @dbc,谢谢,我会检查一下,但首先摆脱 Json.Net 的原因是为了减少对 3rd 方包的依赖... ;-)
  • @dbc,代码位于一个库中,该库将用于反序列化为我在编译时不知道的类,因此无法使用特定的转换器(除非在运行时发出代码,但我会而不是)。我实际上已经编写了可以处理第一级(外部)类上的可为空参数的反射代码,但我一直坚持如何递归地为嵌套对象执行此操作。

标签: c# json deserialization system.text.json


【解决方案1】:

按照@dbc 的建议,我使用JsonConverterFactory 模式来解决我的问题。

这是我写的两个类。 转换器本身:

public class CustomObjectConverter<T> : JsonConverter<T> where T : class
{
    private record struct UnmatchedParameterInfo(string Name, Type Type, bool AcceptsNullValue);
    private record struct MatchedParameterInfo(string Name, Type Type, object Value);

    // ConstructorMatcher is a helper class that will keep track of 
    // Json properties that can be matched with the parameters of 
    // a specific constructor as they are read from the reader
    private class ConstructorMatcher
    {
        public ConstructorInfo ConstructorInfo { get; }
        private Dictionary<string, UnmatchedParameterInfo> UnmatchedParameters { get; }
        private Dictionary<string, MatchedParameterInfo> MatchedParameters { get; } = new();

        private ImmutableList<string> ParameterList { get; }

        public ConstructorMatcher(ConstructorInfo constructorInfo)
        {
            ConstructorInfo = constructorInfo ?? throw new ArgumentNullException(nameof(constructorInfo));
            var parameters = constructorInfo.GetParameters();
            if (parameters.Any(p => p.Name is null))
                throw new Exception("<useful exception message>");
            ParameterList = parameters.Select(p => p.Name!).ToImmutableList();
            UnmatchedParameters = 
                 parameters.ToDictionary(
                              p => p.Name!, 
                              p => new UnmatchedParameterInfo(
                                           p.Name!, 
                                           p.ParameterType,
                                           AcceptsNullValue(p.ParameterType)), 
                              StringComparer.CurrentCultureIgnoreCase);
        }

        // Checks is null can be assigned to a certain type
        private bool AcceptsNullValue(Type type)
        {
            // This is improvable as it doesn't use reference types 
            // nullability information
            return type.IsClass || Nullable.GetUnderlyingType(type) is not null;
        }

        public int MatchCount => MatchedParameters.Count;

        public int UnmatchedCount => UnmatchedParameters.Count;

        // Checks if the constructor still has a yet unmatched parameter
        // named "name"
        public bool HasParameter(string name) => UnmatchedParameters.ContainsKey(name);

        // Returns the type that the constructor expects for parameter "name".
        // The converter needs this because it has to know beforehand the type
        // of the property it is about to deserialize.
        public Type GetTypeForParameter(string name)
        {
            return UnmatchedParameters.TryGetValue(name, out var info) 
                       ? info.Type 
                       : throw new Exception("<useful exception message>");
        }

        // Binds a value to a constructor parameter
        public void AddParameterValue(string name, Type type, object? value)
        {
            if (!UnmatchedParameters.Remove(name, out var unmatchedParameterInfo))
                throw new Exception("<useful exception message>");
            if (unmatchedParameterInfo.Type != type)
                throw new Exception("<useful exception message>");
            MatchedParameters.Add(unmatchedParameterInfo.Name, 
                                  new MatchedParameterInfo(
                                         unmatchedParameterInfo.Name,
                                         unmatchedParameterInfo.Type, 
                                         value));
        }

        // Checks if the constructor has some unbound parameters
        // that won't accept null values
        public bool HasNonNullableUnmatchedParameters => UnmatchedParameters.Values.Any(upi => !upi.AcceptsNullValue);

        // Gets parameter values in the righ order for constructor invocation
        public object?[] GetInvocationParameters()
        {
            return ParameterList.Select(parameterName => MatchedParameters.TryGetValue(parameterName, out var mpi)
                                                             ? mpi.Value
                                                             : null)
                                .ToArray();
        }
    }

    // For each Json property:
    // - read the Json property name, 
    // - eliminate constructors that don't have a parameter with 
    //   a compatible name
    // - get the expected type for this property (end throw if all 
    //   constructors don't expect the same type for the same property)
    // - deserialize the property value to the expected type
    // - bind the deserialized value to the matching parameter of each 
    //   candidate constructor
    // When all properties are read, invoke the constructor that has the 
    // most parameters bound to values, with the less unbound parameters
    // (with the condition that all unbound parameters can be bound to null)
    public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException();
        }

        var candidates = typeToConvert.GetConstructors().Select(ci => new ConstructorMatcher(ci)).ToImmutableList();

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                var bestCandidate = candidates
                                    .Where(c => !c.HasNonNullableUnmatchedParameters)
                                    .OrderByDescending(c => c.MatchCount)
                                              .ThenBy(c => c.UnmatchedCount)
                                              .FirstOrDefault() ??
                                    throw new NoSuitableConstructorFoundException("<useful exception message>");
                return (T?)bestCandidate.ConstructorInfo.Invoke(bestCandidate.GetInvocationParameters());
            }

            if (reader.TokenType != JsonTokenType.PropertyName)
            {
                throw new JsonException();
            }

            var propertyName = reader.GetString() ?? throw new Exception("<useful exception message>");

            candidates = candidates.Where(c => c.HasParameter(propertyName)).ToImmutableList();
            var possibleTypes = candidates.Select(c => c.GetTypeForParameter(propertyName))
                                          .Distinct()
                                          .ToArray();
            if (possibleTypes.Length > 1)
                throw new AmbiguousConfigurationException("<useful exception message>");

            var propertyType = possibleTypes[0];

            var value = JsonSerializer.Deserialize(ref reader, propertyType, options);

            foreach (var constructorMatcher in candidates)
            {
                constructorMatcher.AddParameterValue(propertyName, propertyType, value);
            }
        }

        throw new JsonException();
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        foreach (var property in typeof(T).GetProperties())
        {
            writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name);
            JsonSerializer.Serialize(value, options);
        }
        writer.WriteEndObject();
    }
}

现在对于转换器工厂,我们必须解决的问题是“我们的转换器应该应用于哪些目标类型?”

我们想要处理所有 Json 对象,但没有什么明确的联系 具有特定类别的 CLR 类型的 Json 对象。 Json 对象可以反序列化为 POCO 类,也可以反序列化为字典 和结构。相反,集合类通常会从 Json 数组中反序列化。 一些 Json 对象也可以由序列化选项中的自定义转换器处理。 我很确定我们的策略可以改进,因为我们将自己限制在我们特定情况下足够的范围内。

我们选择将转换器应用于所有未实现 IEnumerable 的类(我猜主要限制是我们不处理结构)。

此外,如果我们在可以转换目标类型的序列化选项中找到更具体的转换器,我们将应用它而不是我们的转换器。

public class CustomObjectConverterFactory: JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert)
    {
        return typeToConvert.IsClass && !typeof(IEnumerable).IsAssignableFrom(typeToConvert);
    }

    public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        var moreSpecificConverter = options.Converters.FirstOrDefault(c => c != this && c.CanConvert(typeToConvert));
        if (moreSpecificConverter is not null)
            return moreSpecificConverter is JsonConverterFactory moreSpecificFactory
                       ? moreSpecificFactory.CreateConverter(typeToConvert, options)
                       : moreSpecificConverter;
        return (JsonConverter?)Activator.CreateInstance(typeof(CustomObjectConverter<>).MakeGenericType(typeToConvert));
    }
}

【讨论】:

  • 我认为我不会接受使用此解决方案的拉取请求。它非常复杂,直到运行时您才知道它是否有效。我希望您的测试覆盖率对此有好处。
  • @tymtam 我非常同意,它涉及重新实现一些反序列化逻辑(例如,找到最好的构造函数),这很糟糕。不幸的是,我认为 System.Text.Json 在其当前状态(.net 6)下不会允许更好的解决方案。所以我想替代答案可能是“等待未来版本允许更多定制”。
【解决方案2】:

我认为最简单的解决方案是使用具有可为空值的类进行反序列化,然后将它们转换为具有不可为空属性的所需类。

我认为这个解决方案是:

  1. 很简单
  2. 简单易懂
  3. 不取消编译时检查
  4. 易于维护。

下面的选项 2 允许在给定属性的值为 null 时使用任意复杂的逻辑(抛出、记录、运行一些代码以获取默认值、从配置中获取值)。

public class OuterExternal // <-- this is given to the deserialise method
{
  public int? Prop1 { get; }
  ...
  public string? Prop4 { get; }
  ...
}

public class Outer
{
  public int Prop1 { get; }
  ...
  public string Prop4 { get; }
}

您可以使用构造函数或工厂方法创建Outer 对象。

public class Outer
{
  ...
  // Option 1: Use constructor
  public Outer(OuterExternal external)
    => this(
      prop1: external.prop1 ?? GetSensibleRuntimeProp1Default(),
      ...
      prop4: ...)
  }
}

public class SomeService
{
  // Option 2 Construction done somewhere else:
  private Outer FromExternal(OuterExternal external)
  {
    return new Outer(
      prop1: external.prop1 ?? GetValueForProp1BecauseInputWasNull(someState),
      ...,
      prop4: ...);
  }
} 

【讨论】:

  • 确实很简单,但这不是对 OP 标题中所述问题的回答。代码将在库中使用,要反序列化的类由库的使用者在运行时提供,它们在编译时是未知的。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-07-31
  • 2010-11-20
  • 1970-01-01
  • 2021-08-02
相关资源
最近更新 更多