0%

在验证程序输入时使用Notification来替代Exception

本文翻译自Martin Fowler博客,原文地址

如果你要验证一些输入数据,通常不应该选择用Exception的方式来表明验证失败。我想仔细的描述如何使用Notification模式来重构这样的代码。

目录:
什么时候使用此项重构 从哪里开始
构建一个Notification 分解检验方法
验证数字 验证日期
*缩减调用栈

我最近正在琢磨一些处理传入Json消息的验证代码。就像下面这个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void check(){
if(date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try{
parsedDate = LocalDate.parse(date);
} catch(Exception 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代码)

这是处理数据验证的通用方式。在一些数据上执行一系列的检查。如果任何一个检查失败了,就抛出一个带有错误信息的异常。

这样的方式存在一些问题。首先,在这样的场景下使用Exception让我不爽。异常表明发生了一些超出程序预期的事情。但是,如果你对外来输入进行了检查,就说明你对一些信息不合法是有预期的。如果这样的失败是可以预期的,那么就不应该使用异常。

其次,程序只能检测出一个错误,但是最好是能够报告输入参数的所有问题。这样客户端可以将所有的错误都展示给用户,而不是让用户一遍一遍的尝试。

我比较倾向的报告验证方式是像后文描述的Notification模式。一个Notification收集错误,每个验证失败都会想notification增加一个错误。一个验证方法返回一个Notification,让你可以向用户交付更多信息。一个简单的例子如下。

1
2
3
4
private void validateNumberOfSeats(Notication note){
if(numberOfSeats < 1) note.addError("number of seats must be positive");
//more check like this
}

我们可以用一个简单的调用aNotification.hasErrors()来检查是不是有错误。Notification的其他方法可以包含更多详细的错误信息。

代码转变

# 何时使用这项重构
我需要强调,我不是建议从你的代码中删除所有的Exception。异常是处理异常行为并使之与正常逻辑流分离的好方式。这个重构方式,在一些特征表明异常并非是异常性的时候,才比较合适,因此我们需要在主要的逻辑流中来处理。我上面举的例子,就是一个典型的代表。

一个《Pragmatic Programmers》提供的比较有用的经验是:
我们相信异常在程序的正常流程里应该是很少使用的。异常应该表征不可预测的行为。假设一个没被抓住的异常是你的程序崩溃了,那么问问你自己,“如果我去掉了所有的异常处理器,程序还能否运行”,如果答案是NO,那么也许是在不应该使用Exception的地方使用了Exception。 – David Thomas and Andy Hunt

这样的一个重要结果是是否要使用异常来处理特定的任务是要看所处环境的。所以,就像《Pragmatic Programmers》继续解释的那样,读取文件时如果文件不存在,抛不抛出异常要根据具体情况分析了。如果要读的文件是一个众所周知(默认一定存在,例如unix环境下的/etc/hosts),我们将假设次文件存在,如果此时文件不存在,那么此时抛出异常就是合理的。然而,如果你要读的文件是用户通过command line输入的,那么应该认为这个文件有可能不存在,并应使用其他的机制(而不是异常),一个可以表征这个错误本质上不是异常状况的方式。

还有一种情况会比较适合食用异常来验证失败。例如,当使用的数据,在之前的流程已经被验证过,但是想要再次检查以防止衰代码引入脏数据。

这篇文章是关于在验证(用户)原始输入时,使用Notification替代Exception。有可能在其他场景下,用Notification也比使用Exception更合适,但是我还是专注于这个问题(校验用户的原始输入)。

# 起点

到现在还没有给个例子,只是由于我对各种形态的代码都很感兴趣(一时无法取舍)。但是如果我们聊的再深入些,我就必须得对某一种(代码形态)深入探讨了。我想用这样一个例子,即预定电影院座位时的JSON信息的处理。代码是在一个预定请求类中,(这个类)是从JSON中(使用gson)提取出来的。

1
gson.fromJson(jsonString, BookingRequest.class)

这个预定请求中,我们需要检查的只有两个域,预定的时间和预定的座位数。

1
2
3
4
class BookingRequest{
private Integer numberOfSeats;
private String date;
}

验证代码既是向上面展示的那样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void check(){
if(date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try{
parsedDate = LocalDate.parse(date);
} catch(Exception 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代码)

# 构建一个通知
为了使用通知,你必须穿件一个通知对象。一个通知可以非常简单,有时候字符串List就可以玩这个把戏。

1
2
3
4
5
6
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

尽管阿哥简单的List实现了非常轻量的Notification模式,我通常喜欢做的更多些,创建一个简单的类。

1
2
3
4
5
6
7
8
public class Notification {
private List<String> errors = new ArrayList<>();

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


通过使用一个类,可以让我的意图更加明确,(代码的)读者不需要去脑补我的意图和我的实现之间的映射关系。

# 分解检查方法
第一步是将检查方法分解为两个部分,一个内含的部分只会处理通知,并不抛出异常;一个外部部分会维持检查当前检查方法的行为,既是抛出一个异常来表明验证失败。

第一步,用Extract Method将检查相关(check方法)的逻辑抽离到一个新的validation方法中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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");
}

接下来,我会把validation的返回值改为返回一个notification。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

我现在可以检查notification,如果有错误的话,可以抛出一个异常了。
1
2
3
4
5
class BookingRequest
public void check() {
*** if (validation().hasErrors())
*** throw new IllegalArgumentException(validation().errorMessage());
}

我把validation方法置为public,因为我希望更多的调用者可以使用这种方式来验证,而不是check方法。

在这点上,我并没有改变代码的行为,notification不会遗漏任何的错误。但是我已经准备好使用notification来替代异常了。

在我继续之前,我必须说一些有关错误信息的事。当我们进行一次重构时,原则是避免对可观测的行为产生影响。很明显地,抛出相应的异常是外部程序所观测的行为。但是他们(外部程序)对错误信息关注到什么程度呢?notification会最终将多个错误收集起来,汇总称为一个单独的信息,如下:

1
2
3
4
5
class Notification
public String errorMessage() {
return errors.stream()
.collect(Collectors.joining(", "));
}

但是,如果高层程序依赖于“获取第一个错误的信息”,那么就会产生问题。别人没有把这个事搞砸,不代表我不会把他搞砸。

# 验证数字
当前要做的就是把第一个验证替换掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

一个理所当然的改变,但是并不好,因为改变了代码行为。如果我们将一个null的date传入下面的处理过程,会引起NPE,这并不是我们需要的异常。
所以,一个不那么直观,但是更有效的方式是后退一步(即从后向前处理)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

前面的检查,是一个null的检查,所以我们需要使用条件判断来避免NPE。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

我们看到下一个价差涉及到了不同的域。我们必须要给之前的重构引入条件判断了,我开始考虑validation方法开始变的太过复杂并且可以对他做一些肢解。所以我把数字验证部分抽离了出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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块,因为很容易会导致代码杂糅在一起。我选择流畅的短路代码,此时我们可以使用哨兵检测来替代嵌套的判断条件。

1
2
3
4
5
6
7
8
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");
}

我决定从后向前来保证代码能够通过遗留测试,是重构过程中的重要原则。重构是一个特殊的技术,通过一系列行为来改变代码结构的同时保持代码原油的行为。所以当我们重构的时候,我们总是需要保持小步前进,以保持代码原有的行为。通过这样做,我们减少犯错机会,减少debug时间。

# 验证日期
对于日期的验证,我将从提取方法开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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");
}

现在我们可以开始从后向前验证日期了
1
2
3
4
5
6
7
8
9
10
11
12
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");
}

通过第二步,处理错误的过程有些复杂,由于抛出的异常中包含了引起它的异常。为了出了这个情况,我需要调整notification让它能够处理嵌套的异常(cause exception).由于我处于重构的中间步骤,我的代码还不能通过测试,所以我决定暂且将validateDate方法留在上述状态中,我将去丰富notification以使其支持嵌套的异常。

我开始修改notification,增加了一个以异常原因为参数的addError方法,并调整原有的方法使其适配。

1
2
3
4
5
6
7
8
class Notification
public void addError(String message) {
addError(message, null);
}

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

这意味着我们接受嵌套异常,并把他忽略了。为了把cause异常纪录下来,我把error记录从一个简单的对象转变成了一个不那么简单的对象。
1
2
3
4
5
6
7
8
9
10
class Notification…
private static class Error {
String message;
Exception cause;

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

我通常不喜欢java中出现非private的域,但由于这是一个私有的内部类,我觉得也还行。如果我要把error类暴露出去,我会将这几个域包装一下。
1
2
3
4
5
6
7
8
9
10
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(", "));
}

通过最新的notification,我可以开始改预定请求了。

1
2
3
4
5
6
7
8
9
10
11
12
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");

由于我已经提取了方法,现在做起来就很容易了。
最后一步修改也很简单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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");
}

# 将工作栈上移
一旦我们有了新方法,下一个任务就是查看调用原来check方法的调用方,调整他们,使之可以使用心得validate方法。这意味着更广阔的视野来查看validation方法是否适合应用的流程,那么这就超出了本次重构的范畴。但是中期目标应该是当我们想验证是否失败时,将所有的异常移除。

许多情况下,本次重构将会完全避免继续使用check方法。所有的验证都应该直接调用validation方法。我们也应该同时调整测试来使用notification。