使用System.Text.Json异步反序列化列表


11

可以说,我请求一个包含许多对象列表的大json文件。我不希望它们一次全部出现在内存中,但我宁愿一个个地读取和处理它们。所以我需要将异步System.IO.Stream流转换为IAsyncEnumerable<T>。如何使用新的System.Text.JsonAPI来做到这一点?

private async IAsyncEnumerable<T> GetList<T>(Uri url, CancellationToken cancellationToken = default)
{
    using (var httpResponse = await httpClient.GetAsync(url, cancellationToken))
    {
        using (var stream = await httpResponse.Content.ReadAsStreamAsync())
        {
            // Probably do something with JsonSerializer.DeserializeAsync here without serializing the entire thing in one go
        }
    }
}

1
您可能需要类似DeserializeAsync方法的东西
Pavel Anikhouski

2
抱歉,上面的方法似乎将整个流加载到内存中。您可以阅读asynchonously使用大块的数据Utf8JsonReader,请看看一些github上的样品,并在现有的线程,以及
帕维尔Anikhouski

GetAsync收到整个响应后,它自己返回。您需要改为使用SendAsyncHttpCompletionOption.ResponseContentRead`。一旦有了它,就可以使用JSON.NET的JsonTextReader正如这个问题所示System.Text.Json,使用它并不容易。该功能不可用,并且使用结构在低分配中实现并不
容易

块反序列化的问题在于,您必须知道何时有完整的块要反序列化。对于一般情况,很难做到这一点。这将需要提前解析,这在性能方面可能是一个很差的折衷。很难一概而论。但是,如果您对JSON实施自己的限制,例如说“单个对象在文件中恰好占据20行”,那么您可以实质上通过异步读取块中的文件来异步反序列化。我想,您将需要一个巨大的json才能在这里看到好处。
侦探

似乎有人已经用完整的代码在这里回答了类似的问题。
Panagiotis Kanavos

Answers:


4

是的,在许多地方,真正的流式JSON(反)序列化器将是一项不错的性能改进。

不幸的是,System.Text.Json目前不这样做。我不确定将来是否会-我希望如此!真正对JSON进行流式反序列化非常具有挑战性。

您也许可以检查极快的Utf8Json是否支持它。

但是,由于您的要求似乎限制了难度,因此可能会针对您的特定情况提供定制解决方案。

这个想法是一次从阵列中手动读取一项。我们利用以下事实:列表中的每个项目本身就是有效的JSON对象。

您可以手动跳过[(对于第一个项目)或,(对于每个下一个项目)。然后,我认为您最好的选择是使用.NET Core Utf8JsonReader来确定当前对象的结束位置,并将扫描的字节提供给JsonDeserializer

这样,您一次只能缓冲一个对象。

由于我们在谈论性​​能,因此您可以从处获得输入PipeReader。:-)


这根本与性能无关。这与已经完成的异步反序列化无关。这是关于流访问的-处理从流中解析的JSON元素,就像JSON.NET的JsonTextReader一样。
Panagiotis Kanavos,

Utf8Json中的相关类是JsonReader,正如作者所说,这很奇怪。JSON.NET的JsonTextReader和System.Text.Json的Utf8JsonReader具有相同的怪异性-您必须不断循环并检查当前元素的类型。
Panagiotis Kanavos,

@PanagiotisKanavos啊,是的,流媒体。那就是我要找的单词!我正在将单词“异步”更新为“流”。我确实相信要进行流传输的原因是限制内存使用,这是性能方面的问题。也许OP可以确认。
蒂莫

性能并不意味着速度。不管反序列化器有多快,如果您必须处理1M项,都不需要将它们存储在RAM中,也不需要等待所有它们都进行反序列化后再处理第一个。
Panagiotis Kanavos

语义学,我的朋友!我很高兴我们终究要实现同样的目标。
蒂莫

4

TL; DR并非不重要


似乎已经 有人发布了一个Utf8JsonStreamReader结构的完整代码,该结构从流中读取缓冲区并将其提供给Utf8JsonRreader,从而可以轻松地反序列化JsonSerializer.Deserialize<T>(ref newJsonReader, options);。该代码也不是简单的。相关的问题在这里,答案在这里

但是这还不够- HttpClient.GetAsync仅在收到整个响应后才返回,本质上将所有内容缓冲在内存中。

为避免这种情况,HttpClient.GetAsync(string,HttpCompletionOption)应该与一起使用HttpCompletionOption.ResponseHeadersRead

反序列化循环也应检查取消令牌,并在发出信号时退出或抛出。否则,循环将继续进行,直到接收并处理了整个流。

该代码基于相关答案的示例,并使用HttpCompletionOption.ResponseHeadersRead并检查取消令牌。它可以解析包含适当项目数组的JSON字符串,例如:

[{"prop1":123},{"prop1":234}]

第一个调用jsonStreamReader.Read()移到数组的开头,而第二个调用移到第一个对象的开头。当]检测到数组()的结尾时,循环本身终止。

private async IAsyncEnumerable<T> GetList<T>(Uri url, CancellationToken cancellationToken = default)
{
    //Don't cache the entire response
    using var httpResponse = await httpClient.GetAsync(url,                               
                                                       HttpCompletionOption.ResponseHeadersRead,  
                                                       cancellationToken);
    using var stream = await httpResponse.Content.ReadAsStreamAsync();
    using var jsonStreamReader = new Utf8JsonStreamReader(stream, 32 * 1024);

    jsonStreamReader.Read(); // move to array start
    jsonStreamReader.Read(); // move to start of the object

    while (jsonStreamReader.TokenType != JsonTokenType.EndArray)
    {
        //Gracefully return if cancellation is requested.
        //Could be cancellationToken.ThrowIfCancellationRequested()
        if(cancellationToken.IsCancellationRequested)
        {
            return;
        }

        // deserialize object
        var obj = jsonStreamReader.Deserialize<T>();
        yield return obj;

        // JsonSerializer.Deserialize ends on last token of the object parsed,
        // move to the first token of next object
        jsonStreamReader.Read();
    }
}

JSON片段,又名流式传输JSON ... *

在事件流或日志记录场景中,将单个JSON对象附加到文件(每行一个元素)非常普遍,例如:

{"eventId":1}
{"eventId":2}
...
{"eventId":1234567}

这不是有效的JSON 文档,但各个片段均有效。对于大数据/高并发场景,这具有多个优势。添加新事件仅需要向文件添加新行,而不需要解析和重建整个文件。处理(尤其是并行处理)更容易,原因有两个:

  • 只需一次从流中读取一行,即可一次检索单个元素。
  • 输入文件可以轻松地划分和跨越线边界,将每个部分提供给单独的工作进程(例如,在Hadoop集群中),或者仅在应用程序中使用不同的线程:计算分割点,例如通过将长度除以工作程序数,然后寻找第一个换行符。将所有东西都喂给一个单独的工人。

使用StreamReader

执行此操作的分配方式是使用TextReader,一次读取一行,然后使用JsonSerializer.Deserialize对其进行解析

using var reader=new StreamReader(stream);
string line;
//ReadLineAsync() doesn't accept a CancellationToken 
while((line=await reader.ReadLineAsync()) != null)
{
    var item=JsonSerializer.Deserialize<T>(line);
    yield return item;

    if(cancellationToken.IsCancellationRequested)
    {
        return;
    }
}

这比反序列化适当数组的代码要简单得多。有两个问题:

  • ReadLineAsync 不接受取消令牌
  • 每次迭代都会分配一个新的字符串,这是我们希望通过使用System.Text.Json 避免的事情之一

尽管尝试生成ReadOnlySpan<Byte>JsonSerializer所需的缓冲区就足够了。反序列化并不简单。

管道和SequenceReader

为了避免分配,我们需要ReadOnlySpan<byte>从流中获取一个。为此,需要使用System.IO.Pipeline管道和SequenceReader结构。Steve Gordon的“ SequenceReader简介”介绍了如何使用此类使用定界符从流中读取数据。

不幸的是,它SequenceReader是一个ref结构,这意味着它不能在异步或本地方法中使用。因此,史蒂夫·戈登(Steve Gordon)在他的文章中

private static SequencePosition ReadItems(in ReadOnlySequence<byte> sequence, bool isCompleted)

从ReadOnlySequence读取项目的方法,并返回结束位置,因此PipeReader可以从中恢复。不幸的是,我们想返回IEnumerable或IAsyncEnumerable,并且迭代器方法也不喜欢inout参数。

我们可以将反序列化的项目收集在List或Queue中,然后将它们作为单个结果返回,但这仍然会分配列表,缓冲区或节点,并且必须等待缓冲区中的所有项目反序列化之后才能返回:

private static (SequencePosition,List<T>) ReadItems(in ReadOnlySequence<byte> sequence, bool isCompleted)

我们需要一种无需枚举器方法就可以枚举的东西,它可以与async一起使用,并且不能一路缓冲所有内容。

添加通道以产生IAsyncEnumerable

ChannelReader.ReadAllAsync返回IAsyncEnumerable。我们可以从无法作为迭代器工作的方法返回ChannelReader,而仍然产生不缓存的元素流。

修改Steve Gordon的代码以使用通道,我们获得了ReadItems(ChannelWriter ...)和ReadLastItem方法。第一个,一次读取一个项目,直到使用为止换行ReadOnlySpan<byte> itemBytes。可以使用JsonSerializer.Deserialize。如果ReadItems找不到分隔符,它将返回其位置,以便PipelineReader可以从流中提取下一个块。

当我们到达最后一个块并且没有其他定界符时,ReadLastItem`读取其余字节并将其反序列化。

该代码几乎与史蒂夫·戈登的代码相同。而不是写入控制台,我们写入ChannelWriter。

private const byte NL=(byte)'\n';
private const int MaxStackLength = 128;

private static SequencePosition ReadItems<T>(ChannelWriter<T> writer, in ReadOnlySequence<byte> sequence, 
                          bool isCompleted, CancellationToken token)
{
    var reader = new SequenceReader<byte>(sequence);

    while (!reader.End && !token.IsCancellationRequested) // loop until we've read the entire sequence
    {
        if (reader.TryReadTo(out ReadOnlySpan<byte> itemBytes, NL, advancePastDelimiter: true)) // we have an item to handle
        {
            var item=JsonSerializer.Deserialize<T>(itemBytes);
            writer.TryWrite(item);            
        }
        else if (isCompleted) // read last item which has no final delimiter
        {
            var item = ReadLastItem<T>(sequence.Slice(reader.Position));
            writer.TryWrite(item);
            reader.Advance(sequence.Length); // advance reader to the end
        }
        else // no more items in this sequence
        {
            break;
        }
    }

    return reader.Position;
}

private static T ReadLastItem<T>(in ReadOnlySequence<byte> sequence)
{
    var length = (int)sequence.Length;

    if (length < MaxStackLength) // if the item is small enough we'll stack allocate the buffer
    {
        Span<byte> byteBuffer = stackalloc byte[length];
        sequence.CopyTo(byteBuffer);
        var item=JsonSerializer.Deserialize<T>(byteBuffer);
        return item;        
    }
    else // otherwise we'll rent an array to use as the buffer
    {
        var byteBuffer = ArrayPool<byte>.Shared.Rent(length);

        try
        {
            sequence.CopyTo(byteBuffer);
            var item=JsonSerializer.Deserialize<T>(byteBuffer);
            return item;
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(byteBuffer);
        }

    }    
}

DeserializeToChannel<T>方法在流的顶部创建一个Pipeline阅读器,创建一个通道并启动一个工作程序任务,该工作程序将分析块并将其推入通道:

ChannelReader<T> DeserializeToChannel<T>(Stream stream, CancellationToken token)
{
    var pipeReader = PipeReader.Create(stream);    
    var channel=Channel.CreateUnbounded<T>();
    var writer=channel.Writer;
    _ = Task.Run(async ()=>{
        while (!token.IsCancellationRequested)
        {
            var result = await pipeReader.ReadAsync(token); // read from the pipe

            var buffer = result.Buffer;

            var position = ReadItems(writer,buffer, result.IsCompleted,token); // read complete items from the current buffer

            if (result.IsCompleted) 
                break; // exit if we've read everything from the pipe

            pipeReader.AdvanceTo(position, buffer.End); //advance our position in the pipe
        }

        pipeReader.Complete(); 
    },token)
    .ContinueWith(t=>{
        pipeReader.Complete();
        writer.TryComplete(t.Exception);
    });

    return channel.Reader;
}

ChannelReader.ReceiveAllAsync()可用于通过消耗所有物品IAsyncEnumerable<T>

var reader=DeserializeToChannel<MyEvent>(stream,cts.Token);
await foreach(var item in reader.ReadAllAsync(cts.Token))
{
    //Do something with it 
}    

0

感觉就像您需要让自己的流阅读器发挥作用。您必须一一读取字节,并在对象定义完成后立即停止。它确实是低级的。因此,您将不会将整个文件加载到RAM中,而是要处理您要处理的部分。看来是答案吗?


-2

也许您可以使用Newtonsoft.Json序列化器? https://www.newtonsoft.com/json/help/html/Performance.htm

特别请参阅部分:

优化内存使用

编辑

您可以尝试从JsonTextReader反序列化值,例如

using (var textReader = new StreamReader(stream))
using (var reader = new JsonTextReader(textReader))
{
    while (await reader.ReadAsync(cancellationToken))
    {
        yield return reader.Value;
    }
}

那没有回答问题。这根本不涉及性能,而是有关将所有内容加载到内存中的流式访问
Panagiotis Kanavos,

您是否打开了相关链接,或者只是说了您的想法?在我提到的部分中发送的链接中,有一个代码片段,说明如何从流中反序列化JSON。
米沃什维乔雷克

请再次阅读问题-OP询问如何在反序列化内存中所有内容的情况下处理元素。不仅从流中读取,而且仅处理流中的内容。I don't want them to be in memory all at once, but I would rather read and process them one by one.JSON.NET中的相关类是JsonTextReader。
Panagiotis Kanavos,

在任何情况下,仅链接的回答都不会被认为是一个好答案,并且该链接中的任何内容都不能回答OP的问题。链接到JsonTextReader会更好
Panagiotis Kanavos,
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.