在.NET 6中如何克隆JsonNode并将其附加到另一个JsonNode?

8

我正在使用.NET 6.0中的System.Text.Json.Nodes,我想要做的事情很简单:从一个JsonNode复制一个节点并将该节点附加到另一个JsonNode。
以下是我的代码。

public static string concQuest(string input, string allQuest, string questId) {
    JsonNode inputNode = JsonNode.Parse(input)!;
    JsonNode allQuestNode = JsonNode.Parse(allQuest)!;
    JsonNode quest = allQuestNode.AsArray().First(quest => 
        quest!["id"]!.GetValue<string>() == questId) ?? throw new KeyNotFoundException("No matching questId found.");
    inputNode["quest"] = quest;  // Exception occured
    return inputNode.ToJsonString(options);
}

但是当我尝试运行它时,出现了一个System.InvalidOperationException,它说"该节点已经有父级。"

我已经尝试过编辑

inputNode["quest"] = quest;

to

inputNode["quest"] = quest.Root; // quest.Root is also a JsonNode

代码可以正常运行,但返回的是所有节点,而不是我指定的那一个。我想要的结果不是这样的。另外,由于代码可以正常工作,我认为直接将一个JsonNode设置为另一个JsonNode是可行的。
根据异常信息,似乎如果我想要将一个JsonNode添加到另一个JsonNode中,我必须先将它从其父节点中分离出来,但是我该如何做呢?

请注意,我的JSON文件非常大(超过6MB),所以我希望确保我的解决方案没有性能问题。


1
你的JSON长什么样?你能分享一个MCVE吗? - dbc
“我想确保我的解决方案没有性能问题。” - 如果您想确保没有问题,需要进行一些实现并测试其在真实数据下的性能。实际上,在许多情况下,“足够好”是一种方法,无需过早优化。 - Guru Stron
3个回答

3

由于.NET 6中JsonNode没有Clone()方法,因此最简单的复制方式可能是调用序列化程序的JsonSerializer.Deserialize<TValue>(JsonNode, JsonSerializerOptions)扩展方法,将您的节点直接反序列化到另一个节点中。首先,引入以下扩展方法来复制或移动节点:

public static partial class JsonExtensions
{
    public static TNode? CopyNode<TNode>(this TNode? node) where TNode : JsonNode => node?.Deserialize<TNode>();

    public static JsonNode? MoveNode(this JsonArray array, int id, JsonObject newParent, string name)
    {
        var node = array[id];
        array.RemoveAt(id); 
        return newParent[name] = node;
    }

    public static JsonNode? MoveNode(this JsonObject parent, string oldName, JsonObject newParent, string name)
    {
        parent.Remove(oldName, out var node);
        return newParent[name] = node;
    }

    public static TNode ThrowOnNull<TNode>(this TNode? value) where TNode : JsonNode => value ?? throw new JsonException("Null JSON value");
}

现在,您的代码可以按照以下方式编写:

public static string concQuest(string input, string allQuest, string questId) 
{
    var inputObject = JsonNode.Parse(input).ThrowOnNull().AsObject();
    var allQuestArray = JsonNode.Parse(allQuest).ThrowOnNull().AsArray();
    concQuest(inputObject, allQuestArray, questId);
    return inputObject.ToJsonString();
}       

public static JsonNode? concQuest(JsonObject inputObject, JsonArray allQuestArray, string questId) 
{
    // Enumerable.First() will throw an InvalidOperationException if no element is found satisfying the predicate.
    var node = allQuestArray.First(quest => quest!["id"]!.GetValue<string>() == questId);
    return inputObject["quest"] = node.CopyNode();
}

或者,如果您不打算保留任务数组,您可以像这样将节点从数组移动到目标:

public static string concQuest(string input, string allQuest, string questId) 
{
    var inputObject = JsonNode.Parse(input).ThrowOnNull().AsObject();
    var allQuestArray = JsonNode.Parse(allQuest).ThrowOnNull().AsArray();
    concQuest(inputObject, allQuestArray, questId);
    return inputObject.ToJsonString();
}       

public static JsonNode? concQuest(JsonObject inputObject, JsonArray allQuestArray, string questId) 
{
    // Enumerable.First() will throw an InvalidOperationException if no element is found satisfying the predicate.
    var (_, index) = allQuestArray.Select((quest, index) => (quest, index)).First(p => p.quest!["id"]!.GetValue<string>() == questId);
    return allQuestArray.MoveNode(index, inputObject, "quest");
}

另外,你写道

由于我的json文件非常大(超过6MB),我担心可能会出现一些性能问题。

在这种情况下,我建议避免将JSON文件加载到 inputallQuest 字符串中,因为字符串大于85,000字节会进入 大对象堆,这可能会导致随后的性能降低。相反,可以直接从相关文件反序列化为 JsonNode 数组和对象,如下所示:

var questId = "2"; // Or whatever

JsonArray allQuest;
using (var stream = new FileStream(allQuestFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read }))
    allQuest = JsonNode.Parse(stream).ThrowOnNull().AsArray();

JsonObject input;
using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read }))
    input = JsonNode.Parse(stream).ThrowOnNull().AsObject();

JsonExtensions.concQuest(input, allQuest, questId);

using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write }))
using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
    input.WriteTo(writer);

或者,如果你的应用程序是异步的,你可以这样做:

JsonArray allQuest;
await using (var stream = new FileStream(allQuestFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read, Options = FileOptions.Asynchronous }))
    allQuest = (await JsonSerializer.DeserializeAsync<JsonArray>(stream)).ThrowOnNull();

JsonObject input;
await using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read, Options = FileOptions.Asynchronous }))
    input = (await JsonSerializer.DeserializeAsync<JsonObject>(stream)).ThrowOnNull();

JsonExtensions.concQuest(input, allQuest, questId);

await using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Options = FileOptions.Asynchronous }))
    await JsonSerializer.SerializeAsync(stream, input, new JsonSerializerOptions { WriteIndented = true });

注意:

演示 fiddles:


1
非常感谢您的答案和建议,我认为这可能是目前最好的解决方案。还有一件事我很好奇,如果一个JsonNode已经有父节点,为什么我们不能直接将其复制到另一个JsonNode中?如果这样做会有什么问题? - Vibbit
2
@Vibbit - 我不知道微软为什么要设计他们的API那样。使用Json.NET时,当您将一个具有父节点的节点分配给另一个节点时,该节点会自动克隆,请参见nested json objects dont update / inherit using Json.NET。也许微软的API设计师不喜欢这样做,所以他们决定抛出异常作为最简单的解决方案。 - dbc

2

最简单的方法是将 JSON 节点转换为字符串,然后再解析它(虽然可能不是最高效的方法):

var destination = @"{}";
var source = "[{\"id\": 1, \"name\":\"some quest\"},{}]";
var sourceJson = JsonNode.Parse(source);
var destinationJson = JsonNode.Parse(destination);
var quest = sourceJson.AsArray().First();
destinationJson["quest"] = JsonNode.Parse(quest.ToJsonString());
Console.WriteLine(destinationJson.ToJsonString(new() { WriteIndented = true }));

将会打印:

{
  "quest": {
    "id": 1,
    "name": "some quest"
  }
}

更新

另一个技巧是将 JsonNode 反序列化为 JsonNode

...
var quest = sourceJson.AsArray().First();    
var clone = quest.Deserialize<JsonNode>();
clone["name"] = "New name"; 
destinationJson["quest"] = clone;
Console.WriteLine(quest["name"]);
Console.WriteLine(destinationJson.ToJsonString(new() { WriteIndented = true }));

输出:

some quest
{
  "quest": {
    "id": 1,
    "name": "New name"
  }
}

是的,我也考虑过这个解决方案。但是由于我的JSON文件相当大(超过6MB),我担心可能会出现一些性能问题。 - Vibbit
@Vibbit 一个任务的大小是6 mb吗? - Guru Stron

1
从一个 .Net 对象(包括另一个 JsonNode)创建 JsonNode 的官方 API 似乎是 JsonSerializer.SerializeToNode 方法,该方法在 .Net 6+ 中可用。深入了解后,发现该方法只是将 .Net 对象写入内存缓冲区,然后进行反序列化。因此,在克隆大型结构时仍可能存在性能下降的问题。
如果 allQuest JSON 很大,您可以使用 JsonSerializer.DeserializeAsyncEnumerable 方法避免将整个内容作为已解析的 JSON 加载到内存中。这种技术在其他地方有所描述(需要登录)。
将这些方法结合起来,代码可能变为。
public static string concQuest(string input, Stream allQuest, string questId) {
    JsonNode inputNode = JsonNode.Parse(input)!;
    JsonNode quest = JsonSerializer.DeserializeAsyncEnumerable<JsonNode>(allQuest)
        .ToBlockingEnumerable()
        .First(quest => quest!["id"]!.GetValue<string>() == questId) 
            ?? throw new KeyNotFoundException("No matching questId found.");
    inputNode["quest"] = JsonSerializer.SerializeToNode(quest);
    return inputNode.ToJsonString(options);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接