Операции запросов и ответов в ASP.NET Core

Автор Джастин Коталик (Justin Kotalik)

В этой статье объясняется, как читать текст запроса и писать текст ответа. Возможно, вам потребуется написать код для этих операций при создании ПО промежуточного слоя. В других случаях писать такой код обычно не нужно, так как эти операции обрабатываются MVC и Razor Pages.

Существует две абстракции для текста запросов и ответов: Stream и Pipe. При чтении запроса HttpRequest.Body — это Stream, а HttpRequest.BodyReader — это PipeReader. При записи ответа HttpResponse.Body — это Stream, а HttpResponse.BodyWriter — это PipeWriter.

Рекомендуется использовать конвейеры, а не потоки. Потоки удобнее использовать для некоторых простых операций, но производительность конвейеров выше и с ними проще работать в большинстве сценариев. Начиная с ASP.NET Core преимущество отдается внутреннему использованию конвейеров вместо потоков. Вот некоторые примеры.

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

Потоки не удаляются из платформы. С ними продолжают работать в .NET, и многие типы потоков не имеют эквивалентного конвейера, например FileStreams и ResponseCompression.

Примеры потоков

Предположим, необходимо создать ПО промежуточного слоя, которое считывает весь текст запроса как список строк с разделением на новые строки. Реализация простого потока может выглядеть следующим образом:

Предупреждение

Следующий код:

  • используется для демонстрации проблем без использования канала для чтения текста запроса;
  • не предназначен для использования в рабочих приложениях.
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"));
}

Если вы хотите увидеть комментарии к коду, переведенные на языки, отличные от английского, сообщите нам на странице обсуждения этой проблемы на сайте GitHub.

Этот код работает, но есть определенные проблемы:

  • Перед добавлением StringBuilder в коде создается другая строка (encodedString), которая сразу отбрасывается. Этот процесс выполняется для всех байтов в потоке, и в результате выделяется дополнительный объем памяти для всего текста запроса.
  • Код считывает всю строку перед разделением на новые строки. Более эффективным вариантом является поиск новых строк в массиве байтов.

Ниже приведен пример, в котором устранены некоторые предыдущие проблемы.

Предупреждение

Следующий код:

  • используется для демонстрации решений некоторых проблем в приведенном выше коде без решения всех проблем;
  • не предназначен для использования в рабочих приложениях.
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;
}

В предшествующем примере:

  • Не создается буфер для всего текста запроса в StringBuilder, если нет символов новой строки.
  • В строке не вызывается Split.

Но некоторые проблемы при этом остаются:

  • Если символы новой строки разрежены, большая часть текста запроса буферизуется в строке.
  • Строки (remainingString) по-прежнему создаются в коде и добавляются в буфер строки, что приводит к выделению дополнительной памяти.

Эти проблемы можно решить, но ценой усложнения кода при незначительных его улучшениях. Конвейеры позволяют решить эти проблемы при минимальной сложности кода.

Конвейеры

В следующем примере показано, как тот же сценарий можно обработать с помощью 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));
}

В этом примере устранены многие проблемы, характерные для реализации посредством потоков.

  • В буфере строки теперь нет необходимости, так как PipeReader обрабатывает байты, которые не использовались.
  • Кодированные строки напрямую добавляются в список возвращенных строк.
  • Создание строк не связано с выделением памяти, кроме памяти, используемой строкой (за исключением вызова ToArray).

Адаптеры

Для HttpRequest и HttpResponse доступны свойства Body, BodyReader и BodyWriter. Если назначить Body другому потоку, новый набор адаптеров автоматически адаптирует каждый тип к другому. Если назначить HttpRequest.Body новому потоку, HttpRequest.BodyReader автоматически назначается новому PipeReader, который создает оболочку для HttpRequest.Body.

StartAsync

Метод HttpResponse.StartAsync используется для указания того, что заголовки являются неизменяемыми, а также для запуска обратных вызовов OnStarting. При использования Kestrel в качестве сервера вызов StartAsync перед применением PipeReader гарантирует, что память, возвращенная GetMemory, относится к внутреннему методу KestrelPipe, а не к внешнему буферу.

Дополнительные ресурсы