2.02.2017

Functional C#: Primitive obsession(譯)

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

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

這是我的blog上Functional C#系列的第二篇文章。

什麼是基本型別偏執?

基本型別偏執表示使用基本型別來建立領域模型。舉例來說,在典型的C#應用程式中,Customer類別可能看起來會像:

public class Customer
{
    public string Name { get; private set; }
    public string Email { get; private set; }

    public Customer(string name, string email)
    {
        Name = name;
        Email = email;
    }
}

這裡的問題是,當你想對你的領域模式強制執行驗證規則時,無法避免地,你會將驗證邏輯放在程式中的每個地方:

public class Customer
{
    public string Name { get; private set; }
    public string Email { get; private set; }

    public Customer(string name, string email)
    {
        // Validate name
        if (string.IsNullOrWhiteSpace(name) || name.Length > 50)
            throw new ArgumentException(“Name is invalid”);

        // Validate e-mail
        if (string.IsNullOrWhiteSpace(email) || email.Length > 100)
            throw new ArgumentException(“E-mail is invalid”);
        if (!Regex.IsMatch(email, @”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”))
            throw new ArgumentException(“E-mail is invalid”);

        Name = name;
        Email = email;
    }

    public void ChangeName(string name)
    {
        // Validate name
        if (string.IsNullOrWhiteSpace(name) || name.Length > 50)
            throw new ArgumentException(“Name is invalid”);

        Name = name;
    }

    public void ChangeEmail(string email)
    {
        // Validate e-mail
        if (string.IsNullOrWhiteSpace(email) || email.Length > 100)
            throw new ArgumentException(“E-mail is invalid”);
        if (!Regex.IsMatch(email, @”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”))
            throw new ArgumentException(“E-mail is invalid”);

        Email = email;
    }
}

此外,相同的驗證邏輯可能會在整個應用層可見:

[HttpPost]
public ActionResult CreateCustomer(CustomerInfo customerInfo)
{
    if (!ModelState.IsValid)
        return View(customerInfo);

    Customer customer = new Customer(customerInfo.Name, customerInfo.Email);
    // Rest of the method
}


public class CustomerInfo
{
    [Required(ErrorMessage = “Name is required”)]
    [StringLength(50, ErrorMessage = “Name is too long”)]
    public string Name { get; set; }

    [Required(ErrorMessage = “E-mail is required”)]
    [RegularExpression(@”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”,
        ErrorMessage = “Invalid e-mail address”)]
    [StringLength(100, ErrorMessage = “E-mail is too long”)]
    public string Email { get; set; }
}

顯然,這種方式打破了DRY原則–我們只需單一來源的真理。這表示在你程式中的每一種領域知識應只有同一個驗證的來源,上面的範例中,至少有三個。

如何去除基本型別偏執?

為了去除基本型別偏執,我們需要引入兩個新的類別,它們可以聚合整個應用程式中的所有驗證邏輯:

public class Email
{
    private readonly string _value;

    private Email(string value)
    {
        _value = value;
    }

    public static Result<Email> Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
            return Result.Fail<Email>(“E-mail can’t be empty”);

        if (email.Length > 100)
            return Result.Fail<Email>(“E-mail is too long”);

        if (!Regex.IsMatch(email, @”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”))
            return Result.Fail<Email>(“E-mail is invalid”);

        return Result.Ok(new Email(email));
    }

    public static implicit operator string(Email email)
    {
        return email._value;
    }

    public override bool Equals(object obj)
    {
        Email email = obj as Email;

        if (ReferenceEquals(email, null))
            return false;

        return _value == email._value;
    }

    public override int GetHashCode()
    {
        return _value.GetHashCode();
    }
}

public class CustomerName
{
    public static Result<CustomerName> Create(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            return Result.Fail<CustomerName>(“Name can’t be empty”);

        if (name.Length > 50)
            return Result.Fail<CustomerName>(“Name is too long”);

        return Result.Ok(new CustomerName(name));
    }

    // The rest is the same as in Email
}

這種方法的優點是,每當驗證邏輯(或附加到這些類別的任何其他邏輯)發生變化時,你只需要在一個地方更改它。你的重複次數越少,得到的錯誤越少,你的客戶就越快樂!

請注意,Email類中的建構函式是封閉的,所以建立Email的唯一方法是使用Create函式,它執行所有需要的驗證。這樣子做,我們確保
Email實體從一開始就處於有效狀態,並且滿足所有不變量。

這是是控制器中如何使用這些類別的方式:

[HttpPost]
public ActionResult CreateCustomer(CustomerInfo customerInfo)
{
    Result<Email> emailResult = Email.Create(customerInfo.Email);
    Result<CustomerName> nameResult = CustomerName.Create(customerInfo.Name);

    if (emailResult.Failure)
        ModelState.AddModelError(“Email”, emailResult.Error);
    if (nameResult.Failure)
        ModelState.AddModelError(“Name”, nameResult.Error);

    if (!ModelState.IsValid)
        return View(customerInfo);

    Customer customer = new Customer(nameResult.Value, emailResult.Value);
    // Rest of the method
}

Result<Email>Result<CustomerName>的實體明確的告訴我們Create函式可能會失敗,而如果失敗,我們可以透過檢查Error屬性來瞭解原因。

在重構後,Customer類別看起來會像:

public class Customer
{
    public CustomerName Name { get; private set; }
    public Email Email { get; private set; }

    public Customer(CustomerName name, Email email)
    {
        if (name == null)
            throw new ArgumentNullException(“name”);
        if (email == null)
            throw new ArgumentNullException(“email”);

        Name = name;
        Email = email;
    }

    public void ChangeName(CustomerName name)
    {
        if (name == null)
            throw new ArgumentNullException(“name”);

        Name = name;
    }

    public void ChangeEmail(Email email)
    {
        if (email == null)
            throw new ArgumentNullException(“email”);

        Email = email;
    }
}

基乎所有驗證都已移至Email和CustomerNam類別,剩下的唯一檢查是Null檢查,它們仍然很煩索,但我們在下一章會知道一個更好的處理的方式。

所以,在移除基本型別偏執後有什麼好處?

  • 我們為每一個領域問題建立一個單一的驗證來源,沒有重覆,只有清楚及dry的程式。
  • 更強的型別系統,編譯器為我們提供了雙重效果:現在不可能錯誤的將一個email指定給customer name,這會發生編譯錯誤。
  • 不需要驗證傳入值,如果我們得到了一個Email或CustomerName的物件,我們可以百分百確認它處於正確的狀態。

我想指出另一個細節,一些人喜歡在單一操作中多次wrap和unwrap基本型別:

public void Process(string oldEmail, string newEmail)
{
    Result<Email> oldEmailResult = Email.Create(oldEmail);
    Result<Email> newEmailResult = Email.Create(newEmail);

    if (oldEmailResult.Failure || newEmailResult.Failure)
        return;

    string oldEmailValue = oldEmailResult.Value;
    Customer customer = GetCustomerByEmail(oldEmailValue);
    customer.Email = newEmailResult.Value;
}

與其這樣,最好是在整個應用程式中使用自定型別,只有在它們離開了當前的領域邊界時(例:儲存至資料庫或呈現為HTML)再對它們unwrap。在你的領域類別中,儘可能的使用它們,你會得到一個更清楚及更可維護的程式:

public void Process(Email oldEmail, Email newEmail)
{
    Customer customer = GetCustomerByEmail(oldEmail);
    customer.Email = newEmail;
}

另一方面:限制

不幸的是,在C#中建立的自定類別並不像函數式語言F#中那樣的簡潔。 That probably will be changed in C# 7 if we get record types and pattern matching, 但在那之前,我們要處理因這目標帶來的笨重。

因此,我發現一些簡單的基本型別不值得包裝,例如,用單一不可變量表示價格不能為負的金額仍然可以以decimal來表示,這會導致一些驗證邏輯的重覆,但–再次的說–在長期來看,這仍不失為一個較簡單的設計決策。

如之前所述,在每種狀況下評估其優缺點,不要猶豫去改變你的想法,即使會反覆多次。

結論

靠著使用不可變性及非基本型別,我們更接近以函數式思考來設計C#應用程式,下一章,我將會展示如何減輕billion dollar mistake

原始碼

Written with StackEdit.

沒有留言:

張貼留言