用通知替换验证中的抛出异常

如果你正在验证一些数据,你通常不应该使用异常来表示验证失败。在这里,我将描述如何将此类代码重构为使用通知模式。

2014 年 12 月 9 日



我最近查看了一些代码,这些代码用于对一些传入的 JSON 消息进行基本验证。它看起来像这样。

public void check() {
   if (date == null) throw new IllegalArgumentException("date is missing");
   LocalDate parsedDate;
   try {
     parsedDate = LocalDate.parse(date);
   }
   catch (DateTimeParseException e) {
     throw new IllegalArgumentException("Invalid format for date", e);
   }
   if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
   if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
   if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
 }

此示例的代码是 Java

这是处理验证的一种常见方法。你对一些数据运行一系列检查(这里只是所讨论类中的一些字段)。如果任何这些检查失败,你将抛出一个包含错误消息的异常。

我对这种方法有两个问题。首先,我不喜欢将异常用于这种事情。异常表示代码中预期行为范围之外的事情。但是,如果你对外部输入运行一些检查,这是因为你预计一些消息会失败——如果失败是预期行为,那么你不应该使用异常。

如果失败是预期行为,那么你不应该使用异常

这种代码的第二个问题是它在检测到第一个错误时就会失败,但通常最好报告传入数据的所有错误,而不仅仅是第一个错误。这样,客户端可以选择将所有错误显示给用户以在一次交互中修复,而不是给她一种与计算机玩打地鼠游戏的印象。

我处理此类验证问题的首选方法是 通知模式。通知是一个收集错误的对象,每个验证失败都会向通知添加一个错误。验证方法返回一个通知,然后你可以对其进行询问以获取更多信息。一个简单的用法对于检查来说有这样的代码。

private void validateNumberOfSeats(Notification note) {
  if (numberOfSeats < 1) note.addError("number of seats must be positive");
  // more checks like this
}

然后,我们可以进行一个简单的调用,例如 aNotification.hasErrors(),以在存在任何错误时做出反应。通知上的其他方法可以深入了解有关错误的更多详细信息。 [1]

何时使用此重构

我需要在这里强调的是,我并不是主张在整个代码库中消除异常。异常是一种非常有用的技术,用于处理异常行为并将其从主要逻辑流中分离出来。这种重构只适合在异常表示的结果并非真正异常时使用,因此应通过程序的主要逻辑来处理。我在这里看到的示例,验证,就是这种情况的常见情况。

考虑异常时一个有用的经验法则来自务实程序员

我们认为异常很少应该用作程序正常流程的一部分:异常应该保留用于意外事件。假设未捕获的异常将终止你的程序,并问问自己,“如果我删除所有异常处理程序,这段代码还能运行吗?” 如果答案是“否”,那么也许异常正在非异常情况下使用。

-- 戴夫·托马斯和安迪·亨特

一个重要的结果是,是否将异常用于特定任务取决于上下文。因此,正如务实程序员所说,读取不存在的文件可能是也可能不是异常,具体取决于情况。如果你试图读取一个众所周知的文件位置,例如 unix 系统上的 /etc/hosts,那么你很可能可以假设该文件应该存在,因此抛出异常是合理的。另一方面,如果你试图从用户在命令行中输入的路径读取文件,那么你应该预计该文件很可能不存在,并且应该使用其他机制——一种传达错误非异常性质的机制。

在某些情况下,将异常用于验证失败可能是明智的。这将是你在处理过程中预计数据已经过验证,但你希望再次运行验证检查以防编程错误导致一些无效数据泄露的情况。

本文介绍了在验证原始输入的上下文中用通知替换异常。你可能还会发现这种技术在其他情况下也很有用,在这些情况下,通知比抛出异常更好,但我在这里重点关注验证情况,因为它是一种常见情况。

起点

到目前为止,我还没有提到示例域,因为我只对代码的总体形状感兴趣。但是,当我们进一步探索示例时,我需要参与域。在这种情况下,它是一些接收 JSON 消息预订剧院座位的代码。代码位于一个预订请求类中,该类使用 gson 库从 JSON 中填充。

gson.fromJson(jsonString, BookingRequest.class)

Gson 获取一个类,查找与 JSON 文档中的键匹配的任何字段,然后填充匹配的字段。

预订请求仅包含两个我们在这里验证的元素,即演出日期和请求的座位数量

class BookingRequest…

  private Integer numberOfSeats; 
  private String date;

验证检查是我上面显示的那些

class BookingRequest…

  public void check() {
     if (date == null) throw new IllegalArgumentException("date is missing");
     LocalDate parsedDate;
     try {
       parsedDate = LocalDate.parse(date);
     }
     catch (DateTimeParseException e) {
       throw new IllegalArgumentException("Invalid format for date", e);
     }
     if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
     if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
     if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
   }

构建通知

为了使用通知,你必须创建通知对象。通知可以非常简单,有时只是一系列字符串就可以完成任务。

通知收集在一起的错误

List<String> notification = new ArrayList<>();
if (numberOfSeats < 5) notification.add("number of seats too small");
// do some more checks

// then later…
if ( ! notification.isEmpty()) // handle the error condition

虽然简单的列表习惯语创建了模式的轻量级实现,但我通常喜欢做更多的事情,而不是创建一个简单的类。

public class Notification {
  private List<String> errors = new ArrayList<>();

  public void addError(String message) { errors.add(message); }
  public boolean hasErrors() {
    return ! errors.isEmpty();
  }
  …

通过使用一个真正的类,我可以使我的意图更清晰——读者不必在习惯语及其完整含义之间进行心理映射。

拆分检查方法

我的第一步是将检查方法拆分为两部分,一个内部部分最终将只处理通知而不抛出任何异常,以及一个外部部分,它将保留检查方法的当前行为,即如果存在任何验证失败,则抛出异常。

我执行此操作的第一步是使用 提取方法,以一种不寻常的方式,即我将检查方法的整个主体提取到一个验证方法中。

class BookingRequest…

  public void check() {
    validation();
  }

  public void validation() {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
  }

然后,我调整验证方法以创建并返回一个通知。

class BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
    return note;
  }

我现在可以测试通知,如果它包含错误,则抛出异常。

class BookingRequest…

  public void check() {
    if (validation().hasErrors()) 
      throw new IllegalArgumentException(validation().errorMessage());
  }

我将验证方法设为公共方法,因为我预计未来大多数调用者将更喜欢使用此方法,而不是检查方法。

拆分原始方法允许我将验证检查与如何响应失败的决定分开。

在这一点上,我还没有改变代码的行为,通知将不包含任何错误,任何验证检查失败都将继续抛出异常并忽略我放入的新机制。但是,我现在已经设置好了一切,准备开始用操作通知来替换异常抛出。

但是,在我继续之前,我需要说一些关于错误消息的内容。当我们进行重构时,规则是避免可观察行为的变化。在这种情况下,这样的规则会立即导致关于什么行为是可观察的问题。显然,抛出正确的异常是外部程序将观察到的东西——但他们在多大程度上关心错误消息?通知最终将收集多个错误,并可以将它们汇总到一条消息中,例如

class Notification…

  public String errorMessage() {
    return errors.stream()
      .collect(Collectors.joining(", "));
  }

但这将是一个问题,如果程序的更高层依赖于仅从检测到的第一个错误中获取消息,在这种情况下,你需要类似的东西

class Notification…

  public String errorMessage() { return errors.get(0); }

你不仅要查看调用函数,还要查看任何异常处理程序,以确定在这种情况下正确的响应是什么。

虽然我应该在这一点上没有引入任何问题,但我肯定会先编译和测试,然后再进行下一步更改。仅仅因为没有任何明智的人可能搞砸了这些更改并不意味着我不能搞砸它。

验证数字

现在要做的显而易见的事情是替换第一个验证

class BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) note.addError("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
    return note;
  }

一个显而易见的举动,但这是一个糟糕的举动,因为这将破坏代码。如果我们将一个空日期传递给函数,它将向通知添加一个错误,然后愉快地尝试解析它并抛出一个空指针异常——这不是我们想要的异常。

因此,在这种情况下,非显而易见但更有效的事情是向后走。

class BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) note.addError("number of seats must be positive");
    return note;
  }

之前的检查是一个空检查,因此我们需要使用条件语句来避免创建空指针异常。

class BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) note.addError("number of seats cannot be null");
    else if (numberOfSeats < 1) note.addError("number of seats must be positive");
    return note;
  }

我看到下一个检查涉及不同的字段。再加上之前重构中必须引入条件语句,我现在认为这个验证方法变得太复杂了,需要分解。因此,我提取了数字验证部分。

class BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    validateNumberOfSeats(note);
    return note;
  }

  private void validateNumberOfSeats(Notification note) {
    if (numberOfSeats == null) note.addError("number of seats cannot be null");
    else if (numberOfSeats < 1) note.addError("number of seats must be positive");
  }

查看提取的数字验证,我不太喜欢它的结构。我不喜欢在验证中使用 if-then-else 块,因为它很容易导致代码过度嵌套。我更喜欢在无法继续时立即中止的线性代码,我们可以使用保护子句来实现。因此,我应用了 用保护子句替换嵌套条件语句

class BookingRequest…

  private void validateNumberOfSeats(Notification note) {
    if (numberOfSeats == null) {
      note.addError("number of seats cannot be null");
      return;
    }
    if (numberOfSeats < 1) note.addError("number of seats must be positive");
  }

当我们重构时,我们应该始终尝试采取尽可能小的步骤来保留行为

我为了保持代码绿色而向后走的决定是重构的一个关键要素的例子。重构是一种通过一系列保留行为的转换来重构代码的特定技术。因此,当我们重构时,我们应该始终尝试采取尽可能小的步骤来保留行为。通过这样做,我们减少了导致我们陷入调试器的错误的可能性

验证日期

对于日期验证,我认为我将从 提取方法 开始

class BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    validateDate(note);
    validateNumberOfSeats(note);
    return note;
  }

  private void validateDate(Notification note) {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
  }

当我使用 IDE 中的自动提取方法时,生成的代码不包含通知参数。因此,我不得不手动添加它。

现在是时候开始通过日期验证向后滚动

class BookingRequest…

  private void validateDate(Notification note) {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
  }

在第二步中,错误处理存在一个复杂之处,因为抛出的异常包含一个原因异常。为了处理这个问题,我需要更改通知以接受原因异常。由于我正在将抛出更改为向通知添加错误,因此我的代码是红色的,因此我撤回了正在做的事情,将 validateDate 方法保留在上面的状态,同时我准备通知以包含原因异常。

我开始通过添加一个新的 addError 方法来修改通知,该方法接受原因,并调整原始方法以调用新方法。 [2]

class Notification…

  public void addError(String message) {
    addError(message, null);
  }

  public void addError(String message, Exception e) {
    errors.add(message);
  }

这意味着我们接受原因异常,但忽略它。为了将它放在某个地方,我需要将错误记录从一个简单的字符串更改为一个稍微不那么简单的对象。

class Notification…

  private static class Error {
    String message;
    Exception cause;

    private Error(String message, Exception cause) {
      this.message = message;
      this.cause = cause;
    }
  }

我通常不喜欢 Java 中的非私有字段,但由于这是一个私有内部类,所以我对此可以接受。如果我要将此错误类暴露在通知之外,我会封装这些字段。

现在我有了这个类,我需要修改通知以使用它而不是字符串。

class Notification…

  private List<Error> errors = new ArrayList<>();

  public void addError(String message, Exception e) {
    errors.add(new Error(message, e));
  }
  public String errorMessage() {
    return errors.stream()
            .map(e -> e.message)
            .collect(Collectors.joining(", "));
  }

有了新的通知,我现在可以对预订请求进行更改

class BookingRequest…

  private void validateDate(Notification note) {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      note.addError("Invalid format for date", e);
      return;
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");

由于我已经在提取的方法中,因此使用 return 中止其余验证很容易。

最后一个更改很简单。

class BookingRequest…

  private void validateDate(Notification note) {
    if (date == null) {
      note.addError("date is missing");
      return;
    }
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      note.addError("Invalid format for date", e);
      return;
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
  }

向上移动堆栈

一旦我们有了新的方法,下一个任务是查看原始检查方法的调用者,并考虑调整它们以使用新的验证方法。这将需要更广泛地了解验证如何在应用程序流程中发挥作用,因此超出了本次重构的范围。但中期目标应该是消除在任何可能出现验证失败的情况下使用异常。

在许多情况下,这应该会导致能够完全摆脱检查方法。在这种情况下,该方法的任何测试都应该重新使用验证方法。我们可能还想调整测试以探测使用通知正确收集多个错误。


脚注

1: 另一种常见的验证方法是只返回一个布尔值,指示输入是否有效。虽然这使得调用者很容易调用不同的行为,但它没有提供任何方法来提供除了无用的“发生错误”之外的诊断信息。

2: 这有时被称为链式构造函数。你也可以把它看作是部分应用的一个例子——不是说函数式程序员会在 Java 程序的贫民窟使用这种术语。

进一步阅读

关于何时使用异常,已经写了很多。正如你可能猜到的,我关于这方面更多阅读的第一个建议是 The Pragmatic Programmer。在 Code Complete 中也有很好的讨论。这两本书都应该是任何专业程序员所熟悉的。

我还很喜欢 Avdi Grimm 的 Exceptional Ruby 中关于如何处理错误条件的讨论。虽然它直接是一本 Ruby 书,但它的大部分建议都适用于任何编程环境。

框架

许多框架提供某种使用通知模式的验证功能。在 Java 世界中,有 Java Bean Validation 工作和 Spring 的验证。这些框架提供某种形式的接口来启动验证并使用通知来收集错误(对于 bean 验证来说是 Set<ConstraintViolation>,对于 Spring 的情况来说是 Errors)。

你应该查看你的语言和平台,看看它们是否有使用通知的验证机制。这种机制的工作细节会改变重构的细节,但总体形状应该非常相似。

致谢

Andy Slocum、Carlos Villela、Charles Haynes、Dave Elliman、Derek Hammer、Ian Cartwright、Ken McCormack、Kornelis Sietsma、Rob Speller、Stefan Smith 和 Steven Lowe 在我们内部邮件列表中对本文草稿进行了评论。

重大修订

2014 年 12 月 9 日: 首次发布