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

Примечание.

Это не последняя версия этой статьи. В текущей версии см. версию .NET 10 этой статьи.

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

Эта версия ASP.NET Core больше не поддерживается. Дополнительные сведения см. в политике поддержки .NET и .NET Core. В текущей версии см. версию .NET 10 этой статьи.

Джастин Коталик

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

Существует две абстракции для текста запросов и ответов: 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"));
}

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

  • Перед добавлением 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 и памяти, используемой строкой, создание строк не связано с выделением памяти.

При прямой записи в HttpResponse.BodyWriter вызовите PipeWriter.FlushAsync вручную, чтобы убедиться, что данные сбрасываются в основное тело ответа. Для этого есть следующие причины.

  • HttpResponse.BodyWriter PipeWriter— это буферизация данных до запуска операции очистки.
  • Вызов FlushAsync записывает буферированные данные в основной текст отклика.

Разработчик может решить, когда следует вызывать FlushAsync, балансировать факторы, такие как размер буфера, расходы на запись сети, а также следует ли отправлять данные в дискретных блоках. Дополнительные сведения см. в разделе System.IO.Pipelines в .NET.

Адаптеры

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

StartAsync

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

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