如何阅读ASP.NET核心Response.Body?

How to read ASP.NET Core Response.Body?

我一直在努力从 ASP.NET 核心操作中获得 Response.Body 属性,而我能够确定的唯一解决方案似乎不是最优的。该解决方案需要在将流读入字符串变量时用 MemoryStream 换出 Response.Body,然后在发送给客户端之前将其换回。在下面的示例中,我试图在自定义中间件 class 中获取 Response.Body 值。 Response.Body 出于某种原因在 ASP.NET 核心中仅 是 属性?我只是遗漏了什么,还是 oversight/bug/design 问题?有没有更好的阅读方式 Response.Body?

当前(次优)解:

public class MyMiddleWare
{
    private readonly RequestDelegate _next;

    public MyMiddleWare(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        using (var swapStream = new MemoryStream())
        {
            var originalResponseBody = context.Response.Body;

            context.Response.Body = swapStream;

            await _next(context);

            swapStream.Seek(0, SeekOrigin.Begin);
            string responseBody = new StreamReader(swapStream).ReadToEnd();
            swapStream.Seek(0, SeekOrigin.Begin);

            await swapStream.CopyToAsync(originalResponseBody);
            context.Response.Body = originalResponseBody;
        }
    }
}  

尝试使用 EnableRewind() 的解决方案: 这仅适用于 Request.Body,不适用于 Response.Body。这导致从 Response.Body 中读取一个空字符串,而不是实际的响应正文内容。

Startup.cs

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, IApplicationLifetime appLifeTime)
{
    loggerFactory.AddConsole(Configuration.GetSection("Logging"));
    loggerFactory.AddDebug();

    app.Use(async (context, next) => {
        context.Request.EnableRewind();
        await next();
    });

    app.UseMyMiddleWare();

    app.UseMvc();

    // Dispose of Autofac container on application stop
    appLifeTime.ApplicationStopped.Register(() => this.ApplicationContainer.Dispose());
}

MyMiddleWare.cs

public class MyMiddleWare
{
    private readonly RequestDelegate _next;

    public MyMiddleWare(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        await _next(context);
        string responseBody = new StreamReader(context.Request.Body).ReadToEnd(); //responseBody is ""
        context.Request.Body.Position = 0;
    }
}  

在我最初的回复中,我完全误读了这个问题,并认为发帖者是在问如何阅读 Request.Body,但他问的是如何阅读 Response.Body。我将保留我原来的答案以保留历史,但也会更新它以显示在正确阅读后我将如何回答问题。

原答案

如果你想要一个支持多次读取的缓冲流你需要设置

   context.Request.EnableRewind()

理想情况下,在任何内容需要读取正文之前,尽早在中间件中执行此操作。

例如,您可以将以下代码放在 Startup.cs 文件的 Configure 方法的开头:

        app.Use(async (context, next) => {
            context.Request.EnableRewind();
            await next();
        });

在启用 Rewind 之前,与 Request.Body 关联的流是仅向前流,不支持第二次查找或读取流。这样做是为了使请求处理的默认配置尽可能轻量级和高性能。但是一旦启用倒带,流就会升级为支持多次查找和读取的流。您可以通过在调用 EnableRewind 之前和之后设置断点并观察 Request.Body 属性来观察这种“升级”。因此,例如 Request.Body.CanSeek 将从 false 更改为 true

更新:从 ASP.NET Core 2.1 Request.EnableBuffering() 开始可用,它将 Request.Body 升级到 FileBufferingReadStream 就像Request.EnableRewind() 并且由于 Request.EnableBuffering() 位于 public 命名空间而不是内部命名空间中,因此它应该优先于 EnableRewind()。 (感谢@ArjanEinbu 指出)

然后要读取正文流,您可以这样做:

   string bodyContent = new StreamReader(Request.Body).ReadToEnd();

不要将 StreamReader 创建包装在 using 语句中,否则它将在 using 块结束时关闭底层主体流,并且请求生命周期中稍后的代码将无法读取主体.

同样为了安全起见,最好遵循上面这行读取正文内容的代码,并用这行代码将正文的流位置重置回 0。

request.Body.Position = 0;

这样,请求生命周期后期的任何代码都会发现 request.Body 处于尚未读取的状态。

更新答案

对不起,我最初误读了你的问题。将关联流升级为缓冲流的概念仍然适用。但是,您必须手动执行此操作,我不知道有任何内置的 .Net Core 功能可以让您以 EnableRewind() 允许开发人员在读取请求流后重新读取请求流的方式编写后读取响应流.

您的“hacky”方法可能完全合适。您基本上是在将无法搜索的流转换为可以搜索的流。在一天结束时,Response.Body 流必须被缓冲并支持搜索的流换出。这是使用中间件执行此操作的另一种方法,但您会注意到它与您的方法非常相似。然而,我确实选择使用 finally 块作为将原始流放回 Response.Body 的附加保护,并且我使用了流的 Position 属性 而不是 Seek方法,因为语法有点简单,但效果与你的方法没有什么不同。

public class ResponseRewindMiddleware 
{
        private readonly RequestDelegate next;

        public ResponseRewindMiddleware(RequestDelegate next) {
            this.next = next;
        }

        public async Task Invoke(HttpContext context) {

            Stream originalBody = context.Response.Body;

            try {
                using (var memStream = new MemoryStream()) {
                    context.Response.Body = memStream;

                    await next(context);

                    memStream.Position = 0;
                    string responseBody = new StreamReader(memStream).ReadToEnd();

                    memStream.Position = 0;
                    await memStream.CopyToAsync(originalBody);
                }

            } finally {
                context.Response.Body = originalBody;
            }

        } 
}

您所说的 hack 实际上是关于如何在自定义中间件中管理响应流的建议方法。

由于中间件设计的管道性质,每个中间件都不知道管道中的前一个或下一个处理程序。不能保证当前的中间件将是编写响应的那个,除非它在传递它(当前的中间件)控制的流之前保留它给出的响应流。这种设计在 OWIN 中出现,并最终融入 asp.net-core.

一旦您开始写入响应流,它就会向客户端发送 body 和 headers(响应)。如果管道中的另一个处理程序在当前处理程序有机会之前执行此操作,那么一旦响应已发送,它将无法向响应添加任何内容。

如果管道中的前一个中间件遵循相同的策略将另一个流向下传递,则不能保证它是实际的响应流。

引用 ASP.NET Core Middleware Fundamentals

Warning

Be careful modifying the HttpResponse after invoking next, because the response may have already been sent to the client. You can use HttpResponse.HasStarted to check whether the headers have been sent.

Warning

Do not call next.Invoke after calling a write method. A middleware component either produces a response or calls next.Invoke, but not both.

来自 aspnet/BasicMiddleware Github repo

的内置基本中间件示例

ResponseCompressionMiddleware.cs

/// <summary>
/// Invoke the middleware.
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async Task Invoke(HttpContext context)
{
    if (!_provider.CheckRequestAcceptsCompression(context))
    {
        await _next(context);
        return;
    }

    var bodyStream = context.Response.Body;
    var originalBufferFeature = context.Features.Get<IHttpBufferingFeature>();
    var originalSendFileFeature = context.Features.Get<IHttpSendFileFeature>();

    var bodyWrapperStream = new BodyWrapperStream(context, bodyStream, _provider,
        originalBufferFeature, originalSendFileFeature);
    context.Response.Body = bodyWrapperStream;
    context.Features.Set<IHttpBufferingFeature>(bodyWrapperStream);
    if (originalSendFileFeature != null)
    {
        context.Features.Set<IHttpSendFileFeature>(bodyWrapperStream);
    }

    try
    {
        await _next(context);
        // This is not disposed via a using statement because we don't want to flush the compression buffer for unhandled exceptions,
        // that may cause secondary exceptions.
        bodyWrapperStream.Dispose();
    }
    finally
    {
        context.Response.Body = bodyStream;
        context.Features.Set(originalBufferFeature);
        if (originalSendFileFeature != null)
        {
            context.Features.Set(originalSendFileFeature);
        }
    }
}

您可以使用 middleware 在请求管道中,为了记录请求和响应。

但是 memory leak 的风险增加了,因为: 1. 溪流, 2. 设置字节缓冲区和 3. 字符串转换

最多可以达到 Large Object Heap(如果请求或响应的正文大于 85,000 字节)。这会增加应用程序内存泄漏的风险。 为了避免LOH,内存流可以用Recyclable Memory stream using the relevant library.

代替

使用可回收内存流的实现:

public class RequestResponseLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger _logger;
    private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
    private const int ReadChunkBufferLength = 4096;

    public RequestResponseLoggingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory)
    {
        _next = next;
        _logger = loggerFactory
            .CreateLogger<RequestResponseLoggingMiddleware>();
        _recyclableMemoryStreamManager = new RecyclableMemoryStreamManager();
    }

    public async Task Invoke(HttpContext context)
    {
        LogRequest(context.Request);
        await LogResponseAsync(context);
    }

    private void LogRequest(HttpRequest request)
    {
        request.EnableRewind();
        using (var requestStream = _recyclableMemoryStreamManager.GetStream())
        {
            request.Body.CopyTo(requestStream);
            _logger.LogInformation($"Http Request Information:{Environment.NewLine}" +
                                   $"Schema:{request.Scheme} " +
                                   $"Host: {request.Host} " +
                                   $"Path: {request.Path} " +
                                   $"QueryString: {request.QueryString} " +
                                   $"Request Body: {ReadStreamInChunks(requestStream)}");
        }
    }

    private async Task LogResponseAsync(HttpContext context)
    {
        var originalBody = context.Response.Body;
        using (var responseStream = _recyclableMemoryStreamManager.GetStream())
        {
            context.Response.Body = responseStream;
            await _next.Invoke(context);
            await responseStream.CopyToAsync(originalBody);
            _logger.LogInformation($"Http Response Information:{Environment.NewLine}" +
                                   $"Schema:{context.Request.Scheme} " +
                                   $"Host: {context.Request.Host} " +
                                   $"Path: {context.Request.Path} " +
                                   $"QueryString: {context.Request.QueryString} " +
                                   $"Response Body: {ReadStreamInChunks(responseStream)}");
        }

        context.Response.Body = originalBody;
    }

    private static string ReadStreamInChunks(Stream stream)
    {
        stream.Seek(0, SeekOrigin.Begin);
        string result;
        using (var textWriter = new StringWriter())
        using (var reader = new StreamReader(stream))
        {
            var readChunk = new char[ReadChunkBufferLength];
            int readChunkLength;
            //do while: is useful for the last iteration in case readChunkLength < chunkLength
            do
            {
                readChunkLength = reader.ReadBlock(readChunk, 0, ReadChunkBufferLength);
                textWriter.Write(readChunk, 0, readChunkLength);
            } while (readChunkLength > 0);

            result = textWriter.ToString();
        }

        return result;
    }
}

注意。由于 textWriter.ToString(),LOH 的危害并未完全根除,另一方面,您可以使用支持结构化日志记录的日志记录客户端库(即 Serilog)并注入可回收内存流的实例。

在 ASP.NET 核心 3 中,情况更糟:即使您忽略了我们正在谈论的 Web 框架这一事实,它已经将读取 Web 请求这样的基本内容变成了与不直观的变通方法的斗争和一个 API 在每个版本之间变化,然后有 an open issue 这意味着如果你使用 EnableBuffering "too late" (包括在你的中间件管道中的后期),它不会做任何东西。

在我的例子中,我使用了在管道中尽早将 body 添加到 HttpContext.Items 的 hacky 解决方案。我敢肯定这是非常低效的,它忽略了 body 很大时出现的那种问题,但是如果你正在寻找 off-the-shelf 的东西(就像我遇到这个问题),那么也许这对您有帮助。

具体来说,我使用了以下中间件:

    public class RequestBodyStoringMiddleware
    {
        private readonly RequestDelegate _next;

        public RequestBodyStoringMiddleware(RequestDelegate next) =>
            _next = next;

        public async Task Invoke(HttpContext httpContext)
        {
            httpContext.Request.EnableBuffering();
            string body;
            using (var streamReader = new System.IO.StreamReader(
                httpContext.Request.Body, System.Text.Encoding.UTF8, leaveOpen: true))
                body = await streamReader.ReadToEndAsync();

            httpContext.Request.Body.Position = 0;

            httpContext.Items["body"] = body;
            await _next(httpContext);
        }
    }

要使用它,请在 Startup.Configure 中尽早 app.UseMiddleware<RequestBodyStoringMiddleware>(); ;问题是,根据您正在做的其他事情,body 流可能会在整个过程中被消耗掉,因此顺序很重要。然后,当您稍后需要 body 时(在控制器中,或另一块中间件中),通过 (string)HttpContext.Items["body"]; 访问它。是的,您的控制器现在依赖于您配置的实现细节,但您能做什么。

在大多数情况下都有效。但我想补充一点。似乎 ASP.NET Core 不喜欢直接从内存流中呈现网页内容(除非它是一个简单的字符串而不是整个 HTML 页面)。我花了几个小时试图解决这个问题,所以我想 post 在这里,这样其他人就不会像我一样浪费时间试图解决这个问题。

这里是@RonC 对响应部分的回答的小修改:

public class ResponseBufferMiddleware
{
    private readonly RequestDelegate _next;

    public ResponseBufferMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Store the original body stream for restoring the response body back to its original stream
        var originalBodyStream = context.Response.Body;

        // Create new memory stream for reading the response; Response body streams are write-only, therefore memory stream is needed here to read
        await using var memoryStream = new MemoryStream();
        context.Response.Body = memoryStream;

        // Call the next middleware
        await _next(context);

        // Set stream pointer position to 0 before reading
        memoryStream.Seek(0, SeekOrigin.Begin);

        // Read the body from the stream
        var responseBodyText = await new StreamReader(memoryStream).ReadToEndAsync();

        // Reset the position to 0 after reading
        memoryStream.Seek(0, SeekOrigin.Begin);

        // Do this last, that way you can ensure that the end results end up in the response.
        // (This resulting response may come either from the redirected route or other special routes if you have any redirection/re-execution involved in the middleware.)
        // This is very necessary. ASP.NET doesn't seem to like presenting the contents from the memory stream.
        // Therefore, the original stream provided by the ASP.NET Core engine needs to be swapped back.
        // Then write back from the previous memory stream to this original stream.
        // (The content is written in the memory stream at this point; it's just that the ASP.NET engine refuses to present the contents from the memory stream.)
        context.Response.Body = originalBodyStream;
        await context.Response.Body.WriteAsync(memoryStream.ToArray());

        // Per @Necip Sunmaz's recommendation this also works:
        // Just make sure that the memoryStrream's pointer position is set back to 0 again.
        // await memoryStream.CopyToAsync(originalBodyStream);
        // context.Response.Body = originalBodyStream;
    }
}

这样,您既可以正确呈现网页内容,又可以在需要时阅读响应正文。这已经过彻底测试。

另请注意,此代码是使用 .NET Core 3.1 和 C# 语言版本 8.0 编写的。 @DalmTo 确认此代码适用于 .NET 5 和 C# 9。

.NET 6.0+ 解决方案

在 ASP.NET Core 6.0+ 中考虑使用内置扩展:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddHttpLogging(options => // <--- Setup logging
{
    // Specify all that you need here:
    options.LoggingFields = HttpLoggingFields.RequestHeaders |
                            HttpLoggingFields.RequestBody |
                            HttpLoggingFields.ResponseHeaders |
                            HttpLoggingFields.ResponseBody;
});
//...
var app = builder.Build();
//...
app.UseHttpLogging(); // <--- Add logging to pipeline
//...
app.Run();