web-dev-qa-db-ja.com

System.Text.Jsonを使用してリストを非同期に逆シリアル化する

多くのオブジェクトのリストを含む大きなjsonファイルを要求するとします。一度にメモリに保存したくはありませんが、1つずつ読み取って処理したいと思います。したがって、非同期System.IO.StreamストリームをIAsyncEnumerable<T>に変換する必要があります。新しいSystem.Text.Json AP​​Iを使用してこれを行うにはどうすればよいですか?

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
        }
    }
}
11
Rick de Water

はい、本当に多くの場所でJSON(デ)シリアライザーをストリーミングすることで、パフォーマンスが向上します。

残念ながら、現時点ではSystem.Text.Jsonはこれを行いませんが、 future で行います。 JSONの真のストリーミング逆シリアル化は、かなり難しいことがわかります。

おそらく、非常に高速な tf8Json がそれをサポートしているかどうかを確認できます。

ただし、要件によって難しさが制限されているように見えるため、特定の状況に合わせたカスタムソリューションがある場合があります。

アイデアは、一度に1つの項目を配列から手動で読み取ることです。リストの各アイテムはそれ自体が有効なJSONオブジェクトであるという事実を利用しています。

[(最初の項目の場合)または,(次の各項目の場合)を手動でスキップできます。次に、.NET CoreのUtf8JsonReaderを使用して現在のオブジェクトが終了する場所を特定し、スキャンしたバイトをJsonDeserializerに送るのが最善の策だと思います。

この方法では、一度に1つのオブジェクトを少しだけバッファリングします。

そして、ここではパフォーマンスについて話しているので、PipeReaderから入力を取得できます。 :-)

3
Timo

TL; DR簡単ではありません


誰かのように見えますalreadyposted full code for Utf8JsonStreamReader_構造体はストリームからバッファを読み取り、それらをUtf8JsonRreaderにフィードし、JsonSerializer.Deserialize<T>(ref newJsonReader, options);。コードも簡単ではありません。関連する質問は ここ で、答えは ここ です。

しかし、それだけでは十分ではありません。_HttpClient.GetAsync_は、応答全体を受信した後でのみ戻り、基本的にすべてをメモリにバッファリングします。

これを回避するには、_HttpCompletionOption.ResponseHeadersRead_と共に HttpClient.GetAsync(string、HttpCompletionOption) を使用する必要があります。

逆シリアル化ループはキャンセルトークンもチェックし、シグナルが出された場合は終了するかスローする必要があります。それ以外の場合は、ストリーム全体が受信されて処理されるまでループが続きます。

このコードは関連する回答の例に基づいており、_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ではありませんdocumentですが、個々のフラグメントは有効です。これには、ビッグデータ/高度に同時のシナリオにいくつかの利点があります。新しいイベントを追加するには、ファイルに新しい行を追加するだけで、ファイル全体を解析して再構築する必要はありません。 処理、特に並列処理は、次の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を使用して、新しい文字列を割り当てますavoid

これで十分かもしれませんが JsonSerializer.Deserializeが必要とする_ReadOnlySpan<Byte>_バッファを生成しようとするのは簡単ではありません。

パイプラインとSequenceReader

すべての割り当てを回避するには、ストリームから_ReadOnlySpan<byte>_を取得する必要があります。これを行うには、System.IO.Pipelineパイプと SequenceReader 構造体を使用する必要があります。スティーブゴードンの SequenceReaderの紹介 は、このクラスを使用して、区切り文字を使用してストリームからデータを読み取る方法を説明しています。

残念ながら、SequenceReaderはref構造体であるため、非同期またはローカルメソッドでは使用できません。そのため、Steve Gordon氏の記事では、

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

readOnlySequenceから項目を読み取り、終了位置を返すメソッド。PipeReaderはそこから再開できます。 残念ながら IEnumerableまたはIAsyncEnumerableを返したいので、イテレータメソッドはinまたはoutパラメータを好まない。

逆シリアル化されたアイテムをリストまたはキューに収集し、それらを単一の結果として返すことができますが、それでもリスト、バッファー、またはノードが割り当てられ、バッファー内のすべてのアイテムが逆シリアル化されるのを待ってから返す必要があります。

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

イテレータメソッドを必要とせずに列挙型のように機能し、非同期で動作し、すべてをバッファリングしないsomethingが必要です。

IAsyncEnumerableを生成するためのチャネルの追加

ChannelReader.ReadAllAsync はIAsyncEnumerableを返します。イテレータとして機能しなかったメソッドからChannelReaderを返すことができ、キャッシュせずに要素のストリームを生成できます。

Steve Gordonのコードをチャネルを使用するように変更すると、ReadItems(ChannelWriter ...)メソッドとReadLastItemメソッドが取得されます。最初のコードは、_ReadOnlySpan<byte> itemBytes_を使用して、一度に1つのアイテムを改行まで読み取ります。これは_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 
}    
_
4

自分のストリームリーダーを提供する必要があるように感じます。バイトを1つずつ読み取り、オブジェクト定義が完了するとすぐに停止する必要があります。それは確かにかなり低レベルです。そのため、ファイル全体をRAMにロードするのではなく、処理している部分を実行します。それは答えのように思えますか?

0