Operazioni di richiesta e risposta in ASP.NET Core

Di Justin Kotalik

Questo articolo illustra come leggere dal corpo della richiesta e scrivere nel corpo della risposta. Il codice per queste operazioni potrebbe essere necessario durante la scrittura del middleware. Al di fuori della scrittura del middleware, il codice personalizzato non è in genere necessario perché le operazioni vengono gestite da MVC e Razor Pages.

Esistono due astrazioni per i corpi di richiesta e risposta: Stream e Pipe. Per la lettura delle richieste, HttpRequest.Body è un Streamoggetto e HttpRequest.BodyReader è un oggetto PipeReader. Per la scrittura delle risposte, HttpResponse.Body è un Streamoggetto e HttpResponse.BodyWriter è un oggetto PipeWriter.

Le pipeline sono consigliate su flussi. I flussi possono essere più facili da usare per alcune operazioni semplici, ma le pipeline hanno prestazioni migliori e sono più facili da usare nella maggior parte degli scenari. ASP.NET Core sta iniziando a usare pipeline anziché flussi internamente. Alcuni esempi:

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

Flussi non vengono rimossi dal framework. Flussi continuare a essere usato in .NET e molti tipi di flusso non hanno equivalenti pipe, ad esempio FileStreams e ResponseCompression.

Esempi di flussi

Si supponga che l'obiettivo sia quello di creare un middleware che legge l'intero corpo della richiesta come elenco di stringhe, suddividendo su nuove righe. Un'implementazione di flusso semplice potrebbe essere simile alla seguente:

Avviso

Il codice seguente:

  • Viene usato per illustrare i problemi relativi all'uso di una pipe per leggere il corpo della richiesta.
  • Non deve essere usato nelle app di produzione.
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"));
}

Per visualizzare i commenti del codice tradotti in lingue diverse dall'inglese, segnalarlo in questo problema di discussione su GitHub.

Questo codice funziona, ma esistono alcuni problemi:

  • Prima dell'aggiunta a StringBuilder, l'esempio crea un'altra stringa (encodedString) che viene eliminata immediatamente. Questo processo si verifica per tutti i byte nel flusso, pertanto il risultato è l'allocazione di memoria aggiuntiva rispetto alle dimensioni dell'intero corpo della richiesta.
  • L'esempio legge l'intera stringa prima della suddivisione in corrispondenza delle nuove righe. È più efficiente verificare la presenza di nuove righe nella matrice di byte.

Ecco un esempio che risolve alcuni dei problemi precedenti:

Avviso

Il codice seguente:

  • Viene usato per illustrare le soluzioni ad alcuni problemi nel codice precedente, senza risolvere tutti i problemi.
  • Non deve essere usato nelle app di produzione.
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;
}

Questo esempio precedente:

  • Non memorizza nel buffer l'intero corpo della richiesta in un StringBuilder, a meno che non siano presenti caratteri di nuova riga.
  • Non chiama Split sulla stringa.

Tuttavia, esistono ancora alcuni problemi:

  • Se i caratteri di nuova riga sono di tipo sparse, gran parte del corpo della richiesta viene memorizzata nel buffer nella stringa.
  • Il codice continua a creare stringhe (remainingString) e le aggiunge al buffer di stringa, che comporta un'allocazione aggiuntiva.

Questi problemi sono risolvibili, ma il codice sta diventando progressivamente più complicato con un piccolo miglioramento. Le pipeline consentono di risolvere questi problemi con complicazioni minime per il codice.

Pipeline

L'esempio seguente illustra come gestire lo stesso scenario usando 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));
}

Questo esempio consente di risolvere molti problemi delle implementazioni dei flussi:

  • Non è necessario un buffer di stringa perché gestisce i PipeReader byte che non sono stati usati.
  • Le stringhe codificate vengono aggiunte direttamente all'elenco di stringhe restituite.
  • Oltre alla ToArray chiamata e alla memoria usata dalla stringa, la creazione di stringhe è libera dall'allocazione.

Adapter

Le Bodyproprietà , BodyReadere BodyWriter sono disponibili per HttpRequest e HttpResponse. Quando si imposta su Body un flusso diverso, un nuovo set di adattatori si adatta automaticamente a ogni tipo all'altro. Se si imposta su HttpRequest.Body un nuovo flusso, HttpRequest.BodyReader viene impostato automaticamente su un nuovo PipeReader oggetto che esegue il wrapping di HttpRequest.Body.

StartAsync

HttpResponse.StartAsync viene usato per indicare che le intestazioni non sono modificabili e per eseguire OnStarting callback. Quando si usa Kestrel come server, la chiamata StartAsync prima di usare garantisce che la PipeReader memoria restituita da GetMemory appartenga al Kestrelbuffer interno Pipe anziché a un buffer esterno.

Risorse aggiuntive