首页 » 软件优化 » 如何战胜软件开发的复杂性?(复杂性函数代码开发战胜)

如何战胜软件开发的复杂性?(复杂性函数代码开发战胜)

南宫静远 2024-11-17 14:08:06 0

扫一扫用手机浏览

文章目录 [+]

充值余额我们可以计算处于激活状态且未过期的信用卡余额。

设定每日限额如果卡未过期且处于激活状态,则用户可以设置每日限额。

如果无法完成操作,则必须返回错误,因此我们需要定义OperationNotAllowedError:

如何战胜软件开发的复杂性?(复杂性函数代码开发战胜) 软件优化
(图片来自网络侵删)

在这个模块的业务逻辑中,上述是唯一需要返回的错误类型。
在这里我不做验证,不与数据库交互,只是执行操作,或返回OperationNotAllowedError。
在这里,我们只讨论最棘手的一段处理:processPayment。
我们必须检查到期、活动/停用状态,今天花费的金额和当前余额。
由于无法与外部世界互动,因此我们必须将所有必要的信息作为参数传递进去。
在这种方式下,这个逻辑很容易测试,而且你也可以进行基于属性的测试。

对于这个spentToday,我们必须从数据库中保存的BalanceOperation集合进行计算。
所以我们为此建立一个模块,基本上只有1个公共函数:

好了,所有的业务逻辑实现都完成了。
现在我们来考虑映射。
我们的很多类型都使用了可识别联合,我们的某些类型没有公共构造函数,所以我们不能将它们暴露给外部世界。
我们需要处理(反)序列化。
除此之外,现在我们的应用程序中只有一个有界的上下文,但是在现实生活中你需要构建具有多个有界上下文的强大系统,而且它们必须通过公共契约相互交互,这应该不难理解,包括其他编程语言。
我们必须做双向的映射:从公共模型到领域,从领域到公共模型。
虽然从领域到模型的映射很简单,但反向有点麻烦:模型可能包含无效数据,毕竟我们使用的是可以序列化为json的普通类型。
别担心,我们必须在这个映射中构建验证。
事实上,我们对可能无效的数据和数据使用不同的类型,这始终有效意味着编译器会提醒我们不要忘记执行验证。
代码如下:

到此为止,我们已经实现了所有业务逻辑、映射、验证等等,而这些都脱离了现实世界:这些代码都是用纯函数编写的。
现在你可能想知道,我们究竟应该如何使用这些代码?因为我们需要与外界互动。
更重要的是,在执行工作流程期间,我们必须根据真实世界的交互结果做决定。
所以现在的问题是我们如何组装所有这些代码?在OOP中,我们可以使用IoC容器来处理,但在这里我们不能这样做,因为我们没有对象,我们只有静态函数。
我们将使用解释器模式(Interpreter pattern)!
这有点棘手,主要是因为我不熟悉,但我会尽力解释这种模式。
首先,我们来谈谈功能构成。
例如,我们有一个函数int -> string,这个函数需要int作为参数并返回字符串。
现在假设我们有另一个函数string->char。
这时,我们可以将这两个函数串到一起,也就是说先执行第一个函数,然后将输出传递给第二个函数,我们可以利用运算符>>。
实际的代码如下:

然而,在有些情况下我们不能简单的将函数串到一起,例如激活卡,这涉及一系列的动作:

验证输入卡号。
如果有效,则继续

通过卡号获取卡。
如果卡存在,则继续

激活卡

保存结果。
如果保存成功,则继续

映射到模型并返回。

上述前两个步骤都有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,它就会等待,并将结果传递给下一个边。
这个边可能会走向另一个节点,然后再次回到解释器,直到这个解释器到达停止节点,递归的底部,然后我们只需返回树的整个执行结果。
整个树用有区别的联合表示,某个节点的代码如下:

节点始终是一个元组,其中第一个元素是依赖项的输入,最后一个元素是一个函数,它接收该依赖项的结果。
你可以在元组元素之间的“空白”中放入依赖项,就像那些组合例子中你有函数'a -> 'b,'c -> 'd,而你需要在二者之间放入'b ->'c。
由于我们处于有界的上下文中,因此我们不应该有太多的依赖关系,这时我们应该将上下文拆分为较小的上下文。
代码如下:

我们可以借助计算表达式,非常轻松地构建工作流程,而无需关心实际交互的实现。
如下是CardWorkflow模块:

这个模块是我们在业务层中的最后一个实现。
此外,我还做了一些重构:我将错误和常见类型移动到Common项目。
下面我们来看看数据访问层的实现。
数据访问层这一层实体的设计取决于与之交互的数据库或框架。
因此,领域层对这些实体一无所知,这意味着我们必须在这里处理与领域模型之间的映射。
这对我们DAL API的消费者来说非常方便。
在这个这个应用程序中,我选择了MongoDB,不是因为MongoDB是这类任务的最佳选择,而是因为已经有了很多使用SQL DB的例子,我希望写一些不一样的东西。
我打算使用C#驱动程序。
在大多数情况下,这些实现很简单,唯一棘手的是Card。
当信用卡处于激活状态时,它内部有一个AccountInfo,而当非激活状态时则没有。
因此,我们必须将其拆分为两个文档:CardEntity和CardAccountInfoEntity,目的是为了在停用卡时,不会删除有关余额和每日限额的信息。
除此之外,我们将使用原始类型以及类型自带的验证。
在使用C#库时,我们需要注意几个问题:

将null转换为Option<'a>

捕获预期的异常,将它们转换为我们的错误,并包装在Result<_,_>中

我们从定义了实体的CardDomainEntities模块开始:

我们将在SRTP的帮助下使用字段EntityId和IdComparer。
我们将定义一些函数,从任意类型中获取这些字段,而不是强制每个实体实现接口:

对于null和Option,由于我们使用了记录类型,因此F#编译器不允许使用null,既不能用于赋值也不能用于比较。
然而,记录类型只是另一种CLR类型,所以严格来讲,我们可以而且也肯定会获得一个null值,这要归功于C#和这个库的设计。
解决这个问题的办法有两种:使用AllowNullLiteral属性,或使用Unchecked.defaultof<'a>。
我选择了第二种方式,因为这种null状态应该尽可能地本地化:

为了处理重复键的异常,我们再次使用Active Patterns:

实现映射后,我们就拥有了为数据访问层组建API所需的一切,如下所示:

最后,我想提一提在映射Entity -> Domain的时候,我们必须使用内置验证来实例化类型,因此可能存在验证错误。
在这种情况下,我们不会使用Result<_,_>,因为如果我们的DB中存在无效数据,那么这就是一个bug,我们可不想写bug。
所以,我们只抛异常。
组建、日志记录和其他功能别忘了,我们不会使用DI框架,我们选择了解释器模式。
原因在于:

IoC容器是在运行时操作的,所以在运行程序之前无法知道是否所有依赖项都已满足。

DI很强大,因此很容易被滥用:你可以通过它实现属性注入,实现

这意味着我们需要一个地方来放置该功能。
我们可以将它放在Web API的最上层,但我认为这并不是最好的选择:我们现在只需处理一个有界上下文,但如果有很多有界上下文,那么将每个上下文的解释器都放在全局的位置上的做法会非常笨拙。
此外还有个单一响应原则,Web API项目应该对Web做出响应,对吧?所以我们创建了CardManagement.Infrastructure项目。
在这个项目中,我们需要处理:

撰写我们的功能

应用配置

日志

如果我们有多于1个上下文,那么就应该将应用程序配置和日志配置移动到全局基础架构项目,并且此项目的唯一功能就是为我们的有界上下文组装API,但在我们的这个例子中,这种分离尚不必要。
我们来看看组合。
我们在领域层中构建了执行树,现在我们需要解释执行树。
树中的每个节点表示某种依赖项调用,我们的例子就是对数据库的调用。
如果我们需要与第三方API进行交互,那么这些交互也会出现在这里。
因此,我们的解释器必须知道如何处理该树中的每个节点,而这一步是在编译时进行验证的,这要归功于<TreatWarningsAsErrors>设置。
代码如下:

请注意,这个解释器中使用了async。
我们可以使用Task编写解释器,也可以简单地编写同步的版本。
现在你可能想知道怎样做单元测试,因为众所周知的mock库在这里没有用武之地。
其实也很容易:只需要再做一个解释器即可。
代码如下:

我们创建了TestInterpreterConfig,它保存了我们想要注入的每个操作的所需结果。
你可以很轻松地更改每个测试的配置,然后运行解释器即可。
这个解释器是同步的,因此没必要牵扯Task或Async。
日志记录没有难度,使用这个模块即可。
方法是我们将函数包装在日志记录中:我们记录函数名称,参数和日志结果。
如果结果没问题,那么输出info级别;如果出错,就输出warning;如果是一个Bug,就输出error。
最后,我们还需要建立一个外观,因为我们不想暴露原始的解释器调用。
整体代码如下:

这里注入了所有依赖项,还处理了日志记录,也不抛出任何异常,非常简单。
对于web api,我使用了Giraffe框架。

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

相关文章