Асинхронно десеріалізує список за допомогою System.Text.Json


11

Скажемо, що я запитую великий файл json, який містить список багатьох об'єктів. Я не хочу, щоб вони запам'ятовувались відразу, але я б швидше читав і обробляв їх по черзі. Тому мені потрібно перетворити 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
Павло Аніхоускі,

2
На жаль, здається, що вищевказаний метод завантажує весь потік у пам'ять. Ви можете прочитати дані по шматках asynchonously з використанням Utf8JsonReader, будь ласка , подивіться на деяких GitHub зразків і при існуючому потоці , а також
Павлу Anikhouski

GetAsyncсам по собі повертається, коли отримана вся відповідь. Вам потрібно використовувати SendAsyncзамість `HttpCompletionOption.ResponseContentRead`. Після цього ви можете використовувати JsonTextReader JSON.NET . Використовувати System.Text.Jsonдля цього не так просто, як показує ця проблема . Функціонал недоступний, і реалізація його в низькому
Panagiotis Kanavos

Проблема десеріалізації в шматках полягає в тому, що ви повинні знати, коли у вас є цілий шматок, щоб його дезаріалізувати. Це буде важко досягти чисто для загальних випадків. Це вимагатиме попереднього розбору, що може бути досить поганим з точки зору продуктивності. Це було б досить важко узагальнити. Але якщо ви застосовуєте власні обмеження на свій JSON, скажімо, "один об'єкт займає рівно 20 рядків у файлі", то ви зможете по суті дезаріалізувати асинхронно, прочитавши файл у шматки асинхронізації. Вам би знадобився величезний json, щоб побачити тут вигоду, я б уявив.
детективу

Схоже, хтось уже відповів на подібне запитання тут із повним кодом.
Панайотис Канавос

Відповіді:


4

Так, справді поточний серіалізатор JSON (де) був би приємним поліпшенням роботи в таких місцях.

На жаль, System.Text.Jsonнаразі цього не робить. Я не впевнений, чи буде це в майбутньому - сподіваюся! По-справжньому поточна десеріалізація JSON виявляється досить складною.

Ви можете перевірити, чи підтримує його надзвичайно швидкий Utf8Json .

Однак для вашої конкретної ситуації може бути індивідуальне рішення, оскільки ваші вимоги, здається, обмежують труднощі.

Ідея полягає в тому, щоб вручну читати один елемент з масиву одночасно. Ми використовуємо той факт, що кожен елемент у списку сам по собі є дійсним об’єктом JSON.

Ви можете вручну пропустити повз [(для першого елемента) або ,(для кожного наступного елемента). Тоді я думаю, що найкраще скористатися .NET Core, Utf8JsonReaderщоб визначити, де закінчується поточний об'єкт, і подати до сканованих байтів JsonDeserializer.

Таким чином, ви буферуєте лише трохи над одним об'єктом за один раз.

А оскільки ми говоримо про ефективність, ви можете отримати вхід від PipeReader, поки ви на це. :-)


Це зовсім не про продуктивність. Йдеться не про асинхронну десеріалізацію, що вона вже робить. Йдеться про потокову передачу - обробку елементів JSON під час їх розбору з потоку, як це робить JsonTextReader JSON.NET.
Панайотис Канавос

Відповідним класом в Utf8Json є JsonReader, і, як каже автор, це дивно. Json.NET's JsonTextReader та System.Text.Json's Utf8JsonReader поділяють таку ж дивність - вам потрібно циклічно перевіряти і перевіряти тип поточного елемента.
Панайотис Канавос

@PanagiotisKanavos Ага, так, потокове. Це слово я шукав! Я поновлюю слово "асинхронний" до "потокового". Я вважаю, що причиною того, щоб хотіти потокове передача, є обмеження використання пам'яті, що викликає занепокоєння щодо продуктивності. Можливо, ОП може підтвердити.
Тимо

Продуктивність не означає швидкість. Незалежно від того, наскільки швидкий буде десяриалізатор, якщо вам доведеться обробляти елементи 1М, ви не хочете зберігати їх в оперативній пам’яті, а також не чекати, поки всі вони десеріалізуються, перш ніж ви зможете обробити перший.
Панайотис Канавос

Семантика, друже! Я радий, що ми намагаємось досягти того ж самого.
Тимо

4

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 
}    

0

Схоже, вам потрібно реалізувати власний зчитувач потоків. Ви повинні читати байти один за одним і зупинятися, як тільки визначення об’єкта завершено. Це дійсно досить низький рівень. В такому випадку ви НЕ будете завантажувати весь файл в оперативну пам’ять, а скоріше візьмете ту частину, з якою маєте справу. Здається, це відповідь?


-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 Kanavos

Ви відкрили пов’язане посилання або просто сказали, що думаєте? У посиланні, яке я надіслав у згаданому розділі, є фрагмент коду, як деріаріалізувати JSON з потоку.
Miłosz Wieczorek

Прочитайте ще раз питання - ОП запитує, як обробити елементи, не десаріалізуючи все в пам'яті. Не просто читати з потоку, а лише обробляти те, що виходить із потоку. 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.
Панайотис Канавос

У будь-якому випадку відповідь лише на посилання не вважається хорошою відповіддю, і нічого в цьому посиланні не відповідає на питання ОП. Посилання на JsonTextReader буде краще
Panagiotis Kanavos
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.