web-dev-qa-db-fra.com

Désérialisation asynchrone d'une liste à l'aide de System.Text.Json

Disons que je demande un gros fichier json qui contient une liste de nombreux objets. Je ne veux pas qu'ils soient en mémoire d'un seul coup, mais je préfère les lire et les traiter un par un. J'ai donc besoin de transformer un async System.IO.Stream diffuser dans un IAsyncEnumerable<T>. Comment utiliser le nouveau System.Text.Json API pour faire ça?

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
        }
    }
}
11
Rick de Water

Oui, un sérialiseur JSON (de) véritablement en streaming serait une amélioration de performance agréable à avoir, dans tant d'endroits.

Malheureusement, System.Text.Json Ne le fait pas pour le moment mais il le sera dans le futur . La désérialisation de JSON en streaming réel s'avère plutôt difficile.

Vous pouvez vérifier si le extrêmement rapide tf8Json le supporte peut-être.

Cependant, il peut y avoir une solution personnalisée pour votre situation spécifique, car vos besoins semblent limiter la difficulté.

L'idée est de lire manuellement un élément du tableau à la fois. Nous utilisons le fait que chaque élément de la liste est, en soi, un objet JSON valide.

Vous pouvez sauter manuellement le [ (Pour le premier élément) ou le , (Pour chaque élément suivant). Ensuite, je pense que votre meilleur pari est d'utiliser Utf8JsonReader De .NET Core pour déterminer où se termine l'objet actuel, et de nourrir les octets numérisés dans JsonDeserializer.

De cette façon, vous ne tamponnez que légèrement sur un objet à la fois.

Et puisque nous parlons de performances, vous pouvez obtenir l'entrée d'un PipeReader, pendant que vous y êtes. :-)

3
Timo

TL; DR Ce n'est pas anodin


On dirait que quelqu'un déjàcode complet publié pour une structure Utf8JsonStreamReader Qui lit les tampons d'un flux et les alimente vers un Utf8JsonRreader, permettant une désérialisation facile avec JsonSerializer.Deserialize<T>(ref newJsonReader, options);. Le code n'est pas non plus trivial. La question connexe est ici et la réponse est ici .

Mais cela ne suffit pas - HttpClient.GetAsync Ne reviendra qu'après réception de la réponse complète, mettant essentiellement tout en mémoire.

Pour éviter cela, HttpClient.GetAsync (string, HttpCompletionOption) doit être utilisé avec HttpCompletionOption.ResponseHeadersRead.

La boucle de désérialisation doit également vérifier le jeton d'annulation et quitter ou lancer s'il est signalé. Sinon, la boucle continuera jusqu'à ce que le flux entier soit reçu et traité.

Ce code est basé sur l'exemple de la réponse associée et utilise HttpCompletionOption.ResponseHeadersRead Et vérifie le jeton d'annulation. Il peut analyser les chaînes JSON qui contiennent un tableau approprié d'éléments, par exemple:

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

Le premier appel à jsonStreamReader.Read() se déplace au début du tableau tandis que le second se déplace au début du premier objet. La boucle elle-même se termine lorsque la fin du tableau (]) Est détectée.

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

Fragments JSON, AKA en streaming JSON aka ... *

Il est assez courant dans les scénarios de streaming ou de journalisation d'événements d'ajouter des objets JSON individuels à un fichier, un élément par ligne, par exemple:

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

Ce n'est pas un JSON valide document mais les fragments individuels sont valides. Cela présente plusieurs avantages pour les scénarios Big Data/hautement concurrents. L'ajout d'un nouvel événement nécessite uniquement l'ajout d'une nouvelle ligne au fichier, et non l'analyse et la reconstruction de l'ensemble du fichier. Traitement, en particulier parallèle le traitement est plus facile pour deux raisons:

  • Les éléments individuels peuvent être récupérés un à la fois, simplement en lisant une ligne dans un flux.
  • Le fichier d'entrée peut être facilement partitionné et divisé à travers les limites de ligne, alimentant chaque partie à un processus de travail séparé, par exemple dans un cluster Hadoop, ou simplement différents threads dans une application: calculez les points de partage, par exemple en divisant la longueur par le nombre de travailleurs , puis recherchez la première nouvelle ligne. Nourrissez tout jusqu'à ce point à un travailleur distinct.

Utilisation d'un StreamReader

La façon d'allouer-y pour ce faire serait d'utiliser un TextReader, de lire une ligne à la fois et de l'analyser avec 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;
    }
}

C'est beaucoup plus simple que le code qui désérialise un tableau approprié. Il y a deux problèmes:

  • ReadLineAsync n'accepte pas de jeton d'annulation
  • Chaque itération alloue une nouvelle chaîne, une des choses que nous voulions éviter en utilisant System.Text.Json

Cela peut cependant suffire car essayer de produire les tampons ReadOnlySpan<Byte> Nécessaires à JsonSerializer.Deserialize n'est pas anodin.

Pipelines et SequenceReader

Pour éviter toute allocation, nous devons obtenir un ReadOnlySpan<byte> Du flux. Pour ce faire, vous devez utiliser les canaux System.IO.Pipeline et la structure SequenceReader . Steve Gordon's An Introduction to SequenceReader explique comment cette classe peut être utilisée pour lire les données d'un flux à l'aide de délimiteurs.

Malheureusement, SequenceReader est une structure ref qui signifie qu'elle ne peut pas être utilisée dans les méthodes asynchrones ou locales. Voilà pourquoi Steve Gordon dans son article crée un

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

pour lire les éléments à partir d'une ReadOnlySequence et renvoyer la position de fin, afin que le PipeReader puisse reprendre à partir de celle-ci. Malheureusement nous voulons retourner un IEnumerable ou IAsyncEnumerable, et les méthodes de l'itérateur n'aiment pas non plus les paramètres in ou out.

Nous pourrions collecter les éléments désérialisés dans une liste ou une file d'attente et les renvoyer en tant que résultat unique, mais cela allouerait toujours des listes, des tampons ou des nœuds et devrait attendre que tous les éléments d'un tampon soient désérialisés avant de retourner:

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

Nous avons besoin de quelque chose qui agit comme un énumérable sans nécessiter de méthode d'itérateur, fonctionne avec async et ne met pas tout en mémoire tampon.

Ajout de canaux pour produire un IAsyncEnumerable

ChannelReader.ReadAllAsync renvoie un IAsyncEnumerable. Nous pouvons renvoyer un ChannelReader à partir de méthodes qui ne pouvaient pas fonctionner comme itérateurs et toujours produire un flux d'éléments sans mise en cache.

En adaptant le code de Steve Gordon pour utiliser des canaux, nous obtenons les méthodes ReadItems (ChannelWriter ...) et ReadLastItem. Le premier, lit un élément à la fois, jusqu'à une nouvelle ligne en utilisant ReadOnlySpan<byte> itemBytes. Cela peut être utilisé par JsonSerializer.Deserialize. Si ReadItems ne trouve pas le délimiteur, il renvoie sa position pour que le PipelineReader puisse extraire le morceau suivant du flux.

Lorsque nous atteignons le dernier morceau et qu'il n'y a pas d'autre délimiteur, ReadLastItem` lit les octets restants et les désérialise.

Le code est presque identique à celui de Steve Gordon. Au lieu d'écrire sur la console, nous écrivons sur 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);
        }

    }    
}

La méthode DeserializeToChannel<T> Crée un lecteur Pipeline au-dessus du flux, crée un canal et démarre une tâche de travail qui analyse les morceaux et les pousse vers le canal:

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() peut être utilisée pour consommer tous les articles via un IAsyncEnumerable<T>:

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

Il semble que vous ayez besoin d'implémenter votre propre lecteur de flux. Vous devez lire les octets un par un et vous arrêter dès que la définition d'objet est terminée. Il est en effet assez bas niveau. En tant que tel, vous ne chargez PAS le fichier entier dans la RAM, mais prenez plutôt la partie avec laquelle vous traitez. Semble-t-il être une réponse?

0
Sereja Bogolubov