TL; DR Non è banale
Sembra che qualcuno abbia già pubblicato il codice completo per una Utf8JsonStreamReader
struttura 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.GetAsync
tornerà 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.ResponseHeadersRead
e 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 in
né ai out
parametri.
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 ReadLastItem
metodi. Il primo, legge un elemento alla volta, fino a una nuova riga usando ReadOnlySpan<byte> itemBytes
. Questo può essere usato da JsonSerializer.Deserialize
. Se ReadItems
non 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
}