如何读取ASP.NET Core Response.Body?

124

我一直在努力获取ASP.NET Core操作中的Response.Body属性,但唯一能够识别的解决方案似乎不太理想。该解决方案要求使用MemoryStream替换Response.Body,同时将流读取到一个字符串变量中,然后在发送到客户端之前再将其替换回去。在下面的示例中,我正在尝试在自定义中间件类中获取Response.Body值。由于某种原因,在ASP.NET Core中,Response.Body是只写属性?我是否遗漏了什么,或者这是一个疏忽/错误/设计问题?有更好的方法来读取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;
    }
}  

1
这是有意为之的设计。 - Nkosi
6个回答

137

在我的原始回答中,我完全误读了问题,并认为发帖人在问如何读取Request.Body。但实际上他问的是如何读取Response.Body。我保留原来的回答以保留历史记录,但同时更新它以展示在正确理解问题后我的回答。

原始回答:

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

   context.Request.EnableRewind()

最好在中间件处理程序需要读取请求正文之前就执行此操作。

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

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

在启用Rewind功能之前,与 Request.Body 相关联的流是一个仅向前的流,不支持寻求或第二次读取流。这样做是为了使请求处理的默认配置尽可能轻量和高效。但是一旦启用Rewind功能,流将升级为支持多次查找和读取的流。您可以通过在调用 EnableRewind 之前和之后设置断点并观察 Request.Body 属性来观察此“升级”。例如, Request.Body.CanSeek 将从 false 更改为 true
更新:自 ASP.NET Core 2.1 开始, Request.EnableBuffering() 可用,它将 Request.Body 升级为 FileBufferingReadStream ,就像 Request.EnableRewind() 一样。由于 Request.EnableBuffering() 位于公共命名空间而不是内部命名空间中,因此应优先使用它而不是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;
            }

        } 
}

1
感谢 @Ron C 提供的额外信息,我尝试了您建议的解决方案,虽然我可以看到 CanRead 和 CanSeek 属性已更新为 true,但读取器仅将空字符串读回 bodyContent 变量。 我可以在 PostMan 中看到实际的完整响应正文返回给客户端。 我将更新我的问题以反映我使用 EnableRewind() 的方法。 - JTW
1
@woogy 对不起!我完全误读了你的问题。我以为你想读取请求正文。但你问的是如何读取响应正文。将相关流升级为缓冲流的概念仍然适用,但我认为你必须手动完成它。你的“hacky”方法可能完全适合。你基本上是将一个无法寻址的流转换为可以寻址的流。 - RonC
2
另外,我可以检查context.Response.ContentLength是否为null或零值,并使用“await _next(context); return;”快速退出。这样可以防止记录空响应并在刷新页面时停止304异常。不过这可能是一种错误的方法,请指出它的问题。 - Steve Hibbert
2
@RonC:我发现了context.Request.EnableBuffering()。这是一个更正确/更新的解决方案吗? - Arjan Einbu
1
@ArjanEinbu 感谢您指出 context.Request.EnableBuffering(),它从ASP.NET Core 2.1开始可用。EnableBuffering()确实将请求正文升级为FileBufferingReadStream,就像Request.EnableRewind()一样,并且由于它在公共命名空间中而不是内部命名空间中,因此应优先使用它而不是EnableRewind()。请注意,这是针对_request_对象的,而问题涉及_response_对象。我将更新我的原始答案以包含此信息。 - RonC
显示剩余18条评论

16

.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();

1
默认情况下,它会记录到控制台。我该如何配置,使这些日志保存到我的SQL数据库中? - Roushan
@Roushan,你应该为此设置日志提供程序:https://learn.microsoft.com/en-us/dotnet/core/extensions/logging-providers 但我更喜欢使用Serilog的Sink来记录日志。 - Rodion Mostovoi
2
谢谢,但我想在中间件中记录请求/响应。 - Yiping
3
如何将相同的行程请求/响应体一起记录到日志中? - Yiping
1
这完全没有回答问题。 - t3chb0t
显示剩余2条评论

15

您可以在请求管道中使用 中间件 来记录请求和响应。

然而,由于以下原因,增加了内存泄漏的风险: 1. 流, 2. 设置字节缓冲区和 3. 字符串转换

可能会导致请求或响应体积超过85,000字节,从而进入大对象堆LOH。这将增加应用程序出现内存泄漏的风险。 为避免LOH,可以使用相关的可回收内存流Recyclable Memory Stream替换内存流,并使用进行实现。

以下是使用Recyclable memory streams的实现:

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)并注入可回收内存流实例的日志客户端库。


3
我猜在记录请求之后需要将request.Body.Position=0,否则会出现空主体异常。 - Riddik
5
在我的情况下,await responseStream.CopyToAsync(originalBody); 没有复制内容。我使用了 responseStream.WriteTo(originalBody); - Riddik
1
似乎要使await responseStream.CopyToAsync(originalBody);正常工作,首先需要执行responseStream.Seek(0, SeekOrigin.Begin); - undefined

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

这是对@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;
    }
}

这样,您可以正确地呈现Web内容,同时如果需要,也可以读取响应正文。这已经经过了彻底的测试。

另外,请注意此代码使用.NET Core 3.1和C#语言版本8.0编写。 @DalmTo确认此代码可以与.NET 5和C# 9一起使用。


2
也适用于 .Net 5 + C# 9。❤ - Linda Lawton - DaImTo
1
谢谢,你救了我的一天。如果你有一个返回文件的端点,这段代码会崩溃。删除Body.WriteAsync行并用这段代码替换它。await memoryStream.CopyToAsync(originalBodyStream); httpContext.Response.Body = originalBodyStream; - Necip Sunmaz
@NecipSunmaz,我已经测试了你的代码,它可以正常运行。但是我也注意到,在以前的代码中,当端点服务于文件(在ASP.NET Core MVC中转换为FileStreamResult)时,应用程序并没有崩溃。你能告诉我你从端点返回的“File”对象是什么类型吗? 另外,我把你的代码添加到上面的示例代码中,作为将内容从内存流复制回原始响应流的另一种替代方法。 - D.K
@D.K 我写的内容类型是动态的。代码:
new FileExtensionContentTypeProvider().TryGetContentType(name, out string contentType);
return File(bytes, contentType);
- Necip Sunmaz
@DaImTo 我正在使用 .NET Core 5,但读取响应正文后页面上的结果为空。 - Alok

9
你所描述的“hack”实际上是如何在自定义中间件中管理响应流的建议方法。
由于中间件设计的管道特性,每个中间件都不知道管道中前一个或后一个处理程序。除非它保留了之前收到的响应流并传递了它(当前中间件)控制的流,否则不能保证当前中间件会写入响应。这种设计在OWIN中出现,最终被集成到asp.net-core中。
一旦开始向响应流中写入内容,就会将正文和标头(响应)发送到客户端。如果管道中的另一个处理程序在当前处理程序有机会之前执行了相同操作,那么它在响应已经发送后就无法再添加任何内容。
而且,如果管道中的前一个中间件遵循了传递另一个流的策略,那么也不能保证它是实际的响应流。
参考ASP.NET Core Middleware Fundamentals

警告

在调用next之后修改HttpResponse时要小心,因为响应可能已经发送给客户端。您可以使用HttpResponse.HasStarted检查标头是否已被发送。

警告

在调用write方法后不要调用next.Invoke。中间件组件要么生成响应,要么调用next.Invoke,但不能两者都有。

aspnet/BasicMiddleware Github仓库中基本内置中间件的示例。

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

3
在ASP.NET Core 3中,情况甚至更糟:即使你忽略了我们谈论的是一个将读取Web请求这样基本的功能变成了一种使用不直观的解决方法和每个版本之间都会改变的API的Web框架,那么还有一个未解决的问题,这意味着如果你“太晚”(包括在中间件管道的后期)使用EnableBuffering,它将无效。
在我的情况下,我使用了hacky的解决方案,尽可能早地将body添加到HttpContext.Items中。我相信这非常低效,并且忽略了当body很大时出现的问题,但如果你正在寻找一些现成的东西(就像我遇到这个问题时一样),那么也许这很有帮助。
具体来说,我使用以下中间件:
    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>();;问题在于,根据您的其他操作,正文流可能会被消耗掉,因此顺序很重要。然后,在以后需要正文(在控制器或另一个中间件中)时,通过(string)HttpContext.Items["body"];访问它。是的,您的控制器现在依赖于配置的实现细节,但您能做什么。

1
那个问题在2019年10月已经在ASP.NET Core 3.1中得到修复,也就是在你发布这个回答之前... - Ian Kemp
5
问题涉及响应体而不是请求体。 - boylec1986

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接