7.5 模块化和组成一个应用程序
随着应用程序的增长,我们需要将其模块化,并将其分解为各个组成部分。例如,在第6章中,你看到了一个处理转账请求的端到端例子。 我们把所有的代码放在控制器中,最后控制器中的成员列表是这样的。
清单 7.9 一个责任太多的控制器?
public class BookTransferController: Controller {
DateTime now;
static readonly Regex regex = new Regex("^[A-Z]{6}[A-Z1-9]{5}$");
string connString;
ILogger < BookTransferController > logger;
public IActionResult BookTransfer([FromBody] BookTransfer request)
IActionResult OnFaulted(Exception ex)
Validation < Exceptional < Unit >> Handle(BookTransfer request)
Validation < BookTransfer > Validate(BookTransfer cmd)
Validation < BookTransfer > ValidateBic(BookTransfer cmd)
Validation < BookTransfer > ValidateDate(BookTransfer cmd)
Exceptional < Unit > Save(BookTransfer transfer)
}
如果这是一个真实世界的银行应用程序,您将拥有的不是两条,而是数十条规则来检查转账请求的有效性。您还可以使用身份和会话管理、检测等功能。简而言之,控制器很快就会变得太大,您需要将其分解为具有更多离散职责的独立组件。这使您的代码更加模块化且更易于管理。
模块化的另一大动力是代码的重用:比如说,会话管理或授权的逻辑可能是几个控制器所需要的,因此应该放在一个单独的组件中。一旦你把一个应用程序分解成不同的组件,你就需要把它重新组合在一起,以便所有需要的组件在运行时都能取消协作。
在本节中,我们将探讨如何处理模块化问题,以及OO和功能化方法在这方面有什么不同。我们将通过重构BookTransferController来说明这一点。
7.5.1 OOP 中的模块化
OOP中的模块化通常是通过将责任分配给不同的对象,并用接口来捕获这些责任来实现的。例如,我们可以定义一个用于验证的IValidator接口和一个用于持久化的IRepository。
清单 7.10 OOP 中的接口捕获组件的职责
public interface IValidator < T > {
Validation < T > Validate(T request);
}
public interface IRepository < T > {
Option < T > Lookup(Guid id);Exceptional < Unit > Save(T entity);
}
然后控制器将依赖这些接口来完成它的工作,如图 7.2 所示。
这遵循一种称为依赖倒置的模式,根据该模式,较高级别的组件(例如控制器)不直接使用较低级别的组件,而是通过抽象,这通常被理解为表示较低级别组件(例如作为验证器和存储库)实现。这种方法有几个好处:
- 解耦 —— 您可以交换存储库实现(将其从写入数据库更改为写入队列),这不会影响控制器。你只需要改变两者的连接方式。 (这通常在一些引导逻辑中定义。)
- 可测试性 —— 您可以通过注入虚假的 IRepository 对处理程序进行单元测试,而无需访问数据库。
还有一个与依赖倒置相关的相当高的成本:
- 接口数量呈爆炸式增长,添加样板并使代码难以导航。
- 组成应用程序的引导逻辑通常不是简单的事情。
- 为可测试性构建虚假实现可能很复杂。
为了管理这种额外的复杂性,通常使用第三方框架,即IoC容器和mocking框架。如果我们遵循这种方法,控制器的实现最终会是这样的。
清单7.11 一个在小范围内是函数式的、在大范围内是OO的实现。
public class BookTransferController: Controller {
IValidator < BookTransfer > validator;
IRepository < BookTransfer > repository;
public BookTransferController(IValidator < BookTransfer > validator, IRepository < BookTransfer > repository) {
this.validator = validator;
this.repository = repository;
}
[HttpPost, Route("api/transfers/book")]
public IActionResult TransferOn([FromBody] BookTransfer cmd)
=> validator.Validate(cmd)
.Map(repository.Save)
.Match(
Invalid: BadRequest,
Valid: result => result.Match < IActionResult > (
Exception: _ => StatusCode(500, Errors.UnexpectedError),
Success: _ => Ok()));
}
你可以说前面的实现是 “小范围函数式,大范围OO”。 主要组件(控制器、验证器、存储库)确实是对象,而程序行为被编码在这些对象的方法中。 另一方面,许多函数式的概念被用在方法的实现和定义它们的签名中。
这种在整个OO软件架构中使用函数式技术的方法,是将FP与OOP结合起来的一种完全有效的方式。 也有可能推动函数式方法,使所有的行为都被捕获在函数中。接下来你会看到这一点。
7.5.2 FP 中的模块化
如果 OOP 的基本单位是对象,那么在 FP 中它们就是函数。 FP 中的模块化是通过将职责分配给函数来实现的,而组合则是通过组合函数来实现的。在函数式方法中,我们不定义接口,因为函数签名已经提供了我们需要的所有接口。
例如,在第二章中,你看到一个需要知道当前时间的验证器类不需要依赖一个 "服务 "对象,而只需要依赖一个返回当前时间的函数。
清单 7.12 将函数作为依赖项注入
public class DateNotPast: IValidator < BookTransfer > {
Func < DateTime > clock;
public DateNotPast(Func < DateTime > clock) {
this.clock = clock;
}
public Validation < BookTransfer > Validate(BookTransfer cmd)
=> cmd.Date.Date < clock().Date
? Errors.TransferDateIsPast
: Valid(cmd);
}
毕竟,如果不是一个你可以调用以获得当前时间的函数,那么什么是时钟呢?但让我们再进一步:为什么你首先需要IValidator接口?毕竟,如果不是一个你可以调用的函数来发现一个给定的对象是否有效,那么什么是验证器呢?让我们用一个委托来表示验证:
// T -> Validation<T>
public delegate Validation<T> Validator<T>(T t);
如果我们遵循这种方法,BookTransferController 不依赖于 IValidator 对象,而是依赖于 Validator 函数。并且要实现验证器,您甚至不需要拥有对象并将依赖项存储为字段;相反,依赖项可以作为函数参数传递。
清单 7.13 依赖项可以作为参数传递给函数
public static Validator < BookTransfer > DateNotPast(Func < DateTime > clock)
=> cmd
=> cmd.Date.Date < clock().Date
? Errors.TransferDateIsPast
: Valid(cmd);
在这里,DateNotPast是一个HOF,它接收一个函数 clock(为了知道当前日期,它需要的依赖关系)并返回一个Validator类型的函数。请注意,这种方法使你免去了创建接口、在构造函数中注入接口并将其存储在字段中的整个仪式。
让我们看看你如何创建一个验证器。在启动应用程序时,你会给DateNotPast一个函数,从系统时钟中读取。
Validator<BookTransfer> val = DateNotPast(() => DateTime.UtcNow());
然而,为了测试目的,你可以提供一个返回恒定日期的时钟。
var uut = DateNotPast(() => new DateTime(2020, 20, 10));
请注意,这实际上是部分应用。DateNotPast是一个二进制函数(柯里形式),需要一个时钟和一个命令来计算其结果。 你在组成应用程序时(或在单元测试的安排阶段)提供第一个参数,而在实际处理收到的请求时(或在单元测试的行为阶段)提供第二个参数。
除了验证器,BookTransferController还需要一个依赖关系来持久化BookTransfer的请求数据。如果我们要使用函数,我们可以用下面的签名来表示:
BookTransfer -> Exceptional< Unit >
同样,我们可以从一个非常通用的函数开始创建这样一个函数,该函数写入数据库,具有以下签名:
TryExecute : ConnectionString -> SqlTemplate -> object -> Exceptional< Unit >
然后,我们可以用配置中的连接字符串和带有我们想执行的命令的SQL模板对其进行参数化。这和你在7.3节中看到的代码非常相似,所以我在这里省略全部细节。现在我们的控制器实现将是这样的:
public class BookTransferController: Controller
{
Validator < BookTransfer > validate;
Func < BookTransfer, Exceptional < Unit >> save;
[HttpPut, Route("api/transfers/book")] public IActionResult BookTransfer([FromBody] BookTransfer cmd)
=> validate(cmd).Map(save).Match( //...
}
当然,如果我们采用这种方法得出其逻辑结论,我们应该质疑为什么我们需要一个控制器类,当我们使用的所有逻辑都可以在这种类型的函数中捕获时:
BookTransfer -> IActionResult
实际上,我们可以在控制器范围之外定义这样一个函数,并配置 ASP.NET 请求管道以在接收到与相关路由匹配的请求时运行它。我不打算在这里展示这种重构,无论是在空间的兴趣,并且还因为 ASP.NET 目前没有为这种处理 HTTP 请求的风格提供很好的支持,所以在大多数情况下使用控制器方法作为入口点是更可取的。
7.5.3 比较两种方法
在刚刚显示的实现中,控制器的所有依赖项都是函数。请注意,使用这种方法,您仍然可以获得与依赖反转相关的好处:
- 解耦 —— 控制器对其使用的函数的实现细节一无所知。
- 可测试性 —— 在测试控制器方法时,您可以只传递返回可预测结果的函数。
你也减轻了它的OOP版本中与依赖反转有关的一些问题:
- 您不需要定义任何接口。
- 这使得测试更容易,因为您不需要设置fakes(模拟)。
例如,我们在本节开发的用例的测试可以是这样的。
清单 7.14 当依赖关系是函数时,单元测试可以不假思索地写。
[Test]
public void WhenCmdIsValid_AndSaveSucceeds_ThenResponseIsOk() {
var controller = new BookTransferController(
validate: cmd => Valid(cmd), //注入返回可预测结果的函数
save: _ => Exceptional(Unit()));
var result = controller.BookTransfer(new BookTransfer());
Assert.AreEqual(typeof (OkResult), result.GetType());
}
到目前为止,函数式方法似乎更可取。还有一个不同之处需要指出。在 OO 实现(清单 7.10)中,控制器依赖于一个 IRepository 接口,定义如下:
public interface IRepository < T > {
Option < T > Lookup(Guid id);
Exceptional < Unit > Save(T entity);
}
但是请注意,控制器只使用了保存方法。这违反了接口隔离原则(ISP),即客户不应该依赖他们不使用的方法。它的意思是,仅仅因为你把房子的钥匙交给你13岁的儿子,并不意味着他也应该拥有你的汽车钥匙。IRepository接口实际上应该被分解成两个单一方法的接口,而控制器应该依赖于一个更小的接口,就像这样:
public interface ISaveToRepository < T > {
Exceptional < Unit > Save(T entity);
}
这就进一步增加了应用程序中接口的数量。如果你对ISP的要求足够高的话,最终你将会得到大量的单方法接口,这些接口传达的信息与函数签名相同,最终使得直接注入函数更加简单,就像你在函数式方法中看到的那样。
当然,如果控制器确实需要一个函数来读取和写入,那么在函数式风格中,我们必须注入两个函数,增加依赖的数量。像往常一样,函数式风格是比较明确的。
7.5.4 组成应用程序
最后,让我们看看所有的部分是如何连接的。这是一个ASP.NET应用程序,所以引导逻辑应该定义在一个IControllerActivator中,每当收到一个应该被路由到控制器的请求时,框架就会调用它。
清单 7.15 组成完成 BookTransfer 请求所需的服务
public class ControllerActivator: IControllerActivator {
IConfigurationRoot configuration;
public object Create(ControllerContext context) {
var type = context.ActionDescriptor.ControllerTypeInfo;
if (type.AsType().Equals(typeof (BookTransferController)))
return ConfigureBookTransferController();
//...
}
BookTransferController ConfigureBookTransferController() {
ConnectionString connString = configuration
.GetSection("ConnectionString").Value;
// 设置持久性
var save = Sql.TryExecute
.Apply(connString)
.Apply(Sql.Queries.InsertTransferOn);
// 设置验证
var validate = Validation.DateNotPast(() => DateTime.UtcNow);
return new BookTransferController(validate, save);
}
}
其中一些代码是ASP.NET特有的,配置其他类型的应用程序,如控制台应用程序或Windows服务可能更容易。 有趣的是ConfigureBookTransferController方法,在这里你将控制器所需的依赖关系与部分应用程序结合起来。
还有最后一件事。请注意,你传递给它一个单一的验证器来验证日期是否正确。但你真正需要的是一个验证器,它将确保许多验证规则(每个规则由一个特定的函数表示)得到满足。
在OOP中,你可以使用一个复合验证器,它实现了IValidator,并在内部使用一个特定的IValidator列表。但我们想用函数式的方式来做这件事,并有一个Validator函数在内部结合许多Validator的规则。我们接下来会研究这个问题,但为了做到这一点,我们必须先退一步,看看将一个值的列表减少到一个单一值的一般模式。