.NET Core/System.Text.Json: 枚举和 add/replace json properties/values

.NET Core/System.Text.Json: Enumerate and add/replace json properties/values

在我之前的一个问题中,我问过如何 使用 System.Text.Json。

展示了一个用 JsonDocument 解析 json 字符串并用 EnumerateObject.

枚举它的解决方案

随着时间的推移,我的 json 字符串发生了变化,现在也包含一个对象数组,当使用链接答案中的代码解析它时,它会抛出以下异常:

The requested operation requires an element of type 'Object', but the target element has type 'Array'.

我发现可以用一种或另一种方式寻找 JsonValueKind.Array,然后做这样的事情

if (json.ValueKind.Equals(JsonValueKind.Array))
{
    foreach (var item in json.EnumerateArray())
    {
        foreach (var property in item.EnumerateObject())
        {
            await OverwriteProperty(???);
        }
    }
}

但我做不到。

如何做到这一点,作为通用解决方案?

我想得到 "Result 1",其中数组项得到 added/updated,"Result 2" (传递变量时),整个数组被替换。

对于 "Result 2" 我假设可以在 OverwriteProperty 方法中检测到 if (JsonValueKind.Array)),并且 where/how 可以通过 "replaceArray “ 多变的? ...在迭代数组或对象时?

一些示例数据:

Json 字符串首字母

{
  "Title": "Startpage",
  "Links": [
    {
      "Id": 10,
      "Text": "Start",
      "Link": "/index"
    },
    {
      "Id": 11,
      "Text": "Info",
      "Link": "/info"
    }
  ]
}

Json 字符串到 add/update

{
  "Head": "Latest news",
  "Links": [
    {
      "Id": 11,
      "Text": "News",
      "Link": "/news"
    },
    {
      "Id": 21,
      "Text": "More News",
      "Link": "/morenews"
    }
  ]
}

结果 1

{
  "Title": "Startpage",
  "Head": "Latest news"
  "Links": [
    {
      "Id": 10,
      "Text": "Start",
      "Link": "/indexnews"
    },
    {
      "Id": 11,
      "Text": "News",
      "Link": "/news"
    },
    {
      "Id": 21,
      "Text": "More news",
      "Link": "/morenews"
    }
  ]
}

结果 2

{
  "Title": "Startpage",
  "Head": "Latest news"
  "Links": [
    {
      "Id": 11,
      "Text": "News",
      "Link": "/news"
    },
    {
      "Id": 21,
      "Text": "More News",
      "Link": "/morenews"
    }
  ]
}

public class Pages
{
    public string Title { get; set; }
    public string Head { get; set; }
    public List<Links> Links { get; set; }
}

public class Links
{
    public int Id { get; set; }
    public string Text { get; set; }
    public string Link { get; set; }
}

C#代码:

public async Task PopulateObjectAsync(object target, string source, Type type, bool replaceArrays = false)
{
    using var json = JsonDocument.Parse(source).RootElement;

    if (json.ValueKind.Equals(JsonValueKind.Array))
    {
        foreach (var item in json.EnumerateArray())
        {
            foreach (var property in item.EnumerateObject())
            {
                await OverwriteProperty(???, replaceArray);  //use "replaceArray" here ?
            }
        }
    }
    else
    {
        foreach (var property in json.EnumerateObject())
        {
            await OverwriteProperty(target, property, type, replaceArray);  //use "replaceArray" here ?
        }
    }

    return;
}

public async Task OverwriteProperty(object target, JsonProperty updatedProperty, Type type, bool replaceArrays)
{
    var propertyInfo = type.GetProperty(updatedProperty.Name);

    if (propertyInfo == null)
    {
        return;
    }

    var propertyType = propertyInfo.PropertyType;
    object parsedValue;

    if (propertyType.IsValueType)
    {
        parsedValue = JsonSerializer.Deserialize(
            updatedProperty.Value.GetRawText(),
            propertyType);
    }
    else if (replaceArrays && "property is JsonValueKind.Array")  //pseudo code sample
    {
        // use same code here as in above "IsValueType" ?
    }
    else
    {
        parsedValue = propertyInfo.GetValue(target);

        await PopulateObjectAsync(
            parsedValue,
            updatedProperty.Value.GetRawText(),
            propertyType);
    }

    propertyInfo.SetValue(target, parsedValue);
}

经过进一步考虑,我认为更简单的替代解决方案应该是使用C#反射而不是依赖JSON。如果不能满足您的需求告诉我:

public class JsonPopulator
{


    public static void PopulateObjectByReflection(object target, string json, bool replaceArray)
    {
        var type = target.GetType();
        var replacements = JsonSerializer.Deserialize(json, type);

        PopulateSubObject(target, replacements, replaceArray);
    }

    static void PopulateSubObject(object target, object? replacements, bool replaceArray)
    {
        if (replacements == null) { return; }

        var props = target.GetType().GetProperties();

        foreach (var prop in props)
        {
            // Skip if can't write
            if (!prop.CanWrite) { continue; }

            // Skip if no value in replacement
            var propType = prop.PropertyType;
            var replaceValue = prop.GetValue(replacements);
            if (replaceValue == GetDefaultValue(propType)) { continue; }

            // Now check if it's array AND we do not want to replace it            
            if (replaceValue is IEnumerable<object> replacementList)
            {
                var currList = prop.GetValue(target) as IEnumerable<object>;

                
                var finalList = replaceValue;
                // If there is no initial list, or if we simply want to replace the array
                if (currList == null || replaceArray)
                {
                    // Do nothing here, we simply replace it
                }
                else
                {
                    // Append items at the end
                    finalList = currList.Concat(replacementList);

                    // Since casting logic is complicated, we use a trick to just
                    // Serialize then Deserialize it again
                    // At the cost of performance hit if it's too big
                    var listJson = JsonSerializer.Serialize(finalList);
                    finalList = JsonSerializer.Deserialize(listJson, propType);
                }

                prop.SetValue(target, finalList);
            }
            else if (propType.IsValueType || propType == typeof(string))
            {
                // Simply copy value over
                prop.SetValue(target, replaceValue);
            }
            else
            {
                // Recursively copy child properties
                var subTarget = prop.GetValue(target);
                var subReplacement = prop.GetValue(replacements);

                // Special case: if original object doesn't have the value
                if (subTarget == null && subReplacement != null)
                {
                    prop.SetValue(target, subReplacement);
                }
                else
                {
                    PopulateSubObject(target, replacements, replaceArray);
                }
            }
        }
    }

    // From 
    static object? GetDefaultValue(Type type)
    {
        if (type.IsValueType)
        {
            return Activator.CreateInstance(type);
        }
        return null;
    }
}

使用:

const string Json1 = "{\n  \"Title\": \"Startpage\",\n  \"Links\": [\n    {\n      \"Id\": 10,\n      \"Text\": \"Start\",\n      \"Link\": \"/index\"\n    },\n    {\n      \"Id\": 11,\n      \"Text\": \"Info\",\n      \"Link\": \"/info\"\n    }\n  ]\n}";

const string Json2 = "{\n  \"Head\": \"Latest news\",\n  \"Links\": [\n    {\n      \"Id\": 11,\n      \"Text\": \"News\",\n      \"Link\": \"/news\"\n    },\n    {\n      \"Id\": 21,\n      \"Text\": \"More News\",\n      \"Link\": \"/morenews\"\n    }\n  ]\n}";

var obj = JsonSerializer.Deserialize<Pages>(Json1)!;

JsonPopulator.PopulateObjectByReflection(obj, Json2, false);
Console.WriteLine(obj.Links.Count); // 4

JsonPopulator.PopulateObjectByReflection(obj, Json2, true);
Console.WriteLine(obj.Links.Count); // 2

当我将 List<Links> 替换为数组 Links[]:

时,该解决方案甚至有效
public class Pages
{
    // ...
    public Links[] Links { get; set; }
}

JsonPopulator.PopulateObjectByReflection(obj, Json2, false);
Console.WriteLine(obj.Links.Length); // 4

JsonPopulator.PopulateObjectByReflection(obj, Json2, true);
Console.WriteLine(obj.Links.Length); // 2

废弃方案:

我认为一个简单的解决方案是包含父项及其当前 属性 信息。一个原因是并非每个 IEnumerable 无论如何都是可变的(例如数组),因此即使 replaceArray 为 false,您也会想要替换它。

using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;

const string Json1 = @"
    {
        ""Bars"": [
            { ""Value"": 0 },
            { ""Value"": 1 }
        ]
    }
";

const string Json2 = @"
    {
        ""Bars"": [
            { ""Value"": 2 },
            { ""Value"": 3 }
        ]
    }
";

var foo = JsonSerializer.Deserialize<Foo>(Json1)!;

PopulateObject(foo, Json2, false);
Console.WriteLine(foo.Bars.Count); // 4

PopulateObject(foo, Json2, true);
Console.WriteLine(foo.Bars.Count); // 2

static void PopulateObject(object target, string replacement, bool replaceArray)
{

    using var doc = JsonDocument.Parse(Json2);
    var root = doc.RootElement;

    PopulateObjectWithJson(target, root, replaceArray, null, null);
}

static void PopulateObjectWithJson(object target, JsonElement el, bool replaceArray, object? parent, PropertyInfo? parentProp)
{
    // There should be other checks
    switch (el.ValueKind)
    {
        case JsonValueKind.Object:
            // Just simple check here, you may want more logic
            var props = target.GetType().GetProperties().ToDictionary(q => q.Name);

            foreach (var jsonProp in el.EnumerateObject())
            {
                if (props.TryGetValue(jsonProp.Name, out var prop))
                {
                    var subTarget = prop.GetValue(target);

                    // You may need to check for null etc here
                    ArgumentNullException.ThrowIfNull(subTarget);

                    PopulateObjectWithJson(subTarget, jsonProp.Value, replaceArray, target, prop);
                }
            }

            break;
        case JsonValueKind.Array:
            var parsedItems = new List<object>();
            foreach (var item in el.EnumerateArray())
            {
                // Parse your value here, I will just assume the type for simplicity
                var bar = new Bar()
                {
                    Value = item.GetProperty(nameof(Bar.Value)).GetInt32(),
                };

                parsedItems.Add(bar);
            }

            IEnumerable<object> finalItems = parsedItems;
            if (!replaceArray)
            {
                finalItems = ((IEnumerable<object>)target).Concat(parsedItems);
            }

            // Parse your list into List/Array/Collection/etc
            // You need reflection here as well
            var list = finalItems.Cast<Bar>().ToList();
            parentProp?.SetValue(parent, list);

            break;
        default:
            // Should handle for other types
            throw new NotImplementedException();
    }
}

public class Foo
{

    public List<Bar> Bars { get; set; } = null!;

}

public class Bar
{
    public int Value { get; set; }
}

万一您想使用仅 JSON 的解决方案,尽管我认为它并不比反射解决方案好多少。它涵盖的用例绝对少于默认 JsonSerializer,例如您可能遇到 IReadOnlyCollections.

的问题
public class JsonPopulator
{
    public static void PopulateObject(object target, string json, bool replaceArray)
    {
        using var jsonDoc = JsonDocument.Parse(json);
        var root = jsonDoc.RootElement;

        // Simplify the process by making sure the first one is Object
        if (root.ValueKind != JsonValueKind.Object)
        {
            throw new InvalidDataException("JSON Root must be a JSON Object");
        }

        var type = target.GetType();
        foreach (var jsonProp in root.EnumerateObject())
        {
            var prop = type.GetProperty(jsonProp.Name);

            if (prop == null || !prop.CanWrite) { continue; }

            var currValue = prop.GetValue(target);
            var value = ParseJsonValue(jsonProp.Value, prop.PropertyType, replaceArray, currValue);

            if (value != null)
            {
                prop.SetValue(target, value);
            }
        }
    }

    static object? ParseJsonValue(JsonElement value, Type type, bool replaceArray, object? initialValue)
    {
        if (type.IsArray || type.IsAssignableTo(typeof(IEnumerable<object>)))
        {
            // Array or List
            var initalArr = initialValue as IEnumerable<object>;

            // Get the type of the Array/List element
            var elType = GetElementType(type);

            var parsingValues = new List<object?>();
            foreach (var item in value.EnumerateArray())
            {
                parsingValues.Add(ParseJsonValue(item, elType, replaceArray, null));
            }

            List<object?> finalItems;
            if (replaceArray || initalArr == null)
            {
                finalItems = parsingValues;
            }
            else
            {
                finalItems = initalArr.Concat(parsingValues).ToList();
            }

            // Cast them to the correct type
            return CastIEnumrable(finalItems, type, elType);
        }
        else if (type.IsValueType || type == typeof(string))
        {
            // I don't think this is optimal but I will just use your code
            // since I assume it is working for you
            return JsonSerializer.Deserialize(
                value.GetRawText(),
                type);
        }
        else
        {
            // Assume it's object
            // Assuming it's object
            if (value.ValueKind != JsonValueKind.Object)
            {
                throw new InvalidDataException("Expecting a JSON object");
            }

            var finalValue = initialValue;

            // If it's null, the original object didn't have it yet
            // Initialize it using default constructor
            // You may need to check for JsonConstructor as well
            if (initialValue == null)
            {
                var constructor = type.GetConstructor(Array.Empty<Type>());
                if (constructor == null)
                {
                    throw new TypeAccessException($"{type.Name} does not have a default constructor.");
                }

                finalValue = constructor.Invoke(Array.Empty<object>());
            }

            foreach (var jsonProp in value.EnumerateObject())
            {
                var subProp = type.GetProperty(jsonProp.Name);
                if (subProp == null || !subProp.CanWrite) { continue; }

                var initialSubPropValue = subProp.GetValue(finalValue);

                var finalSubPropValue = ParseJsonValue(jsonProp.Value, subProp.PropertyType, replaceArray, initialSubPropValue);
                if (finalSubPropValue != null)
                {
                    subProp.SetValue(finalValue, finalSubPropValue);
                }
            }

            return finalValue;
        }
    }

    static object? CastIEnumrable(List<object?> items, Type target, Type elementType)
    {
        object? result = null;

        if (IsList(target))
        {
            if (target.IsInterface)
            {
                return items;
            }
            else
            {
                result = Activator.CreateInstance(target);
                var col = (result as IList)!;

                foreach (var item in items)
                {
                    col.Add(item);
                }
            }
        }
        else if (target.IsArray)
        {
            result = Array.CreateInstance(elementType, items.Count);
            var arr = (result as Array)!;

            for (int i = 0; i < items.Count; i++)
            {
                arr.SetValue(items[i], i);
            }
        }

        return result;
    }

    static bool IsList(Type type)
    {
       return type.GetInterface("IList") != null;
    }

    static Type GetElementType(Type enumerable)
    {
        return enumerable.GetInterfaces()
            .First(q => q.IsGenericType && q.GetGenericTypeDefinition() == typeof(IEnumerable<>))
            .GetGenericArguments()[0];
    }

}

用法:

const string Json1 = "{\n  \"Title\": \"Startpage\",\n  \"Links\": [\n    {\n      \"Id\": 10,\n      \"Text\": \"Start\",\n      \"Link\": \"/index\"\n    },\n    {\n      \"Id\": 11,\n      \"Text\": \"Info\",\n      \"Link\": \"/info\"\n    }\n  ]\n}";

const string Json2 = "{\n  \"Head\": \"Latest news\",\n  \"Links\": [\n    {\n      \"Id\": 11,\n      \"Text\": \"News\",\n      \"Link\": \"/news\"\n    },\n    {\n      \"Id\": 21,\n      \"Text\": \"More News\",\n      \"Link\": \"/morenews\"\n    }\n  ]\n}";

var obj = JsonSerializer.Deserialize<Pages>(Json1)!;

JsonPopulator.PopulateObject(obj, Json2, false);
Console.WriteLine(obj.Links.Count); // 4
Console.WriteLine(JsonSerializer.Serialize(obj));

JsonPopulator.PopulateObject(obj, Json2, true);
Console.WriteLine(obj.Links.Count); // 2
Console.WriteLine(JsonSerializer.Serialize(obj));

好吧,如果你不关心数组是怎么写的,我有一个简单的解决方案。在 2 个阶段中创建一个新的 JSON,1 个循环用于新属性,1 个循环用于更新:

    var sourceJson = @"
{
  ""Title"": ""Startpage"",
  ""Links"": [
    {
      ""Id"": 10,
      ""Text"": ""Start"",
      ""Link"": ""/index""
    },
    {
      ""Id"": 11,
      ""Text"": ""Info"",
      ""Link"": ""/info""
    }
  ]
}";
        var updateJson = @"
{
  ""Head"": ""Latest news"",
  ""Links"": [
    {
      ""Id"": 11,
      ""Text"": ""News"",
      ""Link"": ""/news""
    },
    {
      ""Id"": 21,
      ""Text"": ""More News"",
      ""Link"": ""/morenews""
    }
  ]
}
";
        using var source = JsonDocument.Parse(sourceJson);
        using var update = JsonDocument.Parse(updateJson);
        using var stream = new MemoryStream();
        using var writer = new Utf8JsonWriter(stream);
        writer.WriteStartObject();
        // write non existing properties
        foreach (var prop in update.RootElement.EnumerateObject().Where(prop => !source.RootElement.TryGetProperty(prop.Name, out _)))
        {
            prop.WriteTo(writer);
        }

        // make updates for existing
        foreach (var prop in source.RootElement.EnumerateObject())
        {
            if (update.RootElement.TryGetProperty(prop.Name, out var overwrite))
            {
                writer.WritePropertyName(prop.Name);
                overwrite.WriteTo(writer);
            }
            else
            {
                prop.WriteTo(writer);
            }
        }

        writer.WriteEndObject();
        writer.Flush();
        var resultJson = Encoding.UTF8.GetString(stream.ToArray());
        Console.WriteLine(resultJson);

输出:

{
   "Head":"Latest news",
   "Title":"Startpage",
   "Links":[
      {
         "Id":11,
         "Text":"News",
         "Link":"/news"
      },
      {
         "Id":21,
         "Text":"More News",
         "Link":"/morenews"
      }
   ]
}

Fiddle

预赛

我将大量使用我对链接问题的回答中的现有代码:

正如我提到的,浅拷贝的代码可以工作并产生结果 2。所以我们只需要修复深拷贝的代码并让它产生结果 1。

在我的机器上,当 propertyTypetypeof(string) 时,代码在 PopulateObject 中崩溃,因为 string 既不是值类型,也不是 [ 中的对象表示的东西=122=]。我在原来的答案中修复了它,if 必须是:

if (elementType.IsValueType || elementType == typeof(string))

实施新要求

好的,第一个问题是识别某物是否是一个集合。目前我们查看要覆盖的 属性 的类型来做出决定,所以现在我们将做同样的事情。逻辑如下:

private static bool IsCollection(Type type) =>
        type.GetInterfaces().Any(x => x.IsGenericType && 
        x.GetGenericTypeDefinition() == typeof(ICollection<>));

所以我们唯一考虑集合的是为某些 T 实现 ICollection<T> 的东西。我们将通过实施新的 PopulateCollection 方法完全独立地处理集合。我们还需要一种构造新集合的方法——也许初始对象中的列表是 null,因此我们需要在填充它之前创建一个新集合。为此,我们将寻找它的无参数构造函数:

private static object Instantiate(Type type)
{
    var ctor =  type.GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, Array.Empty<Type>());

    if (ctor is null)
    {
        throw new InvalidOperationException($"Type {type.Name} has no parameterless constructor.");
    }

    return ctor.Invoke(Array.Empty<object?>());
}

我们允许它是 private,因为为什么不呢。

现在我们对OverwriteProperty做一些修改:

    private static void OverwriteProperty(object target, JsonProperty updatedProperty, Type type)
    {
        var propertyInfo = type.GetProperty(updatedProperty.Name);

        if (propertyInfo == null)
        {
            return;
        }

        if (updatedProperty.Value.ValueKind == JsonValueKind.Null)
        {
            propertyInfo.SetValue(target, null);
            return;
        }

        var propertyType = propertyInfo.PropertyType;
        object? parsedValue;

        if (propertyType.IsValueType || propertyType == typeof(string))
        {
            parsedValue = JsonSerializer.Deserialize(
                updatedProperty.Value.GetRawText(),
                propertyType);
        }
        else if (IsCollection(propertyType))
        {
            var elementType = propertyType.GenericTypeArguments[0];
            parsedValue = propertyInfo.GetValue(target);
            parsedValue ??= Instantiate(propertyType);

            PopulateCollection(parsedValue, updatedProperty.Value.GetRawText(), elementType);
        }
        else
        {
            parsedValue = propertyInfo.GetValue(target);
            parsedValue ??= Instantiate(propertyType);

            PopulateObject(
                parsedValue,
                updatedProperty.Value.GetRawText(),
                propertyType);
        }

        propertyInfo.SetValue(target, parsedValue);
    }

最大的变化是 if 语句的第二个分支。我们找出集合中元素的类型,并从对象中提取现有集合。如果它为空,我们创建一个新的空的。然后我们调用新的方法来填充它。

PopulateCollection 方法与 OverwriteProperty 非常相似。

private static void PopulateCollection(object target, string jsonSource, Type elementType)

首先我们得到集合的Add方法:

var addMethod = target.GetType().GetMethod("Add", new[] { elementType });

这里我们需要一个实际的 JSON 数组,所以是时候枚举它了。对于数组中的每个元素,我们需要做与 OverwriteProperty 中相同的事情,具体取决于我们是否有值、数组或对象,我们有不同的流程。

foreach (var property in json.EnumerateArray())
{
    object? element;

    if (elementType.IsValueType || elementType == typeof(string))
    {
        element = JsonSerializer.Deserialize(jsonSource, elementType);
    }
    else if (IsCollection(elementType))
    {
        var nestedElementType = elementType.GenericTypeArguments[0];
        element = Instantiate(elementType);

        PopulateCollection(element, property.GetRawText(), nestedElementType);
    }
    else
    {
        element = Instantiate(elementType);

        PopulateObject(element, property.GetRawText(), elementType);
    }

    addMethod.Invoke(target, new[] { element });
}

唯一性

现在我们遇到了一个问题。当前的实现将总是 添加到集合中,无论其当前内容如何。所以这 return 既不是结果 1 也不是结果 2,而是结果 3:

{
  "Title": "Startpage",
  "Head": "Latest news"
  "Links": [
    {
      "Id": 10,
      "Text": "Start",
      "Link": "/indexnews"
    },
    {
      "Id": 11,
      "Text": "News",
      "Link": "/news"
    },
    {
      "Id": 11,
      "Text": "News",
      "Link": "/news"
    },
    {
      "Id": 21,
      "Text": "More news",
      "Link": "/morenews"
    }
  ]
}

我们有链接 10 和 11 的数组,然后添加了另一个链接 11 和 12 的数组。没有明显的自然方法来处理这个问题。我在这里选择的设计决策是:集合决定元素是否已经存在。我们将在集合上调用默认的 Contains 方法,并当且仅当它 returns false 时添加。它需要我们覆盖 Links 上的 Equals 方法来比较 Id:

public override bool Equals(object? obj) =>
    obj is Links other && Id == other.Id;

public override int GetHashCode() => Id.GetHashCode();

现在需要的更改是:

  • 首先,获取Contains方法:
var containsMethod = target.GetType().GetMethod("Contains", new[] { elementType });
  • 然后,得到一个element:
  • 后检查一下
var contains = containsMethod.Invoke(target, new[] { element });
if (contains is false)
{
    addMethod.Invoke(target, new[] { element });
}

测试

我向您的 PagesLinks class 添加了一些东西,首先我覆盖了 ToString 以便我们可以轻松地检查我们的结果。然后,如前所述,我将 Equals 覆盖为 Links:

public class Pages
{
    public string Title { get; set; }
    public string Head { get; set; }
    public List<Links> Links { get; set; }

    public override string ToString() => 
        $"Pages {{ Title = {Title}, Head = {Head}, Links = {string.Join(", ", Links)} }}";
}

public class Links
{
    public int Id { get; set; }
    public string Text { get; set; }
    public string Link { get; set; }

    public override bool Equals(object? obj) =>
        obj is Links other && Id == other.Id;

    public override int GetHashCode() => Id.GetHashCode();

    public override string ToString() => $"Links {{ Id = {Id}, Text = {Text}, Link = {Link} }}";
}

测试:

var initial = @"{
  ""Title"": ""Startpage"",
  ""Links"": [
    {
      ""Id"": 10,
      ""Text"": ""Start"",
      ""Link"": ""/index""
    },
    {
    ""Id"": 11,
      ""Text"": ""Info"",
      ""Link"": ""/info""
    }
  ]
}";

var update = @"{
  ""Head"": ""Latest news"",
  ""Links"": [
    {
      ""Id"": 11,
      ""Text"": ""News"",
      ""Link"": ""/news""
    },
    {
    ""Id"": 21,
      ""Text"": ""More News"",
      ""Link"": ""/morenews""
    }
  ]
}";

var pages = new Pages();

PopulateObject(pages, initial);

Console.WriteLine(pages);

PopulateObject(pages, update);

Console.WriteLine(pages);

结果:

Initial:
Pages { Title = Startpage, Head = , Links = Links { Id = 10, Text = Start, Link = /index }, Links { Id = 11, Text = Info, Link = /info } }
Update:
Pages { Title = Startpage, Head = Latest news, Links = Links { Id = 10, Text = Start, Link = /index }, Links { Id = 11, Text = Info, Link = /info }, Links { Id = 21, Text = More News, Link = /morenews } }

您可以在 this fiddle 中找到它。

限制

  1. 我们使用 Add 方法,因此这不适用于 .NET 数组的属性,因为您无法对它们进行 Add。它们必须单独处理,首先创建元素,然后构造一个适当大小的数组并填充它。
  2. 使用 Contains 的决定对我来说有点不确定。最好能更好地控制添加到集合中的内容。但这很简单并且有效,所以对于 SO 答案来说就足够了。

最终代码

static class JsonUtils
{
    public static void PopulateObject<T>(T target, string jsonSource) where T : class =>
        PopulateObject(target, jsonSource, typeof(T));

    public static void OverwriteProperty<T>(T target, JsonProperty updatedProperty) where T : class =>
        OverwriteProperty(target, updatedProperty, typeof(T));

    private static void PopulateObject(object target, string jsonSource, Type type)
    {
        using var json = JsonDocument.Parse(jsonSource).RootElement;

        foreach (var property in json.EnumerateObject())
        {
            OverwriteProperty(target, property, type);
        }
    }

    private static void PopulateCollection(object target, string jsonSource, Type elementType)
    {
        using var json = JsonDocument.Parse(jsonSource).RootElement;
        var addMethod = target.GetType().GetMethod("Add", new[] { elementType });
        var containsMethod = target.GetType().GetMethod("Contains", new[] { elementType });

        Debug.Assert(addMethod is not null);
        Debug.Assert(containsMethod is not null);

        foreach (var property in json.EnumerateArray())
        {
            object? element;

            if (elementType.IsValueType || elementType == typeof(string))
            {
                element = JsonSerializer.Deserialize(jsonSource, elementType);
            }
            else if (IsCollection(elementType))
            {
                var nestedElementType = elementType.GenericTypeArguments[0];
                element = Instantiate(elementType);

                PopulateCollection(element, property.GetRawText(), nestedElementType);
            }
            else
            {
                element = Instantiate(elementType);

                PopulateObject(element, property.GetRawText(), elementType);
            }

            var contains = containsMethod.Invoke(target, new[] { element });
            if (contains is false)
            {
                addMethod.Invoke(target, new[] { element });
            }
        }
    }

    private static void OverwriteProperty(object target, JsonProperty updatedProperty, Type type)
    {
        var propertyInfo = type.GetProperty(updatedProperty.Name);

        if (propertyInfo == null)
        {
            return;
        }

        if (updatedProperty.Value.ValueKind == JsonValueKind.Null)
        {
            propertyInfo.SetValue(target, null);
            return;
        }

        var propertyType = propertyInfo.PropertyType;
        object? parsedValue;

        if (propertyType.IsValueType || propertyType == typeof(string))
        {
            parsedValue = JsonSerializer.Deserialize(
                updatedProperty.Value.GetRawText(),
                propertyType);
        }
        else if (IsCollection(propertyType))
        {
            var elementType = propertyType.GenericTypeArguments[0];
            parsedValue = propertyInfo.GetValue(target);
            parsedValue ??= Instantiate(propertyType);

            PopulateCollection(parsedValue, updatedProperty.Value.GetRawText(), elementType);
        }
        else
        {
            parsedValue = propertyInfo.GetValue(target);
            parsedValue ??= Instantiate(propertyType);

            PopulateObject(
                parsedValue,
                updatedProperty.Value.GetRawText(),
                propertyType);
        }

        propertyInfo.SetValue(target, parsedValue);
    }

    private static object Instantiate(Type type)
    {
        var ctor =  type.GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, Array.Empty<Type>());

        if (ctor is null)
        {
            throw new InvalidOperationException($"Type {type.Name} has no parameterless constructor.");
        }

        return ctor.Invoke(Array.Empty<object?>());
    }

    private static bool IsCollection(Type type) =>
        type.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(ICollection<>));
}