夜晚是多麼美好的事物呀!她張開雙臂,把整個世界,所有悲喜擁入懷中,沉默著傾聽萬物的低喃、嘆息、抱怨或喜訊。 我多半時間都是歡迎她的,特別是在一天奔波後,滿身疲憊、挫折與委屈 彷彿蘇軾《臨江仙》中的遊子,渴望遠離塵世喧囂,到一個毫無紛亂之地,過著簡單平安的一生。
然而,每當靜夜離去,我卻也不得不承認 —— 昨夜的自己,只是又一次在苦中找尋出口罷了。 系統亦然。每一次錯誤的發生,也許令人焦躁,但若能妥善處理、柔和回應,就能讓使用者感受到: 「啊,原來這個系統,是有被好好照顧過的。」
🧱 規劃我們定義的 GlobalExceptionHandler
UseExceptionHandler 是什麼? 當你開發一個 ASP.NET Core 應用時,難免會碰上各式各樣的例外(Exception),這時候,如果每個地方都用 try-catch 包起來,不但重複、難維護,還容易漏掉。 ASP.NET Core 為此提供了一個現成的機制:app.UseExceptionHandler(…),讓你能夠集中處理應用程式中的所有錯誤。
根據官方文件, 例外處理器 Lambda 函數 ,這個方法可以讓你註冊一條錯誤專用的 middleware pipeline,當系統內部發生未處理的例外時,就會被導向這條管線進行錯誤處理。
👉 延伸閱讀:
UseExceptionHandler(IApplicationBuilder, Action)
ExceptionHandlerExtensions.cs
這背後其實做了三件事:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static IApplicationBuilder UseExceptionHandler (this IApplicationBuilder app, Action<IApplicationBuilder> configure ){ var errorApp = app.New(); configure(errorApp); var errorPipeline = errorApp.Build(); return app.UseMiddleware<ExceptionHandlerMiddleware>(Options.Create(new ExceptionHandlerOptions { ExceptionHandler = errorPipeline })); }
簡單來說: 當你呼叫 UseExceptionHandler(…),ASP.NET Core 會在背後幫你打造一條獨立的錯誤處理通道,然後安插一個叫做 ExceptionHandlerMiddleware 的元件,當請求途中出現未處理的例外時,就會自動轉送到你定義的錯誤邏輯裡處理。
錯誤真的發生時會怎樣? 一旦請求流程中某個 middleware 拋出了例外,這個錯誤會被 ExceptionHandlerMiddleware 的 Invoke 方法攔截:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public async Task Invoke (HttpContext context ){ try { await _next(context); } catch (Exception ex) { var exceptionFeature = new ExceptionHandlerFeature { Error = ex, Path = context.Request.Path }; context.Features.Set<IExceptionHandlerFeature>(exceptionFeature); await _options.ExceptionHandler(context); } }
這段程式碼會:
捕捉任何未處理的例外。
把錯誤封裝成 ExceptionHandlerFeature 並放入 HttpContext.Features 中。
把請求交給你事先註冊的錯誤管線(也就是我們在 UseExceptionHandler(…) 裡定義的內容)來處理。
那我們該怎麼定義錯誤管線裡的行為? 關鍵就在 UseExceptionHandler(…) 的參數 configure。這個參數是一個 Action,你可以在其中用 .Run(…) 來註冊一個錯誤發生時該執行的 middleware。
1 2 3 4 5 6 7 8 9 app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { }); });
.Run(…) 這裡的 Run(…) 是 middleware 的終點站,它的定義如下:
RunExtensions.Run(IApplicationBuilder, RequestDelegate)
1 2 3 public static void Run (this Microsoft.AspNetCore.Builder.IApplicationBuilder app, Microsoft.AspNetCore.Http.RequestDelegate handler ) ;
這個方法會做兩件事:
✅ 把 handler 這個請求處理函式,加進 pipeline。
🚫 不會再呼叫下一個 middleware,也就是「只處理一次就收工,不再繼續傳遞」。
這就像你在某個岔路口貼了一張紙條說:「如果你迷路了,來這裡找我,不用再往前走了。」
總結來說,UseExceptionHandler(…) 幫你蓋了一條專門處理錯誤的支線。
這條支線的實際邏輯會透過 exceptionHandlerApp.Run(…) 定義。一旦錯誤發生,會中斷原本的請求流程,把處理權移交給你定義的錯誤 middleware。在 .Run(…) 裡的邏輯會是這次錯誤處理的「終點站」。這樣一來,你就能用一致的方式處理整個應用程式的例外狀況,避免讓錯誤默默爆炸而沒人知道,還能統一錯誤訊息格式、發送告警,甚至記錄錯誤 log 或推播 Slack 通知。是不是很方便?
我們的錯誤處理感覺像這樣:
1 2 3 4 5 6 7 8 9 10 主程式的 Middleware Pipeline(你網站的所有請求都會經過) │ ├─ app.UseRouting() ├─ app.UseAuthorization() ├─ app.UseExceptionHandler(...) ← 把 ExceptionHandlerMiddleware 加進來(註冊) │ ↓ │ 當有錯誤發生時,會執行你在 UseExceptionHandler 裡寫的錯誤處理 pipeline │ └─ exceptionHandlerApp.Run(...) ← 這裡才是「當錯誤發生時要做什麼」
🧱 實作
當然,知道架構怎麼運作只是第一步,真正的關鍵還是在於:你要怎麼回應錯誤?
不可能每個錯誤都丟個 500 + Exception Message 給使用者吧? 這樣不但沒幫助,還可能洩漏內部實作細節,讓資安人員睡不著。
錯誤從哪來?從 Feature 裡來 我們可以透過 ASP.NET Core 提供的 ExceptionHandlerFeature ,從 HttpContext.Features 裡撈出剛剛被捕捉到的錯誤內容。
它會長得像這樣:
1 2 3 var exceptionHandlerPathFeature = httpContext.Features.Get<IExceptionHandlerPathFeature>();
這個物件裡會包含兩個重要資訊:
Error:實際丟出的 Exception
Path:是哪一條路徑發生錯誤
定義統一錯誤格式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class ApiErrorResponseEntity <T >{ public string ErrorCode { get ; set ; } public string Message { get ; set ; } public T? Data { get ; set ; } public static ApiErrorResponseEntity<T> Create (string code, string message = "" , T data = default (T )) { return new ApiErrorResponseEntity<T> { ErrorCode = code, Message = message, Data = data }; } }
這樣一來,不論是什麼類型的錯誤,我們都能包成這個格式,清楚地告訴前端:
是哪個錯誤代碼(ErrorCode)
錯誤訊息(Message)
額外的錯誤內容(Data)
錯誤分流策略 在我們自定義的 UseGlobalExceptionHandler 裡,我們根據不同類型的錯誤來源,設計了三種處理方式:
① 自訂業務錯誤(Custom Business Exceptions) 如果是像 InvalidParameterException 或 QuotaExceededException 這類自訂例外,代表你已經在程式中明確拋出了這個錯。這種情況下,我們會:回傳 HTTP 400 Bad Request 把 ErrorCode、Message、Data 包起來,變成一致格式的 JSON 回傳
1 2 3 4 5 6 7 8 9 10 11 12 13 httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; httpContext.Response.ContentType = Application.Json; var response = new ApiErrorResponseEntity(){ ErrorCode = exception.ErrorCode, Message = exception.Message, Data = exception.Data }; await httpContext.Response.WriteAsJsonAsync(response);
② FluentValidation 驗證失敗 如果是來自 FluentValidation 的 ValidationException,我們會針對每個錯誤欄位回傳對應的訊息, 這讓前端可以根據 PropertyName 決定要在表單哪一欄顯示錯誤訊息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = Application.Json; IList<ApiErrorResponseEntity> detailErrors = new List<ApiErrorResponseEntity>(); foreach (var error in validationException.Errors){ detailErrors.Add( new ApiErrorResponseEntity { ErrorCode = error.ErrorCode, Message = error.ErrorMessage, Data = new Dictionary<string , object >() { { $"entity.{error.PropertyName} " , new string [] { error.ErrorMessage } }, }, }); } var apiErrorResponseEntity = new ApiErrorResponseEntity{ ErrorCode = nameof (ErrorCodeEnum.InvalidParams), Message = string .Join(',' , detailErrors.Select(i => i.Message).ToArray()), Data = detailErrors, }; await context.Response.WriteAsJsonAsync(apiErrorResponseEntity);
③ 未知錯誤(Internal Server Error) 最後,如果錯誤不是你能預期的(例如某個 NullReferenceException 悄悄潛入),就讓它回傳:HTTP 500
ErrorCode = InternalServerError
Message = Exception message
Data = StackTrace(僅供 debug 用)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var exception = exceptionHandlerPathFeature?.Error as Exception;context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = Application.Json; var response = new ApiErrorResponseEntity(){ ErrorCode = nameof (ErrorCodeEnum.InternalServerError), Message = exception?.Message, Data = exception?.StackTrace, }; await context.Response.WriteAsJsonAsync(response);
將以上規劃實作出來會是這樣
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 public static class GlobalExceptionHandlerExtension2 { public static void UseGlobalExceptionHandler (this WebApplication app ) { app.UseExceptionHandler(exceptionHandlerApp => exceptionHandlerApp.Run(async httpContext => { var exceptionHandlerPathFeature = httpContext.Features.Get<IExceptionHandlerPathFeature>(); if (exceptionHandlerPathFeature?.Error is InvalidParameterException || exceptionHandlerPathFeature?.Error is ClientInvalidOperationException || exceptionHandlerPathFeature?.Error is InternalServerErrorException || exceptionHandlerPathFeature?.Error is QuotaExceededException || exceptionHandlerPathFeature?.Error is CouponNotExistsException || exceptionHandlerPathFeature?.Error is ExceededTimesLimiterException || exceptionHandlerPathFeature?.Error is TransferInvalidException || exceptionHandlerPathFeature?.Error is DispatchByCodeException || exceptionHandlerPathFeature?.Error is RedeemInvalidException || exceptionHandlerPathFeature?.Error is DispatchByCouponIdInvalidException || exceptionHandlerPathFeature?.Error is TransferInvalidException) { var exception = exceptionHandlerPathFeature?.Error as BaseException; httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; httpContext.Response.ContentType = Application.Json; var response = new ApiErrorResponseEntity() { ErrorCode = exception.ErrorCode, Message = exception.Message, Data = exception.Data }; await httpContext.Response.WriteAsJsonAsync(response); } else if (exceptionHandlerPathFeature?.Error is ValidationException validationException) { httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; httpContext.Response.ContentType = Application.Json; IList<ApiErrorResponseEntity> detailErrors = new List<ApiErrorResponseEntity>(); foreach (var error in validationException.Errors) { detailErrors.Add( new ApiErrorResponseEntity() { ErrorCode = error.ErrorCode, Message = error.ErrorMessage, Data = new Dictionary<string , object >() { {$"entity : {error.PropertyName} " ,new string [] { error.ErrorMessage } } } } ); } var apiErrorResponseEntity = new ApiErrorResponseEntity() { ErrorCode = nameof (ErrorCodeEnum.InvalidParams), Message = string .Join("," , detailErrors.Select(error => error.Message).ToArray()), Data = detailErrors }; await httpContext.Response.WriteAsJsonAsync(apiErrorResponseEntity); } else { var exception = exceptionHandlerPathFeature?.Error as Exception; httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; httpContext.Response.ContentType = Application.Json; var response = new ApiErrorResponseEntity() { ErrorCode = nameof (ErrorCodeEnum.InternalServerError), Message = exception?.Message, Data = exception?.StackTrace }; await httpContext.Response.WriteAsJsonAsync(response); } } ) ); } }
🧱 進階強化:全域錯誤處理的改善策略
現在這個 middleware 架構已經能區分錯誤類型並統一格式回應,但以下幾個方向可以讓它更彈性、更安全、更適應變動:
1️⃣ 改用 Dictionary + Type 做例外類別註冊(消除 if-else 鍊) 目前邏輯中對自訂例外是用 if (…) || … || … 來判斷,這樣未來每多一個新 Exception class 就要手動加一條。
可以改成以下這樣的設計:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private static readonly Dictionary<Type, HttpStatusCode> ExceptionMappings = new (){ { typeof (InvalidParameterException), HttpStatusCode.BadRequest }, { typeof (ClientInvalidOperationException), HttpStatusCode.BadRequest }, { typeof (InternalServerErrorException), HttpStatusCode.InternalServerError }, { typeof (QuotaExceededException), HttpStatusCode.BadRequest }, }; if (exceptionHandlerPathFeature?.Error is BaseException baseEx){ var statusCode = ExceptionMappings.TryGetValue(baseEx.GetType(), out var code) ? (int )code : StatusCodes.Status400BadRequest; httpContext.Response.StatusCode = statusCode; ... }
✅ 好處:集中管理錯誤類別對應邏輯,不會讓 middleware 腫成一條「錯誤判斷長城」。
2️⃣ 加入 Logging 與 TraceId,一次錯誤可追蹤到底 在正式環境中,發生錯誤時紀錄 log 是基本責任,可以這樣加入:
1 2 3 4 var logger = httpContext.RequestServices.GetRequiredService<ILogger<GlobalExceptionHandler>>();logger.LogError(exception, "Exception occurred at {Path}" , httpContext.Request.Path);
若再搭配 TraceId 由前端傳入或由中介層產生,能更清楚地跨系統追蹤這次錯誤的整個生命週期。
3️⃣ 改寫為 IExceptionHandler 介面 + 多型分派 可以抽出一個策略模式(Strategy Pattern)來統一錯誤處理流程,讓每一種錯誤類型都有一個獨立的 handler:
1 2 3 4 5 6 7 public interface ICustomExceptionHandler { bool CanHandle (Exception ex ) ; Task HandleAsync (HttpContext context, Exception ex ) ; }
實作例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class BusinessExceptionHandler : ICustomExceptionHandler { public bool CanHandle (Exception ex ) => ex is BaseException; public async Task HandleAsync (HttpContext context, Exception exception ) { var businessEx = exception as BaseException; var result = new ApiErrorResponseEntity<object > { ErrorCode = businessEx.ErrorCode, Message = businessEx.Message, Data = businessEx.Data }; context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/json" ; await context.Response.WriteAsJsonAsync(result); } }
再用 DI 注入多個實作:
1 2 3 4 5 services.AddScoped<ICustomExceptionHandler, ValidationExceptionHandler>(); services.AddScoped<ICustomExceptionHandler, BusinessExceptionHandler>(); services.AddScoped<ICustomExceptionHandler, UnknownExceptionHandler>();
在 middleware 中這樣呼叫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public static class GlobalExceptionHandlerMiddleware { public static void UseGlobalExceptionHandler (this WebApplication app ) { app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { var feature = context.Features.Get<IExceptionHandlerPathFeature>(); var exception = feature?.Error; var handlers = context.RequestServices.GetServices<ICustomExceptionHandler>(); foreach (var handler in handlers) { if (handler.CanHandle(exception)) { await handler.HandleAsync(context, exception); return ; } } }); }); } }
☘️ 結語 當我們在夜裡靜靜回望這段錯誤處理的設計過程,就像是在燈火闌珊處,替系統也點上一盞微光。
這些錯誤或許無法避免,就如同生活中的困頓與疲憊總會不請自來,但當我們為每一個錯誤準備好溫柔的接住方式,系統便不再只是冷冰冰的程式堆疊,而是有溫度、有責任感的存在。
如同夜晚那雙包容萬象的手臂,錯誤處理的架構若設計得當,也能擁抱使用者的不安,替他們化解那些原本可能成為沮喪的瞬間。
讓我們記住:錯誤並不可怕,無聲無息的錯誤才可怕。 願我們寫下的不只是處理邏輯,而是讓人安心的一句話 ——「我在這裡,沒事的。」