源码

如何战胜软件开发的复杂性?


【CSDN编者按】在开发软件的过程中,我们会遇到很多困难,例如需求不明确、沟通不畅、开发过程不顺利等等。此外,我们还面临一些技术难题,比如遗留代码拖后腿、棘手的规模扩展、遇到一些以往的错误决定。所有这些问题都可以得到解决或减少,但是有一个我们无能为力的基本问题:系统的复杂性。在本文中,我们将以构建一个管理信用卡的 Web 应用程序为例,手把手教你如何编写一套更耐用且易于重构的代码。

photo-1510915228340-29c85a43dcfe (1).jpg

作者 | Anish Karandikar、Roman

译者 | 弯月,责编 | 屠敏

出品 | CSDN(ID:CSDNnews)

以下为译文:
在参与了多起项目之后,我注意到每个项目都存在一些共通的问题,不论项目的领域、架构、代码约定等等。虽然这些问题并不具有挑战性,只是一些乏味的例行程序,但目的是为了确保你不会犯任何明显的错误。我不想日复一日重复这些例行程序,我倒是很希望找到解决方案:一些开发方法、代码约定或其他任何可以帮助我避免这些问题发生的方式,这样在设计项目的时候,我就可以专心做自己感兴趣的工作了。这就是本文的目标:描述这些问题,并向你展示我解决这些问题的工具和方法。

我们面临的问题

在开发软件的过程中,我们会遇到很多困难,例如需求不明确、沟通不畅、开发过程不顺利等等。此外,我们还面临一些技术难题,比如遗留代码拖后腿、棘手的规模扩展、遇到一些以往的错误决定。所有这些问题都可以得到解决或减少,但是有一个我们无能为力的基本问题:系统的复杂性。无论你理解与否,你正在开发的系统总是很复杂。即使你只是在做一个烂大街的CRUD应用程序,也总是会遇到一些极端情况,一些棘手的事情,而且还有人不时地问:“如果我在这种情况下这样做会怎么样?”你可能会说:“嗯,这是一个好问题。容我想想……”你都需要仔细考虑那些棘手的案例,含糊不清的逻辑、验证和访问管理等等。我们常常遇到某个想法过于宏大,无法一个人独自策划,而这种情况往往会衍生出误解等问题。但是,假设这个领域专家和业务分析师团队清晰地沟通并产生了一致的需求。现在我们必须实现这些需求,通过代码表达这个复杂的想法。而代码是另一个系统,比我们原始的想法更复杂。怎么会这样?这就是现实:技术上的局限性迫使你在实现业务逻辑的基础之上,还要处理处理高负载、数据一致性和可用性。如你所见,这项任务的难度非常大,现在我们需要适当的工具来处理。编程语言只是一种工具,就像其他工具一样,你不仅需要考虑编程语言本身的质量,而且还要选择适合工作的工具。工欲善其事必先利其器!

技术介绍

如今,面向对象的编程语言很流行。当有人介绍OOP时,通常他们会使用这样一个例子:假设有一辆来自现实世界的汽车,汽车有品牌、重量、颜色、最大速度、当前速度等各项属性。为了在我们的程序中反映这个对象,我们需要建立一个类收集这些属性。属性可以是永久的或可变的,所有属性一起形成该对象的当前状态和变化的一些边界。然而,仅组合这些属性还不够,我们必须检查当前状态是否有意义,例如当前速度有没有超过最大速度。为了向这个类添加一些逻辑,我们需要将属性标记为私有以防止其他人创建非法状态。可见,对象的核心就是它们的内部状态和生命周期。因此,在这种情况下,OOP的三个支柱完全合理:使用继承来重用某些状态操作;通过封装保护状态;以相同方式处理类似对象的多态性。默认可修改也很合理,因为在这种情况下,不可变对象无法拥有生命周期,而且始终处于同一个状态,这并非是常态。
然而,如今常见的Web应用程序并不处理对象。我们代码中的所有东西都有永恒的生命或根本没有生命。两种最常见的“对象”类型是某种服务,比如UserService、EmployeeRepository、某些模型/实体/ DTO或任何服务。服务的内部没有逻辑状态,它们每次产生和消亡过程完全相同,我们只需建立新的数据库连接并重新创建依赖关系图。实体和模型没有任何附加行为,它们只是数据捆绑,它们的可修改性无关紧要。因此,在开发这种应用程序时,OOP的关键特性并没有用武之地。
常见的Web应用程序中需要处理的是数据流:验证、转换、评估等。适合这种工作的编程语言类型为函数式编程。事实证明,目前流行语言中的所有现代功能都来自:async / await、lambdas和delegates、响应式编程、可识别联合(swift或rust中的枚举,不要与java或.net中的枚举混淆)、元组——所有这些都来自函数式编程。然而,这些只是其中一部分,除此之外还有很多很多。 
在深入讨论之前,还需要交代一点。切换到新语言,尤其是新编程范式,是对开发人员的投资,也是对业务的投资。即便投资错误也不会带来任何麻烦,但合理的投资可以让你受益无穷。

工具介绍

很多人喜欢静态类型的编程语言。原因很简单:编译器负责繁琐的检查,例如将正确的参数传递给函数,正确地构造实体等等。这些检查都是自带的。至于编译器无法检查的东西,我们可以选择听天由命,或编写测试。编写测试意味着成本,而且每项测试都不是一次性的,你还需要付出维护的成本。此外,人们都比较粗心大意,所以每隔一段时间我们就会遇到假阳性和假阴性的测试结果。你编写的测试越多,测试的平均质量就越高。还有另一个问题:为了某些测试,你必须记住测试哪些功能,系统越大越容易漏掉某些功能。
然而,编译器只能发挥与语言的类型系统一样的功能。如果它不接受静态方式的表达,则必须在运行时执行此操作。这种情况下你也需要测试。这不仅仅与类型系统有关,语法和语法糖特征也非常重要,因为我们肯定希望编写尽可能少的代码,所以如果某些方法需要编写10倍的代码,那么就没人用。这就是为什么你需要选择具有适合的特征和技巧的语言。否则的话,你不仅无法利用语言的功能来对抗原有的困难(比如系统的复杂性和不断变化的要求),你还需要与语言本身作斗争。因为你需要为开发人员花费的时间支付费用,所以这一切归根到底都是白花花的银子。开发人员需要解决的问题越多,他们需要的时间就越多,你需要的开发人员就越多。
最后,让我们来看看实际的代码。我是一名.NET开发人员,因此以下代码示例将用C#和F#编写,但即使用其他流行的OOP和函数式编程语言编写,情况也差不多。

下面开始写代码

我们将构建一个管理信用卡的Web应用程序。基本需求如下:

  • 创建/读取用户
  • 创建/读取信用卡
  • 激活/停用信用卡
  • 设置信用卡的每日限额
  • 充值余额
  • 处理付款(考虑余额、信用卡到期时间、活动/停用状态和每日限额)
为了简单起见,我们假设每个账户仅有一张信用卡,我们将忽略授权功能。除了这一点之外,我们将构建一个具有验证、错误处理、数据库和web api等强大功能的应用程序。下面让我们开始动手做第一个任务:设计信用卡。首先,让我们看看C#的代码:
image.png
这些还不够,我们还必须添加验证,通常我们会通过一些Validator完成,就像FluentValidation一样。规则很简单:

  • 卡号是必输项,必须是16位数字符串。
  • 用户名是必输项,只能包含字母,并且中间可以包含空格。
  • 月和年必须满足边界。
  • 当卡处于活动状态时,账号信息必须存在;而当卡被停用时,账号信息不存在。原因很简单:当卡被停用时,余额或每日限额不应该允许更改。
image.pngimage.png
这种方法存在一些问题:

  • 验证与类型声明分离,这意味着如果想查看信用卡完整的信息,我们必须查看所有代码,并在脑海中想象完整的信用卡。如果这种事情只需要做一次的话就还好,但是在大项目中,我们必须为每个实体重复这种动作,所以非常耗时。
  • 上述验证不是强制性的,我们必须牢记在所有地方调用。我们可以通过测试来确保不会有遗漏,但是请不要忘记编写测试的开销。
  • 当我们想要在其他地方验证卡号时,我们必须重新编写相同的代码。当然,我们可以将正则表达式保持在一个共同的地方,但我们仍然需要在每个验证中调用。
在F#中,我们可以采用不同的方式:
// First we define a type for CardNumber with private constructor
// and public factory which receives string and returns `Result`.
// 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。但是我们无法在C#中利用CardAccountInfo的技巧。另外,C#非常依赖异常。这会引发几个问题:

  • 异常有“go to”的语义。前一秒你还在这个方法中,下一秒就在全局异常处理程序中了。
  • 这些异常不会出现在方法签名中。像ValidationException或InvalidUserOperationException这种异常都属于调用协议的一部分,但你只有阅读了实现之后才能得知这一点。这个问题很严重,因为你会经常使用其他人编写的代码,你不能只是阅读签名,所以不得不花费大量时间,一直浏览到调用栈的最底部。 
这个问题让我很烦恼:每当我实现一些新功能时,实现过程本身并不需要花费太多时间,大部分时间都花费在了两件事上:

  • 阅读其他人的代码,并找出业务逻辑规则。
  • 确保没有破坏任何东西。
听起来好像是因为代码设计不合理,但即使是编写良好的项目也会发生同样的事情。下面让我们尝试在C#中使用相同的Result。最简单的实现如下所示:

image.png

image.png然而,这段代码纯属垃圾,它不会阻止我们同时设置Ok和Error,而且还允许我们完全忽略错误。正确的编写方式如下:

image.png
image.png
这段代码很繁琐吧?而且我还没实现void版本的Map和MapError。调用方法大致如下:
void Test(Result<intstring> result)
{
    var squareResult = result.Map(x => x * x);
}
还行吧?现在想象你有三个result,如果它们都是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这样的库情况只会更糟。这种可变性允许我们在某个地方部分初始化对象,然后将其推送到其他地方并继续初始化。这是bug的多发地段。
当然,我们可以比较语言的功能,但这超出了本文的讨论范围。然而,语言功能本身不应成为更换技术的理由。 
于是,我们需要面对以下两个问题:

  1. 为什么我们确实需要舍弃现代OOP?
  2. 为什么我们需要切换到函数式编程?
第一个问题的答案是,在现代应用程序中使用通用的OOP语言会给你带来很多麻烦,因为这些语言的设计目的不同。选择这些语言,你不仅需要花费时间和金钱应对语言本身,还需要处理应用程序的复杂性。
第二个问题的答案是,函数式编程语言提供了一种简单的方法来设计功能,它们就像时钟一样,如果新功能破坏现有逻辑,代码遭到破坏,你会立刻知晓。
然而,这些答案还不够充分。正如我的朋友在一次讨论中指出的那样,如果你没有掌握最佳实践,那么切换到函数式编程也毫无用处。我们身处的大行业出产了大量关于设计OOP应用程序的文章、书籍和教程,而且我们拥有丰富OOP的生产经验,因此我们知道不同方法的预期结果。不幸的是,函数式编程并非如此,所以即使你切换到函数式编程,在第一次尝试中也可能遭遇尴尬,当然也不可能快速而轻松地开发复杂的系统。
本文就是想讨论这一点。我们可以通过构建类似生产的应用程序看看其中的差异。


如何设计应用程序?


在设计过程中,我借鉴了很多来自《Domain Modeling Made Functional》一书的想法,所以我强烈建议你阅读这本书。
点击这里获取完整的源代码(https://github.com/atsapura/CardManagement)。在本文中,我只会介绍一些关键点。
我们有4个主要项目:业务层,数据访问层,基础设施,当然还有(每个解决方案都有的)通用处理。我们从领域中的建模开始。此时我们不了解也不用理会数据库。因为如果我脑海中想着某个特定的数据库,我就会倾向于根据这个数据库做领域设计,从而导致将实体-表的关系带入业务层,到后面就会出问题。我们需要做的只是实现领域->DAL的映射,而错误的设计会不断给我们找麻烦,直到我们改掉这个错误。接下来我们的工作是:创建一个名为CardManagement的项目,然后立即打开项目文件中的true设置。为什么我们需要这个?因为我们将大量使用可识别联合,而在匹配模式时,如果我们没有涵盖所有可能的情况,编译器就会给我们一个警告:
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 }
现在,还有几种类型我们还没有声明:
1. Money
我们可以使用decimal ,但decimal的描述性不太好。此外,decimal 可用于表示金钱之外的其他东西,我们不希望造成混淆。所以我们使用自定义类型type [] Money = Money of decimal。
2. DailyLimit
每日限额可以设置为特定数量,也可能根本不存在。如果存在,那必须是正数。我们没有使用decimal或Money,而是定义了这种类型:
[]
 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默认代表没有限制更具描述性,因为0M也可能意味着你不能动这张卡上的钱。由于我们隐藏了构造函数,因此 唯一的问题是我们无法进行模式匹配。但不用担心,我们可以使用Active Patterns:
let (|Limit|Unlimited|) limit =
     match limit with
     | Limit dec -> Limit dec
     | Unlimited -> Unlimited
现在我们可以将DailyLimit作为常规的DU在任何地方做匹配。
3. LetterString
这个很简单。我们使用与CardNumber相同的处理。但注意,LetterString不是信用卡固有的,它是另一个东西,我们应该把它放入CommonTypes模块的Common项目中。因此我们还需要将ValidationError移动到不同的地方。
4. UserId
我们可以定义成:type UserId = System.Guid。这个类型仅仅为了提供描述性。
5. Month和Year
这两个字段也需要放在Common中。Month是一个可识别联合,还需要一个方法将它转换为unsigned int16,而Year与CardNumber一样,只不过需要把string换成uint16。
到这里为止,领域类型声明就完成了。下面,我们需要为User提供一些用户和卡片信息,我们需要在充值和付款的时候计算余额。
type UserInfo =
        { Name: LetterString
          Id: UserId
          Address: Address }

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

    []
    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

    []
    type BalanceOperation =
        { CardNumber: CardNumber
          Timestamp: DateTimeOffset
          BalanceChange: BalanceChange
          NewBalance: Money }
我们设计类型的方式无法表示无效状态。现在每当处理这些类型的实例时,我们都可以确定其中的数据是有效的,我们不必再次验证。下面我们来看看业务逻辑。
业务逻辑
我们有一个坚决不可违背的规则:所有业务逻辑用纯函数编写。纯函数是满足以下标准的函数:

  • 函数唯一的功能就是计算输出值。根本没有副作用。
  • 针对同一输入,函数始终会产生相同的输出。
因此,纯函数不会抛出异常,不会产生随机值,也不会以任何形式与外部世界交互,无论是数据库还是简单的DateTime.Now。当然,与不纯函数交互会自动导致调用函数不纯。那么我们应该如何实现呢?
我们的需求如下:

  • 激活/停用卡
  • 处理付款
    付款条件:
    卡未过期
    卡处于激活状态
    有足够的钱支付
    今天的支出未超过每日限额
  • 充值余额
    我们可以计算处于激活状态且未过期的信用卡余额。
  • 设定每日限额
    如果卡未过期且处于激活状态,则用户可以设置每日限额。
如果无法完成操作,则必须返回错误,因此我们需要定义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。
点击这里获取该模块完整的代码(https://github.com/atsapura/CardManagement/blob/master/CardManagement/CardActions.fs)。在这里,我们只讨论最棘手的一段处理: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'
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)
对于这个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
好了,所有的业务逻辑实现都完成了。现在我们来考虑映射。我们的很多类型都使用了可识别联合,我们的某些类型没有公共构造函数,所以我们不能将它们暴露给外部世界。我们需要处理(反)序列化。除此之外,现在我们的应用程序中只有一个有界的上下文,但是在现实生活中你需要构建具有多个有界上下文的强大系统,而且它们必须通过公共契约相互交互,这应该不难理解,包括其他编程语言。
我们必须做双向的映射:从公共模型到领域,从领域到公共模型。虽然从领域到模型的映射很简单,但反向有点麻烦:模型可能包含无效数据,毕竟我们使用的是可以序列化为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
    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语句,这就是为什么我们无法直接串到一起的原因。
我们可以简单地将这些函数作为参数注入,如下所示:
let activateCard getCardAsync saveCardAsync cardNumber = ...
但是,这种方法存在一些问题。首先,依赖项会越来越多,函数签名会很难看;其次,这里我们需要实现特定的效果:我们必须选择通过Task、Async或简单同步调用来实现;第三,如果你传递太多函数,就会引起混乱,例如createUserAsync和replaceUserAsync具有相同的签名但效果不同,因此当你必须传递数百次时,可能会因为非常奇怪的现象而犯错误。由于这些原因,我们选择了解释器。
初步的想法是将组合代码分为两部分:执行树和该树的解释器。该树的每个节点都是我们想要注入的函数的位置,例如getUserFromDatabase。这些节点的定义包括名称,例如getCard;输入参数类型,例如CardNumber;返回类型,例如Card option。这里我们指定Task或Async,因为它不是树的一部分,它是解释器的一部分。该树的每一条边都是一系列纯转换,比如验证或业务逻辑函数执行。边也有一些输入,例如卡号的原始字符串,然后还有验证,可以提供给我们一个错误或有效的卡号。如果出现错误,我们将中断这个边;如果没有错误,我们就会进入下一个节点:getCard。如果此节点返回Some card,那我们就继续下一个边——即激活,依此类推。
对于activateCard、processPayment或topUp,我们都要构建一个单独的树。在构建这些树时,有些节点是空白,它们没有真正的函数,它们只是为这些函数预留了位置。解释器的目标是填充这些节点,仅此而已。解释器知道我们使用的效果,例如Task,它知道在给定节点中实际放入哪个函数。在访问节点时,它会执行相应的实际功能,如果是Task或Async,它就会等待,并将结果传递给下一个边。这个边可能会走向另一个节点,然后再次回到解释器,直到这个解释器到达停止节点,递归的底部,然后我们只需返回树的整个执行结果。
整个树用有区别的联合表示,某个节点的代码如下:
image.png节点始终是一个元组,其中第一个元素是依赖项的输入,最后一个元素是一个函数,它接收该依赖项的结果。你可以在元组元素之间的“空白”中放入依赖项,就像那些组合例子中你有函数'a -> 'b,'c -> 'd,而你需要在二者之间放入'b ->'c。
由于我们处于有界的上下文中,因此我们不应该有太多的依赖关系,这时我们应该将上下文拆分为较小的上下文。
代码如下:
type Program<'a> =
        | GetCard of CardNumber * (Card option -> Program<'
a>)
        | GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>)
        | CreateCard of (Card*AccountInfo) * (Result -> Program<'
a>)
        | ReplaceCard of Card * (Result -> Program<'a>)
        | GetUser of UserId * (User option -> Program<'
a>)
        | CreateUser of UserInfo * (Result -> Program<'a>)
        | GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'
a>)
        | SaveBalanceOperation of BalanceOperation * (Result -> 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>`. 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
        }
这个模块是我们在业务层中的最后一个实现。此外,我还做了一些重构:我将错误和常见类型移动到Common项目。下面我们来看看数据访问层的实现。
数据访问层
这一层实体的设计取决于与之交互的数据库或框架。因此,领域层对这些实体一无所知,这意味着我们必须在这里处理与领域模型之间的映射。这对我们DAL API的消费者来说非常方便。在这个这个应用程序中,我选择了MongoDB,不是因为MongoDB是这类任务的最佳选择,而是因为已经有了很多使用SQL DB的例子,我希望写一些不一样的东西。我打算使用C#驱动程序。在大多数情况下,这些实现很简单,唯一棘手的是Card。当信用卡处于激活状态时,它内部有一个AccountInfo,而当非激活状态时则没有。因此,我们必须将其拆分为两个文档:CardEntity和CardAccountInfoEntity,目的是为了在停用卡时,不会删除有关余额和每日限额的信息。除此之外,我们将使用原始类型以及类型自带的验证。
在使用C#库时,我们需要注意几个问题:

  • 将null转换为Option<'a>
  • 捕获预期的异常,将它们转换为我们的错误,并包装在Result<_,_>中
我们从定义了实体的CardDomainEntities模块开始:
image.png
我们将在SRTP的帮助下使用字段EntityId和IdComparer。我们将定义一些函数,从任意类型中获取这些字段,而不是强制每个实体实现接口:
image.png对于null和Option,由于我们使用了记录类型,因此F#编译器不允许使用null,既不能用于赋值也不能用于比较。然而,记录类型只是另一种CLR类型,所以严格来讲,我们可以而且也肯定会获得一个null值,这要归功于C#和这个库的设计。解决这个问题的办法有两种:使用AllowNullLiteral属性,或使用Unchecked.defaultof<'a>。我选择了第二种方式,因为这种null状态应该尽可能地本地化:
image.png
为了处理重复键的异常,我们再次使用Active Patterns:
// First we define a function which checks, whether exception is about duplicate key
    let private isDuplicateKeyException (ex: Exception) =
        ex :? MongoWriteException && (ex :?> MongoWriteException).WriteError.Category = ServerErrorCategory.DuplicateKey

    // Then we have to check wrapping exceptions for this
    let rec private (|DuplicateKey|_|) (ex: Exception) =
        match ex with
        | :? MongoWriteException as ex when isDuplicateKeyException ex ->
            Some ex
        | :? MongoBulkWriteException as bex when bex.InnerException |> isDuplicateKeyException ->
            Some (bex.InnerException :?> MongoWriteException)
        | :? AggregateException as aex when aex.InnerException |> isDuplicateKeyException ->
            Some (aex.InnerException :?> MongoWriteException)
        | _ -> None

    // And here's the usage:
    let inline private executeInsertAsync (func: 'a -> Async) arg =
        async {
            try
                do! func(arg)
                return Ok ()
            with
            | DuplicateKey ex ->
                    return EntityAlreadyExists (arg.GetType().Name, (entityId arg)) |> Error
        }
实现映射后,我们就拥有了为数据访问层组建API所需的一切,如下所示:
image.png
最后,我想提一提在映射Entity -> Domain的时候,我们必须使用内置验证来实例化类型,因此可能存在验证错误。在这种情况下,我们不会使用Result<_,_>,因为如果我们的DB中存在无效数据,那么这就是一个bug,我们可不想写bug。所以,我们只抛异常。
组建、日志记录和其他功能
别忘了,我们不会使用DI框架,我们选择了解释器模式。原因在于:

  • IoC容器是在运行时操作的,所以在运行程序之前无法知道是否所有依赖项都已满足。
  • DI很强大,因此很容易被滥用:你可以通过它实现属性注入,实现懒惰依赖,有时甚至一些业务逻辑也可以导致注册或解决依赖(我亲眼见过)。所有这些都会加大维护代码的难度。
这意味着我们需要一个地方来放置该功能。我们可以将它放在Web API的最上层,但我认为这并不是最好的选择:我们现在只需处理一个有界上下文,但如果有很多有界上下文,那么将每个上下文的解释器都放在全局的位置上的做法会非常笨拙。此外还有个单一响应原则,Web API项目应该对Web做出响应,对吧?所以我们创建了CardManagement.Infrastructure项目。
在这个项目中,我们需要处理:

  • 撰写我们的功能
  • 应用配置
  • 日志
如果我们有多于1个上下文,那么就应该将应用程序配置和日志配置移动到全局基础架构项目,并且此项目的唯一功能就是为我们的有界上下文组装API,但在我们的这个例子中,这种分离尚不必要。
我们来看看组合。我们在领域层中构建了执行树,现在我们需要解释执行树。树中的每个节点表示某种依赖项调用,我们的例子就是对数据库的调用。如果我们需要与第三方API进行交互,那么这些交互也会出现在这里。因此,我们的解释器必须知道如何处理该树中的每个节点,而这一步是在编译时进行验证的,这要归功于设置。代码如下:
image.png
image.png
请注意,这个解释器中使用了async。我们可以使用Task编写解释器,也可以简单地编写同步的版本。现在你可能想知道怎样做单元测试,因为众所周知的mock库在这里没有用武之地。其实也很容易:只需要再做一个解释器即可。代码如下:
image.pngimage.png
我们创建了TestInterpreterConfig,它保存了我们想要注入的每个操作的所需结果。你可以很轻松地更改每个测试的配置,然后运行解释器即可。这个解释器是同步的,因此没必要牵扯Task或Async。
日志记录没有难度,使用这个模块(https://github.com/atsapura/CardManagement/blob/master/CardManagement.Infrastructure/Logging.fs)即可。方法是我们将函数包装在日志记录中:我们记录函数名称,参数和日志结果。如果结果没问题,那么输出info级别;如果出错,就输出warning;如果是一个Bug,就输出error。
最后,我们还需要建立一个外观,因为我们不想暴露原始的解释器调用。整体代码如下:
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项目的代码在这里(https://github.com/atsapura/CardManagement/tree/master/CardManagement.Api/CardManagement.Api)。


结论


我们已经构建了一个带有验证、错误处理、日志记录和业务逻辑的应用程序,这些通常都是应用程序必不可少的功能。不同之处在于,本文中的代码更耐用且易于重构。请注意,我们没有使用反射或代码生成,没有异常,但我们的代码依然很简单,易于阅读,易于理解,且非常稳定。如果在模型中添加另一个字段,或者在我们的某个联合类型中添加另一种情况,那么代码只有在更新所有调用之后才会通过编译。当然,这并不意味着完全安全,或者根本不需要任何类型的测试,这只意味着你在开发新功能或进行重构时遇到的问题会更少。开发成本可以降低,开发过程也很有趣,因为这个工具可以让你专注于领域和业务任务,而不需要小心翼翼不要破坏其他功能。
另外,我并没有说OOP完全没用,也没有说我们不需要它。我说的是并非每一项任务都需要OOP来解决,而且我们的很大一部分任务可以通过函数式编程更好地解决。事实上,我们总是需要寻求平衡:我们无法仅使用一种工具有效地解决所有问题,因此良好的编程语言应该对函数式编程和OOP提供良好的支持。不幸的是,如今许多最流行的语言仅支持lambdas和函数式编程中的async。
原文:https://github.com/atsapura/CardManagement/blob/master/article/Fighting%20complexity%20in%20software%20development.md
作者:Anish Karandikar和Roman,Anish Karandikar是高级后端开发@KaterTech。
声明:本文为CSDN翻译,转载请注明来源出处。

(0)

本文由 投稿者 创作,文章地址:https://blog.isoyu.com/archives/ruhezhanshengruanjiankaifadefuzaxing.html
采用知识共享署名4.0 国际许可协议进行许可。除注明转载/出处外,均为本站原创或翻译,转载前请务必署名。最后编辑时间为:8 月 13, 2019 at 08:45 下午

热评文章