這篇係針對前一篇文章, 摘錄筆者認為在 ASP.NET Core 8 MVC 框架下的適宜作法.
這篇不作太多的文字說明, 主要是描述實作程序, 及相關程式碼.
章節內容
- 壹. 相關資源
- 貳. 文章摘要 (建議的方案)
- 一. ASP.NET Core MVC 訊息流程
- 二. 實作 基礎 MVC 專案
(一) 定義例外類別
(二) 撰寫產生 HTTP RQ/RP 的唯一識別碼 的 Middleware
(三) 撰寫例外攔截的 Middleware (採用 ProblemDetails 類別作為統一的回傳格式
(四) 撰寫未通過 Model Validation Attribute 的 Action Filter - 三. 實作 範例 MVC 專案
(一) Program.cs
(二) Model Validation Attribute 的處理
(三) 自行在 controller 或 service 撰寫檢核邏輯
(四) 前端 AJAX 的處理
壹. 相關資源
- (Google Blog) https://www.jasperstudy.com/2024/02/aspnet-mvc-aspnet-core-mvc.html
- (GitHub Repo) https://github.com/jasper-lai/20240223_ASPNETCore8ErrorHandling
貳. 文章摘要 (建議的方案)
一. ASP.NET Core MVC 訊息流程
二. 實作 基礎 MVC 專案
該專案主要提供以下功能:
(1) 自定義例外類別
(2) 取得唯一 Request 識別碼的 Middleware
(3) 例外攔截的 Middleware: 會在這裡把所有的例外, 轉為對應的 HTTP Response
(4) 針對 Validation Attribute 撰寫 Action Filter
(一) 定義例外類別
(二) 撰寫產生 HTTP RQ/RP 的唯一識別碼 的 Middleware
public class TraceIdMiddleware
{
private readonly RequestDelegate _next;
public TraceIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
context.TraceIdentifier = Guid.NewGuid().ToString();
string traceId = context.TraceIdentifier;
context.Response.Headers["X-Trace-Id"] = traceId;
await _next(context);
}
}
(三) 撰寫例外攔截的 Middleware (採用 ProblemDetails 類別作為統一的回傳格式)
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly static JsonSerializerOptions _jsonOptions = new()
{
// 預設第一字母轉小寫 (camel), 若要改成 C# 的字首大寫格式 (Pascal), 要設為 null
// 重要:
// (1) 如果有設 options 的話, 就一定維持字首大寫, 因為沒設 PropertyNamingPolicy, 等同 null.
// (2) 只有在完全沒設 options, 或設為 JsonNamingPolicy.CamelCase, 才會是小寫.
// (3) 若設成字首小寫, 則自行在 ProblemDetails 擴增的欄位 (TraceId, ControllerName...), 並不會轉小寫 !!
//
//PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
//PropertyNamingPolicy = null,
Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.CjkUnifiedIdeographs)
};
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
#region 方式二: 回傳 ASP.NET Core 內建的 ProblemDetails 類別
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// STEP 1: 取得 controller name / action name / trace id
var routeData = context.GetRouteData();
var controllerName = routeData?.Values["controller"]?.ToString();
var actionName = routeData?.Values["action"]?.ToString();
var traceId = context.TraceIdentifier;
// STEP 2: 建立回傳物件
ProblemDetails response = exception switch
{
MyParamNullException _ or
MyOutRangeException _ or
MyClientException _ => new ProblemDetails()
{
Title = HttpStatusCode.BadRequest.ToString(),
Status = StatusCodes.Status400BadRequest,
},
MyDataNotExistException _ => new ProblemDetails()
{
Title = HttpStatusCode.NotFound.ToString(),
Status = StatusCodes.Status404NotFound,
},
MyDataExistException _ => new ProblemDetails()
{
Title = HttpStatusCode.Conflict.ToString(),
Status = StatusCodes.Status409Conflict,
},
MyUnauthorizedException _ => new ProblemDetails()
{
Title = HttpStatusCode.Unauthorized.ToString(),
Status = StatusCodes.Status401Unauthorized,
},
MyForbiddenException _ => new ProblemDetails()
{
Title = HttpStatusCode.Forbidden.ToString(),
Status = StatusCodes.Status403Forbidden,
},
_ => new()
{
Title = HttpStatusCode.InternalServerError.ToString(),
Status = StatusCodes.Status500InternalServerError,
}
};
if (response.Status != StatusCodes.Status500InternalServerError)
response.Detail = exception.Message;
else
response.Detail = "伺服器發生未預期的錯誤";
response.Instance = context.Request.Path;
response.Extensions.Add("TraceId", traceId);
response.Extensions.Add("ControllerName", controllerName);
response.Extensions.Add("ActionName", actionName);
// STEP 3: 設定回傳的 response header
context.Response.ContentType = "application/json";
context.Response.StatusCode = response.Status ?? StatusCodes.Status500InternalServerError;
// STEP 4: 寫入至 Log
//var options = new JsonSerializerOptions()
//{
// Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.CjkUnifiedIdeographs)
//};
string jsonString = JsonSerializer.Serialize(response, _jsonOptions);
if (response.Status >= 400 && response.Status < 500)
_logger.LogWarning("Controller={controllerName} Action={actionName} => Message={message}", controllerName, actionName, exception.Message);
if (response.Status >= 500)
_logger.LogError(exception, "Controller={controllerName} Action={actionName} => Message={message}", controllerName, actionName, exception.Message);
_logger.LogInformation("{json}", jsonString); //輸出完整的 json 字串
// STEP 5: 回傳結果
//await context.Response.WriteAsJsonAsync(response);
await context.Response.WriteAsJsonAsync(response, _jsonOptions);
}
#endregion
}
(四) 撰寫未通過 Model Validation Attribute 的 Action Filter
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
// 方式1: 統一集中例外處理 if you want to throw a custom exception
var description = ModelErrorToString(context.ModelState);
throw new MyClientException(description);
//// 方式2: 利用 context.Result 回傳, 但這就無法統一回傳格式了.
//context.Result = new BadRequestObjectResult(context.ModelState);
}
}
/// <summary>
/// 將未通過 Model Validation 資料檢核的清單, 轉為字串
/// </summary>
/// <returns></returns>
private string ModelErrorToString(ModelStateDictionary modelState)
{
// Your implementation to convert model state errors to a single string
var errors = modelState.Values.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.ToList();
var description = string.Join(Environment.NewLine, errors);
description = "輸入的資料有誤: " + Environment.NewLine + description;
return description;
}
}
三. 實作 範例 MVC 專案
主要是在未通過資料檢核時, 要拋出對應的 Exception.
(一) Program.cs
builder.Services.AddControllersWithViews(options =>
{
// 註冊全域的 Filter
options.Filters.Add(new ValidateModelAttribute());
})
.AddJsonOptions(jsonOptions =>
{
// PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
// null: 維持 C# 字首大寫的 json 欄位格式.
jsonOptions.JsonSerializerOptions.PropertyNamingPolicy = null;
//允許基本拉丁英文及中日韓文字維持原字元
jsonOptions.JsonSerializerOptions.Encoder =
JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.CjkUnifiedIdeographs);
});
#region 使用 TraceIdMiddleware
// {jasper} 若要停用自定義 ttpContext.TraceIdentifier 的話, 只要把這段作 Remark 即可
// 註冊自定義產出 TraceId 的 Middleware
app.UseMiddleware<TraceIdMiddleware>();
#endregion
#region 使用 ExceptionHandlingMiddleware
// {jasper} 註冊例外攔截的 Middleware
// 注意: 這個必須排在預設內建的例外處理機制之後, 在發生例外時, 才能由自定義的 Middleware 作處理
app.UseMiddleware<ExceptionHandlingMiddleware>();
#endregion
(二) Model Validation Attribute 的處理
public class ProductViewModel : IValidatableObject
{
[Display(Name = "產品代號")]
[Required(ErrorMessage = "{0} 必須要有值")]
public int Id { get; set; }
[Display(Name = "產品名稱")]
[Required(ErrorMessage = "{0} 必須要有值")]
[StringLength(10, ErrorMessage = "產品名稱長度最多為 10 個字元")]
public string Name { get; set; } = string.Empty;
[Display(Name = "訂購數量")]
[Range(1, 10, ErrorMessage = "{0} 的資料值, 必須介於 {1} ~ {2}.")]
public int OrderQty { get; set; }
[Display(Name = "產品單價")]
public int UnitPrice { get; set; }
/// <summary>
/// Validates the specified validation context.
/// </summary>
/// <param name="validationContext">The validation context.</param>
/// <remarks>
/// 重要: 只有在前述的 Validation Attribute 都通過以後, 才會執行這裡的檢核.
/// 亦即: (1) Validation Attribute 有誤, 前端只會看到 Validation Attribute 的錯誤.
/// (2) Validation Attribute 正確, 前端才看到以下的錯誤.
/// </remarks>
/// <returns></returns>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Custom validation logic for UnitPrice
if (UnitPrice < 1 || UnitPrice > 1000)
{
string[] memberNames = [nameof(UnitPrice)];
yield return new ValidationResult("產品單價必須在 1 ~ 1000", memberNames);
}
}
}
[HttpPost]
public IActionResult CreateAjaxJson([FromBody] ProductViewModel product)
{
// -----------------
// 以下已經移到 Action Filter 作處理了
// -----------------
//// 處理 validation attribute (model binding) 檢核未過的錯誤
//if (!ModelState.IsValid)
//{
// var description = this.ModelErrorToString();
// throw new MyClientException(description);
// //return BadRequest(ModelState);
//}
var result = _service.Create(product);
_logger.LogInformation("處理結果: {result}", result);
return View("Create",product);
}
(三) 自行在 controller 或 service 撰寫檢核邏輯
public IActionResult About(int id = 0)
{
if (id == 1)
{
throw new MyClientException("傳入的參數值有誤");
}
return View();
}
(四) 前端 AJAX 的處理
可以在 error: 段落, 統一進行錯誤訊息的呈現.
function btnDataNotExistException() {
let product = makeProductObject();
clearJsonResult();
$.ajax({
url: '@Url.Action("OccursDataNotExistException", "Product")', // Replace 'YOUR_ENDPOINT_URL' with the URL you're posting to
type: 'POST',
contentType: 'application/json', // This tells the server that you're sending JSON data
data: JSON.stringify(product), // Convert the person object to a JSON string
success: function (response) {
// Handle success
console.log('Success:', response);
},
error: function (xhr, status, error) {
// 真正有用的, 只有 xhr 物件 !!!
try {
// 將回傳的錯誤內容, 轉換為 errorJson 物件
var errorJson = JSON.parse(xhr.responseText);
// 呈現完整 json 內容
console.log('Error JSON:', errorJson);
// 只取其中部份的 json 欄位
console.log('Title:', errorJson.Title);
console.log('Detail:', errorJson.Detail);
// Pretty-print the JSON error object to make it readable
// with a spacing of 4 characters for indentation, making it more readable.
var prettyErrorJson = JSON.stringify(errorJson, null, 4);
// Set the pretty-printed JSON as the text content of the #jsonResult element
$('#jsonResult').text(prettyErrorJson);
} catch (e) {
console.log('Error parsing error response:', e);
$('#jsonResult').text('An error occurred, but the error details could not be parsed.');
}
}
});
// 美化 <code></code> 的內容
hljs.highlightAll();
}
沒有留言:
張貼留言