欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

【随笔·技术】重构错误处理程式

程序员文章站 2022-06-01 21:02:02
...

原网页


有人研究过,程式中可能会有高达90%的比率在管理与处理错误,Bob大叔在《Clean Code》中谈到「许多程式码[1]完全由错误处理所主宰」,90%的比率是真的在管理与处理错误的逻辑吗?还是只是如Bob大叔说的,根本就是散乱的错误处理程式码?商务逻辑相关的程式码需要重构,对于错误处理程式码的重构,我们也有许多需要学习的地方。

错误处理就是一件事

在重构或程式码可读性的概念中有个共同特性,就是函式(方法)应该只做一件事,避免函式中的程式码陷入逻辑泥块(Logical clump)。在没有例外处理的语言中,透过回传错误码,让函式客户端可以检查执行结果,确认后续是要进行正常或错误处理流程,如果客户端必须呼叫多个函式来完成一项任务,检查错误码、正常与多个错误处理流程夹杂的情况下,容易使得客户端程式码变得混乱。

例外[2]处理机制可以在错误发生的时候抛出例外,让错误处理能推到想要的边界进行处理。以Java来说,客户端可以在try区块中处理正常流程,在catch区块处理呼叫各函式时可能抛出的例外,让原先纠缠在错误处理流程中的正常流程清楚地呈现出来,try区块中的流程亦可抽取为函式独立地做一件事,那么目前的try-catch就能专心地做错误处理这件事,如同Bob大叔说的「函式应该只做一件事,而错误处理就是一件事」。

有时例外处理流程会形成一种模式,例如涉及资源建立、使用与关闭的操作若会抛出例外,为了有限资源在各种错误发生时都能确实释放,不免要撰写类似的try-catch-finally流程,在具有受检例外[3]的Java中更是难以避免这类情况,像是JDBC的处理流程就是如此,此时可以采用样版回呼(Template callback)模式,适当地让资源相关操作从错误处理流程中独立出来,Spring的JdbcTemplate就是这类实现,因为这类资源建立、关闭的操作模式太频繁出现,JDK7就提出了try-with-resources语法来解决这类需求,确实地让资源建立、使用与关闭的操作与错误处理分离,若进一步地结合JDK8的Lambda语法,还可让资源的使用从建立与关闭中分离。例如设计一个open方法,就可以专心在FileInputStream的使用,让开启档案的意图显而易见:

open(fileName, fileInputStream -> {
    // 操作FileInputStream实例
});

多个捕捉做相同处理时的重构

如果多种例外捕捉后,做的都是相同的错误处理,像是日志,或者是将程式库的例外封装为自定义例外等,错误处理的程式码必然就出现重复,自然就会呈现需要重构的讯号。因为多种例外做的都是相同的事,可将有继承关系的例外处理程式码,合并在父类别的捕捉区块中,但不建议使用catch-all的方式,例如使用ExceptionThrowable来捕捉所有例外,因为对于其他不相关的例外,这是一种隐藏错误的做法。

然而在合并有继承关系的例外处理程式码之后,仍会发现没有继承关系的例外处理程式码出现重复,Bob大叔在《Clean Code》中提出的作法是包裹呼叫的API,确保它在捕捉各种例外后,能转换为(自定义的)共同例外型态,如此客户端就只需要捕捉一种例外,因而可让客户端程式码大幅简化,如果使用的是第三方API,也可以同时降低了对它的依赖。

如果多种例外在捕捉之后,做完相同处理就将原例外重新抛出,可以参考guava-libraries的作法,你可以使用catch-all的方式捕捉各种型态的例外,做完相同错误处理之后,使用Throwables.propagateIfInstanceOf以指定的例外型态重新抛出(通常是受检例外),或者是使用Throwables.propagate,将原例外以RuntimeException包裹后重新抛出,既消除了重复的错误处理程式码,又避免了隐藏错误。

虽然实际上,Throwables.propagateIfInstanceOf只是将型态判断与转型的逻辑封装并予以重用,但对客户端程式码的简化确实有所帮助,不过,这种方式对于错误处理时进行例外型态转换,或者是不重新抛出的情况并不适用,guava-libraries的〈ThrowablesExplained〉文中也解释了其他一些不适用的场合。JDK7中,对于多个捕捉做同一件事的情况,提出了Multi-catch语法,算是为这问题提出了较好的解决方案。

多个捕捉做不同处理时的重构

如果多种例外捕捉后,分别进行不同的错误处理,此时得检视多种例外是由单一方法抛出,或多个方法操作而分别抛出不同例外,最常见的情况是一个try区块进行了数个会抛出例外的操作,然后底下连续多个catch区块逐一针对不同例外作处理。实际上每个会抛出例外的方法发生错误时,理由应该是各不相同的,应试着让这些方法各有一个try-catch区块,让每个方法的错误处理流程各自显露出来。

一旦你根据不同方法引发的例外,将一个try搭配多个catch的程式码,分解为数个try-catch区块之后,应当立即想到「错误处理就是一件事」,而两个以上的try-catch时,无论那些try-catch是形成巢状或者是瀑布式流程,都意谓着你的程式码做了两件以上的事,重构的方式之一,就是每个try-catch重构至独立的方法之中,让每个方法都只会出一个try陈述。

当发现一个方法中会出现多个try-catch时,而每个try-catch都做类似模式(但细节不同)的转换或错误处理时,如果你接触过函数式的错误处理风格,例如我先前专栏〈函数式风格错误处理〉中谈过的OptionEitherTry等概念,就有可能进行Monad风格的错误处理,我在专栏〈神秘的Monad不神秘〉中谈到OptionalflatMap可连续处理null与物件值转换的问题,实际上,Mario Fusco在〈Monadic Java〉中就以类似风格,设计了Validation等类别,可以用Monad风格对使用者进行如下的程式码验证与验证失败讯息之收集,而又不会迷失在瀑布式的ValidationException捕捉程式码之中:

Validation<List<String>, Person> validation = success(person)
    .failList()
    .flatMap(Validator::validAge)
    .flatMap(Validator::validName);

重构是看待错误处理的一个角度

既然程式中可能会有高达90%的比率在管理与处理错误,我们真的该认真且从不同角度去看待,像是受检或非受检例外的运用、例外应捕捉或抛出、避免隐藏错误、换个典范风格思考错误处理的可能性等,都该有所思考,我的专栏〈Shit Happens!该抓还是该丢?〉、〈避免隐藏错误的防御性设计〉与〈函数式风格错误处理〉都曾做过一些探讨。

从重构角度出发来看待错误处理程式码,你会发现Martin Fowler的《Refactoring》中揭露的重构原则,对待错误处理程式码也是适用的,错误处理之所以重要,就在于它是处理不对的事情,本身必须正确,然而就如Bob大叔说的「如果它糢糊了原本程式码的逻辑,那就不对了」


  1. 代码

  2. 异常

  3. Checked Exception