DDD函式程式設計案例:戰勝軟體開發的複雜性! 戰勝方式本身有點複雜哦!

banq發表於2019-07-05

在經歷不同的專案之後,我注意到每個專案都存在一些常見問題,無論領域,架構,程式碼約定等等。這些問題並不具有挑戰性,我更專注於尋求解決方案:一些開發方法或程式碼約定或任何可以幫助我以防止這些問題發生的東西,所以我專注於有趣的東西。這就是本文的目標:描述這些問題並向您展示我發現的解決這些問題的工具和方法的組合。

我們面臨的問題
在開發軟體的過程中,我們遇到了很多困難:需求不明確,溝通不暢,開發過程不良等等。我們還面臨一些技術難題:遺留程式碼讓我們放慢腳步,縮放是棘手的,過去的一些錯誤決定突然讓我們感到震驚。
所有這些都可以被消除然後顯著減少,但是有一個基本問題你無能為力:系統的複雜性。
無論您是否理解,您正在開發的系統的想法總是很複雜。即使你正在製作一個CRUD應用程式,它總是有一些邊緣情況,一些棘手的事情,並且不時有人問“嘿,如果我這樣做會發生什麼事呢?” 你說“嗯,這是一個非常好的問題。” 那些棘手的案例,陰暗的邏輯,驗證和訪問管理 - 所有這些都提高你的思考。
假設領域專家和業務分析師團隊清晰地溝通併產生一致的要求。現在我們必須實現它們,在我們的程式碼中表達這個複雜的想法。現在程式碼是另一個系統,比我們想到的原始想法更復雜。怎麼會這樣?
面臨現實:技術限制迫使您在實施實際業務邏輯的基礎上處理高負載,資料一致性和可用性。
。正如您所看到的,任務非常具有挑戰性,現在我們需要適當的工具來處理它。程式語言只是另一種工具,就像其他工具一樣,它不僅僅是關於它的質量,更可能是適合工作的工具。你可能有最好的螺絲刀,但如果你需要把一些釘子釘在木頭上,那麼蹩腳的錘子會更好,對吧?可能需要更適合工作的工具。

技術方面
今天最流行的語言是物件導向的。當有人介紹OOP時,他們通常會使用例子:考慮一輛汽車,這是一個來自現實世界的物體。它具有品牌,重量,顏色,最大速度,當前速度等各種屬性。為了在我們的程式中反映這個物件,我們在一個類中收集這些屬性。屬性可以是永久的或可變的,它們一起形成該物件的當前狀態和可以變化的一些邊界。
然而,組合這些屬性是不夠的,因為我們必須檢查當前狀態是否有意義,例如當前速度不超過最大速度。為了確保我們將一些邏輯附加到此類,請將屬性標記為私有以防止任何人建立非法狀態。
正如您所看到的,物件是關於它們的內部狀態和生命週期。
因此,在這種情況下,OOP的這三個支柱是完全合理的:我們使用繼承來重用某些狀態操作,用於狀態保護的封裝和用於以相同方式處理類似物件的多型性。
作為預設值的可變性也是有意義的,因為在這種情況下,不可變物件就無法具備生命週期,也無法始終具有一個狀態。
當你看看這些典型Web應用程式時,它並不處理物件。我們程式碼中的幾乎所有東西都有永恆的生命或根本沒有適當的生命。兩種最常見的型別的“物件”如:UserService,EmployeeRepository或者無論你怎麼稱呼他們:一些型號/實體/ DTO。
服務在它們內部沒有邏輯狀態,它們會再次死亡並重新生成完全相同,我們只需使用新的資料庫連線重新建立依賴關係圖。實體和模型沒有附加任何行為,它們只是資料捆綁,它們的可變性無關緊要。因此,OOP的關鍵特性對於開發這種應用程式並不是很有用。
典型的Web應用程式中發生的是資料流:驗證,轉換,評估等。並且有一種適合這種工作的範例:函數語言程式設計。並且有一個證明:今天流行語言的所有現代特徵都來自那裡:async/await、lambdas和委託,反應式程式設計,有區別的聯合(在Swift或rust中的列舉,不要與java或.net中的列舉混淆),元組 - 所有來自FP。
在我深入研究之前,有一點是要做的。切換到新語言,尤其是新範例,是對開發人員的投資,因此也是對業務的投資。做愚蠢的投資不會給你帶來任何麻煩,但合理的投資可能會讓你無所事事。

我們擁有的工具以及他們給我們的東西
很多人喜歡靜態型別的語言。原因很簡單:編譯器負責繁瑣的檢查,例如將適當的引數傳遞給函式,正確構造實體等等。這些支票都是免費提供。至於編譯器無法檢查的東西,我們有一個選擇:希望最好或做一些測試。
編寫測試意味著金錢成本,而且每次測試不支援支付一次成本,你還必須維護它們。此外,人們變得草率,所以每隔一段時間我們就會得到假陽性和假陰性的測試結果。您必須編寫越多的測試,測試的平均質量才會越高。還有另一個問題:為了測試某些東西,你必須知道並記住那個東西應該被測試,但你的系統越大越容易錯過。
但是編譯器如果不允許您以靜態方式表達某些內容,則必須在執行時執行此操作。這不僅僅是關於型別系統,語法糖也非常重要,因為在一天結束時我們想要儘可能少地編寫程式碼,所以如果某些方法需要你寫十倍的行,那麼,沒有人會用它。
這就是為什麼你選擇的語言具有適合的特徵和技巧的重要性 。
最後,我們將看到一些程式碼來證明這一切。我碰巧是一名.NET開發人員,因此程式碼示例將在C#和F#中,但在其他流行的OOP和FP語言中,一般情況看起來或多或少相同。

讓編碼開始吧
我們將構建一個用於管理信用卡的Web應用程式。基本要求:

  • 建立/讀取使用者
  • 建立/閱讀信用卡
  • 啟用/取消啟用信用卡
  • 設定卡的每日限額
  • 充值平衡
  • 處理付款(考慮餘額,卡到期日,活動/停用狀態和每日限額)

為簡單起見,我們將為每個帳戶使用一張卡,我們將跳過授權。但對於其他人,我們將構建具有驗證,錯誤處理,資料庫和web api的功能強大的應用程式。讓我們開始我們的第一個任務:設計信用卡。首先,讓我們看看它在C#中會是什麼樣子:

public class Card
{
    public string CardNumber {get;set;}
    public string Name {get;set;}
    public int ExpirationMonth {get;set;}
    public int ExpirationYear {get;set;}
    public bool IsActive {get;set;}
    public AccountInfo AccountInfo {get;set;}
}

public class AccountInfo
{
    public decimal Balance {get;set;}
    public string CardNumber {get;set;}
    public decimal DailyLimit {get;set;}
}

但這還不夠,我們必須新增驗證,通常它會在某些內容中完成Validator,例如來自FluentValidation。規則很簡單:
  • 卡號是必需的,必須是16位數字符串。
  • 名稱是必需的,必須只包含字母,並且中間可以包含空格。
  • 月和年必須滿足邊界。
  • 當卡處於活動狀態時必須存在帳戶資訊,而當卡被停用時,帳戶資訊不存在。如果您想知道原因,那很簡單:當卡被停用時,不應該更改餘額或每日限額。


public class CardValidator : IValidator
{
    internal static CardNumberRegex = new Regex("^[0-9]{16}$");
    internal static NameRegex = new Regex("^[\w]+[\w ]+[\w]+$");

    public CardValidator()
    {
        RuleFor(x => x.CardNumber)
            .Must(c => !string.IsNullOrEmpty(c) && CardNumberRegex.IsMatch(c))
            .WithMessage("oh my");

        RuleFor(x => x.Name)
            .Must(c => !string.IsNullOrEmpty(c) && NameRegex.IsMatch(c))
            .WithMessage("oh no");

        RuleFor(x => x.ExpirationMonth)
            .Must(x => x >= 1 && x <= 12)
            .WithMessage("oh boy");
            
        RuleFor(x => x.ExpirationYear)
            .Must(x => x >= 2019 && x <= 2023)
            .WithMessage("oh boy");
            
        RuleFor(x => x.AccountInfo)
            .Null()
            .When(x => !x.IsActive)
            .WithMessage("oh boy");

        RuleFor(x => x.AccountInfo)
            .NotNull()
            .When(x => x.IsActive)
            .WithMessage("oh boy");
    }
}

現在這種方法存在一些問題:
  • 驗證類與實體型別的宣告分離了,這意味著要檢視我們必須在程式碼中導航的卡片的完整畫面,並在腦海中重新建立此影像。當它只發生一次時,這不是一個大問題,但是當我們必須為一個大專案中的每個實體做這件事時,那麼,它非常耗時。
  • 這種驗證不是強制性的,我們必須牢記在任何地方使用它。我們可以透過測試來確保這一點,但是再次,你必須在編寫測試時記住它。
  • 當我們想要在其他地方驗證卡號時,我們必須重新做同樣的事情。當然,我們可以將正規表示式保持在一個共同的位置,但我們仍然必須在每個驗證器中呼叫它。

在F#中,我們可以用不同的方式完成它:

// First we define a type for CardNumber with private constructor
// and public factory which receives string and returns `Result<CardNumber, string>`.
// Normally we would use `ValidationError` instead, but string is good enough for example
type CardNumber = private CardNumber of string
    with
    member this.Value = match this with CardNumber s -> s
    static member create str =
        match str with
        | (null|"") -> Error "card number can't be empty"
        | str ->
            if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok
            else Error "Card number must be a 16 digits string"

// Then in here we express this logic "when card is deactivated, balance and daily limit manipulations aren't available`.
// Note that this is way easier to grasp that reading `RuleFor()` in validators.
type CardAccountInfo =
    | Active of AccountInfo
    | Deactivated

// And then that's it. The whole set of rules is here, and it's described in a static way.
// We don't need tests for that, the compiler is our test. And we can't accidentally miss this validation.
type Card =
    { CardNumber: CardNumber
      Name: LetterString // LetterString is another type with built-in validation
      HolderId: UserId
      Expiration: (Month * Year)
      AccountDetails: CardAccountInfo }


當然,我們可以在C#中做一些事情。我們也可以建立CardNumber類,在其中丟擲ValidationException。但是與CardAccountInfo相關操作無法在C#中以簡單的方式完成。另一件事--C#嚴重依賴異常。有幾個問題:
  • 例外Exception有“go to”語義。有一刻你在這個方法,一會兒會跳到另外一個地方,捉摸不定, 你最終需要做一些全域性處理程式。
  • 它們不出現在方法簽名中。例外ValidationException或者InvalidUserOperationException等Exceptions 是方法約定的一部分,但在您閱讀具體實現之前,您是不知道這一點的。這是一個主要問題,因為通常你必須使用其他人編寫的程式碼,而不是隻讀取方法簽名,你必須一直導航到呼叫堆疊的底部,這需要花費很多時間。

這就是困擾我的事情:每當我實現一些新功能時,實現過程本身並不需要花費太多時間,其中大部分都涉及兩件事:

  • 閱讀其他人的程式碼並找出業務邏輯規則。
  • 確保沒有任何東西被打破。

這可能聽起來像是一個糟糕的程式碼設計的症狀,但同樣的事情即使在寫得很好的專案上也會發生。好的,但我們可以嘗試在C#中使用類似的Result的東西。最明顯的實現看起來像這樣:

public class Result<TOk, TError>
{
    public TOk Ok {get;set;}
    public TError Error {get;set;}
}

它是一個純垃圾,它不會阻止我們設定兩者都是Ok,Error,並允許完全忽略錯誤。正確的版本將是這樣的:

public abstract class Result<TOk, TError>
{
    public abstract bool IsOk { get; }

    private sealed class OkResult : Result<TOk, TError>
    {
        public readonly TOk _ok;
        public OkResult(TOk ok) { _ok = ok; }

        public override bool IsOk => true;
    }
    private sealed class ErrorResult : Result<TOk, TError>
    {
        public readonly TError _error;
        public ErrorResult(TError error) { _error = error; }

        public override bool IsOk => false;
    }

    public static Result<TOk, TError> Ok(TOk ok) => new OkResult(ok);
    public static Result<TOk, TError> Error(TError error) => new ErrorResult(error);

    public Result<T, TError> Map<T>(Func<TOk, T> map)
    {
        if (this.IsOk)
        {
            var value = ((OkResult)this)._ok;
            return Result<T, TError>.Ok(map(value));
        }
        else
        {
            var value = ((ErrorResult)this)._error;
            return Result<T, TError>.Error(value);
        }
    }

    public Result<TOk, T> MapError<T>(Func<TError, T> mapError)
    {
        if (this.IsOk)
        {
            var value = ((OkResult)this)._ok;
            return Result<TOk, T>.Ok(value);
        }
        else
        {
            var value = ((ErrorResult)this)._error;
            return Result<TOk, T>.Error(mapError(value));
        }
    }
}

很麻煩,對吧?我甚至沒有實現void版本Map和MapError。用法如下所示:

void Test(Result<int, string> result)
{
    var squareResult = result.Map(x => x * x);
}


不是很糟糕,呃?嗯,現在想象你有三個結果,並且當它們都是ok時,你要做些什麼。很討厭,所以這幾乎不是一個選擇。

F#版本:

// this type is in standard library, but declaration looks like this:
type Result<'ok, 'error> =
    | Ok of 'ok
    | Error of 'error
// and usage:
let test res1 res2 res3 =
    match res1, res2, res3 with
    | Ok ok1, Ok ok2, Ok ok3 -> printfn "1: %A 2: %A 3: %A" ok1 ok2 ok3
    | _ -> printfn "fail"


基本上,您必須選擇是否編寫合理數量的程式碼,但程式碼是模糊的,依賴於異常,反射,表示式和其他“魔術”,或者您編寫的程式碼更多,難以閱讀,但它更耐用直截了當。
當這樣的專案變得很大時,你就無法對抗它,而不是使用類似C#型別系統的語言。
讓我們考慮一個簡單的場景:你的程式碼庫中有一些實體已經有一段時間了。今天你想新增一個新的必填欄位。當然,您需要在建立此實體的任何位置初始化此欄位,但編譯器根本不會幫助您,因為類是可變的並且null是有效值。類似AutoMapper之類的庫讓它變得更難。

這種可變性允許我們在一個地方部分初始化物件,然後將其推送到其他地方並繼續初始化。這是另一個錯誤來源。

這就把我們帶到了這些問題:

  1. 為什麼我們真的需要從現代OOP切換出來?
  2. 我們為什麼要切換到FP?

回答第一個問題是在現代應用中使用通用的OOP語言會給你帶來很多麻煩,因為它們是為不同的目的而設計的。它可以節省您花在設計上的時間和金錢,以及應用程式的複雜性。
第二個答案是FP語言為您提供了一種簡單的方法來設計您的功能,使它們像時鐘一樣工作,如果新功能破壞現有邏輯,它會破壞程式碼,因此您立即就知道了。

但是這些答案還不夠。正如我的朋友在我們的一次討論中指出的那樣,當你不瞭解最佳實踐時,切換到FP將毫無用處。我們的大型行業製作了大量關於設計OOP應用程式的文章,書籍和教程,我們擁有OOP的生產經驗,因此我們知道對不同方法的期望。
不幸的是,函式程式設計不是這樣,所以即使你切換到FP,你的第一次嘗試很可能會很尷尬,當然也不會帶來你想要的結果:快速而輕鬆地開發複雜的系統。

嗯,這正是本文的內容。正如我所說,我們將構建類似生產的應用程式以檢視差異。

我們如何設計應用程式?
我在設計過程中使用了很多想法是借鑑了偉大的書籍Domain Modeling Made Functional中,所以我強烈建議你閱讀它。

這裡有完整的原始碼。當然,我不會把所有這些都放在這裡,所以我只是介紹一些關鍵點。

我們將有4個主要專案:業務層,資料訪問層,基礎設施,當然還有常見的common。

我們從建模域開始:此時我們不知道也不關心資料庫。這是有目的的,因為如果考慮到特定的資料庫我們會傾向於根據它來設計我們的領域,我們將這個實體 - 表關係帶入業務層,以後就會帶來問題。你只需要實現domain -> DAL一次對映,而錯誤的設計會不斷給我們帶來麻煩,直到我們修復它為止。

所以下面就是我們所要做的:我們建立一個名為CardManagement(非常有創意,我知道)的專案,並立即開啟設定<TreatWarningsAsErrors>true</TreatWarningsAsErrors>在專案檔案中。我們為什麼需要這個?好吧,我們將大量使用受歧視的聯合,當你進行模式匹配時,編譯器會給我們一個警告,如果我們沒有涵蓋所有可能的情況:

let fail result =
    match result with
    | Ok v -> printfn "%A" v
    // warning: Incomplete pattern matches on this expression. For example, the value 'Error' may indicate a case not covered by the pattern(s).


啟用此設定後,當我們擴充套件現有功能並希望在任何地方進行調整時,此程式碼將無法編譯,這正是我們所需要的。接下來我們要做的是建立模組CardDomain(它在靜態類中編譯)。在這個檔案中,我們描述了域型別,僅此而已。請記住,在F#中,程式碼和檔案順序很重要:預設情況下,您只能使用之前宣告的內容。

領域型別
我們之前開始定義我們的型別CardNumber,雖然我們需要更多實用Error而不僅僅是一個字串,所以我們將使用ValidationError。

type ValidationError =
    { FieldPath: string
      Message: string }

let validationError field message = { FieldPath = field; Message = message }

// Actually we should use here Luhn's algorithm, but I leave it to you as an exercise,
// so you can see for yourself how easy is updating code to new requirements.
let private cardNumberRegex = new Regex("^[0-9]{16}$", RegexOptions.Compiled)

type CardNumber = private CardNumber of string
    with
    member this.Value = match this with CardNumber s -> s
    static member create fieldName str =
        match str with
        | (null|"") -> validationError fieldName "card number can't be empty"
        | str ->
            if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok
            else validationError fieldName "Card number must be a 16 digits string"


那麼我們當然要定義Card,這是我們領域的核心。我們知道該卡具有一些永久屬性,如數字,到期日期和卡上的名稱,以及一些可更改的資訊,如餘額和每日限制,因此我們將這些可變資訊封裝在其他型別中:

type AccountInfo =
    { HolderId: UserId
      Balance: Money
      DailyLimit: DailyLimit }

type Card =
    { CardNumber: CardNumber
      Name: LetterString
      HolderId: UserId
      Expiration: (Month * Year)
      AccountDetails: CardAccountInfo }


現在,這裡有幾種型別,我們還沒有宣告定義:
  • Money
    我們可以使用decimal,但decimal描述性較差。此外,它可以用於表示除金錢之外的其他東西,我們不希望它被混淆。所以我們使用自定義型別type [<Struct>] Money = Money of decimal 。
  • DailyLimit
    每日限額可以設定為特定數量,也可以根本不存在。如果它存在,它必須是積極的,而不只是使用decimal或Money,我們定義此型別:

     [<Struct>]
     type DailyLimit =
         private // private constructor so it can't be created directly outside of module
         | Limit of Money
         | Unlimited
         with
         static member ofDecimal dec =
             if dec > 0m then Money dec |> Limit
             else Unlimited
         member this.ToDecimalOption() =
             match this with
             | Unlimited -> None
             | Limit limit -> Some limit.Value
    

    0M表示你不能在這張卡上花錢了, 唯一的問題是因為我們隱藏了建構函式,所以我們無法進行模式匹配。但不用擔心,我們可以使用Active Patterns

    let (|Limit|Unlimited|) limit =
         match limit with
         | Limit dec -> Limit dec
         | Unlimited -> Unlimited
    

    現在我們可以作為常規DU到處模式匹配DailyLimit。

 
  • LetterString
    很簡單。我們使用CardNumber中相同的技術。但有一件事:LetterString幾乎無關信用卡,我們應該遷移到CommonTypes模組中的Common專案中,也是時候將ValidationError遷移到不同的地方。
  • UserId
    只是一個別名type UserId = System.Guid,我們僅將其用於描述性。
  • 月和年
    放入common


現在讓我們完成我們的域型別宣告。我們需要User一些使用者資訊和卡片收集,我們需要對充值和付款進行餘額操作。

   type UserInfo =
        { Name: LetterString
          Id: UserId
          Address: Address }

    type User =
        { UserInfo : UserInfo
          Cards: Card list }

    [<Struct>]
    type BalanceChange =
        | Increase of increase: MoneyTransaction // another common type with validation for positive amount
        | Decrease of decrease: MoneyTransaction
        with
        member this.ToDecimal() =
            match this with
            | Increase i -> i.Value
            | Decrease d -> -d.Value

    [<Struct>]
    type BalanceOperation =
        { CardNumber: CardNumber
          Timestamp: DateTimeOffset
          BalanceChange: BalanceChange
          NewBalance: Money }


現在我們可以進入業務邏輯了!

業務邏輯
我們在這裡有一個牢不可破的規則:所有業務邏輯都將用純函式編碼。純函式是滿足以下標準的函式:

  • 它唯一能做的就是計算輸出值。它根本沒有副作用。
  • 它總是為同一輸入產生相同的輸出。

因此,純函式不會丟擲異常,不會產生隨機值,也不會以任何形式與外部世界互動,無論是資料庫還是簡單DateTime.Now。當然,與不純函式互動會自動使呼叫函式不純。那麼我們應該實施什麼呢?
以下列出了我們的需求:
  • 啟用/停用卡
  • 處理付款
    我們可以處理付款,如果:
    1. 卡未過期
    2. 卡片有效
    3. 有足夠的錢支付
    4. 今天的支出未超過每日限額。
  • 充值平衡
    我們可以為有效卡和未過期卡充值餘額。
  • 設定每日限額
    如果卡未過期且處於活動狀態,使用者可以設定每日限額。

當操作無法完成時,我們必須返回錯誤,因此我們需要定義OperationNotAllowedError:

type OperationNotAllowedError =
        { Operation: string
          Reason: string }

    // and a helper function to wrap it in `Error` which is a case for `Result<'ok,'error> type
    let operationNotAllowed operation reason = { Operation = operation; Reason = reason } |> Error


在這個模組中業務邏輯是:我們返回的唯一的錯誤型別。我們不在這裡進行驗證,不與資料庫互動 - 只要我們可以返回就好,否則返回OperationNotAllowedError。

完整模組可以在這裡找到,我在這裡列出最棘手的案例:processPayment:我們必須檢查到期,活動/停用狀態,今天花費的金額和當前餘額。由於我們無法與外部世界互動,我們必須將所有必要的資訊作為引數傳遞。這樣,這個邏輯很容易測試,並允許您進行基於屬性的測試

let processPayment (currentDate: DateTimeOffset) (spentToday: Money) card (paymentAmount: MoneyTransaction) =
        // first check for expiration
        if isCardExpired currentDate card then
            cardExpiredMessage card.CardNumber |> processPaymentNotAllowed
        else
        // then active/deactivated
        match card.AccountDetails with
        | Deactivated -> cardDeactivatedMessage card.CardNumber |> processPaymentNotAllowed
        | Active accInfo ->
            // if active then check balance
            if paymentAmount.Value > accInfo.Balance.Value then
                sprintf "Insufficent funds on card %s" card.CardNumber.Value
                |> processPaymentNotAllowed
            else
            // if balance is ok check limit and money spent today
            match accInfo.DailyLimit with
            | Limit limit when limit < spentToday + paymentAmount ->
                sprintf "Daily limit is exceeded for card %s with daily limit %M. Today was spent %M"
                    card.CardNumber.Value limit.Value spentToday.Value
                |> processPaymentNotAllowed
            (*
            We could use here the ultimate wild card case like this:
            | _ ->
            but it's dangerous because if a new case appears in `DailyLimit` type,
            we won't get a compile error here, which would remind us to process this
            new case in here. So this is a safe way to do the same thing.
            *)
            | Limit _ | Unlimited ->
                let newBalance = accInfo.Balance - paymentAmount
                let updatedCard = { card with AccountDetails = Active { accInfo with Balance = newBalance } }
                // note that we have to return balance operation, so it can be stored to DB later.
                let balanceOperation =
                    { Timestamp = currentDate
                      CardNumber = card.CardNumber
                      NewBalance = newBalance
                      BalanceChange = Decrease paymentAmount }
                Ok (updatedCard, balanceOperation)


(banq注:從這麼多註釋來看,這種實現方法本身就有複雜性,因為複雜了才需要解釋,簡單的東西需要解釋嗎?)
spentToday- 我們必須從BalanceOperation集合中計算出來,我們將保留它在資料庫中。所以我們需要模組做這事,它基本上有1個公共函式:

 let private isDecrease change =
        match change with
        | Increase _ -> false
        | Decrease _ -> true

    let spentAtDate (date: DateTimeOffset) cardNumber operations =
        let date = date.Date
        let operationFilter { CardNumber = number; BalanceChange = change; Timestamp = timestamp } =
            isDecrease change && number = cardNumber && timestamp.Date = date
        let spendings = List.filter operationFilter operations
        List.sumBy (fun s -> -s.BalanceChange.ToDecimal()) spendings |> Money



好。現在我們已經完成了所有業務邏輯實現,是時候考慮對映了。

上下文對映
我們的很多型別使用有區別的聯合(discriminated unions),我們的某些型別沒有公共建構函式,所以我們不能將它們暴露給外部世界。我們需要處理(反)序列化。除此之外,現在我們的應用程式中只有一個有界的上下文,但是在現實生活中你需要構建一個具有多個有界上下文的更大系統,並且它們必須透過公共合同相互互動,這應該是可理解的。

我們必須做兩種方式對映:從公共模型到領域,模型可能有無效資料,其中我們使用了可以序列化為json的普通型別。別擔心,我們必須在該對映中構建我們的驗證。事實上,我們對可能無效的資料和資料使用不同的型別,這意味著總是進行校驗,編譯器不會讓我們忘記執行驗證。

  // You can use type aliases to annotate your functions. This is just an example, but sometimes it makes code more readable
    type ValidateCreateCardCommand = CreateCardCommandModel -> ValidationResult<Card>
    let validateCreateCardCommand : ValidateCreateCardCommand =
        fun cmd ->
        // that's a computation expression for `Result<>` type.
        // Thanks to this we don't have to chose between short code and strait forward one,
        // like we have to do in C#
        result {
            let! name = LetterString.create "name" cmd.Name
            let! number = CardNumber.create "cardNumber" cmd.CardNumber
            let! month = Month.create "expirationMonth" cmd.ExpirationMonth
            let! year = Year.create "expirationYear" cmd.ExpirationYear
            return
                { Card.CardNumber = number
                  Name = name
                  HolderId = cmd.UserId
                  Expiration = month,year
                  AccountDetails =
                     AccountInfo.Default cmd.UserId
                     |> Active }
        }


對映和驗證全模組是在這裡和對映模型模組這裡

與外界互動
在這一點上,我們已經實現了所有業務邏輯,對映,驗證等等,到目前為止,所有這些都與現實世界完全隔離:它完全是用純函式編寫的。現在你可能想知道,我們究竟要怎樣使用這些純函式?因為我們確實需要與外界互動。更重要的是,在工作流程執行期間,我們必須根據這些真實世界的互動結果做出一些決定。所以問題是我們如何組裝所有這些?在OOP中,他們使用IoC容器來處理它,但在這裡我們不能這樣做,因為我們甚至沒有物件,我們有靜態函式。

我們將用Interpreter pattern直譯器模式!我會盡力解釋這種模式。首先,我們來談談函式構成:例如,我們有一個函式int -> string。這意味著函式需要int作為引數並返回字串。現在讓我們說我們有另一個功能string -> char,我們可以連結它們兩個了,即執行第一個,獲取它的輸出並將其提供給第二個函式,甚至還有一個運算子:>>。以下是它的工作原理:

let intToString (i: int) = i.ToString()
let firstCharOrSpace (s: string) =
    match s with
    | (null| "") -> ' '
    | s -> s.[0]

let firstDigitAsChar = intToString >> firstCharOrSpace

// And you can chain as many functions as you like
let alwaysTrue = intToString >> firstCharOrSpace >> Char.IsDigit


但是,在某些情況下我們不能使用簡單的連結,例如啟用卡。這是一系列動作:
  • 驗證輸入卡號。如果它有效,那麼
  • 嘗試透過這個號碼獲得卡。如果有的話
  • 啟用它。
  • 儲存結果。如果沒關係的話
  • 對映到模型並返回。

前兩步有這個If it's ok then...。這就是直接連結不起作用的原因。
我們可以簡單地將這些函式作為引數注入,如下所示:

let activateCard getCardAsync saveCardAsync cardNumber = ...


但是這方面存在一些問題。
  • 首先,依賴項的數量可能會變大,函式簽名看起來會很難看;
  • 第二,我們在這裡是繫結到具體結果,如果它是一個Task或Async或只是簡單的同步呼叫,因此我們必須進行選擇。
  • 第三,當你有許多函式可以透過時很容易搞砸:例如createUserAsync並且replaceUserAsync具有相同的簽名但效果不同,所以當你必須傳遞數百次時,你可能會產生真正奇怪的症狀。

由於這些原因,我們需要直譯器模式。
我們的想法是將組合程式碼分為兩部分:執行樹和該樹的直譯器。這個樹中的每個節點都是一個我們想要注入的函式的地方。
比如getUserFromDatabase,這些節點是由名稱定義,又例如getCard的輸入引數型別,又例如CardNumber返回型別,還有Card option。我們沒有在這裡指定Task或者Async,這不是樹的一部分,它是直譯器的一部分。
該樹的每個邊緣都是一系列純轉換,如驗證或業務邏輯函式執行。邊緣也有一些輸入,例如原始的字串卡號,然後有驗證,這可以給我們一個錯誤或有效的卡號。如果有錯誤,我們將中斷該邊緣,如果沒有,它會引導我們到下一個節點:getCard。如果此節點將返回一些card,我們可以繼續下一個邊緣(樹的末枝葉),這將是啟用,依此類推。

對於每個場景activateCard,processPayment或者topUp我們要構建一個單獨的樹。當這些樹被構建時,它們的節點有點空白,它們沒有真正的函式,它們只是代表這些函式的位置。直譯器的目標是填充那些節點,就像那樣簡單。直譯器知道我們使用的效果,例如Task,它知道在給定節點中放置哪個真實函式。當它訪問一個節點時,它執行相應的實函式,等待Task或者等待它Async,並將結果傳遞給下一個邊緣。該邊緣可能會導致另一個節點,然後它再次為直譯器工作,直到這個直譯器到達停止節點,我們遞迴的底部,我們只返回樹的整個執行結果。

整個樹將用有區別的聯合表示,一個節點看起來像這樣:

 type Program<'a> =
        | GetCard of CardNumber * (Card option -> Program<'a>) // <- THE NODE
        | ... // ANOTHER NODE



它總是一個元組,其中第一個元素是依賴項的輸入,最後一個元素是一個函式,它接收該依賴項的結果。

由於我們處於有界上下文中,因此我們不應該有太多的依賴關係,如果我們這樣做 - 可能是將上下文拆分為較小的上下文的時候了。
這是它的樣子,完整的來源在這裡

type Program<'a> =
        | GetCard of CardNumber * (Card option -> Program<'a>)
        | GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>)
        | CreateCard of (Card*AccountInfo) * (Result<unit, DataRelatedError> -> Program<'a>)
        | ReplaceCard of Card * (Result<unit, DataRelatedError> -> Program<'a>)
        | GetUser of UserId * (User option -> Program<'a>)
        | CreateUser of UserInfo * (Result<unit, DataRelatedError> -> Program<'a>)
        | GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'a>)
        | SaveBalanceOperation of BalanceOperation * (Result<unit, DataRelatedError> -> Program<'a>)
        | Stop of 'a

    // This bind function allows you to pass a continuation for current node of your expression tree
    // the code is basically a boiler plate, as you can see.
    let rec bind f instruction =
        match instruction with
        | GetCard (x, next) -> GetCard (x, (next >> bind f))
        | GetCardWithAccountInfo (x, next) -> GetCardWithAccountInfo (x, (next >> bind f))
        | CreateCard (x, next) -> CreateCard (x, (next >> bind f))
        | ReplaceCard (x, next) -> ReplaceCard (x, (next >> bind f))
        | GetUser (x, next) -> GetUser (x,(next >> bind f))
        | CreateUser (x, next) -> CreateUser (x,(next >> bind f))
        | GetBalanceOperations (x, next) -> GetBalanceOperations (x,(next >> bind f))
        | SaveBalanceOperation (x, next) -> SaveBalanceOperation (x,(next >> bind f))
        | Stop x -> f x


    // this is a set of basic functions. Use them in your expression tree builder to represent dependency call
    let stop x = Stop x
    let getCardByNumber number = GetCard (number, stop)
    let getCardWithAccountInfo number = GetCardWithAccountInfo (number, stop)
    let createNewCard (card, acc) = CreateCard ((card, acc), stop)
    let replaceCard card = ReplaceCard (card, stop)
    let getUserById id = GetUser (id, stop)
    let createNewUser user = CreateUser (user, stop)
    let getBalanceOperations (number, fromDate, toDate) = GetBalanceOperations ((number, fromDate, toDate), stop)
    let saveBalanceOperation op = SaveBalanceOperation (op, stop)


藉助計算表示式,我們現在可以非常輕鬆地構建工作流程,而無需關心實際互動的實現。我們在CardWorkflow模組中這樣做:

  // `program` is the name of our computation expression.
    // In every `let!` binding we unwrap the result of operation, which can be
    // either `Program<'a>` or `Program<Result<'a, Error>>`. What we unwrap would be of type 'a.
    // If, however, an operation returns `Error`, we stop the execution at this very step and return it.
    // The only thing we have to take care of is making sure that type of error is the same in every operation we call
    let processPayment (currentDate: DateTimeOffset, payment) =
        program {
            (* You can see these `expectValidationError` and `expectDataRelatedErrors` functions here.
               What they do is map different errors into `Error` type, since every execution branch
               must return the same type, in this case `Result<'a, Error>`.
               They also help you quickly understand what's going on in every line of code:
               validation, logic or calling external storage. *)
            let! cmd = validateProcessPaymentCommand payment |> expectValidationError
            let! card = tryGetCard cmd.CardNumber
            let today = currentDate.Date |> DateTimeOffset
            let tomorrow = currentDate.Date.AddDays 1. |> DateTimeOffset
            let! operations = getBalanceOperations (cmd.CardNumber, today, tomorrow)
            let spentToday = BalanceOperation.spentAtDate currentDate cmd.CardNumber operations
            let! (card, op) =
                CardActions.processPayment currentDate spentToday card cmd.PaymentAmount
                |> expectOperationNotAllowedError
            do! saveBalanceOperation op |> expectDataRelatedErrorProgram
            do! replaceCard card |> expectDataRelatedErrorProgram
            return card |> toCardInfoModel |> Ok
        }


(banq注:我去,這麼複雜,這麼多解釋,還是找回SQL來實現吧)

這個模組是我們在業務層中實現的最後一件事。此外,我做了一些重構:我將錯誤和常見型別移動到Common專案。大約時間我們開始實施資料訪問層。

資料訪問層
此層中實體的設計可能取決於我們用於與之互動的資料庫或框架。因此,域層對這些實體一無所知,這意味著我們必須在這裡處理與域模型之間的對映。這對我們的DAL API的消費者來說非常方便。對於這個應用程式,我選擇了MongoDB,不是因為它是這類任務的最佳選擇,而是因為有很多使用SQL DB的例子,我想新增不同的東西。我們將使用C#驅動程式。

(點選標題參考原文.net中相關具體技術描述)

基礎設施層
記住,我們不會使用DI框架,我們選擇瞭直譯器模式。如果你想知道原因,這裡有一些原因:

  • IoC容器在執行時執行。因此,在執行程式之前,您無法知道所有依賴項都已滿足。
  • 它是一個非常容易被濫用的強大工具:你可以進行屬性注入,使用延遲依賴,有時甚至一些業務邏輯可以找到依賴註冊/解析的方式(是的,我見過它)。所有這些都使程式碼維護變得非常困難。

這意味著我們需要一個適合該功能的地方。我們可以把它放在我們的Web Api的頂層,但在我看來它不是一個最好的選擇:現在我們只處理一個有界的上下文,但如果有更多的話,這個全域性的地方將為每個上下文提供所有直譯器變得累贅。此外,還有單一的責任規則,web api專案應該對web負責,對吧?所以我們建立了CardManagement.Infrastructure專案
在這裡,我們將做幾件事:
  • 撰寫我們的功能
  • 應用配置
  • 記錄

如果我們有超過1個上下文,應該將應用程式配置和日誌配置移動到全域性基礎架構專案,並且此專案中唯一發生的事情是為我們的有界上下文組裝API,但在我們的情況下,這種分離是不必要的。

...(此處節省有關直譯器模式,個人認為引入直譯器模式導致這個解決方案格外複雜,有興趣點選標題進入原文理解)

日誌記錄並不是很棘手,但你可以在這個模組中找到它。方法是我們將函式包裝在日誌記錄中:我們記錄函式名稱,引數和日誌結果。
最後一件事是建立一個外觀fascade模式,因為我們不想暴露原始的直譯器呼叫。這是整個事情:

 let createUser arg =
        arg |> (CardWorkflow.createUser >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createUser")
    let createCard arg =
        arg |> (CardWorkflow.createCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createCard")
    let activateCard arg =
        arg |> (CardWorkflow.activateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.activateCard")
    let deactivateCard arg =
        arg |> (CardWorkflow.deactivateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.deactivateCard")
    let processPayment arg =
        arg |> (CardWorkflow.processPayment >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.processPayment")
    let topUp arg =
        arg |> (CardWorkflow.topUp >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.topUp")
    let setDailyLimit arg =
        arg |> (CardWorkflow.setDailyLimit >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.setDailyLimit")
    let getCard arg =
        arg |> (CardWorkflow.getCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.getCard")
    let getUser arg =
        arg |> (CardWorkflow.getUser >> CardProgramInterpreter.interpretSimple |> logifyResultAsync "CardApi.getUser")


這裡注入所有依賴項,記錄日誌,不丟擲任何異常 - 就是這樣。對於web api,我使用了Giraffe框架。Web專案就在這裡

結論
我們已經構建了一個帶有驗證,錯誤處理,日誌記錄,業務邏輯的應用程式 - 您通常在應用程式中擁有的所有內容。不同之處在於此程式碼更耐用且易於重構。
請注意,我們沒有使用反射或程式碼生成,沒有Exception例外,但我們的程式碼仍然不詳細。它易於閱讀,易於理解且難以破解。只要在模型中新增另一個欄位,或者在我們的某個聯合型別中新增另一個案例,程式碼就會在您更新每個用法之後才會編譯。
當然,這並不意味著您完全安全,或者您根本不需要任何型別的測試,這隻意味著您在開發新功能或進行重構時遇到的問題會更少。開發過程既便宜又有趣,
另一件事:我沒有聲稱OOP完全沒用,我們不需要它,但事實並非如此。我說我們不需要它來解決我們所擁有的每一項任務,而且我們的任務的很大一部分可以透過FP更好地解決。事實上,事實總是處於平衡狀態:我們無法僅使用一種工具有效地解決所有問題,因此良好的程式語言應該對FP和OOP提供良好的支援。不幸的是,今天許多最流行的語言只有lambdas和函式世界的非同步程式設計。

相關文章