6.4 向客户端应用程序表示结果
现在你已经看到了很多使用Option和Either的用例。这两种类型都可以被看作是代表结果:在Option的情况下,None可以表示失败;在Either的情况下,它是Left。我们已经将Option和Either定义为C#类型,但在本节中你将看到如何将它们转化到外部世界。
尽管我们已经为这两种类型定义了 Match,但我们很少使用它,而是依靠 Map, Bind, 和 Where 来定义工作流。 请记住,这里的关键区别在于后者是在抽象的范围内工作(比如说你从 Option开始,最后得到一个 Option)。另一方面,Match允许你离开抽象(你从 Option开始,最后得到一个 R)。见图6.3。
一般来说,一旦你引入了像 Option 这样的抽象概念,最好是尽可能地坚持下去。什么是 "尽可能长 "呢?理想情况下,这意味着当你跨越应用程序的边界时,你会离开这个抽象的世界。
在设计应用程序时,将包含服务和领域逻辑的应用核心与包含一组适配器的外层分开是一种很好的做法,通过这些适配器,你的应用程序与外部世界进行交互。你可以把你的应用程序看成是一个橙色,皮肤是由一层适配器组成的,如图6.4所示。
像Option和Either这样的抽象在应用核心中是很有用的,但它们可能不能很好地转化为交互应用所期望的消息契约。因此,外层是你需要离开抽象的地方,并转化为你的客户应用程序所期望的表示。
6.4.1 暴露一个类似 Option 的接口
想象一下,在我们的银行业务场景中,我们有一个API,给定一个 “代码”(股票或其他金融工具的标识符,如AAPL、GOOG或MSFT),返回所请求的金融工具的详细信息。这些细节可能包括该金融工具的交易市场,当前的价格水平,等等。
在应用核心中,我们可以有一个暴露这种功能的服务:
public interface IInstrumentService {
Option < InstrumentDetails > GetInstrumentDetails(string ticker);
}
我们不能知道作为ticker的字符串是否真的识别了一个有效的实例,所以这在应用核心中用Option来模拟。
接下来,让我们看看如何将这些数据暴露给外部世界。我们将创建一个API端点,把它映射到一个扩展控制器的类上的方法。该控制器有效地充当了应用核心和消费API的客户端之间的适配器。
假设 API 返回 HTTP 上的 JSON(一种不涉及选项的格式和协议),因此控制器是我们可以将我们的Option“转换”为该协议支持的内容的最后一点。这是我们将使用Match的地方。我们可以实现控制器,如下面的清单所示。
清单 6.8 将 None 转换为状态码 404
using Microsoft.AspNet.Mvc;
public class InstrumentsController: Controller {
Func < string, Option < InstrumentDetails >> getInstrumentDetails;
[HttpGet, Route("api/instruments/{ticker}/details")]
public IActionResult GetInstrumentDetails(string ticker)
=> getInstrumentDetails(ticker)
.Match < IActionResult > (
() => NotFound(), //None 映射到 404。
(result) => Ok(result)); // some 映射到 200。
}
事实上,前面的方法体可以写得更简洁:
=> getInstrumentDetails(ticker)
.Match<IActionResult>(
None: NotFound,
Some: Ok);
(可以写得更简洁些,不写参数名称)。
POINT-FREE STYLET 这种省略显式参数(在我们的例子中是结果)的方式有时被称为 “point-free”,因为 "data points"是省略的。开始时有点令人生畏,但一旦你习惯了它,就会变得更干净。
让我们暂停一下,看看为什么我们能把这个写得如此简洁。请记住,Match期待以下内容:
- 如果 Option 为 None 则要调用的空函数
- 一个接受Option内部值类型的函数,如果Option是Some,则调用该函数。
特别针对当前情况,其中 T 是 InstrumentDetails 并且所需的结果类型是 IActionResult,我们需要这些类型的函数:
None : () -> IActionResult
Some : InstrumentDetails -> IActionResult
这里我们使用了两个在 Controller 基类上定义的方法:
- HttpNotFound - 将 None 转换为 404 响应
- Ok - 将 Option 的内部值转换为状态代码为 200 的响应
我们来看看这些方法的类型:
HttpNotFound : () -> HttpNotFoundResult
Ok : object -> HttpOkObjectResult
类型对齐是因为 HttpOkObjectResult 和 HttpNotFoundResult 都实现了 IActionResult,而 InstrumentDetails 自然是一个对象。
现在你已经看到了如何将一个基于 Option 的工作流程模型化,并通过HTTP API将其暴露出来。接下来,让我们看看基于Either的接口。
6.4.2 暴露一个类似Either的接口
就像 Option 一样,一旦你把你的价值提升到 Either 的高层次世界,最好是呆在那里直到工作流结束。但是,所有的好事都必须结束,所以在某些时候你需要离开你的应用域,向外部世界公开你的Either的代表。
让我们回到我们在本章中看到的银行业务场景–客户要求在未来某个日期预订转账。我们的服务层返回一个Either<Error,Unit>,我们必须把它翻译成,例如,通过HTTP的JSON。
一种方法类似于我们刚才看的Option:我们可以使用HTTP status代码400来表示我们收到了一个错误的请求。
清单 6.9 将 Left 转换为状态码 400
public class BookTransferController: Controller {
private IHandler < BookTransfer > transfers;
[HttpPost, Route("api/transfers/future")]
public IActionResult BookTransfer([FromBody] BookTransfer request)
=> transfers.Handle(request).Match < IActionResult > (
Left: BadRequest,
Right: _ => Ok());
}
这很有效。 唯一的缺点是,业务验证与HTTP错误代码之间的关系是非常不稳定的。 有些人认为,400标志着语法上不正确的请求,而不是语义上不正确的请求,就像这里的情况。
在并发的情况下,一个在发出请求时有效的请求在服务器收到时可能不再有效(例如,账户余额可能已经下降)。400是否表达了这一点?
与其试图找出最适合特定错误情况的HTTP状态代码(毕竟HTTP的设计并没有考虑到RESTful APIs),另一种方法是在响应中返回结果的表示。我们接下来将探讨这个选项。
6.4.3 返回结果 DTO (data transfer object )
这种方法包括总是返回一个成功的状态代码(因为在低层次上,响应被正确接收和处理),以及在响应体中对结果的任意表述。
这种表示法只是一个简单的数据传输对象(DTO),它表示完整的结果,包括其左右两部分。
清单 6.10 表示结果的 DTO,在响应中被序列化
public class ResultDto < T > {
publicbool Succeeded {
get;
}
publicbool Failed => !Succeeded;public T Data {
get;
}
public Error Error {
get;
}
public ResultDto(T data) {
Succeeded = true;
Data = data;
}
public ResultDto(Error error) {
Error = error;
}
}
这个ResultDto与Either非常相似。但与Either不同的是,Either的内部值只能通过高阶函数访问,而DTO将它们暴露出来,以便于在客户端进行序列化和访问。
然后我们可以定义一个实用函数,将一个Either转化为一个ResultDto。
public static ResultDto < T > ToResult < T > (this Either < Error, T > either)
=> either.Match(
Left: error => new ResultDto < T > (error),
Right: data => new ResultDto < T > (data));
现在我们可以在我们的 API 方法中公开 Result ,如下所示。
清单 6.11 作为成功响应有效载荷的一部分返回错误细节
public class BookTransferController: Controller {
[HttpPost, Route("api/transfers/book")]
public ResultDto < Unit > BookTransfer([FromBody] BookTransfer cmd)
=> Handle(cmd).ToResult();
Either < Error, Unit > Handle(BookTransfer cmd) //...
}
总的来说,这种方法意味着控制器中的代码更少。更重要的是,这意味着您在表示结果时不依赖于 HTTP 协议的特性,而是可以创建最适合您的结构来表示您选择视为 Left 的任何条件。
最后,这两种方法都是可行的,并且都被广泛用于 API。您选择哪种方法更多地与 API 设计有关,而不是与函数式编程有关。关键是,当暴露给客户端应用程序结果时,您通常必须做出一些选择,您可以在应用程序中使用任何一个进行建模。
我通过HTTP API的例子来说明 "降低 "抽象的值,因为这是一个常见的需求,但是如果你暴露了其他类型的端点,这些概念并没有改变。总而言之,如果你在橙色的表面上,请使用Match;在橙色的核心部分,请留心抽象概念。