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
}