Operaciones de solicitud y respuesta en ASP.NET Core

Por Justin Kotalik

En este artículo se explica cómo leer el cuerpo de la solicitud y escribir el cuerpo de respuesta. El código para estas operaciones podría ser necesario al escribir middleware. Fuera de la escritura de middleware, el código personalizado no suele ser necesario porque las operaciones se controlan mediante MVC and Razor Pages.

Hay dos abstracciones para los cuerpos de solicitud y respuesta: Stream y Pipe. En la lectura de solicitudes, HttpRequest.Body es Stream y HttpRequest.BodyReader es PipeReader. En la escritura de respuestas, HttpResponse.Body es Stream y HttpResponse.BodyWriter es PipeWriter.

Se recomienda el uso de canalizaciones por encima de las secuencias. Las secuencias pueden ser más fáciles de usar en el caso de algunas operaciones sencillas, pero las canalizaciones son más ventajosas para el rendimiento y son más fáciles de usar en la mayoría de los casos. ASP.NET Core está empezando a usar internamente canalizaciones en lugar de secuencias. Ejemplos:

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

Las secuencias no se quitan del marco. Se seguirán usando en todo .NET, y además muchos tipos de secuencias no tienen equivalentes de canalización, como FileStreams y ResponseCompression.

Ejemplos de secuencias

Imagine que el objetivo es crear un middleware que lee el cuerpo de la solicitud entero como una lista de cadenas, que se divide en nuevas líneas. Una implementación de secuencias sencilla podría parecerse al ejemplo siguiente:

Advertencia

El código siguiente:

  • Se usa para describir los problemas que se producen al no usar una canalización para leer el cuerpo de la solicitud.
  • No está pensado para usarse en aplicaciones de producción.
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 quiere que los comentarios de código se traduzcan en más idiomas además del inglés, háganoslo saber en este problema de debate de GitHub.

Este código funciona, pero hay algunos problemas:

  • Antes de realizar la anexión a StringBuilder, en el ejemplo se crea otra cadena (encodedString) que se desecha inmediatamente. Este proceso se produce con todos los bytes de la secuencia, por lo que el resultado es la asignación de memoria adicional del tamaño del cuerpo de la solicitud entera.
  • En el ejemplo se lee toda la cadena antes de la división en nuevas líneas. Sería más eficaz buscar nuevas líneas en la matriz de bytes.

En este ejemplo se corrigen algunos de los problemas anteriores:

Advertencia

El código siguiente:

  • Se usa para describir las soluciones a algunos problemas en el código anterior, pero no todos.
  • No está pensado para usarse en aplicaciones de producción.
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;
}

Este ejemplo anterior:

  • No se almacena en búfer todo el cuerpo de la solicitud en StringBuilder a menos que no haya ningún carácter de nueva línea.
  • No se llama a Split en la cadena.

Sin embargo, todavía hay algunos problemas:

  • Si los caracteres de nueva línea están dispersos, gran parte del cuerpo de la solicitud se almacena en búfer en la cadena.
  • El código sigue creando cadenas (remainingString) y agrega al búfer de cadenas, lo que resulta en una asignación adicional.

Estos problemas se pueden corregir, pero el código se vuelve cada vez más complicado y las mejoras son pocas. Las canalizaciones ofrecen una manera de resolver estos problemas con una complejidad mínima del código.

Canalizaciones

En el siguiente ejemplo se describe cómo abordar el mismo escenario usando un objeto 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));
}

En este ejemplo se solucionan muchos problemas que tenían las implementaciones de secuencias:

  • No es necesario un búfer de cadenas porque PipeReader controla los bytes que no se han usado.
  • Las cadenas codificadas se agregan directamente a la lista de cadenas devueltas.
  • Aparte de la llamada a ToArray y de la memoria que usa la cadena, la creación de cadenas es de libre asignación.

Adaptadores

Las propiedades Body, BodyReader y BodyWriter están disponibles para HttpRequest y HttpResponse. Al establecer Body en una secuencia diferente, un nuevo conjunto de adaptadores se adaptan automáticamente al tipo del otro. Si establece HttpRequest.Body en una nueva secuencia, HttpRequest.BodyReader se establece automáticamente en un nuevo objeto PipeReader que encapsula a HttpRequest.Body.

StartAsync

HttpResponse.StartAsync se usa para indicar que los encabezados no se pueden modificar y para ejecutar devoluciones de llamada OnStarting. Al usar Kestrel como servidor, si se llama a StartAsync antes de usar el objeto PipeReader, se garantiza que la memoria que devuelve GetMemory pertenezca al objeto interno Pipe de Kestrel en lugar de a un búfer externo.

Recursos adicionales