TL; DRそれは簡単ではありません
誰かがストリームからバッファを読み取り、それらをUtf8JsonRreaderにフィードする構造体の完全なコードをすでに 投稿Utf8JsonStreamReader
しているようJsonSerializer.Deserialize<T>(ref newJsonReader, options);
です。コードも簡単ではありません。関連する質問はこちら、答えはこちらです。
しかし、それだけでは十分ではありません- HttpClient.GetAsync
応答全体が受信された後にのみ戻り、基本的にすべてをメモリにバッファします。
これを回避するには、でHttpClient.GetAsync(string、HttpCompletionOption)を使用する必要がありHttpCompletionOption.ResponseHeadersRead
ます。
デシリアライゼーションループはキャンセルトークンもチェックし、シグナルが出された場合は終了するかスローする必要があります。それ以外の場合は、ストリーム全体が受信されて処理されるまでループが続きます。
このコードは、関連する回答の例に基づいておりHttpCompletionOption.ResponseHeadersRead
、キャンセルトークンを使用およびチェックします。アイテムの適切な配列を含むJSON文字列を解析できます。例:
[{"prop1":123},{"prop1":234}]
最初の呼び出しjsonStreamReader.Read()
は配列の先頭に移動し、2番目の呼び出しは最初のオブジェクトの先頭に移動します。ループ自体は、配列の終わり(]
)が検出されると終了します。
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オブジェクトをファイルに1行に1つずつ追加するのが一般的です。例:
{"eventId":1}
{"eventId":2}
...
{"eventId":1234567}
これは有効なJSON ドキュメントではありませんが、個々のフラグメントは有効です。これには、ビッグデータ/高度に同時のシナリオにいくつかの利点があります。新しいイベントを追加するには、ファイルに新しい行を追加するだけで、ファイル全体を解析して再構築する必要はありません。処理、特に並列処理は、次の2つの理由で簡単です。
- 個々の要素は、ストリームから1行を読み取るだけで、一度に1つずつ取得できます。
- 入力ファイルは簡単に分割してラインの境界を越えて分割でき、Hadoopクラスターなどの個別のワーカープロセス、またはアプリケーションの単純に異なるスレッドに各パーツを供給します。たとえば、長さをワーカー数で割ることにより、分割ポイントを計算します。 、次に最初の改行を探します。その時点までのすべてを別の労働者に与える。
StreamReaderの使用
これを行うためのallocate-yの方法は、TextReaderを使用し、一度に1行ずつ読み取り、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;
}
}
これは、適切な配列を逆シリアル化するコードよりもはるかに単純です。2つの問題があります。
ReadLineAsync
キャンセルトークンを受け入れない
- 各反復で新しい文字列が割り当てられます。これは、System.Text.Jsonを使用して回避したかったことの1つです。
ReadOnlySpan<Byte>
JsonSerializer.Deserializeが必要とするバッファーを生成しようとするのは簡単ではありませんが、これで十分かもしれません。
パイプラインとSequenceReader
すべての割り当てを回避するにReadOnlySpan<byte>
は、ストリームからを取得する必要があります。これを行うには、System.IO.PipelineパイプとSequenceReader構造体を使用する必要があります。Steve Gordonの「Introduction to SequenceReader」では、このクラスを使用して、区切り文字を使用してストリームからデータを読み取る方法について説明しています。
残念ながら、SequenceReader
ref構造体です。つまり、非同期メソッドやローカルメソッドでは使用できません。そのため、Steve Gordon氏の記事では、
private static SequencePosition ReadItems(in ReadOnlySequence<byte> sequence, bool isCompleted)
ReadOnlySequenceから項目を読み取り、終了位置を返すメソッド。PipeReaderはそこから再開できます。残念ながら、IEnumerableまたはIAsyncEnumerableを返したいのですが、イテレーターメソッドはin
、out
パラメーターやパラメーターが好きではありません。
逆シリアル化されたアイテムをリストまたはキューに収集して1つの結果として返すことができますが、それでもリスト、バッファー、またはノードが割り当てられ、バッファー内のすべてのアイテムが逆シリアル化されるのを待ってから返す必要があります。
private static (SequencePosition,List<T>) ReadItems(in ReadOnlySequence<byte> sequence, bool isCompleted)
イテレータメソッドを必要とせずに列挙型のように動作し、非同期で動作し、すべてをバッファリングしないものが必要です。
チャネルを追加してIAsyncEnumerableを作成する
ChannelReader.ReadAllAsyncはIAsyncEnumerableを返します。イテレータとして機能しなかったメソッドからChannelReaderを返すことができ、キャッシュせずに要素のストリームを生成できます。
Steve Gordonのコードをチャネルを使用するように変更して、ReadItems(ChannelWriter ...)とReadLastItem
メソッドを取得します。最初のものは、一度に1つの項目を読み取り、改行までを使用しReadOnlySpan<byte> 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<T>
メソッドは、ストリームの上にパイプラインリーダーを作成し、チャネルを作成して、チャンクを解析してチャネルにプッシュするワーカータスクを開始します。
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
}