Exception 是微軟預設中斷流程的手段,要控制好流程就要好好的處理例外,也就是寫 try catch,這會讓流程控制的程式碼看起來很凌亂。於是我需要幾個原則
- 被流程所呼叫的方法都要處理好 Exception/錯誤(根據需求場景,例如:找不到某個資料) 並回傳 Failure 物件
- 若有攔截到 Exception,Failure 物件要裝載 Exception 訊息,並且給予該例外正確的錯誤訊息。
- 盡量在最外層紀錄 Failure 日誌,例如,Controller、Application Layer
- Web API 回傳值不可以包含 Exception
以上是我的原則,在你的團隊裡面應該也有自己的錯誤處理機制;接下來,我用 ASP.NET Core Web API 實際的演練一遍
Copy from BingChat
程式設計中要考慮可能發生的錯誤,並採取適當的措施來避免或處理錯誤。流程控制不要用 Exception 的原因可能有以下幾點:
- 例外狀況是一種非正常的流程控制,它會打斷程式的正常執行,並可能導致資源洩漏、效能下降或不可預期的結果。
- 例外狀況是一種昂貴的操作,它會產生額外的開銷,例如堆疊追蹤、記憶體分配和垃圾收集。
- 例外狀況是一種不易測試和偵錯的機制,它會使程式碼難以閱讀和維護,並可能隱藏真正的問題或邏輯錯誤。
- 例外狀況是一種不符合商業邏輯的表達方式,它會使程式碼與商業需求脫節,並可能造成使用者體驗不佳或客戶損失。
因此,在程式設計中,流程控制不要用 Exception ,而是要使用其他更合適的錯誤處理方式,例如:
- 回傳值或物件,表示操作的結果或狀態,例如成功、失敗、警告或訊息。
- 使用布林值或列舉型別,表示操作是否成功或失敗,並提供相關的資訊或建議。
- 使用委派或回呼函數,將錯誤處理的邏輯交由呼叫者決定,並提供足夠的彈性和可擴充性。
- 使用事件或觀察者模式,將錯誤通知給感興趣的物件或元件,並讓它們自行決定如何回應
擲回例外狀況 - Framework Design Guidelines | Microsoft Learn
❌ 請盡量不要對一般控制流程使用例外狀況。
除了具有潛在競爭條件的系統失敗和作業之外,架構設計人員應該設計 API,讓使用者可以撰寫不會擲回例外狀況的程式碼。 例如,您可以提供一個在呼叫成員之前檢查前置條件的方法,協助使用者撰寫不會擲回例外狀況的程式碼。
開發環境
- Windows 11
- ASP.NET Core 7
- Rider 2023.2
實作
在流程控制的方法,以前我可能會這樣寫
public async Task<GetMemberResult> GetMemberAsync(int memberId,
CancellationToken cancel = default)
{
try
{
//模擬發生例外
throw new Exception("Member not found.");
}
catch (Exception e)
{
throw;
}
}
現在我改成這樣 Tuple(Failure,T),例外既然都處理了,就不再 throw,明確的回傳失敗原因
public async Task<(Failure Failure, GetMemberResult Data)> GetMemberAsync(int memberId,
CancellationToken cancel = default)
{
try
{
if (memberId == 1)
{
//模擬找不到資料所回傳的失敗
return (new Failure
{
Code = FailureCode.MemberNotFound,
Message = "member not found.",
}, null);
}
//模擬發生例外
throw new Exception($"can not connect db.");
}
catch (Exception e)
{
return (new Failure
{
Code = FailureCode.DbError,
Message = e.Message,
Data = memberId,
Exception = e,
}, null);
}
}
不喜歡 Tuple,也可以用一個類別裝載起來
public class GenericResult<T>
{
public Failure Failure { get; set; }
public T Data { get; set; }
}
Failure 定義如下:
- Exception:不序列化(回傳不顯示)
- TraceId:追蹤,用來串接整個服務的 Request 識別,最好是各個服務使用的 Id 都是相同的
- Data:存放 Model Validation 錯誤或是 Request Body
public class Failure
{
public Failure()
{
}
public Failure(FailureCode code, string message)
{
this.Code = code;
this.Message = message;
}
/// <summary>
/// 錯誤碼
/// </summary>
public FailureCode Code { get; init; }
/// <summary>
/// 錯誤訊息
/// </summary>
public string Message { get; init; }
/// <summary>
/// 錯誤發生時的資料
/// </summary>
public object Data { get; init; }
/// <summary>
/// 追蹤 Id
/// </summary>
public string TraceId { get; set; }
/// <summary>
/// 例外,不回傳給 Web API
/// </summary>
[JsonIgnore]
public Exception Exception { get; set; }
public List<Failure> Details { get; init; } = new();
//用了 [JsonIgnore] 似乎就不需要它了 QQ,寫完了才想到可以 Ignore,不過這仍然可以適用在其他場景,例如 CLI、Console App
public Failure WithoutException()
{
List<Failure> details = new();
foreach (var detail in this.Details)
{
details.Add(this.WithoutException(detail));
}
return new Failure(this.Code, this.Message)
{
Data = this.Data,
Details = details,
TraceId = this.TraceId,
};
}
public Failure WithoutException(Failure error)
{
var result = new Failure(error.Code, error.Message)
{
TraceId = error.TraceId
};
foreach (var detailError in this.Details)
{
// 遞迴處理 Details 屬性
var detailResult = this.WithoutException(detailError);
result.Details.Add(detailResult);
}
return result;
}
}
- 把 Failure 的處理集中在 FailureContent 方法
- 用關鍵字來代表 FailureCode,對應到 HttpStatusCode,主要的目的是簡化配置。
public class GenericController : ControllerBase
{
public Dictionary<FailureCode, int> FailureCodeLookup => s_failureCodeLookupLazy.Value;
private static readonly Lazy<Dictionary<FailureCode, int>> s_failureCodeLookupLazy = new(CreateFailureCodeLookup);
private static Dictionary<string, int> CreateFailureCodeMappings()
{
//用關鍵字定義錯誤代碼
return new Dictionary<string, int>(StringComparer.InvariantCultureIgnoreCase)
{
{ "error", StatusCodes.Status500InternalServerError },
{ "invalid", StatusCodes.Status400BadRequest },
{ "notfound", StatusCodes.Status404NotFound },
{ "concurrency", StatusCodes.Status429TooManyRequests },
{ "conflict", StatusCodes.Status404NotFound },
};
}
[NonAction]
public FailureObjectResult FailureContent(Failure failure)
{
if (string.IsNullOrWhiteSpace(failure.TraceId))
{
failure.TraceId = Activity.Current?.Id ?? this.HttpContext.TraceIdentifier;
}
if (FailureCodeLookup.TryGetValue(failure.Code, out int statusCode))
{
return new FailureObjectResult(failure, statusCode);
}
return new FailureObjectResult(failure);
}
private static Dictionary<FailureCode, int> CreateFailureCodeLookup()
{
var result = new Dictionary<FailureCode, int>();
var type = typeof(FailureCode);
var names = Enum.GetNames(type);
var failureMappings = CreateFailureCodeMappings();
foreach (var name in names)
{
var failureCode = FailureCode.Parse<FailureCode>(name);
var isDefined = false;
foreach (var mapping in failureMappings)
{
var key = mapping.Key;
var statusCode = mapping.Value;
if (name.Contains(key, StringComparison.OrdinalIgnoreCase))
{
isDefined = true;
result.Add(failureCode, statusCode);
break;
}
}
if (isDefined == false)
{
result.Add(failureCode, StatusCodes.Status500InternalServerError);
}
}
return result;
}
}
FailureObjectResult 處理 Web API 回傳結果,預設 Failure 不會有 Exception資訊
public class FailureObjectResult : ObjectResult
{
public FailureObjectResult(Failure failure, int statusCode = StatusCodes.Status400BadRequest)
: base(failure)
{
this.StatusCode = statusCode;
// Failure.Exception 已經使用 [JsonIgnore],不會再回傳給調用端
// this.Value = failure.WithoutException();
this.Value = failure.WithoutException();
}
}
Controller 實作 GenericController 即可
public class MembersController : GenericController
{
…
}
- Log Failure:當 MemberService 有 Failure 時,紀錄完整錯誤,並回傳不包含 Exception 的結果
- Log EventId:由開發者決定,這對分析 Log 會很有用
[Produces("application/json")]
[HttpPost("{memberId}/bind-cellphone", Name = "BindCellphone")]
[ProducesResponseType(typeof(Failure), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> Post(int memberId,
BindCellphoneRequest request,
CancellationToken cancel = default)
{
request.MemberId = memberId;
var bindCellphoneResult =
await this._memberService.BindCellphoneAsync(request, cancel);
if (bindCellphoneResult .Failure != null)
{
this._logger.LogInformation(500, "Bind cellphone failure:{@Failure}", bindCellphoneResult .Failure);
return this.FailureContent(createMemberResult.Failure);
}
return this.NoContent();
}
當一個聚合方法依賴了其他的方法,可能會有各種不同狀況的錯誤,只要有一個點發生了錯誤,就立即返回,例如下列 BindCellphoneAsync 方法,只要有一個錯誤就立即中止
public class MemberService1
{
private readonly IValidator<BindCellphoneRequest> _validator;
public MemberService1(IValidator<BindCellphoneRequest> validator)
{
this._validator = validator;
}
//一個方法有多種可能的 Failure
public async Task<(Failure Failure, bool Data)> BindCellphoneAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
var validationResult = await this._validator.ValidateAsync(request, cancel);
if (validationResult.IsValid == false)
{
return (validationResult.ToFailure(), false);
}
//找不到會員
var getMemberResult = await this.GetMemberAsync(request.MemberId, cancel);
if (getMemberResult.Failure != null)
{
return (getMemberResult.Failure, false);
}
//手機格式無效
var validateCellphoneResult = await this.ValidateCellphoneAsync(getMemberResult.Data.Cellphone, cancel);
if (validateCellphoneResult.Failure != null)
{
return validateCellphoneResult;
}
//資料衝突,手機已經被綁定
var saveChangeResult = await this.SaveChangeAsync(request, cancel);
return saveChangeResult;
}
public async Task<(Failure Failure, bool Data)> SaveChangeAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
try
{
//模擬發生例外
throw new DBConcurrencyException("insert data row concurrency error.");
}
catch (Exception e)
{
return (new Failure
{
Code = FailureCode.DataConcurrency,
Message = e.Message,
Exception = e,
Data = request
}, false);
}
}
public async Task<(Failure Failure, bool Data)> ValidateCellphoneAsync(string cellphone,
CancellationToken cancel = default)
{
return (new Failure
{
Code = FailureCode.CellphoneFormatInvalid,
Message = "Cellphone format invalid.",
Data = cellphone
}, false);
}
public async Task<(Failure Failure, GetMemberResult Data)> GetMemberAsync(int memberId,
CancellationToken cancel = default)
{
try
{
if (memberId == 1)
{
//模擬找不到資料所回傳的失敗
return (new Failure
{
Code = FailureCode.MemberNotFound,
Message = "member not found.",
}, null);
}
//模擬發生例外
throw new Exception($"can not connect db.");
}
catch (Exception e)
{
return (new Failure
{
Code = FailureCode.DbError,
Message = e.Message,
Data = memberId,
Exception = e,
}, null);
}
}
//具有多個 Detail 的 Failure
public async Task<(Failure Failure, bool Data)> CreateMemberAsync(CreateMemberRequest request,
CancellationToken cancel = default)
{
var failure = new Failure()
{
Code = FailureCode.InputInvalid,
Message = "view detail errors",
Details = new List<Failure>()
{
new(code: FailureCode.InputInvalid, message: "Input invalid."),
new(code: FailureCode.CellphoneFormatInvalid, message: "Cellphone format invalid."),
new(code: FailureCode.DataConflict, message: "Member already exist."),
}
};
return (failure, false);
}
}
或者是,有錯誤不返回,繼續往下執行,常見的場景,Model Validation、Batch Process
public async Task<(Failure Failure, bool Data)> CreateMemberAsync(CreateMemberRequest request,
CancellationToken cancel = default)
{
var failure = new Failure()
{
Code = FailureCode.InputInvalid,
Message = "view detail errors",
Details = new List<Failure>()
{
new(code: FailureCode.InputInvalid, message: "Input invalid."),
new(code: FailureCode.CellphoneFormatInvalid, message: "Cellphone format invalid."),
new(code: FailureCode.DataConflict, message: "Member already exist."),
}
};
return (failure, false);
}
用 Fluent Pattern 優化一下,把相關的動作收攏在 MemberWorkflow:
- 讓每一個方法都回傳 MemberWorkflow
- 進入方法之前會先檢查全域 Failure
- 但由於 Fluent Pattern 處理非同步會麻煩些,會有一堆 await,像是這樣
var result =
await (await (await (await this._workflow.ValidateModelAsync(request, cancel))
.GetMemberAsync(request.MemberId, cancel))
.ValidateCellphone(request.Cellphone, cancel)).SaveChangeAsync(request, cancel);
所以,我改用 Then 把它們連接起來
public class MemberService2
{
private MemberWorkflow _workflow;
public MemberService2(MemberWorkflow workflow)
{
this._workflow = workflow;
}
//一個方法有多種可能的 Failure
public async Task<(Failure Failure, bool Data)> BindCellphoneAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
var result = await _workflow.ValidateModelAsync(request, cancel)
.Then(p => _workflow.GetMemberAsync(request.MemberId, cancel))
.Then(p => _workflow.ValidateCellphone(request.Cellphone, cancel))
.Then(p => _workflow.SaveChangeAsync(request, cancel))
;
if (result.Failure != null)
{
return (result.Failure, false);
}
return (null, true);
}
public class MemberWorkflow
{
private readonly IValidator<BindCellphoneRequest> _validator;
public MemberWorkflow(IValidator<BindCellphoneRequest> validator)
{
this._validator = validator;
}
public Failure Failure { get; private set; }
public async Task<MemberWorkflow> ValidateModelAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
var validationResult = await this._validator.ValidateAsync(request, cancel);
if (validationResult.IsValid == false)
{
this.Failure = validationResult.ToFailure();
}
return this;
}
public async Task<MemberWorkflow> SaveChangeAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
if (this.Failure != null)
{
return this;
}
try
{
//模擬發生例外
throw new DBConcurrencyException("insert data row concurrency error.");
}
catch (Exception e)
{
this.Failure = new Failure
{
Code = FailureCode.DataConcurrency,
Message = e.Message,
Exception = e,
Data = request
};
}
return this;
}
public async Task<MemberWorkflow> ValidateCellphone(string cellphone,
CancellationToken cancel = default)
{
if (this.Failure != null)
{
return this;
}
this.Failure = new Failure
{
Code = FailureCode.CellphoneFormatInvalid,
Message = "Cellphone format invalid.",
Data = cellphone
};
return this;
}
public async Task<MemberWorkflow> GetMemberAsync(int memberId,
CancellationToken cancel = default)
{
if (this.Failure != null)
{
return this;
}
try
{
if (memberId == 1)
{
this.Failure = new Failure
{
Code = FailureCode.MemberNotFound,
Message = "member not found.",
};
return this;
}
//模擬發生例外
throw new Exception($"can not connect db.");
}
catch (Exception e)
{
this.Failure = new Failure
{
Code = FailureCode.DbError,
Message = e.Message,
Data = memberId,
Exception = e,
};
}
return this;
}
}
}
再次優化,Workflow 管理每一個步驟之間的狀態,感覺是多餘的,可以拔掉,然後用 WhenSuccess 連接上下。
public class MemberService3
{
private readonly IValidator<BindCellphoneRequest> _validator;
public MemberService3(IValidator<BindCellphoneRequest> validator)
{
this._validator = validator;
}
public async Task<(Failure Failure, bool Data)> BindCellphoneAsync(BindCellphoneRequest request,
CancellationToken cancel = default) =>
await this.ValidateModelAsync(request, cancel)
.WhenSuccess(p => this.GetMemberAsync(request.MemberId, cancel))
.WhenSuccess(p => this.ValidateCellphoneAsync(p.Cellphone, cancel))
.WhenSuccess(p => this.SaveChangeAsync(request, cancel));
public async Task<(Failure Failure, bool Data)> ValidateModelAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
var validationResult = await this._validator.ValidateAsync(request, cancel);
if (validationResult.IsValid == false)
{
return (validationResult.ToFailure(), false);
}
return (null, true);
}
public async Task<(Failure Failure, bool Data)> SaveChangeAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
try
{
//模擬發生例外
throw new DBConcurrencyException("insert data row concurrency error.");
}
catch (Exception e)
{
return (new Failure
{
Code = FailureCode.DataConcurrency,
Message = e.Message,
Exception = e,
Data = request
}, false);
}
}
public async Task<(Failure Failure, bool Data)> ValidateCellphoneAsync(string cellphone,
CancellationToken cancel = default)
{
return (new Failure
{
Code = FailureCode.CellphoneFormatInvalid,
Message = "Cellphone format invalid.",
Data = cellphone
}, false);
}
public async Task<(Failure Failure, GetMemberResult Data)> GetMemberAsync(int memberId,
CancellationToken cancel = default)
{
try
{
if (memberId == 1)
{
return (new Failure
{
Code = FailureCode.MemberNotFound,
Message = "member not found.",
}, null);
}
//模擬發生例外
throw new Exception($"can not connect db.");
}
catch (Exception e)
{
return (new Failure
{
Code = FailureCode.DbError,
Message = e.Message,
Data = memberId,
Exception = e,
}, null);
}
}
}
連接 Task 的擴充方法
public static class SecondStepExtensions
{
/// <summary>
/// 接續執行第二個方法
/// </summary>
/// <param name="first"></param>
/// <param name="second"></param>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TResult"></typeparam>
/// <returns></returns>
public static async Task<TResult> Then<TSource, TResult>(this Task<TSource> first,
Func<TSource, Task<TResult>> second)
{
return await second(await first.ConfigureAwait(false)).ConfigureAwait(false);
}
/// <summary>
/// 接續第二個方法,第一個方法有錯誤時,不執行第二個方法
/// </summary>
/// <param name="first"></param>
/// <param name="second"></param>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TResult"></typeparam>
/// <returns></returns>
public static async Task<(Failure Failure, TResult Data)> WhenSuccess<TSource, TResult>(
this Task<(Failure Failure, TSource Data)> first,
Func<TSource, Task<(Failure, TResult)>> second)
{
var result = await first.ConfigureAwait(false);
if (result.Failure != null)
{
return (result.Failure, default(TResult));
}
return await second(result.Data).ConfigureAwait(false);
}
}
既然每一個方法的合約都一樣了,Tuple(Failure,T),也已經知道要做甚麼事了,那麼就直接給他具名的方法名稱,捨棄 WhenSuccess+委派
static class MemberWorkflowExtensions
{
public static async Task<(Failure Failure, GetMemberResult Data)> GetMemberAsync<TSource>(
this Task<(Failure Failure, TSource Data)> previousStep,
int memberId,
CancellationToken cancel = default)
{
try
{
var previousStepResult = await previousStep;
if (previousStepResult.Failure != null)
{
return (previousStepResult.Failure, null);
}
//模擬發生例外
throw new Exception($"can not connect db.");
}
catch (Exception e)
{
return (new Failure
{
Code = FailureCode.DbError,
Message = e.Message,
Data = memberId,
Exception = e,
}, null);
}
}
public static async Task<(Failure Failure, bool Data)> SaveChangeAsync<TSource>(
this Task<(Failure Failure, TSource Data)> previousStep,
BindCellphoneRequest request,
CancellationToken cancel = default)
{
try
{
var previousStepResult = await previousStep;
if (previousStepResult.Failure != null)
{
return (previousStepResult.Failure, false);
}
//模擬發生例外
throw new DBConcurrencyException("insert data row concurrency error.");
}
catch (Exception e)
{
return (new Failure
{
Code = FailureCode.DataConcurrency,
Message = e.Message,
Exception = e,
Data = request
}, false);
}
}
public static async Task<(Failure Failure, bool Data)> ValidateCellphoneAsync<TSource>(
this Task<(Failure Failure, TSource Data)> previousStep,
string cellphone,
CancellationToken cancel = default)
{
var previousStepResult = await previousStep;
if (previousStepResult.Failure != null)
{
return (previousStepResult.Failure, false);
}
return (new Failure
{
Code = FailureCode.CellphoneFormatInvalid,
Message = "Cellphone format invalid.",
Data = cellphone
}, false);
}
}
最後,調用端的寫法長的就像這樣
很明顯的,這方法沒有辦法直接在 Method 傳遞上下文,必須要把狀態記錄在全域變數,這不見得是最好的方法,提供另一種思路讓大家選擇
完整代碼如下
public class MemberService4
{
private readonly IValidator<BindCellphoneRequest> _validator;
public MemberService4(IValidator<BindCellphoneRequest> validator)
{
this._validator = validator;
}
//一個方法有多種可能的 Failure
public async Task<(Failure Failure, bool Data)> BindCellphoneAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
var executeResult = await this.ValidateModelAsync(request, cancel)
.GetMemberAsync(request.MemberId, cancel)
.ValidateCellphoneAsync(request.Cellphone, cancel)
.SaveChangeAsync(request, cancel);
if (executeResult.Failure != null)
{
return (executeResult.Failure, false);
}
return (null, true);
}
public async Task<(Failure Failure, bool Data)> ValidateModelAsync(BindCellphoneRequest request,
CancellationToken cancel = default)
{
var validationResult = await this._validator.ValidateAsync(request, cancel);
if (validationResult.IsValid == false)
{
return (validationResult.ToFailure(), false);
}
return (null, true);
}
}
我個人比較喜歡上一個方法,MemberService3 的寫法
實際運行一下,這幾個寫法的運行結果都一樣,隨便挑一個來跑,模擬找不到會員流程,如下圖:
模擬例外發生,確定 Exception 欄位沒有出現,[JsonIgnore] 真的有正常的工作,如下圖:
Log 則是完整記錄 Exception,若擔心 Log 記錄了機敏性資料,一樣可以加工處理。
心得
雖然說 throw exception,可以快速地替我們中斷工作流程,但是一旦需要複雜控制時,它用起來又非常的彆扭,或許 catch 例外後返回一個 Failure 是一個不錯的選擇,這也是我目前正在做的事,或許你也可以試試看。上面幾種方法裡, 個人認為 WhenSuccess + 委派是最有彈性的,缺點就是需要多一個連接詞,讀起來沒有那麼直觀。
參考其他語言的經驗,下圖是 Rust 的返回結果值,左邊是 Err,右邊是結果。
流程的控制,換成 WhenSuccess + 委派 讀起來的確是舒服了許多;實務上,你要視狀況來決定要拋 exception,或是 catch,不要一股腦都用相同的做法,目前我的作法是底層維持 throw,流程控制再依狀況處理例外
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET