
通过ASP.NET Core Middleware实现请求响应日志记录与异常处理:从基础到实战优化
你好,我是源码库的技术博主。在构建健壮的Web API或应用时,请求/响应日志和统一的异常处理是必不可少的“基础设施”。它们能帮助我们快速定位问题、分析性能瓶颈,并提供友好的错误信息给客户端。今天,我就来分享一下如何在ASP.NET Core中,利用强大的中间件(Middleware)管道,优雅地实现这两大功能。我会结合我自己的实战经验,包括一些踩过的“坑”,希望能让你少走弯路。
一、理解ASP.NET Core中间件:我们的核心工具
在动手之前,我们得先明白中间件是什么。你可以把ASP.NET Core的HTTP请求处理管道想象成一条流水线,而中间件就是流水线上的一个个“处理器”。每个中间件都可以:
- 检查并处理传入的HTTP请求。
- 将请求传递给管道中的下一个中间件。
- 处理从管道返回的HTTP响应。
这种设计模式让我们能够以高度可配置、可复用的方式,在请求生命周期的特定阶段插入自定义逻辑。我们即将构建的日志记录和异常处理,就是两个典型的、应该尽早放入管道的中间件。
二、实战第一步:创建自定义日志记录中间件
我们的目标是记录每一个请求的进入和完成,包含路径、方法、状态码和耗时。我们将创建一个名为 `RequestResponseLoggingMiddleware` 的类。
首先,在项目中创建一个 `Middlewares` 文件夹,并添加以下类:
using System.Diagnostics;
using System.Text;
using Microsoft.IO;
namespace YourProjectName.Middlewares
{
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
private readonly RecyclableMemoryStreamManager _memoryStreamManager;
public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger logger)
{
_next = next;
_logger = logger;
_memoryStreamManager = new RecyclableMemoryStreamManager();
}
public async Task InvokeAsync(HttpContext context)
{
// 记录请求
var stopwatch = Stopwatch.StartNew();
var requestBody = await ReadRequestBodyAsync(context.Request);
_logger.LogInformation($"HTTP Request: {context.Request.Method} {context.Request.Path} | Body: {requestBody}");
// 捕获原始响应流,以便我们能在它被写入后读取
var originalResponseBodyStream = context.Response.Body;
using var responseBodyStream = _memoryStreamManager.GetStream();
context.Response.Body = responseBodyStream;
try
{
// 将请求传递给管道中的下一个中间件
await _next(context);
}
finally
{
// 记录响应
stopwatch.Stop();
var responseBody = await ReadResponseBodyAsync(context.Response);
_logger.LogInformation($"HTTP Response: {context.Response.StatusCode} in {stopwatch.ElapsedMilliseconds}ms | Body: {responseBody}");
// 将我们修改后的响应流复制回原始流
await responseBodyStream.CopyToAsync(originalResponseBodyStream);
context.Response.Body = originalResponseBodyStream;
}
}
private async Task ReadRequestBodyAsync(HttpRequest request)
{
// 确保请求体可以多次读取(默认只能读一次)
request.EnableBuffering();
using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true);
var body = await reader.ReadToEndAsync();
// 将流指针重置回开头,以便后续中间件可以正常读取
request.Body.Position = 0;
return body;
}
private async Task ReadResponseBodyAsync(HttpResponse response)
{
response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(response.Body, Encoding.UTF8, leaveOpen: true);
var body = await reader.ReadToEndAsync();
response.Body.Seek(0, SeekOrigin.Begin); // 重置
return body;
}
}
}
踩坑提示1: 这里我使用了 `Microsoft.IO.RecyclableMemoryStream`(需要通过NuGet安装 `Microsoft.IO.RecyclableMemoryStream` 包)。为什么不用普通的 `MemoryStream`?在高并发场景下,频繁创建和销毁 `MemoryStream` 会导致严重的GC(垃圾回收)压力,进而影响性能。`RecyclableMemoryStreamManager` 提供了一个高效的内存流池,能显著减少内存分配和GC次数。
踩坑提示2: 注意 `ReadRequestBodyAsync` 方法中的 `request.EnableBuffering()` 和重置 `Body.Position`。HTTP请求体流默认是只读一次的,如果我们直接读取了,后续的模型绑定(如 `[FromBody]`)就会失败。`EnableBuffering()` 允许我们缓冲流并多次读取,而重置位置是确保后续读取能从流的开头开始。
三、实战第二步:创建全局异常处理中间件
接下来,我们创建一个异常处理中间件。它的核心职责是捕获管道中所有未处理的异常,记录错误详情,并返回一个结构化的、友好的错误响应,而不是暴露出堆栈跟踪给客户端。
namespace YourProjectName.Middlewares
{
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, $"An unhandled exception occurred: {ex.Message}");
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
var response = new
{
StatusCode = context.Response.StatusCode,
Message = "An internal server error occurred. Please try again later.",
// 仅在开发环境下暴露详细异常信息,生产环境不暴露
Detailed = _env.IsDevelopment() ? exception.ToString() : null
};
var jsonResponse = System.Text.Json.JsonSerializer.Serialize(response);
await context.Response.WriteAsync(jsonResponse);
}
}
}
实战经验: 注意我注入了 `IHostEnvironment` 来检查当前是否是开发环境。这是一个非常重要的安全实践。在生产环境中,绝不应该将详细的异常堆栈信息返回给客户端,这可能会泄露敏感信息(如文件路径、SQL片段等)。开发环境则可以提供详细信息以便调试。
四、组装管道:在Program.cs中注册中间件
中间件创建好了,但要让它们生效,必须在请求管道中按正确的顺序进行注册。顺序至关重要!
var builder = WebApplication.CreateBuilder(args);
// ... 其他服务配置 (如AddControllers, AddSwaggerGen等)
var app = builder.Build();
// 1. 异常处理中间件应该放在最外层,以捕获后续所有中间件和端点中抛出的异常。
app.UseMiddleware();
// 2. 日志记录中间件紧随其后,这样它就能记录到经过异常处理“包装”后的响应状态码。
// 注意:如果放在异常处理中间件之前,当发生异常时,日志中间件可能无法正确记录到响应状态。
app.UseMiddleware();
// ... 其他中间件,如 UseRouting, UseAuthentication, UseAuthorization
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
顺序的奥秘: 为什么是这个顺序?想象一下请求的流动:请求进入 -> 异常处理中间件(`try` 块开始)-> 日志记录中间件 -> 你的控制器/端点。如果控制器抛出异常,它会向上冒泡,首先被日志中间件的 `finally` 块捕获并记录(此时状态码可能还是200),然后继续向上被异常处理中间件的 `catch` 块捕获。异常处理中间件会修改响应状态码为500并写入错误信息。最终,这个被修改后的响应会流回日志中间件,并被复制到原始响应流。这个顺序确保了日志能记录到最终的状态码,而异常处理能作为最后的安全网。
五、进阶优化:性能、安全与可配置性
上面的基础版本已经能用,但在生产环境中,我们还需要考虑更多:
- 性能优化: 记录完整的请求/响应体对性能有影响,尤其是上传/下载大文件时。我们可以通过配置来决定是否记录Body,或者只记录超过特定大小的Body的前N个字节。可以通过 `IOptions` 模式从 `appsettings.json` 读取配置。
- 敏感信息过滤: 请求体或响应体中可能包含密码、令牌等敏感信息。我们必须在日志中间件里加入一个“过滤器”,在记录前将这些字段的值替换为 `[REDACTED]`。这通常需要结合JSON解析来实现。
- 更精细的异常分类处理: 在 `ExceptionHandlingMiddleware` 中,我们可以根据异常类型(如 `ValidationException`, `NotFoundException`)返回不同的HTTP状态码(400, 404等)和消息格式,使API更加规范。
- 使用Serilog等结构化日志库: 直接使用 `_logger.LogInformation` 输出字符串不利于后续的日志分析(如ELK栈)。可以集成Serilog,将日志结构化为JSON对象,包含固定的属性如 `RequestPath`, `StatusCode`, `ElapsedMs` 等。
六、总结
通过自定义ASP.NET Core中间件来实现全局的请求响应日志和异常处理,是一种清晰、解耦且功能强大的方式。它让我们能够将这类横切关注点(Cross-Cutting Concerns)从业务控制器中剥离出来,保持代码的整洁。
回顾一下关键点:理解中间件管道的顺序是成功的关键;使用 `RecyclableMemoryStream` 和 `EnableBuffering()` 来避免性能和功能上的坑;始终在生产环境中隐藏异常的详细堆栈信息。
希望这篇教程能帮助你构建出更健壮、更易于维护的ASP.NET Core应用。在实际项目中,你可以根据上述的进阶优化建议,将这个基础框架打磨得更加完善。如果在实践中遇到问题,欢迎在源码库社区交流讨论!

评论(0)