【问题标题】:Asynchonously deserializing a list using System.Text.Json使用 System.Text.Json 异步反序列化列表
【发布时间】:2019-10-26 16:15:14
【问题描述】:

假设我请求一个包含许多对象列表的大型 json 文件。我不希望它们一次全部进入内存,但我宁愿一个一个地阅读和处理它们。所以我需要将异步System.IO.Stream 流转换为IAsyncEnumerable<T>。如何使用新的System.Text.Json API 来执行此操作?

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
        }
    }
}

【问题讨论】:

  • 你可能需要类似DeserializeAsync 方法
  • 对不起,上面的方法似乎将整个流加载到内存中。您可以使用Utf8JsonReader 按块异步读取数据,请查看一些github samples 和现有的thread 以及
  • GetAsync 在收到 整个 响应时自行返回。您需要将 SendAsync 与 `HttpCompletionOption.ResponseContentRead` 一起使用。一旦你有了它,你就可以使用 JSON.NET 的JsonTextReader。为此使用System.Text.Json 并不容易as this issue shows。该功能不可用,并且使用结构在低分配中实现它并非易事
  • 分块反序列化的问题是你必须知道什么时候你有一个完整的块要反序列化。对于一般情况,这将难以干净地完成。它需要提前解析,这在性能方面可能是一个相当糟糕的权衡。比较难一概而论。但是,如果您对 JSON 实施自己的限制,例如“单个对象在文件中恰好占据 20 行”,那么您基本上可以通过异步读取块中的文件来异步反序列化。我想你需要一个巨大的 json 才能看到这里的好处。
  • 看起来有人已经用完整代码回答了一个类似问题here。

标签: c# .net-core .net-core-3.0 c#-8.0 system.text.json


【解决方案1】:

TL;DR这不是小事


看起来像某人已经 posted full code 的 Utf8JsonStreamReader 结构从流中读取缓冲区并将它们提供给 Utf8JsonRreader,允许使用 JsonSerializer.Deserialize&lt;T&gt;(ref newJsonReader, options); 轻松反序列化。代码也不是微不足道的。相关问题是here,答案是here。

但这还不够——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 片段,AKA 流式 JSON 又名 ...*

在事件流或日志记录场景中,将单个 JSON 对象附加到文件中是很常见的,每行一个元素,例如:

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

这不是一个有效的 JSON 文档,但各个片段是有效的。这对于大数据/高并发场景有几个优势。添加新事件只需要在文件中追加一个新行,而不是解析和重建整个文件。 处理,尤其是并行处理更容易,原因有二:

  • 可以一次检索一个元素,只需从流中读取一行即可。
  • 输入文件可以很容易地跨行边界进行分区和拆分,将每个部分提供给单独的工作进程,例如在 Hadoop 集群中,或者只是应用程序中的不同线程:计算拆分点,例如通过将长度除以工人数量,然后寻找第一个换行符。将之前的所有内容提供给单独的工作人员。

使用 StreamReader

执行此操作的 allocate-y 方法是使用 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 来避免的事情之一

这可能就足够了,因为尝试生成 JsonSerializer.Deserialize 所需的 ReadOnlySpan&lt;Byte&gt; 缓冲区并非易事。

管道和序列读取器

为了避免分配,我们需要从流中获取ReadOnlySpan&lt;byte&gt;。这样做需要使用 System.IO.Pipeline 管道和 SequenceReader 结构。 Steve Gordon 的An Introduction to SequenceReader 解释了如何使用这个类通过分隔符从流中读取数据。

不幸的是,SequenceReader 是一个 ref 结构,这意味着它不能用于异步或本地方法。这就是为什么史蒂夫戈登在他的文章中创建了一个

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

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

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

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

我们需要某种东西,它就像一个可枚举而不需要迭代器方法,与异步一起工作并且不会以这种方式缓冲所有内容。

添加通道以生成 IAsyncEnumerable

ChannelReader.ReadAllAsync 返回一个 IAsyncEnumerable。我们可以从不能用作迭代器的方法中返回 ChannelReader,并且仍然可以在没有缓存的情况下生成元素流。

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

当我们到达最后一个块并且没有其他分隔符时,ReadLastItem` 读取剩余的字节并反序列化它们。

代码几乎与 Steve Gordon 的相同。我们没有写入控制台,而是写入 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&lt;T&gt; 方法在流顶部创建管道读取器,创建通道并启动解析块并将它们推送到通道的工作任务:

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&lt;T&gt; 消费所有物品:

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

【讨论】:

  • 除非我弄错了,否则你已经错过了一件事。 Utf8JsonStreamReader 是一个 ref 结构,因此不能在异步方法中使用。我看到你在其他情况下也提到了这个问题,但没有提到最初的例子。
  • 感谢@PanagiotisKanavos 的详细回答(您删除的回答也很有帮助!)。令人惊讶的是,System.Text.Json 的 JSON 流仍然如此困难。 AFAICT,新的DeserializeAsyncEnumerable 还没有为通过 socked 或命名管道无限流传输 JSON 提供可行的解决方案。它仍然会尝试将输入流读取到最后,并且在流结束之前不会产生任何项目。
【解决方案2】:

是的,一个真正的流式 JSON(反)序列化器将是一个很好的性能改进,在很多地方都有。

不幸的是,System.Text.Json 在我写这篇文章时并没有这样做。我不确定将来是否会 - 我希望如此! JSON 的真正流式反序列化是相当具有挑战性的。

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

但是,可能有针对您的具体情况的自定义解决方案,因为您的要求似乎限制了难度。

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

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

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

由于我们谈论的是性能,您可以在使用时从PipeReader 获得输入。 :-)

【讨论】:

  • 这根本与性能无关。这与异步反序列化无关,它已经 这样做了。这是关于流式访问 - 在从流中解析 JSON 元素时对其进行处理,就像 JSON.NET 的 JsonTextReader 所做的那样。
  • Utf8Json 中的相关类是 JsonReader,正如作者所说,这很奇怪。 JSON.NET 的 JsonTextReader 和 System.Text.Json 的 Utf8JsonReader 具有相同的怪异之处 - 您必须随时循环并检查当前元素的类型。
  • @PanagiotisKanavos 啊,是的,流媒体。这就是我要找的词!我正在将“异步”一词更新为“流式传输”。我确实相信想要流式传输的原因是限制内存使用,这是一个性能问题。也许OP可以确认。
  • 性能不代表速度。无论反序列化器的速度有多快,如果您必须处理 1M 项,您不想将它们存储在 RAM 中,也不想等它们全部反序列化后再处理第一个.
  • 语义学,我的朋友!毕竟,我很高兴我们正在努力实现同样的目标。
【解决方案3】:

我知道这是一篇旧帖子,但最近在 .Net 6 Preview 4 中宣布的 System.Text.Json support for IAsyncEnumerable 提供了 OP 中提到的问题的解决方案。

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())
        {

            await foreach(var item in JsonSerializer.DeserializeAsyncEnumerable<T>(stream))
            {
                yield return item;
            }
        }
    }
}

这将提供按需反序列化,并且在处理大数据时非常有用。请注意,目前该功能仅限于根级 JSON 数组。

有关该功能的更多详细信息可以找到here

【讨论】:

【解决方案4】:

感觉你需要实现自己的流阅读器。您必须逐个读取字节并在对象定义完成后立即停止。这确实是相当低级的。因此,您不会将整个文件加载到 RAM 中,而是选择您正在处理的部分。这似乎是一个答案?

【讨论】:

    【解决方案5】:

    可以在 .NET 5 (C# 9) 中使用System.IO.Pipelines 扩展包和System.Text.Json.JsonSerializer,而不是使用ChannelReader 的多个任务,如下所示:

    using System;
    using System.Buffers;
    using System.Collections.Generic;
    using System.IO;
    using System.IO.Pipelines;
    using System.Text;
    using System.Text.Json;
    using System.Threading.Tasks;
    
    class Program
    {
        static readonly byte[] NewLineChars = {(byte)'\r', (byte)'\n'};
        static readonly byte[] WhiteSpaceChars = {(byte)'\r', (byte)'\n', (byte)' ', (byte)'\t'};
    
        private static async Task Main()
        {
            JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
            var json = "{\"some\":\"thing1\"}\r\n{\"some\":\"thing2\"}\r\n{\"some\":\"thing3\"}";
            var contentStream = new MemoryStream(Encoding.UTF8.GetBytes(json));
            var pipeReader = PipeReader.Create(contentStream);
            await foreach (var foo in ReadItemsAsync<Foo>(pipeReader, jsonOptions))
            {
                Console.WriteLine($"foo: {foo.Some}");
            }
        }
    
        static async IAsyncEnumerable<TValue> ReadItemsAsync<TValue>(PipeReader pipeReader, JsonSerializerOptions jsonOptions = null)
        {
            while (true)
            {
                var result = await pipeReader.ReadAsync();
                var buffer = result.Buffer;
                bool isCompleted = result.IsCompleted;
                SequencePosition bufferPosition = buffer.Start;
                while (true)
                {
                    var(value, advanceSequence) = TryReadNextItem<TValue>(buffer, ref bufferPosition, isCompleted, jsonOptions);
                    if (value != null)
                    {
                        yield return value;
                    }
    
                    if (advanceSequence)
                    {
                        pipeReader.AdvanceTo(bufferPosition, buffer.End); //advance our position in the pipe
                        break;
                    }
                }
    
                if (isCompleted)
                    yield break;
            }
        }
    
        static (TValue, bool) TryReadNextItem<TValue>(ReadOnlySequence<byte> sequence, ref SequencePosition sequencePosition, bool isCompleted, JsonSerializerOptions jsonOptions)
        {
            var reader = new SequenceReader<byte>(sequence.Slice(sequencePosition));
            while (!reader.End) // loop until we've come to the end or read an item
            {
                if (reader.TryReadToAny(out ReadOnlySpan<byte> itemBytes, NewLineChars, advancePastDelimiter: true))
                {
                    sequencePosition = reader.Position;
                    if (itemBytes.TrimStart(WhiteSpaceChars).IsEmpty)
                    {
                        continue;
                    }
    
                    return (JsonSerializer.Deserialize<TValue>(itemBytes, jsonOptions), false);
                }
                else if (isCompleted)
                {
                    // read last item
                    var remainingReader = sequence.Slice(reader.Position);
                    using var memoryOwner = MemoryPool<byte>.Shared.Rent((int)reader.Remaining);
                    remainingReader.CopyTo(memoryOwner.Memory.Span);
                    reader.Advance(remainingReader.Length); // advance reader to the end
                    sequencePosition = reader.Position;
                    if (!itemBytes.TrimStart(WhiteSpaceChars).IsEmpty)
                    {
                        return (JsonSerializer.Deserialize<TValue>(memoryOwner.Memory.Span, jsonOptions), true);
                    }
                    else
                    {
                        return (default, true);
                    }
                }
                else
                {
                    // no more items in sequence
                    break;
                }
            }
    
            // PipeReader needs to read more
            return (default, true);
        }
    }
    
    public class Foo
    {
        public string Some
        {
            get;
            set;
        }
    }
    

    在https://dotnetfiddle.net/6j3KGg运行

    【讨论】:

      【解决方案6】:

      也许您可以使用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;
          }
      }
      

      【讨论】:

      • 这不能回答问题。这根本与性能无关,而是关于流式访问无需将所有内容加载到内存中
      • 您打开了相关链接还是只是说出了您的想法?在我在我提到的部分中发送的链接中,有一个关于如何从流中反序列化 JSON 的代码 sn-p。
      • 请再次阅读问题 - 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。
      • 在任何情况下,仅链接的答案都不是一个好的答案,并且该链接中的任何内容都无法回答 OP 的问题。一个指向 JsonTextReader 的链接会更好
      猜你喜欢
      • 1970-01-01
      • 2021-12-08
      • 2021-12-30
      • 2021-07-17
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2020-02-20
      相关资源
      最近更新 更多