Opérations de demande et de réponse dans ASP.NET Core

Par Justin Kotalik

Cet article explique comment lire à partir du corps de la demande et écrire dans le corps de la réponse. Le code pour ces opérations peut être requis lors de l’écriture d’intergiciels. En dehors de l’écriture d’intergiciels, le code personnalisé n’est généralement pas requis, car les opérations sont gérées par MVC et Razor Pages.

Il existe deux abstractions pour les corps de la requête et de la réponse : Stream et Pipe. Pour la lecture des requêtes, HttpRequest.Body est un Streamet HttpRequest.BodyReader est un PipeReader. Pour l’écriture de réponses, HttpResponse.Body est un Streamet HttpResponse.BodyWriter est un PipeWriter.

Les pipelines sont recommandés sur les flux. Les flux peuvent être plus faciles à utiliser pour des opérations simples, mais les pipelines présentent un avantage de performances et sont plus faciles à utiliser dans la plupart des scénarios. ASP.NET Core commence à utiliser des pipelines au lieu de flux en interne. Voici quelques exemples :

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

Les flux ne sont pas supprimés de l’infrastructure. Les flux continuent à être utilisés dans .NET et de nombreux types de flux n’ont d’équivalents en termes de canal, comme FileStreams et ResponseCompression.

Exemples de flux

Supposons que votre objectif est de créer un intergiciel qui lit le corps de la requête dans sa totalité comme une liste de chaînes, avec un fractionnement sur les nouvelles lignes. Une implémentation simple de flux peut se présenter comme dans l’exemple suivant :

Avertissement

Le code suivant :

  • Est utilisé pour illustrer les problèmes liés à l’utilisation d’un canal pour lire le corps de la requête.
  • N’est pas destiné à être utilisé dans les applications de production.
private async Task<List<string>> GetListOfStringsFromStream(Stream requestBody)
{
    // Build up the request body in a string builder.
    StringBuilder builder = new StringBuilder();

    // Rent a shared buffer to write the request body into.
    byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);

    while (true)
    {
        var bytesRemaining = await requestBody.ReadAsync(buffer, offset: 0, buffer.Length);
        if (bytesRemaining == 0)
        {
            break;
        }

        // Append the encoded string into the string builder.
        var encodedString = Encoding.UTF8.GetString(buffer, 0, bytesRemaining);
        builder.Append(encodedString);
    }

    ArrayPool<byte>.Shared.Return(buffer);

    var entireRequestBody = builder.ToString();

    // Split on \n in the string.
    return new List<string>(entireRequestBody.Split("\n"));
}

Si vous souhaitez voir les commentaires de code traduits dans une langue autre que l’anglais, dites-le nous dans cette discussion GitHub.

Ce code fonctionne, mais il existe certains problèmes :

  • Avant d’ajouter à StringBuilder, l’exemple crée une autre chaîne (encodedString) qui est immédiatement rejetée. Ce processus se produit pour tous les octets dans le flux, il en résulte une allocation de mémoire supplémentaire de la taille de la totalité du corps de la demande.
  • L’exemple lit la chaîne entière avant de fractionner sur les nouvelles lignes. Il est plus efficace de rechercher les nouvelles lignes dans le tableau d’octets.

Voici un exemple qui résout certains des problèmes précédents :

Avertissement

Le code suivant :

  • Est utilisé pour illustrer les solutions à certains problèmes dans le code précédent sans résoudre tous les problèmes.
  • N’est pas destiné à être utilisé dans les applications de production.
private async Task<List<string>> GetListOfStringsFromStreamMoreEfficient(Stream requestBody)
{
    StringBuilder builder = new StringBuilder();
    byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
    List<string> results = new List<string>();

    while (true)
    {
        var bytesRemaining = await requestBody.ReadAsync(buffer, offset: 0, buffer.Length);

        if (bytesRemaining == 0)
        {
            results.Add(builder.ToString());
            break;
        }

        // Instead of adding the entire buffer into the StringBuilder
        // only add the remainder after the last \n in the array.
        var prevIndex = 0;
        int index;
        while (true)
        {
            index = Array.IndexOf(buffer, (byte)'\n', prevIndex);
            if (index == -1)
            {
                break;
            }

            var encodedString = Encoding.UTF8.GetString(buffer, prevIndex, index - prevIndex);

            if (builder.Length > 0)
            {
                // If there was a remainder in the string buffer, include it in the next string.
                results.Add(builder.Append(encodedString).ToString());
                builder.Clear();
            }
            else
            {
                results.Add(encodedString);
            }

            // Skip past last \n
            prevIndex = index + 1;
        }

        var remainingString = Encoding.UTF8.GetString(buffer, prevIndex, bytesRemaining - prevIndex);
        builder.Append(remainingString);
    }

    ArrayPool<byte>.Shared.Return(buffer);

    return results;
}

Cet exemple précédent :

  • Ne met pas en mémoire tampon le corps entier de la demande dans un StringBuilder , sauf s’il n’y a pas de caractère de nouvelle ligne.
  • N’appelle pas Split sur la chaîne.

Toutefois, il existe toujours quelques problèmes :

  • Si les caractères nouvelle ligne sont épars, une grande partie du corps de la requête est mis en mémoire tampon dans la chaîne.
  • Le code continue de créer des chaînes (remainingString) et les ajoute à la mémoire tampon de chaîne, ce qui entraîne une allocation supplémentaire.

Ces problèmes sont réparables, mais le code est de plus en plus compliqué avec peu d’amélioration. Les pipelines permettent de résoudre ces problèmes avec un code peu compliqué.

Pipelines

L’exemple suivant indique comment le même scénario peut être géré à l’aide d’un PipeReader :

private async Task<List<string>> GetListOfStringFromPipe(PipeReader reader)
{
    List<string> results = new List<string>();

    while (true)
    {
        ReadResult readResult = await reader.ReadAsync();
        var buffer = readResult.Buffer;

        SequencePosition? position = null;

        do
        {
            // Look for a EOL in the buffer
            position = buffer.PositionOf((byte)'\n');

            if (position != null)
            {
                var readOnlySequence = buffer.Slice(0, position.Value);
                AddStringToList(results, in readOnlySequence);

                // Skip the line + the \n character (basically position)
                buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
            }
        }
        while (position != null);


        if (readResult.IsCompleted && buffer.Length > 0)
        {
            AddStringToList(results, in buffer);
        }

        reader.AdvanceTo(buffer.Start, buffer.End);

        // At this point, buffer will be updated to point one byte after the last
        // \n character.
        if (readResult.IsCompleted)
        {
            break;
        }
    }

    return results;
}

private static void AddStringToList(List<string> results, in ReadOnlySequence<byte> readOnlySequence)
{
    // Separate method because Span/ReadOnlySpan cannot be used in async methods
    ReadOnlySpan<byte> span = readOnlySequence.IsSingleSegment ? readOnlySequence.First.Span : readOnlySequence.ToArray().AsSpan();
    results.Add(Encoding.UTF8.GetString(span));
}

Cet exemple résout de nombreux problèmes trouvés dans les implémentations de flux :

  • Une mémoire tampon de chaîne est inutile, car le PipeReader gère des octets qui n’ont pas été utilisés.
  • Les chaînes codées sont ajoutées directement à la liste des chaînes retournées.
  • À l’exception de l’appel ToArray, et de la mémoire utilisée par la chaîne, la création de chaînes est libre d’allocation.

Adaptateurs

Les propriétés Body, BodyReaderet BodyWriter sont disponibles pour HttpRequest et HttpResponse. Lorsque vous définissez Body sur un autre flux, un nouvel ensemble d’adaptateurs adapte automatiquement chaque type à l’autre. Si vous définissez HttpRequest.Body sur un nouveau flux, HttpRequest.BodyReader est automatiquement défini sur un nouveau PipeReader qui enveloppe HttpRequest.Body.

StartAsync

HttpResponse.StartAsync est utilisé pour indiquer que les en-têtes sont non modifiables et pour exécuter des rappels OnStarting. Lors de l’utilisation de Kestrel en tant que serveur, l’appel de StartAsync avant d’utiliser le PipeReader garantit que la mémoire retournée par GetMemory appartiendra au Pipe interne de Kestrel au lieu d’une mémoire tampon externe.

Ressources supplémentaires