请求和响应操作

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"));
}

此代码有效,但存在一些问题:

  • 在追加到 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)
        {
            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');
            if (index == -1)
            {
                break;
            }

            var encodedString = Encoding.UTF8.GetString(buffer, prevIndex, index);

            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, index, bytesRemaining - index);
        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(ref results, in readOnlySequence);

                // Skip the line + the \n character (basically position)
                buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
            }
        }
        while (position != null);

        // At this point, buffer will be updated to point one byte after the last
        // \n character.
        reader.AdvanceTo(buffer.Start, buffer.End);

        if (readResult.IsCompleted)
        {
            break;
        }
    }

    return results;
}

private static void AddStringToList(ref 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() 调用除外)。

适配器

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

StartAsync

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

其他资源