之前的文章有提到這位作者(Vladimir Khorikov)對函數式編程的有趣概念,趁目前有空順便翻譯下,原文:Functional C#: Handling failures, input errors
本文描述的主題是我的Pluralsight課程的一部份–Applying Functional Principles in C#。
這篇文章我將介紹如何以函數式的方法來處理錯誤及無效的輸入。
- Functional C#: Immutability
- Functional C#: Primitive obsession
- Functional C#: Non-nullable reference types
- Functional C#: Handling failures and input errors
Handling errors in C#: the common approach
驗證和錯誤處理的概念是眾所周知的,但是在像C#的語言中處理它的程式碼可能非常煩人。本文的靈感來自於軌道式編程,這是由Scott Wlaschin在他的演講中介紹的。我鼓勵你觀看完整的介紹,因為它提供了我們的日常C#程式中很尷尬的部份的非常寶貴的見解。
看下面的例子:
[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
Customer customer = new Customer(name);
_repository.Save(customer);
_paymentGateway.ChargeCommission(billingInfo);
_emailSender.SendGreetings(name);
return new HttpResponseMessage(HttpStatusCode.OK);
}
它看起來很簡單且直接,我們先建了一個客戶的實體,儲存,然後收取一些佣金,最後傳送問候的e-mail,這裡的問題是它只處理「快樂路徑」,即一切都沒有錯誤的路徑。
當你要開始處理可能地錯誤、輸入錯誤及日誌等,函式開始變得煩人:
[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
Result<CustomerName> customerNameResult = CustomerName.Create(name);
if (customerNameResult.Failure)
{
_logger.Log(customerNameResult.Error);
return Error(customerNameResult.Error);
}
Result<BillingInfo> billingInfoResult = BillingInfo.Create(billingInfo);
if (billingInfoResult.Failure)
{
_logger.Log(billingInfoResult.Error);
return Error(billingInfoResult.Error);
}
Customer customer = new Customer(customerNameResult.Value);
try
{
_repository.Save(customer);
}
catch (SqlException)
{
_logger.Log(“Unable to connect to database”);
return Error(“Unable to connect to database”);
}
_paymentGateway.ChargeCommission(billingInfoResult.Value);
_emailSender.SendGreetings(customerNameResult.Value);
return new HttpResponseMessage(HttpStatusCode.OK);
}
更糟的是,如果我們需要處理Save和ChargeCommission函式中的失敗,我們最終建立了補償邏輯,以便在其中一個函式失敗時可以rollback更改:
[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
Result<CustomerName> customerNameResult = CustomerName.Create(name);
if (customerNameResult.Failure)
{
_logger.Log(customerNameResult.Error);
return Error(customerNameResult.Error);
}
Result<BillingInfo> billingIntoResult = BillingInfo.Create(billingInfo);
if (billingIntoResult.Failure)
{
_logger.Log(billingIntoResult.Error);
return Error(billingIntoResult.Error);
}
try
{
_paymentGateway.ChargeCommission(billingIntoResult.Value);
}
catch (FailureException)
{
_logger.Log(“Unable to connect to payment gateway”);
return Error(“Unable to connect to payment gateway”);
}
Customer customer = new Customer(customerNameResult.Value);
try
{
_repository.Save(customer);
}
catch (SqlException)
{
_paymentGateway.RollbackLastTransaction();
_logger.Log(“Unable to connect to database”);
return Error(“Unable to connect to database”);
}
_emailSender.SendGreetings(customerNameResult.Value);
return new HttpResponseMessage(HttpStatusCode.OK);
}
你可以看到原先的5行程式變為35行–該函式變成了7倍大!現在我們很難理解程式流程,5行有意義的程式被埋在了大量的boilerplate orchestration
下。
Handling failures and input errors in a functional way
它可以被修正嗎?幸運的是,Yes。讓我們來看看可以做什麼。
你可能已經注意到,我們使用在我的primitive obsession文章中提到的技術,與其使用raw name和billingInfo字串,我們用CustomerName和BillingInfo類別包裝,這讓我們有機會在同一個地方放置相關的驗證邏輯並符合DRY原則。
靜態Create函式回傳一個名為Result的特殊類別,它封裝了與操作結果有關的所有資訊:錯誤時的錯誤訊息或成功時的物件實體。
另外,請注意,可能的失敗會用try/catch包裝起來。這功能打破了我曾寫的Exceptions for flow control的最佳實作,它說若你知道如何處理異常,請儘可能在最底層捕獲它們。
它表示ChargeCommission和Save函式應自己捕獲已知的異常並回傳結果,如同靜態函式Create做的。讓我們來重構程式:
[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
Result<CustomerName> customerNameResult = CustomerName.Create(name);
if (customerNameResult.Failure)
{
_logger.Log(customerNameResult.Error);
return Error(customerNameResult.Error);
}
Result<BillingInfo> billingIntoResult = BillingInfo.Create(billingInfo);
if (billingIntoResult.Failure)
{
_logger.Log(billingIntoResult.Error);
return Error(billingIntoResult.Error);
}
Result chargeResult = _paymentGateway.ChargeCommission(billingIntoResult.Value);
if (chargeResult.Failure)
{
_logger.Log(chargeResult.Error);
return Error(chargeResult.Error);
}
Customer customer = new Customer(customerNameResult.Value);
Result saveResult = _repository.Save(customer);
if (saveResult.Failure)
{
_paymentGateway.RollbackLastTransaction();
_logger.Log(saveResult.Error);
return Error(saveResult.Error);
}
_emailSender.SendGreetings(customerNameResult.Value);
return new HttpResponseMessage(HttpStatusCode.OK);
}
如你所見,現在ChargeCommission和Save函式回傳Result物件。
Result類別的目的很簡單,且和我們之前提到的Maybe monad很相似:它讓我們在不用看程式實作細節的狀況下瞭解程式,它的程式如下(為簡潔起見隱藏了一些細即):
public class Result
{
public bool Success { get; private set; }
public string Error { get; private set; }
public bool Failure { /* … */ }
protected Result(bool success, string error) { /* … */ }
public static Result Fail(string message) { /* … */ }
public static Result<T> Ok<T>(T value) { /* … */ }
}
public class Result<T> : Result
{
public T Value { get; set; }
protected internal Result(T value, bool success, string error)
: base(success, error)
{
/* … */
}
}
現在,我們可以使用函數式語言使用的相同原則,那是魔術發生的地方:
[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
Result<BillingInfo> billingInfoResult = BillingInfo.Create(billingInfo);
Result<CustomerName> customerNameResult = CustomerName.Create(name);
return Result.Combine(billingInfoResult, customerNameResult)
.OnSuccess(() => _paymentGateway.ChargeCommission(billingInfoResult.Value))
.OnSuccess(() => new Customer(customerNameResult.Value))
.OnSuccess(
customer => _repository.Save(customer)
.OnFailure(() => _paymentGateway.RollbackLastTransaction())
)
.OnSuccess(() => _emailSender.SendGreetings(customerNameResult.Value))
.OnBoth(result => Log(result))
.OnBoth(result => CreateResponseMessage(result));
}
如果你瞭解函數式語言,你可能會注意到OnSuccess擴充函式實際上就是Bind函式,我使用此命名以讓你知道它做了什麼。
OnSuccess函式實際上做的是檢查前一個Result實體,如果它是成功的,就執行傳入的委託,否則就回傳上一個結果。因此,這個鏈結繼續直到其中一個操作失敗。若失敗,其它的操作會被跳過。
OnFailure函式,你可能會猜測,只在前一個操作失敗時執行。它很適合當資料庫呼叫失敗時做為我們需執行的補償邏輯。
OnBoth函式放在鏈結的最後面,它主要用在當操作失敗時的日誌記錄並建立結果訊息。
所以這邊我們得到的結果是和原先相同的行為,但少了很多煩瑣的程式,你可以看到,程式流程更容易去遵循。
What about the CQS principle?
那麼命令-查詢分離的原則呢?上面描述的方法意味著使用回傳值(也就是,在我們的例子中的Result類別)即使函式本身是一個命令(即會變更物件的狀態)。和CQS有任何衝突嗎?
不,沒有。此外,這種方法以與CQS原則相同的方式都會增加程式的可讀性。它不僅讓你知道一個函式是命令或查詢,也可知道函式本身是否可能失敗。
故障設計擴展了從函式定義中得到的潛在資訊範圍。你現在有4個,而不是原先的兩個結果(命令的void和查詢所回傳的類型)。
1 函式是命令且不會失敗:
public void Save(Customer customer)
2 函式是查詢且不會失敗:
public Customer GetById(long id)
3 函式是命令且可能失敗:
public Result Save(Customer customer)
4 函式是查詢且可能失敗:
public Result<Customer> GetById(long id)
當我說一個函式不會失敗,不是指任何狀況下都不會失敗。當然,剛開始時總是會有無法預期到的例外發生。我的意思是函式被設計為總是成功的,即開發者會預期函式中不應丟出任何例外(有關預期和未預期的例外,請參閱Exceptions for flow control in C#)。
使用這種方法,例外成為他們最初想要的東西:它們代表你的系統有問題,從這時開始,它們是你建立軟體的很有用的助理。
結論
如果你想提高你的程式的可讀性,明確的顯示你的意圖是很重要的。引入Result類別可幫助你展示函式可能失敗與否。另一方面,OnSuccess,OnFailure和OnBoth函式,幫助您移除煩瑣的處理,以成為一個乾淨和簡潔的設計。
結合其它三種技術–不可變性,移除基本型別偏執和非空參考型別–這種方法引入了一個強大的編程模式,可以顯著地提高你的生產力。
原始碼
Source code for the Result, ResultExtensions classes and the usage example
Update
我已經建立了一個NuGet的套件,包含Result類別和其它擴充函式。如果你想要其它功能與符合你的使用,可在此處留言。
Written with StackEdit.
沒有留言:
張貼留言