2.02.2017

Functional C#: Handling failures, input errors(譯)

之前的文章有提到這位作者(Vladimir Khorikov)對函數式編程的有趣概念,趁目前有空順便翻譯下,原文:Functional C#: Handling failures, input errors

本文描述的主題是我的Pluralsight課程的一部份–Applying Functional Principles in C#

這篇文章我將介紹如何以函數式的方法來處理錯誤及無效的輸入。

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.

沒有留言:

張貼留言