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


11

多くのオブジェクトのリストを含む大きなjsonファイルを要求するとします。一度にメモリに保存したくはありませんが、1つずつ読み取って処理したいと思います。したがって、非同期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 '26 / 10/26

2
上記のメソッドはストリーム全体をメモリにロードしているようです。チャンクで非同期にデータを読み取ることができます。githubのサンプルと既存のスレッドUtf8JsonReaderご覧ください
Pavel Anikhouski

GetAsync応答全体を受信すると、それ自体が戻ります。SendAsync代わりに `HttpCompletionOption.ResponseContentRead` を使用する必要があります。それができたら、JSON.NETのJsonTextReaderを使用できます。この問題が示すようにSystem.Text.Json、これを使用するのはそれほど簡単ではありません。この機能は使用できず、構造体を使用して低割り当てで実装することは簡単ではありません
Panagiotis Kanavos '29

チャンクでの逆シリアル化の問題は、逆シリアル化する完全なチャンクがあるときを知る必要があることです。これは、一般的なケースでは完全に達成することは困難です。事前に解析する必要があるため、パフォーマンスの点でかなりのトレードオフになる可能性があります。一般化するのはかなり難しいでしょう。ただし、JSONに独自の制限を適用する場合、「1つのオブジェクトがファイル内でちょうど20行を占める」と言うと、ファイルを非同期でチャンクで読み取ることにより、基本的に非同期でデシリアライズできます。ここで利益を得るには、巨大なjsonが必要になると思います。
DetectivePikachu

誰かが完全なコードでここ同様の質問にすでに回答したようです。
Panagiotis Kanavos

回答:


4

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

残念ながら、System.Text.Json現時点ではこれを行いません。将来的になるかどうかはわかりませんが、期待しています!JSONの真のストリーミング逆シリアル化は、かなり難しいことがわかります。

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

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

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

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

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

また、ここではパフォーマンスについて説明しているためPipeReader、操作中にから入力を取得できます。:-)


これはパフォーマンスに関するものではありません。それは非同期の逆シリアル化についてではなく、すでに行っています。JSON.NETのJsonTextReaderが行うように、ストリームアクセスからの解析-JSON要素がストリームから解析されるときに処理することです。
Panagiotis Kanavos

Utf8Jsonの関連クラスはJsonReaderであり、作者が言うように奇妙です。JSON.NETのJsonTextReaderとSystem.Text.JsonのUtf8JsonReaderは同じ奇妙さを共有しています-ループして現在の要素のタイプを確認する必要があります。
Panagiotis Kanavos

@PanagiotisKanavosああ、はい、ストリーミング。それが私が探していた言葉です!「非同期」という言葉を「ストリーミング」に更新しています。ストリーミングが必要な理由は、パフォーマンスの問題であるメモリ使用量を制限するためだと思います。おそらくOPが確認できます。
Timo

パフォーマンスは速度を意味するものではありません。あなたは1Mアイテムを処理する必要があればどんなにデシリアライザがどのように速く、あなたはしていない RAMに保存したい、でもあなたが最初のものを処理する前に、それらのすべてのための待ち時間は、デシリアライズ取得します。
Panagiotis Kanavos

意味論、友よ!結局同じことを成し遂げられてうれしいです。
ティモ

4

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」では、このクラスを使用して、区切り文字を使用してストリームからデータを読み取る方法について説明しています。

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

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

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

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

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

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

チャネルを追加してIAsyncEnumerableを作成する

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

Steve Gordonのコードをチャネルを使用するように変更して、ReadItems(ChannelWriter ...)とReadLastItemメソッドを取得します。最初のものは、一度に1つの項目を読み取り、改行までを使用しReadOnlySpan<byte> itemBytesます。これはで使用できますJsonSerializer.DeserializeReadItems区切り文字が見つからない場合は、その位置を返し、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 
}    

0

自分のストリームリーダーを提供する必要があるように感じます。バイトを1つずつ読み取り、オブジェクト定義が完了するとすぐに停止する必要があります。それは確かにかなり低レベルです。そのため、ファイル全体を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

関連リンクを開いたり、考えたことを言ったりしましたか?前述のセクションで送信したリンクには、ストリームからJSONを逆シリアル化する方法のコードスニペットがあります。
MiłoszWieczorek

もう一度質問を読んでください-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 '29
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.