#函数式编程 Functional Programming in C# [35]

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的规则。我们接下来会研究这个问题,但为了做到这一点,我们必须先退一步,看看将一个值的列表减少到一个单一值的一般模式。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值