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,并且迭代器方法也不喜欢in
或out
参数。
我们可以将反序列化的项目收集在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
}