对抗软件开发中的复杂性

这是怎么回事


在完成了不同的项目之后,我注意到它们中的每一个都有一些共同的问题,而与域,体系结构,代码约定等无关。 这些问题并不具有挑战性,只是一个乏味的例行工作:确保您不会错过任何愚蠢而显而易见的事情。 我不再每天都执行此例程,而是沉迷于寻求解决方案:某些开发方法或代码约定或任何能够帮助我以防止那些问题发生的方式设计项目的事物,因此我可以专注于有趣的事情。 这就是本文的目的:描述这些问题,并向您展示我找到的解决问题的各种工具和方法。


我们面临的问题


在开发软件时,我们会遇到很多困难:要求不明确,沟通不畅,开发过程不佳等。


我们还面临一些技术难题:遗留代码使我们放慢速度,扩展很棘手,过去的一些错误决定使我们今天陷入困境。


所有这些问题都可以消除,甚至可以大大减少,但是有一个根本问题您无能为力:系统的复杂性。


无论您是否了解,您自己开发的系统的想法总是很复杂。
即使当您正在制作另一个CRUD应用程序时 ,总会出现一些极端情况,一些棘手的事情,并且有时会有人问“嘿,如果在这种情况下这样做,会发生什么?” 然后您说“嗯,这是一个很好的问题。”


这些棘手的情况,可疑的逻辑,验证和访问管理-所有这些加在一起就构成了您的重要构想。
通常,这个想法如此之大,以至于无法一head而就,仅这个事实就带来了沟通不畅等问题。


但是,请让我们大方一些,并假设该领域专家和业务分析人员团队进行了清晰的沟通,并提出了很好的一致要求。


现在,我们必须实现它们,以在我们的代码中表达这个复杂的想法。 现在,该代码是另一个系统,比我们想到的原始想法复杂得多。


怎么会这样 它面临现实:技术限制迫使您在实施实际业务逻辑的基础上应对高负载,数据一致性和可用性。


如您所见,任务非常艰巨,现在我们需要适当的工具来处理它。
编程语言只是另一种工具,与其他所有工具一样,它不仅与语言的质量有关,还与适合工作的工具有关。 您可能拥有最好的螺丝刀,但是如果您需要在木头上钉一些钉子,那么a头的锤子会更好,对吧?


技术方面


如今,大多数流行语言都是面向对象的。 当有人介绍OOP时,他们通常使用示例:
考虑一下汽车,它是现实世界中的物体。 它具有各种属性,例如品牌,重量,颜色,最大速度,当前速度等。


为了在我们的程序中反映该对象,我们将这些属性收集在一个类中。 属性可以是永久性的也可以是可变的,它们共同构成该对象的当前状态以及可能会变化的某些边界。 但是结合这些属性是不够的,因为我们必须检查当前状态是否有意义,例如当前速度没有超过最大速度。 为了确保我们在此类上附加一些逻辑,请将属性标记为私有,以防止任何人创建非法状态。
如您所见,对象是关于其内部状态和生命周期的。


因此,在这种情况下,OOP的这三个支柱非常有意义:我们使用继承重用某些状态操作,使用封装进行状态保护以及使用多态性以相同方式处理相似对象。 默认情况下,可变性也很有意义,因为在这种情况下,不可变对象不能具有生命周期,并且始终具有一个状态,这不是最常见的情况。


事情是当您看到当今的典型Web应用程序时,它不处理对象。 我们代码中的几乎所有内容都具有永生或根本没有生命。 两种最常见的“对象”是某种服务,例如UserServiceEmployeeRepository或某些模型/实体/ DTO或您所谓的它们。 服务内部没有逻辑状态,它们的死亡和重生完全相同,我们只是使用新的数据库连接来重新创建依赖关系图。


实体和模型没有任何附加的行为,它们只是数据束,其可变性无济于事,而恰恰相反。


因此,OOP的关键功能对于开发此类应用程序并不是真正有用。


在典型的Web应用程序中,发生的事情是数据流动:验证,转换,评估等。 这里有一个非常适合这种工作的范例:函数式编程。 有一个证明:当今流行语言的所有现代功能都来自那里: async/await ,lambda和委托,反应式编程,有区别的联合(快速枚举或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 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"); } } 

现在,这种方法存在一些问题:


  • 验证与类型声明是分开的,这意味着要查看一张真正的卡的完整图片,我们必须浏览代码并在脑海中重新创建此图像。 当它只发生一次时,这不是一个大问题,但是当我们必须对一个大型项目中的每个实体都这样做时,这是非常耗时的。
  • 这种验证不是强制性的,我们必须牢记在任何地方都使用它。 我们可以通过测试来确保这一点,但是再次,您在编写测试时必须记住这一点。
  • 当我们想在其他地方验证卡号时,我们必须再次做同样的事情。 当然,我们可以将regex放在一个普通的地方,但是仍然必须在每个验证器中调用它。

在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#严重依赖异常。 有几个问题:


  • 异常具有“执行”语义。 您使用此方法的一刻,另一刻-您遇到了某个全局处理程序。
  • 它们不会出现在方法签名中。 诸如ValidationExceptionInvalidUserOperationException类的异常是合同的一部分,但是直到您阅读Implementation才知道。 这是一个主要的问题,因为很多时候您必须使用别人编写的代码,而不仅仅是阅读签名,而是必须一直浏览到调用堆栈的底部,这需要很多时间。

这就是困扰我的地方:每当我实现一些新功能时,实现过程本身就不会花费很多时间,其中大部分用于两件事:


  • 阅读其他人的代码并弄清楚业务逻辑规则。
  • 确保没有损坏。

这听起来像是代码设计不佳的征兆,但即使在编写得体的项目上也一样。
好的,但是我们可以尝试在C#中使用相同的Result 。 最明显的实现如下所示:


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

而且这纯粹是垃圾,它不会阻止我们同时设置OkError并允许错误被完全忽略。 正确的版本应该是这样的:


 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)); } } } 

很累吧? 而且我什至没有实现MapMapErrorvoid版本。 用法如下所示:


 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#类类型系统的语言。 让我们考虑一个简单的场景:在代码库中有一段时间的实体。 今天,您想添加一个新的必填字段。 自然地,您需要在创建该实体的任何地方都初始化该字段,但是编译器根本无法为您提供帮助,因为class是可变的并且null是有效值。 像AutoMapper这样的库使它变得更加困难。 这种可变性使我们可以在一个地方部分地初始化对象,然后将其推到其他位置并在那里继续初始化。 那是错误的另一个来源。


同时,语言功能比较不错,但这不是本文的目的。 如果您对此感兴趣,我会在上一篇文章中介绍了该主题。 但是语言功能本身不应该成为切换技术的理由。


因此,我们想到了以下问题:


  1. 为什么我们真的需要从现代OOP转换?
  2. 为什么要切换到FP?

第一个问题的答案是将常见的OOP语言用于现代应用程序会给您带来很多麻烦,因为它们是为不同的目的而设计的。 这会导致您花费大量时间和金钱来与他们的设计抗衡,同时还与应用程序的复杂性作斗争。


第二个答案是FP语言为您提供了一种简单的方法来设计功能,使它们像时钟一样工作,并且如果新功能破坏了现有逻辑,则会破坏代码,因此您立即就知道了。




但是,这些答案还不够。 正如我的朋友在我们的一次讨论中所指出的那样,如果您不了解最佳做法,则切换到FP毫无用处。 我们这个庞大的行业编写了有关设计OOP应用程序的大量文章,书籍和教程,并且我们具有OOP的生产经验,因此我们知道对不同方法的期望。 不幸的是,函数编程并非如此,因此,即使您切换到FP,您的第一次尝试也很可能会很尴尬,并且肯定不会为您带来预期的结果:快速而轻松地开发复杂的系统。


好吧,这正是本文的主题。 正如我所说,我们将构建类似于生产的应用程序以了解差异。


我们如何设计应用程序?


我在出色的《 领域建模使功能》一书中借鉴了许多设计过程中使用的想法,因此强烈建议您阅读。


带有注释的完整源代码在这里 。 自然,我不会将所有内容都放在这里,因此我将仅介绍关键点。


我们将有4个主要项目:业务层,数据访问层,基础结构,当然还有公共项目。 每个解决方案都有,对吗?


我们从建模领域开始。 在这一点上,我们不知道也不在乎数据库。 这样做是有目的的,因为考虑到特定的数据库,我们倾向于根据该数据库设计域,因此我们在业务层中引入了实体表关系,随后又带来了问题。 您只需要实现映射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 } 

现在,这里有几种类型,我们尚未声明:


  1. 钱款


    我们可以使用decimal (我们将使用,但不能直接使用),但是decimal的描述较少。 此外,它可以用于表示金钱以外的其他事物,我们不希望将其混淆。 因此,我们使用自定义类型type [<Struct>] Money = Money of decimal


  2. 每日限额


    每日限额可以设置为特定数量,也可以完全不设置。 如果存在,则必须为正。 我们定义这种类型而不是使用decimalMoney


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

    现在,我们可以在任何地方将常规的DailyLimit模式匹配为常规DU。


  3. 字母串


    那很简单。 我们使用与CardNumber相同的技术。 不过有一点要注意: LetterString几乎与信用卡LetterString ,这是一件相当的事情,我们应该将其移动到CommonTypes模块的Common项目中。 时间到了,我们也将ValidationError移到单独的位置。


  4. 用户名


    那只是别名type UserId = System.Guid 。 我们仅将其用于描述性目的。


  5. 月和年


    那些也必须去CommonMonth将成为一个有区别的联合,其方法可将其与unsigned int16进行相互转换, Year将类似于unsigned int16 ,但对于uint16而不是string。



现在,让我们完成域类型声明。 我们需要给User提供一些用户信息和卡收集信息,我们需要余额操作来充值和付款。


  type UserInfo = { Name: LetterString Id: UserId Address: Address } type User = { UserInfo : UserInfo Cards: Card list } [<Struct>] type BalanceChange = //-- another common type with validation for positive amount | Increase of increase: MoneyTransaction | 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) 

今天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<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还是普通的同步调用。 第三,当您传递了许多函数时,很容易弄乱事情:例如createUserAsyncreplaceUserAsync具有相同的签名但效果不同,因此当您必须传递数百次时,您可能会犯一个真正奇怪的症状的错误。 由于这些原因,我们选择了口译员。


想法是将合成代码分为两部分:执行树和该树的解释器。 该树中的每个节点都是一个函数的位置,该函数具有我们要注入的效果,例如getUserFromDatabase 。 这些节点由名称(例如getCard ,输入参数类型(例如CardNumber和返回类型(例如Card option 。 我们此处未指定TaskAsync ,这不是树的一部分, 而是解释器的一部分 。 这棵树的每一个边缘都是一系列纯转换,例如验证或业务逻辑功能执行。 边缘也有一些输入,例如原始的字符串卡号,然后是验证,这可能会给我们带来错误或有效的卡号。 如果有错误,我们将中断该边缘,如果没有,它将导致我们到达下一个节点: getCard 。 如果此节点将返回Some card ,我们可以继续到下一个边缘,即激活,依此类推。


对于像topUptopUptopUp这样的每种情况,我们将构建一个单独的树。 当那些树被构建时,它们的节点有点空白,它们中没有真正的功能, 它们有放置这些功能的位置。 解释器的目标是填充这些节点,就这么简单。 解释器知道我们使用的效果,例如Task ,并且知道要在给定节点中放置哪个实函数。 当它访问节点时,它执行相应的实函数,在TaskAsync情况下等待它,并将结果传递到下一个边缘。 该边缘可能会导致另一个节点,然后再次为解释器工作,直到该解释器到达停止节点(递归的底部)为止,在该节点处,我们只返回树的整个执行结果。


整个树将用有区别的联合表示,并且一个节点如下所示:


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

它始终是一个元组,其中第一个元素是依赖项的输入,最后一个元素是function ,该函数接收该依赖项的结果。 元组的这些元素之间的“空格”就是您的依赖项所适合的地方,例如在那些合成示例中,您具有函数'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<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 -> fx (*{- 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) 

With a help of computation expressions , we now have a very easy way to build our workflows without having to care about implementation of real-world interactions. We do that in CardWorkflow module :


 (*{- `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 } 

This module is the last thing we need to implement in business layer. Also, I've done some refactoring: I moved errors and common types to Common project . About time we moved on to implementing data access layer.


Data access layer


The design of entities in this layer may depend on our database or framework we use to interact with it. Therefore domain layer doesn't know anything about these entities, which means we have to take care of mapping to and from domain models in here. Which is quite convenient for consumers of our DAL API. For this application I've chosen MongoDB, not because it's a best choice for this kind of task, but because there're many examples of using SQL DBs already and I wanted to add something different. We are gonna use C# driver.


For the most part it's gonna be pretty strait forward, the only tricky moment is with Card . When it's active it has an AccountInfo inside, when it's not it doesn't. So we have to split it in two documents: CardEntity and CardAccountInfoEntity , so that deactivating card doesn't erase information about balance and daily limit.


Other than that we just gonna use primitive types instead of discriminated unions and types with built-in validation.


There're also few things we need to take care of, since we are using C# library:


  • Convert null s to Option<'a>
  • Catch expected exceptions and convert them to our errors and wrap it in Result<_,_>

We start with CardDomainEntities module , where we define our entities:


  [<CLIMutable>] type CardEntity = { [<BsonId>] CardNumber: string Name: string IsActive: bool ExpirationMonth: uint16 ExpirationYear: uint16 UserId: UserId } with //-- we're gonna need this in every entity for error messages member this.EntityId = this.CardNumber.ToString() (*{- we use this Id comparer quotation (F# alternative to C# Expression) for updating entity by id, since for different entities identifier has different name and type -}*) member this.IdComparer = <@ System.Func<_,_> (fun c -> c.CardNumber = this.CardNumber) @> 

Those fields EntityId and IdComparer we are gonna use with a help of SRTP . We'll define functions that will retrieve them from any type that has those fields define, without forcing every entity to implement some interface:


  let inline (|HasEntityId|) x = fun () -> (^a : (member EntityId: string) x) let inline entityId (HasEntityId f) = f() let inline (|HasIdComparer|) x = fun () -> (^a : (member IdComparer: Quotations.Expr<Func< ^a, bool>>) x) //-- We need to convert F# quotations to C# expressions //-- which C# mongo db driver understands. let inline idComparer (HasIdComparer id) = id() |> LeafExpressionConverter.QuotationToExpression |> unbox<Expression<Func<_,_>>> 

As for null and Option thing, since we use record types, F# compiler doesn't allow using null value, neither for assigning nor for comparison. At the same time record types are just another CLR types, so technically we can and will get a null value, thanks to C# and design of this library. We can solve this in 2 ways: use AllowNullLiteral attribute, or use Unchecked.defaultof<'a> . I went for the second choice since this null situation should be localized as much as possible:


  let isNullUnsafe (arg: 'a when 'a: not struct) = arg = Unchecked.defaultof<'a> //-- then we have this function to convert nulls to option, //-- therefore we limited this toxic null thing in here. let unsafeNullToOption a = if isNullUnsafe a then None else Some a 

In order to deal with expected exception for duplicate key, we use Active Patterns again:


  //-- 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<unit>) arg = async { try do! func(arg) return Ok () with | DuplicateKey ex -> return EntityAlreadyExists (arg.GetType().Name, (entityId arg)) |> Error } 

After mapping is implemented we have everything we need to assemble API for our data access layer , which looks like this:


  //-- `MongoDb` is a type alias for `IMongoDatabase` let replaceUserAsync (mongoDb: MongoDb) : ReplaceUserAsync = fun user -> user |> DomainToEntityMapping.mapUserToEntity |> CommandRepository.replaceUserAsync mongoDb let getUserInfoAsync (mongoDb: MongoDb) : GetUserInfoAsync = fun userId -> async { let! userInfo = QueryRepository.getUserInfoAsync mongoDb userId return userInfo |> Option.map EntityToDomainMapping.mapUserInfoEntity } 

The last moment I mention is when we do mapping Entity -> Domain , we have to instantiate types with built-in validation, so there can be validation errors. In this case we won't use Result<_,_> because if we've got invalid data in DB, it's a bug, not something we expect. So we just throw an exception. Other than that nothing really interesting is happening in here. The full source code of data access layer you'll find here .


Composition, logging and all the rest


As you remember, we're not gonna use DI framework, we went for interpreter pattern. If you want to know why, here's some reasons:


  • IoC container operates in runtime. So until you run your program you can't know that all the dependencies are satisfied.
  • It's a powerful tool which is very easy to abuse: you can do property injection, use lazy dependencies, and sometimes even some business logic can find it's way in dependency registering/resolving (yeah, I've witnessed it). All of that makes code maintaining extremely hard.

That means we need a place for that functionality. We could place it on a top level in our Web Api, but in my opinion it's not a best choice: right now we are dealing with only 1 bounded context, but if there's more, this global place with all the interpreters for each context will become cumbersome. Besides, there's single responsibility rule, and web api project should be responsible for web, right? So we create CardManagement.Infrastructure project .


Here we will do several things:


  • Composing our functionality
  • App configuration
  • Logging

If we had more than 1 context, app configuration and log configuration should be moved to global infrastructure project, and the only thing happening in this project would be assembling API for our bounded context, but in our case this separation is not necessary.


Let's get down to composition. We've built execution trees in our domain layer, now we have to interpret them. Every node in that tree represents some dependency call, in our case a call to database. If we had a need to interact with 3rd party api, that would be in here also. So our interpreter has to know how to handle every node in that tree, which is verified in compile time, thanks to <TreatWarningsAsErrors> setting. Here's what it looks like:


 (*{- Those `bindAsync (next >> interpretCardProgram mongoDb)` work pretty simple: we execute async function to the left of this expression, await that operation and pass the result to the next node, after which we interpret that node as well, until we reach the bottom of this recursion: `Stop a` node. -}*) let rec private interpretCardProgram mongoDb prog = match prog with | GetCard (cardNumber, next) -> cardNumber |> getCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetCardWithAccountInfo (number, next) -> number |> getCardWithAccInfoAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | CreateCard ((card,acc), next) -> (card, acc) |> createCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | ReplaceCard (card, next) -> card |> replaceCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetUser (id, next) -> getUserAsync mongoDb id |> bindAsync (next >> interpretCardProgram mongoDb) | CreateUser (user, next) -> user |> createUserAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetBalanceOperations (request, next) -> getBalanceOperationsAsync mongoDb request |> bindAsync (next >> interpretCardProgram mongoDb) | SaveBalanceOperation (op, next) -> saveBalanceOperationAsync mongoDb op |> bindAsync (next >> interpretCardProgram mongoDb) | Stop a -> async.Return a let interpret prog = try let interpret = interpretCardProgram (getMongoDb()) interpret prog with | failure -> Bug failure |> Error |> async.Return 

Note that this interpreter is the place where we have this async thing. We can do another interpreter with Task or just a plain sync version of it. Now you're probably wondering, how we can cover this with unit-test, since familiar mock libraries ain't gonna help us. Well, it's easy: you have to make another interpreter. Here's what it can look like:


  type SaveResult = Result<unit, DataRelatedError> type TestInterpreterConfig = { GetCard: Card option GetCardWithAccountInfo: (Card*AccountInfo) option CreateCard: SaveResult ReplaceCard: SaveResult GetUser: User option CreateUser: SaveResult GetBalanceOperations: BalanceOperation list SaveBalanceOperation: SaveResult } let defaultConfig = { GetCard = Some card GetUser = Some user GetCardWithAccountInfo = (card, accountInfo) |> Some CreateCard = Ok() GetBalanceOperations = balanceOperations SaveBalanceOperation = Ok() ReplaceCard = Ok() CreateUser = Ok() } let testInject a = fun _ -> a let rec interpretCardProgram config (prog: Program<'a>) = match prog with | GetCard (cardNumber, next) -> cardNumber |> testInject config.GetCard |> (next >> interpretCardProgram config) | GetCardWithAccountInfo (number, next) -> number |> testInject config.GetCardWithAccountInfo |> (next >> interpretCardProgram config) | CreateCard ((card,acc), next) -> (card, acc) |> testInject config.CreateCard |> (next >> interpretCardProgram config) | ReplaceCard (card, next) -> card |> testInject config.ReplaceCard |> (next >> interpretCardProgram config) | GetUser (id, next) -> id |> testInject config.GetUser |> (next >> interpretCardProgram config) | CreateUser (user, next) -> user |> testInject config.CreateUser |> (next >> interpretCardProgram config) | GetBalanceOperations (request, next) -> testInject config.GetBalanceOperations request |> (next >> interpretCardProgram config) | SaveBalanceOperation (op, next) -> testInject config.SaveBalanceOperation op |> (next >> interpretCardProgram config) | Stop a -> a 

We've created TestInterpreterConfig which holds desired results of every operation we want to inject. You can easily change that config for every given test and then just run interpreter. This interpreter is sync, since there's no reason to bother with Task or Async .


There's nothing really tricky about the logging, but you can find it in this module . The approach is that we wrap the function in logging: we log function name, parameters and log result. If result is ok, it's info, if error it's a warning and if it's a Bug then it's an error. That's pretty much it.


One last thing is to make a facade, since we don't want to expose raw interpreter calls. Here's the whole thing:


  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") 

All the dependencies here are injected, logging is taken care of, no exceptions is thrown — that's it. For web api I used Giraffe framework. Web project is here .


结论


We have built an application with validation, error handling, logging, business logic — all those things you usually have in your application. The difference is this code is way more durable and easy to refactor. Note that we haven't used reflection or code generation, no exceptions, but still our code isn't verbose. It's easy to read, easy to understand and hard to break. As soon as you add another field in your model, or another case in one of our union types, the code won't compile until you update every usage. Sure it doesn't mean you're totally safe or that you don't need any kind of testing at all, it just means that you're gonna have fewer problems when you develope new features or do some refactoring. The development process will be both cheaper and more interesting, because this tool allows you to focus on your domain and business tasks, instead of drugging focus on keeping an eye out that nothing is broken.


Another thing: I don't claim that OOP is completely useless and we don't need it, that's not true. I'm saying that we don't need it for solving every single task we have, and that a big portion of our tasks can be better solved with FP. And truth is, as always, in balance: we can't solve everything efficiently with only one tool, so a good programming language should have a decent support of both FP and OOP. And, unfortunately, a lot of most popular languages today have only lambdas and async programming from functional world.

Source: https://habr.com/ru/post/zh-CN458730/


All Articles