ASP.NET Core 中的请求和响应操作

作者:Justin Kotalik

本文介绍如何读取请求正文和写入响应正文。 写入中间件时,可能需要这些操作的代码。 除写入中间件外,通常不需要自定义代码,因为操作由 MVC 和 Razor Pages 处理。

请求正文和响应正文有两个抽象元素:StreamPipe。 对于请求读取,HttpRequest.BodyStreamHttpRequest.BodyReaderPipeReader。 对于响应写入,HttpResponse.BodyStreamHttpResponse.BodyWriterPipeWriter

建议使用管道替代流。 在一些简单操作中,使用流会比较简单,但管道具有性能优势,并且在大多数场景中更易于使用。 ASP.NET Core 开始在内部使用管道替代流。 示例包括:

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

流不会从框架中删除。 流将继续在 .NET 中使用,并且许多流类型不具有等效的管道,如 FileStreamsResponseCompression

流示例

假设目标是要创建一个中间件,它将整个请求正文作为一个字符串列表读取,并在新行上进行拆分。 一个简单的流实现可能如下例所示:

警告

下面的代码:

  • 用于演示不使用管道读取请求正文的问题。
  • 不应在生产应用中使用。
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 调用以及字符串使用的内存,创建字符串时无需分配。

适配器

BodyBodyReaderBodyWriter 属性可用于 HttpRequestHttpResponse。 将 Body 设置为另一个流时,一组新的适配器会自动使每种类型彼此适应。 如果将 HttpRequest.Body 设置为新流,则 HttpRequest.BodyReader 将自动设置为包装 HttpRequest.Body 的新 PipeReader

StartAsync

HttpResponse.StartAsync 用于指示标头不可修改并运行 OnStarting 回调。 使用 Kestrel 作为服务器时,在使用 PipeReader 之前调用 StartAsync 可确保 GetMemory 返回的内存属于 Kestrel 的内部 Pipe 而不是外部缓冲区。

其他资源