TL; DR Це не банально
Схоже, хтось уже розмістив повний код для Utf8JsonStreamReader
структури, яка зчитує буфери з потоку і подає їх на Utf8JsonRreader, що дозволяє легко десеріалізувати JsonSerializer.Deserialize<T>(ref newJsonReader, options);
. Код теж не банальний. Пов'язане питання тут, а відповідь - тут .
Але цього недостатньо - HttpClient.GetAsync
повернеться лише після отримання всієї відповіді, по суті буферизації всього в пам'яті.
Щоб цього уникнути, слід використовувати HttpClient.GetAsync (рядок, HttpCompletionOption)HttpCompletionOption.ResponseHeadersRead
.
Цикл деріаріалізації також повинен перевірити маркер скасування, а також вийти або викинути, якщо про це сигналізується. В іншому випадку цикл буде тривати, поки весь потік не буде отримано та оброблено.
Цей код базується на прикладі відповідної відповіді та використовує HttpCompletionOption.ResponseHeadersRead
та перевіряє маркер скасування. Він може проаналізувати рядки JSON, які містять правильний масив елементів, наприклад:
[{"prop1":123},{"prop1":234}]
Перший виклик jsonStreamReader.Read()
переходить до початку масиву, а другий - до початку першого об’єкта. Сам цикл припиняється, коли ]
виявляється кінець масиву ( ).
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, AKA потокове JSON aka ... *
Це досить часто в сценаріях потокового потоку або ведення журналів для додавання окремих файлів JSON до файлу, один елемент у рядку, наприклад:
{"eventId":1}
{"eventId":2}
...
{"eventId":1234567}
Це не дійсний документ JSON, але окремі фрагменти є дійсними. Це має ряд переваг для великих сценаріїв даних / дуже одночасних. Додавання нової події вимагає лише додавання до файлу нового рядка, а не розбору та відновлення всього файлу. Обробка , особливо паралельна обробка, легша з двох причин:
- Окремі елементи можна отримати по одному, просто прочитавши один рядок із потоку.
- Вхідний файл можна легко розділити і розділити через межі ліній, подаючи кожну частину в окремий робочий процес, наприклад, в кластер Hadoop або просто різні потоки програми: Обчисліть точки розділення, наприклад, поділивши довжину на кількість робітників , тоді шукайте перший новий рядок. Подавайте все до цього моменту окремому працівникові.
Використання StreamReader
Для цього можна виділити TextReader, прочитати по черзі один рядок і проаналізувати його за допомогою 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;
}
}
Це набагато простіше, ніж код, який десеріалізує правильний масив. Є два питання:
ReadLineAsync
не приймає маркер скасування
- Кожна ітерація виділяє нову рядок, одну з речей, якої ми хотіли уникати , використовуючи System.Text.Json
Цього може бути досить, хоча спроба створити ReadOnlySpan<Byte>
буфери, необхідні JsonSerializer.Deserialize не є тривіальною.
Трубопроводи та SequenceReader
Щоб уникнути всіх місць розташування, нам потрібно дістати ReadOnlySpan<byte>
з потоку. Для цього потрібно використовувати системи System.IO.Pipeline і структуру SequenceReader . Вступ Стіва Гордона в «SequenceReader» пояснює, як цей клас можна використовувати для читання даних із потоку за допомогою роздільників.
На жаль, SequenceReader
це структура ref, що означає, що її не можна використовувати в асинхронних або локальних методах. Ось чому Стів Гордон у своїй статті створює а
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)
Нам потрібно щось, що діє як чисельне, не вимагаючи методу ітератора, працює з асинхронізацією і не буферизує все так, як потрібно.
Додавання каналів для створення IAsyncEnumerable
ChannelReader.ReadAllAsync повертає IAsyncEnumerable. Ми можемо повернути ChannelReader з методів, які не могли працювати ітераторами і все ще створюють потік елементів без кешування.
Пристосовуючи код Стіва Гордона для використання каналів, ми отримуємо ReadItems (ChannelWriter ...) та ReadLastItem
методи. Перший - читає по одному предмету, аж до нового рядка, використовуючи ReadOnlySpan<byte> itemBytes
. Цим може скористатися JsonSerializer.Deserialize
. Якщо ReadItems
не вдається знайти роздільник, він повертає своє положення, щоб PipelineReader міг витягнути наступний шматок із потоку.
Коли ми дістаємося до останнього фрагменту і немає іншого роздільника, ReadLastItem` зчитує решта байтів і десеріалізує їх.
Код майже ідентичний коду Стіва Гордона. Замість того, щоб писати в консоль, ми пишемо в 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
}