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

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

2014年12月09



我最近看一些代码来做一些传入的JSON消息的基本验证。它看起来像这样。

public void check() {if (date == null) throw new IllegalArgumentException("date is missing");LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);如果(numberOfSeats == null)抛出新的IllegalArgumentException("number of seats cannot be null");如果(numberOfSeats < 1)抛出新的IllegalArgumentException("number of seats must be positive");}

此示例的代码是Java

这是一种常见的验证方法。您对一些数据(这里只是涉及的类中的一些字段)运行一系列检查。如果这些检查中的任何一个失败,您将抛出一个异常并给出错误消息。

这种方法有几个问题。首先,我不喜欢在这种情况下使用异常。异常表明存在问题的代码的行为超出预期范围。但是,如果您正在对外部输入运行一些检查,这是因为您预计一些消息会失败——如果失败是预期的行为,那么就不应该使用异常。

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

这类代码的第二个问题是,它在检测到的第一个错误时失败,但通常最好是用传入的数据报告所有错误,而不仅仅是第一个错误。这样一来,客户端就可以选择在一次交互中显示所有的错误,让用户修复,而不是让用户觉得自己在用电脑玩打地鼠游戏。

处理此类报告验证问题的首选方法是通知模式.通知是收集错误的对象,每个验证故障都会向通知添加错误。验证方法返回通知,然后您可以询问以获取更多信息。简单的使用外观具有这样的代码,用于检查。

私有void validatenumberofseats(通知笔记){if(numberofseats <1)注释.Adderror(“座位数量为正数”);//更多检查这样的}

然后我们可以有一个简单的调用,例如aNotification.hasErrors ()如果有任何错误就做出反应。通知上的其他方法可以钻取有关错误的更多细节。[1]

何时使用此重构188app彩票ios

这里需要强调的是,我并不是提倡在代码库中消除异常。异常是一种非常有用的技术,用于处理异常行为并将其从逻辑的主流中分离出来。只有当异常188app彩票ios发出的结果并不是真正异常时,才可以使用这种重构,因此应该通过程序的主要逻辑进行处理。我在这里看到的例子,验证,是一个常见的例子。

在考虑例外情况时,拇指的一个有用规则来自务实程序员:

我们认为异常应该是程序正常流量的一部分:应为意外事件保留异常。假设未捕获的异常将终止您的程序并询问自己,“如果我删除所有异常处理程序,此代码仍然会运行吗?”如果答案是“否”,则可能在非引用环境中使用异常。

--Dave Thomas和Andy Hunt

这是一个重要的结果是,是否使用特定任务的例外取决于上下文。因此,正如Prags所说的那样,从一个文件中读取不存在的文件,或者可能不是一个例外,具体取决于情况。如果您尝试阅读众所周知的文件位置,例如设置在Unix系统上,您可能会假设文件应该存在,因此抛出异常是合理的。另一方面,如果您试图从用户在命令行中输入的路径读取文件,那么您应该预料到文件很可能不存在,并且应该使用另一种机制——一种告知异常错误本质的机制。

有一种情况,可以使用验证失败的例外。这将是您希望在处理中之前已验证的数据的情况,但您希望再次运行验证检查以防范编程错误,让某些无效的数据流过。

本文讨论的是在验证原始输入的上下文中如何替换通知的异常。您可能还会发现这种技术在其他情况下也很有用,在这种情况下,通知是比抛出异常更好的选择,但这里我主要讨论验证用例,因为它是常见的。

起点

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

GSON.FROMJSON(JSONSTRING,BookingRequest.class)

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

预订请求仅包含我们在此处验证的两个元素,性能日期以及正在要求有多少席位

类BookingRequest ...

私人整数numberOfSeats;私人字符串的日期;

验证检查是我上面所显示的

类BookingRequest ...

public void check() {if (date == null) throw new IllegalArgumentException("date is missing");LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);如果(numberOfSeats == null)抛出新的IllegalArgumentException("number of seats cannot be null");如果(numberOfSeats < 1)抛出新的IllegalArgumentException("number of seats must be positive");}

建立一个通知

要使用通知,您必须创建通知对象。通知可能真的很简单,有时只是一个字符串列表会做这个技巧。

通知收集在一起错误

List notification = new ArrayList<>();if (numberOfSeats < 5)通知。增加(“座位太少”);//做更多的检查//然后稍后…notification.isEmpty()) //处理错误条件

虽然一个简单的列表成语进行了轻量级实现模式,但我通常喜欢做点一点,而是创建一个简单的类。

public class Notification {private List errors = new ArrayList<>();public void addError(String message) {errors.add(message);}公共boolean hasErrors(){返回!errors.isEmpty ();}……

通过使用一个真正的类,我可以使我的意图更清晰——读者不需要执行习语与其全部含义之间的心理地图。

拆分检查方法

我的第一步是将检查方法分为两个部分,一个内部部分最终只处理通知,而不是抛出任何例外,以及将保留检查方法的当前行为的外部部分,这是抛出异常是否有任何验证失败。

我做这个的第一步是使用提取方法以一种不寻常的方式,即我将Check方法的整个正文提取到验证方法中。

类BookingRequest ...

公共空白检查(){验证();公共void验证(){if(日期== null)抛出新的IllegalArgumentException(“日期缺失”);LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);如果(numberOfSeats == null)抛出新的IllegalArgumentException("number of seats cannot be null");如果(numberOfSeats < 1)抛出新的IllegalArgumentException("number of seats must be positive");}

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

类BookingRequest ...

公共通知验证(){Notification note = new Notification();如果(date == null)抛出新的IllegalArgumentException("date is missing");LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);如果(numberOfSeats == null)抛出新的IllegalArgumentException("number of seats cannot be null");如果(numberOfSeats < 1)抛出新的IllegalArgumentException("number of seats must be positive");返回注意;}

我现在可以测试通知,并在其中包含错误时抛出异常。

类BookingRequest ...

公共空白检查(){if(validation()。haserrors())抛出新的IllegalArgumentException(验证()。errormessage());}

我提出了验证方法公众,因为我期望将来大多数呼叫者都希望使用这种方法,而不是检查方法。

分割原始方法允许我从决定如何响应故障时分开验证检查。

此时我还没有改变代码的行为,通知不会包含任何错误,并且任何失败的验证检查将继续抛出异常并忽略我所投入的新机器。但我'现在,随着操作通知,现在将准备好替换异常抛出的东西。

但是,在我继续之前,我需要说出关于错误消息的些什么。当我们正在进行重构时,规则是避免可观察188app彩票ios行为的变化。在这样的情况下,这样的规则立即引导到什么行为是可观察到的问题。显然,抛出了正确的例外是外部程序将观察的东西 - 但他们在多大程度上关心错误消息?通知最终将收集多个错误,并且可以将它们汇总到一个与类似的消息中

类通知…

public String errorMessage(){返回错误。加入(","));}

但是,如果程序的高层只依赖于从检测到的第一个错误中获取消息,那么这就会成为一个问题,在这种情况下,您需要类似于

类通知…

公共字符串errormessage(){returner errors.get(0);}

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

虽然现在不应该引入任何问题,但我肯定会在进行下一个更改之前进行编译和测试。任何明智的人都不可能搞砸这些改变,但这不意味着我也不能搞砸。

验证数字

现在是明显的事情是取代第一个验证

类BookingRequest ...

public Notification validation(){通知说明= new Notification();If (date == null)note.addError(“缺失”);LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);如果(numberOfSeats == null)抛出新的IllegalArgumentException("number of seats cannot be null");如果(numberOfSeats < 1)抛出新的IllegalArgumentException("number of seats must be positive");返回注意;}

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

因此,在这种情况下,不明显,但更有效的事情就是向后。

类BookingRequest ...

public Notification validation(){通知说明= new Notification();如果(date == null)抛出新的IllegalArgumentException("date is missing");LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);如果(numberOfSeats == null)抛出新的IllegalArgumentException("number of seats cannot be null");if (numberOfSeats < 1)note.addError(“座位数必须为正”);返回注意;}

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

类BookingRequest ...

public Notification validation(){通知说明= new Notification();如果(date == null)抛出新的IllegalArgumentException("date is missing");LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);if(numberofseats == null)note.adderror(“座位数不能为null”);否则if(numberofseats <1)注释.Adderror(“座位数量为正数”);返回注意;}

我看到下一张检查涉及一个不同的字段。同时必须与先前的重构一起引入条件,我现在认为这种验证方法太复杂,并且可以与分解进行腐败。188app彩票ios所以我提取数字验证部分。

类BookingRequest ...

public Notification validation(){通知说明= new Notification();如果(date == null)抛出新的IllegalArgumentException("date is missing");LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);validateNumberOfSeats(注意);返回注意;} private void validateNumberOfSeats(通知注意){if (numberOfSeats == null)注意。addError("number of seats cannot be null");否则if(numberofseats <1)注释.Adderror(“座位数量为正数”);}

查看提取的数字验证,我不太喜欢它的结构。我不喜欢在验证中使用if-then-else块,因为它很容易导致过度嵌套代码。我更喜欢线性代码,一旦它不能继续下去就会中止,我们可以用一个保护子句来做到这一点。所以我申请用防护条文替换嵌套条件

类BookingRequest ...

私有void validatenumberofseats(通知笔记){if(numberofseats == null){note.adderror(“座位数不能为null”);返回;如果(numberofseats <1)注释.Adderror(“座位数量为正数”);}

当我们重构时,我们应该总是尽量采取尽可能小的步骤来保持行为

为了让代码保持绿色,我决定往回走,这就是重构的一个关键元素。188app彩票ios188app彩票ios重构是一种通过一系列保持行为的转换来重构代码的特定技术。因此,当我们重构时,我们应该尽量采取尽可能小的步骤来保持行为。通过这样做,我们减少了在调试器中陷入错误的机会

验证日期

关于日期验证,我想我将从这里开始提取方法

类BookingRequest ...

public Notification validation(){通知说明= new Notification();验证(注);validateNumberOfSeats(注意);返回注意;} private void validateDate(通知说明){如果(日期== null)抛出新的IllegalArgumentException(“日期缺失”);LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);} if (parsedDate.isBefore(LocalDate.now()))抛出新的IllegalArgumentException(“日期不能在今天之前”);}

当我在IDE中使用自动提取方法时,生成的代码没有包含通知参数。所以我必须手动添加。

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

类BookingRequest ...

私有void验证(通知笔记){if(日期== null)抛出新的IllegalArgumentException(“日期缺失”);LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);} catch (DateTimeParseException e){抛出新的IllegalArgumentException("Invalid format for date", e);}如果(parseddate.isbefore(localdate.now())))))note.addError(日期不能在今天之前);}

通过第二步,由于抛出异常包括原因异常,因此在错误处理中存在复杂性。要处理我需要更改通知以接受原因异常。由于我处于更改抛出的中间来向通知添加错误,因此我的代码是红色的,所以我在上面的状态下返回我正在做的验证方法,而我准备包括一个通知导致例外。

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

类通知…

公共voidAdderror(字符串消息){Adderror(消息,NULL);}公共voidAdderror(字符串消息,例外e){errors.add(消息);}

这意味着我们接受原因异常,但忽略它。要将其放在某个地方,我需要将简单字符串从一个简单的字符串更改为唯一略微略微的简单对象。

类通知…

私有静态类错误{字符串消息;异常原因;private Error(String message, Exception cause) {this。消息=消息;这一点。导致=事业;} }

我通常不喜欢Java中的非私有字段,但由于这是一个私人内部类,我很好。如果我要在通知之外公开此错误类,我将封装这些字段。

现在我有课程,我需要修改通知来使用它而不是字符串。

类通知…

private List errors = new ArrayList<>();public void addError(String message, Exception e) {error .add(新错误(消息,e));}公共String errorMessage(){返回错误。. map (e - > e.message).collect(收集器.joining(“,”));}

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

类BookingRequest ...

私有void验证(通知笔记){if(日期== null)抛出新的IllegalArgumentException(“日期缺失”);LocalDate parsedDate;try {parsedDate = LocalDate.parse(date);catch(datetimepepseexception e){note.addError(“日期格式无效”,e);返回;}如果(parseddate.isbefore(localdate.now()))注释.Adderror(“日期不能在今天之前”);

因为我已经在一个提取的方法中,所以很容易用返回终止验证的其余部分。

最后一个改变很简单

类BookingRequest ...

private void validateDate(Notification note) {if (date == null) {note.addError(“缺失”);返回;LocalDate Parseddate;try {parsedDate = LocalDate.parse(date);catch(datetimeparseexception e){note.adderror(“日期的”无效格式“,e);返回;}如果(parseddate.isbefore(localdate.now()))注释.Adderror(“日期不能在今天之前”);}

向上移动堆栈

一旦我们有新方法,下一个任务就是要查看原始检查方法的呼叫者,并考虑调整它们以使用新的验证方法。这将需要更广泛地查看验证如何适合应用程序的流程,因此它超出了这一重构的范围。188app彩票ios但中期目标应该是消除我们可能预期验证失败的任何情况下的例外情况。

在许多情况下,这应该能够完全摆脱check方法。在这种情况下,该方法上的任何测试都应该重新设计以使用验证方法。我们可能还希望调整测试,以使用通知探测多个错误的适当集合。


脚注

1:另一种常见的验证方法只是为了返回一个布尔表示,指示输入是否有效。虽然这使得来电者可以轻松调用不同的行为,但它并没有提供以超越无用的“发生错误”的任何方式。

2:这有时被称为链构造函数。您也可以把if看作是部分应用程序的一个例子——并不是说函数式程序员会赞成在Java程序的“贫民窟”中使用这样的术语。

进一步阅读

何时使用异常写入很大程度上。正如您可能猜到的那样,我对更多阅读的第一个建议是务实程序员.还有一个合理的讨论代码完成.任何专业程序员都应该熟悉这两本书。

我还享有如何在Avdi Grimm处理错误条件的讨论特殊的红宝石.虽然直接是一本Ruby的书,但它的大多数建议适用于任何编程环境。

框架

许多框架提供了使用通知模式的某种验证功能。在Java世界中,有Java Bean验证工作和Spring的验证.这些框架提供了某种形式的界面来启动验证并使用通知来收集错误(a设置< ConstraintViolation >对于bean验证和一个错误在Spring的情况下)。

您应该看看您的语言和平台,看看它们对使用通知的验证机制有什么。如何改变重构的细节的细节,但常规形状应该非常相似。188app彩票ios

致谢

Andy Slocum, Carlos Villela, Charles Haynes, Dave Elliman, Derek Hammer, Ian Cartwright, Ken McCormack, Kornelis Sietsma, Rob Speller, Stefan Smith和Steven Lowe在我们的内部邮件列表上评论了这篇文章的草稿。

重大修订

2014年12月09:第一次出版