5.5 端到端的服务器端工作流
现在,我们已经有了主要的工作流程骨架和简单的领域模型,我们准备完成端到端的工作流程。我们仍然需要实现Book函数,它应该做以下事情:
- 加载帐户。
- 如果账户有足够的资金,从账户中扣款。
- 保留对帐户的更改。
- 通过 SWIFT 网络电汇资金。
让我们定义两个服务,捕获DB访问和SWIFT访问。
public interface IRepository<T>{
Option<T> Get(Guid id);
void Save(Guid id, T t);
}
interface ISwiftService{
void Wire(MakeTransfer transfer, AccountState account);
}
使用这些接口仍然是一种 OO 模式,但我们现在还是坚持使用它(你会在第 7 章看到如何使用函数)。请注意,IRepository.Get 返回一个 Option,以确认不能保证为任何给定的 Guid 找到一个Item的事实。
这是完全实现的控制器,包括到目前为止缺少的 Book 方法。
清单 5.8 在控制器中实现端到端的工作流程
public class MakeTransferController: Controller
{
IValidator < MakeTransfer > validator;
IRepository < AccountState > accounts;
ISwiftService swift;
public void MakeTransfer([FromBody] MakeTransfer transfer)
=> Some(transfer)
.Map(Normalize)
.Where(validator.IsValid)
.ForEach(Book);
void Book(MakeTransfer transfer)
=> accounts.Get(transfer.DebitedAccountId)
.Bind(account => account.Debit(transfer.Amount))
.ForEach(account => {
accounts.Save(transfer.DebitedAccountId, account);
swift.Wire(transfer, account);
});
}
让我们来看看新增加的Book方法。 请注意,accounts.Get返回一个Option(如果没有找到给定ID的账户),Debit也返回一个Option(如果有足够的资金)。因此,我们用Bind来组合这两个操作。最后,我们使用 ForEach 来执行我们需要的副作用:用新的、较低的余额保存账户,并将资金汇给 SWIFT。
在整个解决方案中,有几个明显的不足之处。 首先,我们有效地使用了Option来停止计算,如果过程中出现了问题,但我们没有给用户任何反馈,说明请求是否成功或为什么成功。 在第6章中,你会看到如何用Either和相关的结构来解决这个问题;这允许你在不从根本上改变这里的方法的情况下捕获错误的细节。
另一个问题是,保存账户和汇出资金的工作应该在原子上完成:如果这个过程在中间失败了,我们本可以在不向SWIFT发送资金的情况下扣款。 这个问题的解决方案往往是针对基础设施的,而不是针对FP的。既然我已经说清楚了缺少什么,我们就来讨论一下好的部分。
5.5.1 表达式与陈述式
当你看清单5.8中的控制器时,应该注意的是没有if语句,没有for语句。事实上,几乎没有任何状态。
函数式和命令式风格之间的一个根本区别是命令式代码依赖于语句;函数式代码依赖于表达式。有关这些差异的更多信息,请参阅“表达式、语句、声明”侧栏。本质上,表达式具有值;陈述没有。虽然诸如函数调用之类的表达式可能有副作用,但陈述式只有副作用,因此它们不会组合。
如果你像我们一样通过组合函数来创建工作流,那么副作用自然会被吸引到工作流的末端:像ForEach这样的函数没有一个有用的返回值,所以这就是管道的终点。 这有助于隔离副作用,甚至在视觉上。
不使用陈述式进行编程的想法一开始看起来很陌生,但正如本章和前几章的代码所证明的,在C#中是完全可行的。 请注意,唯一的语句是最后一个ForEach中的两个;这很好,因为我们想有两个副作用——没有必要隐藏它。
我建议你尝试使用表达式进行编码。 它不能保证好的设计,但它肯定能促进更好的设计。(提示:从C#6开始,有一个赠品——如果你有大括号,它就是一个陈述式。)
表达式、陈述式、声明
表达式包括任何产生值的东西,例如:
- 文字,例如 123 或 “something”
- 变量,例如 x
- 调用,如 “hello”.ToUpper()或Math.Sqrt(Math.Abs(n) + m)
- 操作符和操作数,如a || b, b ? x : y或new object()
表达式可以用在期望有值的地方:例如,作为函数调用的参数或作为函数的返回值。
陈述式语句是对程序的指令,如赋值、条件(if/else)、循环等。
声明(类、方法、字段等)通常被认为是语句,但为了讨论的目的,最好将其本身视为一个类别。无论你喜欢陈述式还是表达式,声明都是同样必要的,所以最好不要把它们放在 "陈述式与表达式 "的争论中。
5.5.2 声明式与命令式
当我们喜欢用表达式来代替陈述式时,我们的代码就会变得更加声明性。 它 "声明 "正在计算的内容,而不是指示计算机在计算中要进行哪些具体的操作。换句话说,它是更高层次的,而且更接近于我们与其他人类交流的方式。
例如,我们控制器中的顶层工作流程如下:
=> Some(transfer)
.Map(Normalize)
.Where(validator.IsValid)
.ForEach(Book);
如果不考虑像Map和Where这样的东西,它们基本上是作为操作之间的粘合剂,这读起来非常像工作流的口头、要点定义。这意味着代码更接近语言,因此更容易理解和维护。让我们在表5.1中对比一下命令式和声明式风格。
另一件值得指出的事情是,由于声明式代码是更高层次的,它很难在没有单元测试的情况下看实现并看到它的工作。这实际上是一件好事:通过单元测试来说服自己,要比依靠看代码并看到它看起来像在做正确的事情的错误信心要好得多。
5.5.3 函数式的分层处理
我们所看到的实现揭示了一种自然的方法,即用函数组合来构造应用程序。 在任何合理复杂的应用程序中,我们都会引入某种形式的分层,区分从高到低的组件层次,其中最高级别的组件是应用程序的入口(在我们的例子中是控制器),最低级别的是出口(在我们的例子中是存储库和 SWIFT 服务)。
不幸的是,我在许多项目中,分层更像是一种诅咒,而不是一种祝福,因为你需要为任何操作遍历几个层。 这是因为有一种趋势是在各层之间构造调用,如图5.8所示。
在这种方法中,有一个隐含的假设,即一个层应该只调用到紧邻的层。这使得架构变得僵硬。此外,这意味着整个实现将是不纯的:因为最底层的组件有副作用(它们通常访问数据库或外部API),上面的一切也是不纯的–调用不纯函数的函数本身就是不纯的。
在本章演示的方法中,各层之间的互动看起来更像图5.9。
也就是说,高层组件可以依赖任何低层组件,但反之亦然–这是一种更灵活、更有效的分层方法。在我们的例子中,有一个顶层的工作流程,对低层组件所暴露的功能进行组合。
这里有几个优点:
- 你可以在顶层组件中得到一个清晰的、合成的工作流程概览(但请注意,这并不排除你在低层组件中定义子工作流程)。
- 中级组件可以是纯的。
在我们的例子中,组件之间的互动看起来像图5.10。
正如你所看到的,领域表示可以(而且应该!)只由纯函数组成,因为没有与下层组件的交互;只有基于输入的结果的计算。其他功能也是如此,比如验证(取决于验证的内容)。因此,这种方法可以帮助你隔离副作用,方便测试。因为领域模型和其他中层组件是纯函数,它们可以很容易地被测试,而不需要模拟。
总结
- 函数组合是指将两个或多个函数组合成一个新的函数,它在FP中被广泛使用。
- 在C#中,扩展方法语法允许你通过链式方法来使用函数组合。
- 如果函数是纯的、可链接的和保持形状的,则它们适合组合。
- 工作流程是一系列的操作,可以通过函数管道在你的程序中有效地表达出来:工作流程的每一步都有一个函数,每个函数的输出都被输入下一个函数。
- LINQ库有一套丰富的、容易组合的函数来处理IEnumerables,你可以用它作为灵感来编写你自己的API。
- 与命令式代码不同,函数式代码更倾向于表达式,而不是陈述式。
- 依靠表达式可以使你的代码变得更具有声明性,因此也更具有可读性。