注:本文隸屬於《理解ASP.NET Core》系列文章,請查看置頂博客或點擊此處查看全文目錄
使用中間件進行錯誤處理
開發人員異常頁
開發人員異常頁用於顯示未處理的請求異常的詳細信息。當我們通過ASP.NET Core模板創建一個項目時,Startup.Configure
方法中會自動生成以下代碼:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
// 添加開發人員異常頁中間件
app.UseDeveloperExceptionPage();
}
}
需要注意的是,與“異常處理”有關的中間件,一定要盡早添加,這樣,它可以最大限度的捕獲后續中間件拋出的未處理異常。
可以看到,當程序運行在開發環境中時,才會啟用開發人員異常頁,這很好理解,因為在生產環境中,我們不能將異常的詳細信息暴露給用戶,否則,這將會導致一系列安全問題。
現在我們在下方添加如下代碼拋出一個異常:
app.Use((context, next) =>
{
throw new NotImplementedException();
});
當開發人員異常頁中間件捕獲了該未處理異常時,會展示類似如下的相關信息:
該異常頁面展示了如下信息:
- 異常消息
- 異常堆棧追蹤(Stack)
- HTTP請求查詢參數(Query)
- Cookies
- HTTP請求標頭(Headers)
- 路由(Routing),包含了終結點和路由信息
IDeveloperPageExceptionFilter
當你查看DeveloperExceptionPageMiddleware
的源碼時,你會在構造函數中發現一個入參,類型為IEnumerable<IDeveloperPageExceptionFilter>
。通過這個Filter集合,組成一個錯誤處理器管道,按照先注冊先執行的原則,順序進行錯誤處理。
下面是DeveloperExceptionPageMiddleware
的核心源碼:
public class DeveloperExceptionPageMiddleware
{
public DeveloperExceptionPageMiddleware(
RequestDelegate next,
IOptions<DeveloperExceptionPageOptions> options,
ILoggerFactory loggerFactory,
IWebHostEnvironment hostingEnvironment,
DiagnosticSource diagnosticSource,
IEnumerable<IDeveloperPageExceptionFilter> filters)
{
// ...
// 將 DisplayException 放置在管道最底部
// DisplayException 就用於向響應中寫入我們上面見到的異常頁
_exceptionHandler = DisplayException;
foreach (var filter in filters.Reverse())
{
var nextFilter = _exceptionHandler;
_exceptionHandler = errorContext => filter.HandleExceptionAsync(errorContext, nextFilter);
}
}
public async Task Invoke(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
// 響應已經啟動,則跳過處理,直接上拋
if (context.Response.HasStarted)
{
throw;
}
try
{
context.Response.Clear();
context.Response.StatusCode = 500;
// 錯誤處理
await _exceptionHandler(new ErrorContext(context, ex));
// ...
// 錯誤已成功處理
return;
}
catch (Exception ex2) { }
// 若處理過程中拋出了新的異常ex2,則重新引發原始異常ex
throw;
}
}
}
這也就說明,如果我們想要自定義開發者異常頁,那我們可以通過實現IDeveloperPageExceptionFilter
接口來達到目的。
先看一下IDeveloperPageExceptionFilter
接口定義:
public interface IDeveloperPageExceptionFilter
{
Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next);
}
public class ErrorContext
{
public ErrorContext(HttpContext httpContext, Exception exception)
{
HttpContext = httpContext ?? throw new ArgumentNullException(nameof(httpContext));
Exception = exception ?? throw new ArgumentNullException(nameof(exception));
}
public HttpContext HttpContext { get; }
public Exception Exception { get; }
}
HandleExceptionAsync
方法除了錯誤上下文信息外,還包含了一個Func<ErrorContext, Task> next
,這是干嘛的呢?其實,前面我們已經提到了,IDeveloperPageExceptionFilter
的所有實現,會組成一個管道,當錯誤需要在管道中的后續處理器作進一步處理時,就是通過這個next
傳遞錯誤的,所以,當需要傳遞錯誤時,一定要記得調用next
!
不廢話了,趕緊實現一個看看效果吧:
public class MyDeveloperPageExceptionFilter : IDeveloperPageExceptionFilter
{
public Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next)
{
errorContext.HttpContext.Response.WriteAsync($"MyDeveloperPageExceptionFilter: {errorContext.Exception}");
// 我們不調用 next,這樣就不會執行 DisplayException
return Task.CompletedTask;
}
}
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IDeveloperPageExceptionFilter, MyDeveloperPageExceptionFilter>();
}
當拋出一個異常,你會看到類似如下的頁面:
異常處理程序
上面介紹了開發環境中的異常處理,現在我們來看一下生產環境中的異常處理,通過調用UseExceptionHandler
擴展方法注冊中間件ExceptionHandlerMiddleware
。
該異常處理程序:
- 可以捕獲后續中間件未處理的異常
- 若無異常或HTTP響應已經啟動(
Response.HasStarted == true
),則不做任何處理 - 不會改變URL中的路徑
默認情況下,會生成類似如下的模板:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
// 添加異常處理程序
app.UseExceptionHandler("/Home/Error");
}
}
通過lambda提供異常處理程序
我們可以通過lambda向UseExceptionHandler
中提供一個異常處理邏輯:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseExceptionHandler(errorApp =>
{
var loggerFactory = errorApp.ApplicationServices.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ExceptionHandlerWithLambda");
errorApp.Run(async context =>
{
// 這里可以自定義 http response 內容,以下僅是示例
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
logger.LogError($"Exception Handled:{exceptionHandlerPathFeature.Error}");
var statusCode = StatusCodes.Status500InternalServerError;
var message = exceptionHandlerPathFeature.Error.Message;
if (exceptionHandlerPathFeature.Error is NotImplementedException)
{
message = "俺未實現";
statusCode = StatusCodes.Status501NotImplemented;
}
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
Message = message,
Success = false,
});
});
});
}
可以看到,當捕獲到異常時,可以通過HttpContext.Features
,並指定類型IExceptionHandlerPathFeature
或IExceptionHandlerFeature
(前者繼承自后者),來獲取到異常信息。
public interface IExceptionHandlerFeature
{
// 異常信息
Exception Error { get; }
}
public interface IExceptionHandlerPathFeature : IExceptionHandlerFeature
{
// 未被轉義的http請求資源路徑
string Path { get; }
}
再提醒一遍,千萬不要將敏感的錯誤信息暴露給客戶端。
異常處理程序頁
除了使用lambda外,我們還可以指定一個路徑,指向一個備用管道進行異常處理,這個備用管道對於MVC來說,一般是Controller中的Action,例如MVC模板默認的/Home/Error
:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseExceptionHandler("/Home/Error");
}
public class HomeController : Controller
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
當捕獲到異常時,你會看到類似如下的頁面:
你可以在ActionError
中自定義錯誤處理邏輯,就像lambda一樣。
需要注意的是,不要隨意對Error
添加[HttpGet]
、[HttpPost]
等限定Http請求方法的特性。一旦你加上了[HttpGet]
,那么該方法只能處理Get
請求的異常。
不過,如果你就是打算將不同方法的Http請求分別進行處理,你可以類似如下進行處理:
public class HomeController : Controller
{
// 處理Get請求的異常
[HttpGet("[controller]/error")]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult GetError()
{
_logger.LogInformation("Get Exception Handled");
return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
// 處理Post請求的異常
[HttpPost("[controller]/error")]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult PostError()
{
_logger.LogInformation("Post Exception Handled");
return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
另外,還需要提醒一下,如果在請求備用管道(如示例中的Error
)時也報錯了,無論是Http請求管道中的中間件報錯,還是Error
里面報錯,此時ExceptionHandlerMiddleware
均會重新引發原始異常,而不是向外拋出備用管道的異常。
一般異常處理程序頁是面向所有用戶的,所以請保證它可以匿名訪問。
下面一塊看一下ExceptionHandlerMiddleware
吧:
public class ExceptionHandlerMiddleware
{
public ExceptionHandlerMiddleware(
RequestDelegate next,
ILoggerFactory loggerFactory,
IOptions<ExceptionHandlerOptions> options,
DiagnosticListener diagnosticListener)
{
// 要么手動指定一個異常處理器(如通過lambda)
// 要么提供一個資源路徑,重新發送給后續中間件,進行異常處理
if (_options.ExceptionHandler == null)
{
if (_options.ExceptionHandlingPath == null)
{
throw new InvalidOperationException(Resources.ExceptionHandlerOptions_NotConfiguredCorrectly);
}
else
{
_options.ExceptionHandler = _next;
}
}
}
public Task Invoke(HttpContext context)
{
ExceptionDispatchInfo edi;
try
{
var task = _next(context);
if (!task.IsCompletedSuccessfully)
{
return Awaited(this, context, task);
}
return Task.CompletedTask;
}
catch (Exception exception)
{
edi = ExceptionDispatchInfo.Capture(exception);
}
// 同步完成並拋出異常時,進行處理
return HandleException(context, edi);
static async Task Awaited(ExceptionHandlerMiddleware middleware, HttpContext context, Task task)
{
ExceptionDispatchInfo edi = null;
try
{
await task;
}
catch (Exception exception)
{
edi = ExceptionDispatchInfo.Capture(exception);
}
if (edi != null)
{
// 異步完成並拋出異常時,進行處理
await middleware.HandleException(context, edi);
}
}
}
private async Task HandleException(HttpContext context, ExceptionDispatchInfo edi)
{
// 響應已經啟動,則跳過處理,直接上拋
if (context.Response.HasStarted)
{
edi.Throw();
}
PathString originalPath = context.Request.Path;
if (_options.ExceptionHandlingPath.HasValue)
{
context.Request.Path = _options.ExceptionHandlingPath;
}
try
{
ClearHttpContext(context);
// 將 exceptionHandlerFeature 存入 context.Features
var exceptionHandlerFeature = new ExceptionHandlerFeature()
{
Error = edi.SourceException,
Path = originalPath.Value,
};
context.Features.Set<IExceptionHandlerFeature>(exceptionHandlerFeature);
context.Features.Set<IExceptionHandlerPathFeature>(exceptionHandlerFeature);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.OnStarting(_clearCacheHeadersDelegate, context.Response);
// 處理異常
await _options.ExceptionHandler(context);
if (context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response)
{
return;
}
}
catch (Exception ex2) { }
finally
{
// 還原請求路徑,保證瀏覽器中的Url不變
context.Request.Path = originalPath;
}
// 如果異常未被處理,則重新引發原始異常
edi.Throw();
}
}
無響應正文的Http錯誤狀態碼處理
默認情況下,當ASP.NET Core遇到沒有正文的400-599Http錯誤狀態碼時,不會為其提供頁面,而是返回狀態碼和空響應正文。可是,為了良好的用戶體驗,一般我們會對常見的錯誤狀態碼(404)提供友好的頁面,如gitee404
請注意,本節所涉及到的中間件與上兩節所講解的錯誤異常處理中間件不沖突,可以同時使用。確切的說,本節並不是處理異常,只是為了提升用戶體驗。
UseStatusCodePages
我們可以通過StatusCodePagesMiddleware
中間件實現該功能:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseDeveloperExceptionPage();
// 添加 StatusCodePagesMiddleware 中間件
app.UseStatusCodePages();
// ...請求處理中間件
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
注意,一定要在異常處理中間件之后,請求處理中間件之前調用
UseStatusCodePages
。
現在,你可以請求一個不存在的路徑,例如Home/Index2
,你會在瀏覽器中看到如下輸出:
Status Code: 404; Not Found
UseStatusCodePages
也提供了重載,允許我們自定義響應內容類型和正文內容,如:
// 使用占位符 {0} 來填充Http狀態碼
app.UseStatusCodePages("text/plain", "Status code is: {0}");
瀏覽器輸出為:
Status code is: 404
同樣地,我們也可以通過向UseStatusCodePages
傳入lambda表達式進行處理:
app.UseStatusCodePages(async context =>
{
context.HttpContext.Response.ContentType = "text/plain";
await context.HttpContext.Response.WriteAsync(
$"Status code is: {context.HttpContext.Response.StatusCode}");
});
介紹了那么多,你也看到了,事實上UseStatusCodePages
效果並不好,所以我們在生產環境一般是不會用這玩意的,那用啥呢?請隨我繼續往下看。
UseStatusCodePagesWithRedirects
該擴展方法,內部實際上是通過調用UseStatusCodePages
並傳入lambda進行實現的,該方法:
- 接收一個Http資源定位字符串。同樣的,會有一個占位符
{0}
,用於填充Http狀態碼 - 向客戶端發送Http狀態碼302-已找到
- 然后將客戶端重定向到指定的終結點,在該終結點中,可以針對不同錯誤狀態碼分別進行處理
app.UseStatusCodePagesWithRedirects("/Home/StatusCodeError?code={0}");
public class HomeController : Controller
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult StatusCodeError(int code)
{
return code switch
{
// 跳轉到404頁面
StatusCodes.Status404NotFound => View("404"),
// 跳轉到統一展示頁面
_ => View(code),
};
}
}
現在你可以自己試一下。
不知道你有沒有注意:當我們請求一個不存在的路徑時,它的確會跳轉到404頁面,但是,Url也變了,變成了/Home/StatusCodeError?code=404
,而且,響應狀態碼也變了,變成了200Ok
。可以通過源碼看一下咋回事(我相信,大家看到302其實也都明白了):
public static IApplicationBuilder UseStatusCodePagesWithRedirects(this IApplicationBuilder app, string locationFormat)
{
// 兩個條件分支都差不多,我們看第二個,容易理解一些
if (locationFormat.StartsWith("~"))
{
locationFormat = locationFormat.Substring(1);
return app.UseStatusCodePages(context =>
{
var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
context.HttpContext.Response.Redirect(context.HttpContext.Request.PathBase + location);
return Task.CompletedTask;
});
}
else
{
return app.UseStatusCodePages(context =>
{
// 格式化資源定位,context.HttpContext.Response.StatusCode 作占位符
var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
// 重定向(302)到設定的資源
context.HttpContext.Response.Redirect(location);
return Task.CompletedTask;
});
}
}
如果你不想更改原始請求的Url,而且保留原始狀態碼,那么你應該使用接下來要介紹的UseStatusCodePagesWithReExecute
。
UseStatusCodePagesWithReExecute
同樣的,該擴展方法,內部也是通過調用UseStatusCodePages
並傳入lambda進行實現的,不過該方法:
- 接收1個路徑字符串和和1個查詢字符串。同樣的,會有一個占位符
{0}
,用於填充Http狀態碼 - Url保持不變,並向客戶端返回原始Http狀態碼
- 執行備用管道,用於生成響應正文
// 注意,這里要分開寫
app.UseStatusCodePagesWithReExecute("/Home/StatusCodeError", "?code={0}");
具體例子就不再列舉了,用上面的就行了。現在來看看源碼:
public static IApplicationBuilder UseStatusCodePagesWithReExecute(
this IApplicationBuilder app,
string pathFormat,
string queryFormat = null)
{
return app.UseStatusCodePages(async context =>
{
// 請注意,此時Http響應還未啟動
// 格式化資源路徑,context.HttpContext.Response.StatusCode 作占位符
var newPath = new PathString(
string.Format(CultureInfo.InvariantCulture, pathFormat, context.HttpContext.Response.StatusCode));
// 格式化查詢字符串,context.HttpContext.Response.StatusCode 作占位符
var formatedQueryString = queryFormat == null ? null :
string.Format(CultureInfo.InvariantCulture, queryFormat, context.HttpContext.Response.StatusCode);
var newQueryString = queryFormat == null ? QueryString.Empty : new QueryString(formatedQueryString);
var originalPath = context.HttpContext.Request.Path;
var originalQueryString = context.HttpContext.Request.QueryString;
// 將原始請求信息保存下來,以便后續進行還原
context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(new StatusCodeReExecuteFeature()
{
OriginalPathBase = context.HttpContext.Request.PathBase.Value,
OriginalPath = originalPath.Value,
OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null,
});
context.HttpContext.SetEndpoint(endpoint: null);
var routeValuesFeature = context.HttpContext.Features.Get<IRouteValuesFeature>();
routeValuesFeature?.RouteValues?.Clear();
// 構造新請求
context.HttpContext.Request.Path = newPath;
context.HttpContext.Request.QueryString = newQueryString;
try
{
// 執行備用管道,生成響應正文
await context.Next(context.HttpContext);
}
finally
{
// 還原原始請求信息
context.HttpContext.Request.QueryString = originalQueryString;
context.HttpContext.Request.Path = originalPath;
context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(null);
}
});
}
在MVC中,你可以通過給控制器或其中的Action方法添加
[SkipStatusCodePages]
特性,可以略過StatusCodePagesMiddleware
。
使用過濾器進行錯誤處理
除了錯誤處理中間件外,ASP.NET Core 還提供了異常過濾器,用於錯誤處理。
異常過濾器:
- 通過實現接口
IExceptionFilter
或IAsyncExceptionFilter
來自定義異常過濾器 - 可以捕獲Controller創建時(也就是只捕獲構造函數中拋出的異常)、模型綁定、Action Filter和Action中拋出的未處理異常
- 其他地方拋出的異常不會捕獲
本節僅介紹異常過濾器,有關過濾器的詳細內容,后續文章將會介紹
先來看一下這兩個接口:
// 僅具有標記作用,標記其為 mvc 請求管道的過濾器
public interface IFilterMetadata { }
public interface IExceptionFilter : IFilterMetadata
{
// 當拋出異常時,該方法會捕獲
void OnException(ExceptionContext context);
}
public interface IAsyncExceptionFilter : IFilterMetadata
{
// 當拋出異常時,該方法會捕獲
Task OnExceptionAsync(ExceptionContext context);
}
OnException
和OnExceptionAsync
方法都包含一個類型為ExceptionContext
參數,很顯然,它就是與異常有關的上下文,我們的異常處理邏輯離不開它。那接着來看一下它的結構吧:
public class ExceptionContext : FilterContext
{
// 捕獲到的未處理異常
public virtual Exception Exception { get; set; }
public virtual ExceptionDispatchInfo? ExceptionDispatchInfo { get; set; }
// 指示異常是否已被處理
// true:表示異常已被處理,異常不會再向上拋出
// false:表示異常未被處理,異常仍會繼續向上拋出
public virtual bool ExceptionHandled { get; set; }
// 設置響應的 IActionResult
// 如果設置了結果,也表示異常已被處理,異常不會再向上拋出
public virtual IActionResult? Result { get; set; }
}
除此之外,ExceptionContext
還繼承了FilterContext
,而FilterContext
又繼承了ActionContext
(這也從側面說明,過濾器是為Action服務的),也就是說我們也能夠獲取到一些過濾器和Action相關的信息,看看都有什么吧:
public class ActionContext
{
// Action相關的信息
public ActionDescriptor ActionDescriptor { get; set; }
// HTTP上下文
public HttpContext HttpContext { get; set; }
// 模型綁定和驗證
public ModelStateDictionary ModelState { get; }
// 路由數據
public RouteData RouteData { get; set; }
}
public abstract class FilterContext : ActionContext
{
public virtual IList<IFilterMetadata> Filters { get; }
public bool IsEffectivePolicy<TMetadata>(TMetadata policy) where TMetadata : IFilterMetadata {}
public TMetadata FindEffectivePolicy<TMetadata>() where TMetadata : IFilterMetadata {}
}
更多參數細節,我會在專門講過濾器的文章中詳細介紹。
下面,我們就來實現一個自定義的異常處理器:
public class MyExceptionFilterAttribute : ExceptionFilterAttribute
{
private readonly IModelMetadataProvider _modelMetadataProvider;
public MyExceptionFilterAttribute(IModelMetadataProvider modelMetadataProvider)
{
_modelMetadataProvider = modelMetadataProvider;
}
public override void OnException(ExceptionContext context)
{
if (!context.ExceptionHandled)
{
// 此處僅為簡單演示
var exception = context.Exception;
var result = new ViewResult()
{
ViewName = "Error",
ViewData = new ViewDataDictionary(_modelMetadataProvider, context.ModelState)
{
// 記得給 ErrorViewModel 加上 Message 屬性
Model = new ErrorViewModel
{
Message = exception.ToString()
}
}
};
context.Result = result;
// 標記異常已處理
context.ExceptionHandled = true;
}
}
}
接着,找到/Views/Shared/Error.cshtml
,展示一下錯誤消息:
@model ErrorViewModel
@{
ViewData["Title"] = "Error";
}
<p>@Model.Message</p>
最后,將服務MyExceptionFilterAttribute
注冊到DI容器:
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<MyExceptionFilterAttribute>();
services.AddControllersWithViews();
}
現在,我們將該異常處理器加在/Home/Index
上,並拋個異常:
public class HomeController : Controller
{
[ServiceFilter(typeof(MyExceptionFilterAttribute))]
public IActionResult Index()
{
throw new Exception("Home Index Error");
return View();
}
}
當請求/Home/Index
時,你會得到如下頁面:
錯誤處理中間件 VS 異常過濾器
現在,我們已經介紹了兩種錯誤處理的方法——錯誤處理中間件和異常過濾器。現在來比較一下它們的異同,以及我們何時應該選擇哪種處理方式。
錯誤處理中間件:
- 可以捕獲后續中間件的所有未處理異常
- 擁有
RequestDelegate
,操作更加靈活 - 粒度較粗,僅可針對全局進行配置
錯誤處理中間件適合用於處理全局異常。
異常過濾器:
- 僅可捕獲Controller創建時(也就是構造函數中拋出的異常)、模型綁定、Action Filter和Action中拋出的未處理異常,其他地方拋出的異常捕獲不到
- 粒度更小,可以靈活針對Controller或Action配置不同的異常過濾器
異常過濾器非常適合用於捕獲並處理Action中的異常。
在我們的應用中,可以同時使用錯誤處理中間件和異常過濾器,只有充分發揮它們各自的優勢,才能處理好程序中的錯誤。