6.3 验证:Either 的完美用例
让我们重新审视请求转账的场景,但在这种情况下,我们将处理更简单的场景,在该场景中,客户明确请求在未来某个日期进行转账。
应用程序应执行以下操作:
- 验证请求。
- 存储转账详细信息以备将来执行。
- 返回带有成功指示或任何失败详细信息的响应。
我们可以用Either来模拟该操作可能失败的事实。如果传输请求被成功存储,就没有任何有意义的数据可以返回给客户端,所以Right类型参数将是Unit。Left 的类型应该是什么?
6.3.1 为错误选择一个合适的表述
让我们看一下你可以用来捕获错误细节的几个类型。 你看到当通过Map或Bind将函数应用于Either时,Right类型会改变,而Left类型保持不变。因此,一旦你为Left选择了一个类型,这个类型将在整个工作流程中保持不变。
我在之前的一些例子中使用了字符串,但这似乎有局限性;你可能想添加更多关于错误的结构化细节。那么Exception呢?它是一个基类,可以用任意丰富的子类型进行扩展。然而,这里的语义是错误的:Exception表示发生了一些特殊的事情。相反,在这里我们为 "像往常一样 "的错误编码。
相反,我已经包含了一个非常简单的错误基类,只暴露了一个Message属性。我们可以为特定的错误进行子类化。
清单 6.5 代表故障的基类
namespace LaYumba.Functional
{
public class Error
{
public virtual string Message { get; }
}
}
虽然严格来说,Error的表示是领域的一部分,但这是一个足够普遍的要求,我已经把这个类型添加到功能库中。我推荐的方法是为每个错误类型创建一个子类。
例如,这里有一些我们需要的错误类型,以表示验证失败的一些情况。
清单 6.6 不同的类型捕获关于特定错误的细节
namespace Boc.Domain {
public sealed class InvalidBic: Error {
public override string Message {
get;
} = "The beneficiary's BIC/SWIFT code is invalid";
}
public sealed class TransferDateIsPast: Error {
public override string Message {
get;
} = "Transfer date cannot be in the past";
}
}
而且,为了方便,我们将添加一个静态类,Errors,它包含工厂函数,用于创建Error的特定子类:
public static class Errors {
public static InvalidBic InvalidBic => new InvalidBic();
public static TransferDateIsPast TransferDateIsPast => new TransferDateIsPast();
}
这是一个技巧,可以帮助我们保持业务决策所在的代码更干净,正如你将在下面看到的。它还提供了很好的文档,因为它给我们提供了一个为该领域定义的所有具体错误的概览。
6.3.2 定义基于 Either 的 API
让我们假设关于转移请求的细节被捕获在一个类型为BookTransfer的数据转移对象中:这就是我们从客户那里收到的东西,它是我们工作流的输入数据。 我们还确定,工作流应该返回Either<Error,Unit>;也就是说,如果成功的话,没有什么值得注意的,如果失败的话,则返回Error。
这意味着我们需要实现来表示此工作流的主要功能具有类型
BookTransfer -> Either<Error, Unit>
我们现在准备介绍实现的框架。请注意,前面的签名是在 Handle 中捕获的:
public class BookTransferController: Controller {
Either < Error, Unit > Handle(BookTransfer cmd)
=> Validate(cmd).Bind(Save); //使用 "绑定"(Bind)将两个可能失败的操作连在一起。
Either < Error, BookTransfer > Validate(BookTransfer cmd) //使用Either来确认验证可能失败
=> // TODO: add validation...
Either <Error, Unit> Save(BookTransfer cmd)//使用Either来确认持久化请求可能失败。
=> // TODO: save the request...
}
Handle 方法定义了高级工作流:首先验证,然后持久化。 Validate 和 Save 都返回一个Either确认操作可能失败。另请注意,Validate 的签名是Either<Error,BookTransfer>。也就是说,需要右侧的 BookTransfer 命令,以便传输数据可用并且可以通过管道传输到 Save。
接下来,让我们添加一些验证。
6.3.3 添加验证逻辑
让我们先验证一下关于请求的几个简单条件:
- 转账的日期确实在未来
- 提供的 BIC 代码格式正确
我们可以让一个函数执行每个验证。典型的方案将是这样的:
Regex bicRegex = new Regex("[A-Z]{11}");
Either < Error, BookTransfer > ValidateBic(BookTransfer cmd)
{
if (!bicRegex.IsMatch(cmd.Bic))
return Errors.InvalidBic; // 失败:错误将被包裹在一个Either在左侧状态中。
else return cmd; //成功:原始请求将被包裹在一个处于右侧的状态Either中。
}
也就是说,每个验证器函数接受一个请求作为输入,并返回(有价值的)请求或适当的错误。(我通常会在这里使用三元组的if操作符,但它在隐式转换中不能很好地工作。)
每个验证函数都是一个跨越世界的函数(从一个 "正常 "的值BookTransfer到一个 "升高 "的值Either<Error,BookTransfer>),所以我们可以使用Bind来组合这些函数。
清单 6.7 使用 Bind 链接多个验证函数
public class BookTransferController: Controller {
DateTime now;
Regex bicRegex = new Regex("[A-Z]{11}");
Either < Error, Unit > Handle(BookTransfer cmd)
=> Right(cmd) //将cmd提升为Either
.Bind(ValidateBic) //将这些有能失败的操作绑定到一起
.Bind(ValidateDate)
.Bind(Save);
Either < Error, BookTransfer > ValidateBic(BookTransfer cmd) {
if (!bicRegex.IsMatch(cmd.Bic)) return Errors.InvalidBic;
elsereturn cmd;
}
Either < Error, BookTransfer > ValidateDate(BookTransfer cmd) {
if (cmd.Date.Date <= now.Date) return Errors.TransferDateIsPast;
elsereturn cmd;
}
Either < Error, Unit > Save(BookTransfer cmd) => //...
}
总之,用Either来确认一个操作可能会失败,用Bind来链接几个可能失败的操作。 但是如果应用程序在内部使用Either来表示结果,那么它应该如何向通过HTTP等协议与之交流的客户程序表示结果呢? 这是一个同样适用于Option的问题。 每当你使用这些升高的类型时,你就需要在与其他应用程序通信时定义一个翻译。我们接下来会看一下这个问题。