在经历不同的项目之后,我注意到每个项目都存在一些常见问题,无论领域,架构,代码约定等等。这些问题并不具有挑战性,我更专注于寻求解决方案:一些开发方法或代码约定或任何可以帮助我以防止这些问题发生的东西,所以我专注于有趣的东西。这就是本文的目标:描述这些问题并向您展示我发现的解决这些问题的工具和方法的组合。
我们面临的问题
在开发软件的过程中,我们遇到了很多困难:需求不明确,沟通不畅,开发过程不良等等。我们还面临一些技术难题:遗留代码让我们放慢脚步,缩放是棘手的,过去的一些错误决定突然让我们感到震惊。
所有这些都可以被消除然后显着减少,但是有一个基本问题你无能为力:系统的复杂性。
无论您是否理解,您正在开发的系统的想法总是很复杂。即使你正在制作一个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之类的库让它变得更难。
这种可变性允许我们在一个地方部分初始化对象,然后将其推送到其他地方并继续初始化。这是另一个错误来源。
这就把我们带到了这些问题:
- 为什么我们真的需要从现代OOP切换出来?
- 我们为什么要切换到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。当然,与不纯函数交互会自动使调用函数不纯。那么我们应该实施什么呢?
以下列出了我们的需求:
- 激活/停用卡
- 处理付款
我们可以处理付款,如果:
- 卡未过期
- 卡片有效
- 有足够的钱支付
- 今天的支出未超过每日限额。
- 充值平衡
我们可以为有效卡和未过期卡充值余额。
- 设定每日限额
如果卡未过期且处于活动状态,用户可以设置每日限额。
当操作无法完成时,我们必须返回错误,因此我们需要定义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和函数世界的异步编程。