C#最小API中的全局错误处理
[[academy-video-youtube({"vid": "B5NsgtdwOlg", "start_time": "0", "title": "C# 最小化 API 中的全局错误处理", "creator": "Tim Corey", "length": "13m 30s"})]]
抛出未处理异常的Web API默认情况下会返回帮助开发人员本地调试并帮助陌生人映射调用栈的错误页面。 行号、类型名和源文件路径都会流回给请求者。在错误可能发生的端点处捕获每个错误是正确的方法,但它仅在下一个被遗忘的try/catch为止有效。 全局处理器是捕获端点错过的安全网。
在他的视频《C#最小API中的全局错误处理》中,Tim Corey 构建了一个带有故意损坏的端点的小型最小API,展示了开发者错误页面在没有保护下如何返回,然后连接app.UseExceptionHandler以拦截任何未捕获的异常并以通用的500响应。他还强调了为什么端点级别的处理仍然是首选路径:全局处理程序是后备而不是策略。 任何希望确保堆栈跟踪从不离开服务器的API赠送者及其设计理由的团队都将在下面找到中间件设置和设计理由。
构建具有故障端点的最小API
[1:08 - 3:01] Tim从一个新的.NET 8 ASP.NET Core Web API项目中启动,名为ErrorDemoApp。 项目模板选项接近于默认值:启用HTTPS,启用OpenAPI,无身份验证,启用顶级声明,没有勾选控制器复选框,因为这是一个最小API。 生成的Program.cs保留了Swagger,但天气预报示例端点及其记录被删除,因此文件仅显示基础内容。
代替示例,他在/demo上添加了一个端点,其全部目的是失败:
app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});用Ctrl+F5(开始而不调试)运行项目,可防止Visual Studio调试器拦截错误,从而使失败以实际HTTP调用者的方式显而易见。 Swagger打开,/demo端点是唯一可用的,执行返回500响应。 响应体持有异常类型、消息和Program.cs第18行的引用。
为何默认错误页面泄露实现细节
[3:01 - 5:00] 在浏览器中直接点击/demo(无需?message= Swagger包装)显示的是开发者异常页面,而不是JSON响应。 页面呈现异常名称、消息、抛出发生的文件路径和行号、原始异常详情以及抛出上方的堆栈帧。 对于在本地工作的开发者来说,这是金子。 对于其他任何人来说,这是一张代码库的免费地图。
Tim的观点清晰无保留:这个页面是为了帮助开发者存在的,它不应该到达最终用户手中。 偶尔它会出现的事实是全局处理器重要的理由。 即使是积极地在每个端点包裹异常处理的团队最终也会漏掉一个,而漏掉一个的代价是整个调用栈送到请求者手中。
首先在端点处捕获错误
[5:00 - 6:30] 在安装全局处理器之前,Tim将演示端点包裹在try/catch中以展示首选路径。 处理程序对于任何抛出的异常返回Results.BadRequest(ex.Message):
app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});结果是一个400,只携带消息字符串。 没有堆栈跟踪,没有文件路径,没有行号。 消息本身是否应暴露取决于应用程序; 对于一个公共API,即使是消息也可能泄露比团队希望的更多,在这种情况下,处理器替换一个通用字符串。 本地捕获使端点完全控制调用者看到的内容,包括在故障模式实际已知时返回更具体状态码的选择。
这种模式无法做到的是捕获端点忘记包裹的内容。任何新代码路径,任何从更深层重抛出的异常,任何在独立线程上抛出的Task,都将绕过端点的try/catch。 这就是中间件填补的缺口。
接上UseExceptionHandler中间件
[6:30 - 10:00] 在app.UseExceptionHandler注册。 接受构建器操作的重载暴露了基础管道,允许处理器明确设置响应形状:
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});在该块中的几个选择很重要。 将状态码强制设为500意味着调用者无法从响应编号中推断出任何信息; 无论内在异常类型是什么,表面看起来都是一样的。 将内容类型强制为application/json与API的其他响应匹配,这确保客户端在同一解析器上工作。 IExceptionHandlerFeature暴露了原始异常,以便真实的处理程序可以记录它; Tim在这里使用Console.WriteLine作为项目实际携带的任何记录器的替代。
最终的WriteAsJsonAsync调用返回一个包含状态码和通用消息的匿名对象。 主体没有说明除了出了问题这一事实外还有什么失败,这就是重点。 内部诊断属于日志,而不是响应。
测试处理和未处理路径
[10:00 - 13:14] 使用仍在的try/catch,端点运行本地路径:Swagger显示携带"这是一种演示异常"的400。 中间件永远没有看到抛出,因为catch块首先解决了它。 这是Tim希望的默认设计:本地处理器做它们的工作,全局处理器处于休眠状态。
去掉try/catch并重新运行则锻炼了后备。 同样的请求现在返回带有JSON主体{ "statusCode": 500, "message": "Internal Server Error" }的500。 响应中没有任何内容透露异常在哪里抛出或它是什么类型。 然而,Visual Studio控制台窗口显示通过Console.WriteLine占位符记录的原始异常文本,包括文件路径和行号。 完整的诊断停留在开发人员可以阅读的地方; 响应保持在不能泄露的地方。
这种模式一直延续到具有验证中间件、自定义身份验证或任何其他管道组件的minimal API。 异常处理器位于管道中较早的部分,捕获任何从稍后阶段传上来的内容。
总结:深度防御
[13:14 - 13:30] 本地处理和全局处理不是替代品; 它们是层。 本地处理器使端点在失败模式已知时有机会进行有意义的响应。 全局处理器确保本地层漏掉的任何内容生成的响应是一致的、通用的和安全的。 基于控制器的API使用相同的概念进行少量语法调整,但minimal API形式是值得首先变得舒适的,因为表面区域小到可以在一个文件中看到整体。
结论
[13:14 - 13:30] 在最小API中设置全局错误处理程序有三个步骤:在管道中尽早注册UseExceptionHandler,在处理程序内部设置响应状态和内容类型,并编写一个故意通用的主体以防止实现细节泄漏。 将其与围绕最有可能失败的代码路径的本地try/catch块结合,您就拥有了深度防御模型,其中全局处理器是安全网,而不是策略。
例子提示:当处理器进入真实记录器时,传递整个异常对象(不仅是消息),以便结构化日志管道捕获类型、堆栈和任何内部异常。 一个像Serilog这样的记录器将保留所有这些作为可查询的属性,这意味着一个生产500的警报携带足够的上下文以本地重现,而无需重新运行请求。
在他的YouTube频道上观看完整视频,在10-Minute Training系列中获得更多关于构建生产就绪minimal API的见解。

