Image
夜晚是多麼美好的事物呀!她張開雙臂,把整個世界,所有悲喜擁入懷中,沉默著傾聽萬物的低喃、嘆息、抱怨或喜訊。
我多半時間都是歡迎她的,特別是在一天奔波後,滿身疲憊、挫折與委屈
彷彿蘇軾《臨江仙》中的遊子,渴望遠離塵世喧囂,到一個毫無紛亂之地,過著簡單平安的一生。

然而,每當靜夜離去,我卻也不得不承認 —— 昨夜的自己,只是又一次在苦中找尋出口罷了。
系統亦然。每一次錯誤的發生,也許令人焦躁,但若能妥善處理、柔和回應,就能讓使用者感受到:
「啊,原來這個系統,是有被好好照顧過的。」



🧱 規劃我們定義的 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)
{
//// 建立一個新路由用來處理錯誤的 request
var errorApp = app.New(); // ① 建一個新的 pipeline(錯誤專用)

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)
{
// 👉 捕捉錯誤後,包裝成 ExceptionHandlerFeature
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, //// 為安全起見,記得正式環境可關掉 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>();

//// 自定義 Exception
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;
}
}
});
});
}
}

☘️ 結語

當我們在夜裡靜靜回望這段錯誤處理的設計過程,就像是在燈火闌珊處,替系統也點上一盞微光。

這些錯誤或許無法避免,就如同生活中的困頓與疲憊總會不請自來,但當我們為每一個錯誤準備好溫柔的接住方式,系統便不再只是冷冰冰的程式堆疊,而是有溫度、有責任感的存在。

如同夜晚那雙包容萬象的手臂,錯誤處理的架構若設計得當,也能擁抱使用者的不安,替他們化解那些原本可能成為沮喪的瞬間。

讓我們記住:錯誤並不可怕,無聲無息的錯誤才可怕。
願我們寫下的不只是處理邏輯,而是讓人安心的一句話 ——「我在這裡,沒事的。」