Deserializzare in modo asincrono un elenco usando System.Text.Json


11

Diciamo che richiedo un file json di grandi dimensioni che contiene un elenco di molti oggetti. Non voglio che siano in memoria tutti in una volta, ma preferirei leggerli ed elaborarli uno per uno. Quindi ho bisogno di trasformare un System.IO.Streamflusso asincrono in un IAsyncEnumerable<T>. Come posso usare la nuova System.Text.JsonAPI per fare questo?

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
Probabilmente avrai bisogno di qualcosa come il metodo DeserializeAsync
Pavel Anikhouski,

2
Siamo spiacenti, sembra che il metodo sopra carichi l'intero flusso in memoria. È possibile leggere i dati di pezzi asynchonously utilizzando Utf8JsonReader, si prega di dare un'occhiata a qualche GitHub campioni ea esistenti filo così
Pavel Anikhouski

GetAsyncda solo ritorna quando viene ricevuta l' intera risposta. Devi invece usare SendAsynccon `HttpCompletionOption.ResponseContentRead`. Una volta ottenuto, è possibile utilizzare JsonTextReader di JSON.NET . L'uso System.Text.Jsonper questo non è così facile come mostra questo problema . La funzionalità non è disponibile e implementarla in una bassa allocazione usando le strutture non è banale
Panagiotis Kanavos

Il problema con la deserializzazione in blocchi è che devi sapere quando hai un pezzo completo da deserializzare. Ciò sarebbe difficile da realizzare in modo chiaro per i casi generali. Richiederebbe l'analisi in anticipo, il che potrebbe essere un compromesso piuttosto scarso in termini di prestazioni. Sarebbe piuttosto difficile generalizzare. Ma se imponi le tue restrizioni sul tuo JSON, dici "un singolo oggetto occupa esattamente 20 righe nel file", allora potresti essenzialmente deserializzare in modo asincrono leggendo il file in blocchi asincroni. Avresti bisogno di un enorme json per vedere i benefici qui, immagino.
Detective

Sembra che qualcuno abbia già risposto a una domanda simile qui con il codice completo.
Panagiotis Kanavos,

Risposte:


4

Sì, un serializzatore JSON (de) veramente streaming sarebbe un bel miglioramento delle prestazioni da avere, in così tanti posti.

Sfortunatamente, System.Text.Jsonnon lo fa in questo momento. Non sono sicuro che lo farà in futuro - lo spero! La deserializzazione in streaming di JSON si rivela piuttosto impegnativa.

Potresti verificare se Utf8Json è estremamente veloce , forse.

Tuttavia, potrebbe esserci una soluzione personalizzata per la tua situazione specifica, poiché le tue esigenze sembrano limitare la difficoltà.

L'idea è di leggere manualmente un elemento alla volta dall'array. Stiamo sfruttando il fatto che ogni elemento nell'elenco è, di per sé, un oggetto JSON valido.

È possibile saltare manualmente [(per il primo elemento) o ,(per ciascun elemento successivo). Quindi penso che la soluzione migliore sia quella di utilizzare .NET Core Utf8JsonReaderper determinare dove finisce l'oggetto corrente e alimentare i byte scansionati JsonDeserializer.

In questo modo, esegui il buffering solo leggermente di un oggetto alla volta.

E poiché stiamo parlando di performance, potresti ottenere l'input da a PipeReader, mentre ci sei. :-)


Non si tratta affatto di prestazioni. Non si tratta di deserializzazione asincrona, cosa che già fa. Si tratta dell'accesso allo streaming - elaborazione degli elementi JSON mentre vengono analizzati dallo stream, come fa JsonTextReader di JSON.NET.
Panagiotis Kanavos,

La classe rilevante in Utf8Json è JsonReader e come dice l'autore, è strano. JsonTextReader e System.Text.Json di JSON.NET Utf8JsonReader condividono la stessa stranezza: è necessario eseguire il ciclo e controllare il tipo di elemento corrente mentre si procede.
Panagiotis Kanavos,

@PanagiotisKanavos Ah, sì, streaming. Questa è la parola che stavo cercando! Sto aggiornando la parola "asincrono" a "streaming". Credo che il motivo per volere lo streaming sia limitare l'utilizzo della memoria, il che è un problema di prestazioni. Forse OP può confermare.
Timo,

Le prestazioni non significano velocità. Non importa quanto sia veloce il deserializzatore, se devi elaborare 1M articoli, non vuoi archiviarli nella RAM, né aspettare che vengano deserializzati tutti prima di poter elaborare il primo.
Panagiotis Kanavos,

Semantica, amico mio! Sono contento che stiamo cercando di ottenere la stessa cosa dopo tutto.
Timo,

4

TL; DR Non è banale


Sembra che qualcuno abbia già pubblicato il codice completo per una Utf8JsonStreamReaderstruttura che legge i buffer da uno stream e li invia a un Utf8JsonRreader, consentendo una facile deserializzazione con JsonSerializer.Deserialize<T>(ref newJsonReader, options);. Neanche il codice è banale. La domanda correlata è qui e la risposta è qui .

Non è abbastanza però - HttpClient.GetAsynctornerà solo dopo aver ricevuto l'intera risposta, essenzialmente bufferando tutto in memoria.

Per evitare questo, HttpClient.GetAsync (stringa, HttpCompletionOption) deve essere usato con HttpCompletionOption.ResponseHeadersRead.

Il ciclo di deserializzazione dovrebbe controllare anche il token di annullamento e uscire o lanciare se è segnalato. Altrimenti il ​​ciclo continuerà fino a quando l'intero flusso non verrà ricevuto ed elaborato.

Questo codice si basa nell'esempio della risposta correlata e utilizza HttpCompletionOption.ResponseHeadersReade controlla il token di annullamento. Può analizzare le stringhe JSON che contengono una matrice corretta di elementi, ad esempio:

[{"prop1":123},{"prop1":234}]

La prima chiamata a si jsonStreamReader.Read()sposta all'inizio dell'array mentre la seconda si sposta all'inizio del primo oggetto. Il ciclo stesso termina quando ]viene rilevata la fine dell'array ( ).

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();
    }
}

Frammenti JSON, streaming AKA JSON aka ... *

È abbastanza comune negli scenari di streaming o registrazione degli eventi aggiungere singoli oggetti JSON a un file, un elemento per riga, ad esempio:

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

Questo non è un documento JSON valido ma i singoli frammenti sono validi. Ciò presenta numerosi vantaggi per i big data / scenari altamente concorrenti. L'aggiunta di un nuovo evento richiede solo l'aggiunta di una nuova riga al file, non l'analisi e la ricostruzione dell'intero file. L'elaborazione , in particolare l' elaborazione parallela , è più semplice per due motivi:

  • I singoli elementi possono essere recuperati uno alla volta, semplicemente leggendo una riga da un flusso.
  • Il file di input può essere facilmente partizionato e diviso attraverso i confini della linea, alimentando ciascuna parte in un processo di lavoro separato, ad esempio in un cluster Hadoop, o semplicemente thread diversi in un'applicazione: Calcola i punti di divisione, ad esempio dividendo la lunghezza per il numero di lavoratori , quindi cerca la prima riga. Nutri tutto fino a quel momento a un lavoratore separato.

Utilizzando uno StreamReader

Il modo allocato-y per fare ciò sarebbe usare un TextReader, leggere una riga alla volta e analizzarlo con JsonSerializer .

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

È molto più semplice del codice che deserializza un array adeguato. Ci sono due problemi:

  • ReadLineAsync non accetta un token di annullamento
  • Ogni iterazione alloca una nuova stringa, una delle cose che volevamo evitare usando System.Text.Json

Questo potrebbe essere sufficiente, anche se cercare di produrre i ReadOnlySpan<Byte>buffer necessari per JsonSerializer. La serializzazione non è banale.

Pipeline e SequenceReader

Per evitare tutte le posizioni, dobbiamo ottenere uno ReadOnlySpan<byte>dallo stream. Per fare ciò è necessario utilizzare le pipe System.IO.Pipeline e la struttura SequenceReader . Un'Introduzione a SequenceReader di Steve Gordon spiega come questa classe può essere utilizzata per leggere i dati da un flusso usando i delimitatori.

Sfortunatamente, SequenceReaderè una struttura di riferimento che significa che non può essere utilizzata in metodi asincroni o locali. Ecco perché Steve Gordon nel suo articolo crea un

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

metodo per leggere gli elementi forma ReadOnlySequence e restituire la posizione finale, in modo che PipeReader possa riprendere da esso. Sfortunatamente vogliamo restituire un IEnumerable o IAsyncEnumerable e ai metodi iteratore non piacciono inné ai outparametri.

Potremmo raccogliere gli elementi deserializzati in un elenco o in una coda e restituirli come un unico risultato, ma ciò allocarebbe ancora elenchi, buffer o nodi e dovremmo attendere che tutti gli elementi in un buffer vengano deserializzati prima di restituire:

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

Abbiamo bisogno di qualcosa che si comporti come un enumerabile senza richiedere un metodo iteratore, funzioni con asincrono e non bufferizza tutto nel modo.

Aggiunta di canali per produrre un IAsyncEnumerable

ChannelReader.ReadAllAsync restituisce un IAsyncEnumerable. Possiamo restituire un ChannelReader da metodi che non potrebbero funzionare come iteratori e produrre comunque un flusso di elementi senza memorizzazione nella cache.

Adattando il codice di Steve Gordon per usare i canali, otteniamo ReadItems (ChannelWriter ...) e i ReadLastItemmetodi. Il primo, legge un elemento alla volta, fino a una nuova riga usando ReadOnlySpan<byte> itemBytes. Questo può essere usato da JsonSerializer.Deserialize. Se ReadItemsnon riesce a trovare il delimitatore, restituisce la sua posizione in modo che PipelineReader possa estrarre il blocco successivo dallo stream.

Quando raggiungiamo l'ultimo blocco e non c'è nessun altro delimitatore, ReadLastItem` legge i byte rimanenti e li deserializza.

Il codice è quasi identico a quello di Steve Gordon. Invece di scrivere sulla console, scriviamo su 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);
        }

    }    
}

Il DeserializeToChannel<T>metodo crea un lettore Pipeline in cima allo stream, crea un canale e avvia un'attività di lavoro che analizza i blocchi e li spinge sul canale:

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()può essere utilizzato per consumare tutti gli articoli attraverso un IAsyncEnumerable<T>:

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

0

Sembra che tu debba impiantare il tuo lettore di stream. Devi leggere i byte uno a uno e fermarti non appena la definizione dell'oggetto è stata completata. È davvero piuttosto di basso livello. Come tale NON caricherai l'intero file nella RAM, ma piuttosto prenderai la parte con cui hai a che fare. Sembra essere una risposta?


-2

Forse potresti usare il Newtonsoft.Jsonserializzatore? https://www.newtonsoft.com/json/help/html/Performance.htm

Soprattutto vedi la sezione:

Ottimizza l'utilizzo della memoria

modificare

Potresti provare a deserializzare i valori da JsonTextReader, ad es

using (var textReader = new StreamReader(stream))
using (var reader = new JsonTextReader(textReader))
{
    while (await reader.ReadAsync(cancellationToken))
    {
        yield return reader.Value;
    }
}

Questo non risponde alla domanda. Non si tratta affatto di prestazioni, si tratta dell'accesso allo streaming senza caricare tutto in memoria
Panagiotis Kanavos,

Hai aperto il link correlato o hai appena detto cosa ne pensi? Nel link che ho inviato nella sezione che ho citato c'è uno snippet di codice su come deserializzare JSON dallo stream.
Miłosz Wieczorek,

Leggi di nuovo la domanda per favore: l'OP chiede come elaborare gli elementi senza deserializzare tutto in memoria. Non solo legge da uno stream, ma elabora solo ciò che proviene dallo stream. I don't want them to be in memory all at once, but I would rather read and process them one by one.La classe pertinente in JSON.NET è JsonTextReader.
Panagiotis Kanavos,

In ogni caso, una risposta di solo collegamento non è considerata una buona risposta e nulla in quel collegamento risponde alla domanda del PO. Un collegamento a JsonTextReader sarebbe meglio
Panagiotis Kanavos,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.