在我们日常工作中,经常会遇到一些异常,比如:NullPointerException、NumberFormatException、ClassCastException等等。
那么问题来了,我们该如何处理异常,让代码变得更优雅呢?
不知道你有没有遇到过下面这段代码:
反例:
Long id = null; try { id = Long.parseLong(keyword); } catch(NumberFormatException e) { //忽略异常 }
用户输入的参数,使用Long.parseLong方法转换成Long类型的过程中,如果出现了异常,则使用try/catch直接忽略了异常。
并且也没有打印任何日志。
如果后面线上代码出现了问题,有点不太好排查问题。
建议大家不要忽略异常,在后续的工作中,可能会带来很多麻烦。
正例:
Long id = null; try { id = Long.parseLong(keyword); } catch(NumberFormatException e) { log.info(String.format("keyword:{} 转换成Long类型失败,原因:{}",keyword , e)) }
后面如果数据转换出现问题,从日志中我们一眼就可以查到具体原因了。
有些小伙伴,经常喜欢在Service代码中捕获异常。
不管是普通异常Exception,还是运行时异常RuntimeException,都使用try/catch把它们捕获。
反例:
try { checkParam(param); } catch (BusinessException e) { return ApiResultUtil.error(1,"参数错误"); }
在每个Controller类中都捕获异常。
在UserController、MenuController、RoleController、JobController等等,都有上面的这段代码。
显然这种做法会造成大量重复的代码。
我们在Controller、Service等业务代码中,尽可能少捕获异常。
这种业务异常处理,应该交给拦截器统一处理。
在SpringBoot中可以使用@RestControllerAdvice注解,定义一个全局的异常处理handler,然后使用@ExceptionHandler注解在方法上处理异常。
例如:
@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { /** * 统一处理异常 * * @param e 异常 * @return API请求响应实体 */ @ExceptionHandler(Exception.class) public ApiResult handleException(Exception e) { if (e instanceof BusinessException) { BusinessException businessException = (BusinessException) e; log.info("请求出现业务异常:", e); return ApiResultUtil.error(businessException.getCode(), businessException.getMessage()); } log.error("请求出现系统异常:", e); return ApiResultUtil.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务器内部错误,请联系系统管理员!"); } }
有了这个全局的异常处理器,之前我们在Controller或者Service中的try/catch代码可以去掉。
如果在接口中出现异常,全局的异常处理器会帮我们封装结果,返回给用户。
在你的业务逻辑方法中,有可能需要去处理多种不同的异常。
你可能你会觉得比较麻烦,而直接捕获Exception。
反例:
try { doSomething(); } catch(Exception e) { log.error("doSomething处理失败,原因:",e); }
这样捕获异常太笼统了。
其实doSomething方法中,会抛出FileNotFoundException和IOException。
这种情况我们最好捕获具体的异常,然后分别做处理。
正例:
try { doSomething(); } catch(FileNotFoundException e) { log.error("doSomething处理失败,文件找不到,原因:",e); } catch(IOException e) { log.error("doSomething处理失败,IO出现了异常,原因:",e); }
这样如果后面出现了上面的异常,我们就非常方便知道是什么原因了。
我们在使用IO流的时候,用完了之后,一般需要及时关闭,否则会浪费系统资源。
我们需要在try/catch中处理IO流,因为可能会出现IO异常。
反例:
try { File file = new File("/tmp/1.txt"); FileInputStream fis = new FileInputStream(file); byte[] data = new byte[(int) file.length()]; fis.read(data); for (byte b : data) { System.out.println(b); } fis.close(); } catch (IOException e) { log.error("读取文件失败,原因:",e) }
上面的代码直接在try的代码块中关闭fis。
假如在调用fis.read方法时,出现了IO异常,则可能会直接抛异常,进入catch代码块中,而此时fis.close方法没办法执行,也就是说这种情况下,无法正确关闭IO流。
正例:
FileInputStream fis = null; try { File file = new File("/tmp/1.txt"); fis = new FileInputStream(file); byte[] data = new byte[(int) file.length()]; fis.read(data); for (byte b : data) { System.out.println(b); } } catch (IOException e) { log.error("读取文件失败,原因:",e) } finally { if(fis != null) { try { fis.close(); fis = null; } catch (IOException e) { log.error("读取文件后关闭IO流失败,原因:",e) } } }
在finally代码块中关闭IO流。
但要先判断fis不为空,否则在执行fis.close()方法时,可能会出现NullPointerException异常。
需要注意的地方时,在调用fis.close()方法时,也可能会抛异常,我们还需要进行try/catch处理。
前面在finally代码块中关闭IO流,还是觉得有点麻烦。
因此在JDK7之后,出现了一种新的语法糖try-with-resource。
上面的代码可以改造成这样的:
File file = new File("/tmp/1.txt"); try (FileInputStream fis = new FileInputStream(file)) { byte[] data = new byte[(int) file.length()]; fis.read(data); for (byte b : data) { System.out.println(b); } } catch (IOException e) { e.printStackTrace(); log.error("读取文件失败,原因:",e) }
try括号里头的FileInputStream实现了一个AutoCloseable
接口,所以无论这段代码是正常执行完,还是有异常往外抛,还是内部代码块发生异常被截获,最终都会自动关闭IO流。
我们尽量多用try-catch-resource的语法关闭IO流,可以少写一些finally中的代码。
而且在finally代码块中关闭IO流,有顺序的问题,如果有多种IO,关闭的顺序不对,可能会导致部分IO关闭失败。
而try-catch-resource就没有这个问题。
我们在某个方法中,可能会有返回数据。
反例:
public int divide(int dividend, int divisor) { try { return dividend / divisor; } catch (ArithmeticException e) { // 异常处理 } finally { return -1; } }
上面的这个例子中,我们在finally代码块中返回了数据-1。
这样最后在divide方法返回时,会将dividend / divisor的值覆盖成-1,导致正常的结果也不对。
我们尽量不要在finally代码块中返回数据。
正解:
public int divide(int dividend, int divisor) { try { return dividend / divisor; } catch (ArithmeticException e) { // 异常处理 return -1; } }
如果dividend / divisor出现了异常,则在catch代码块中返回-1。
我们在本地开发中,喜欢使用e.printStackTrace()方法,将异常的堆栈跟踪信息输出到标准错误流中。
反例:
try { doSomething(); } catch(IOException e) { e.printStackTrace(); }
这种方式在本地确实容易定位问题。
但如果代码部署到了生产环境,可能会带来下面的问题:
可能会暴露敏感信息,如文件路径、用户名、密码等。
可能会影响程序的性能和稳定性。
正解:
try { doSomething(); } catch(IOException e) { log.error("doSomething处理失败,原因:",e); }
我们要将异常信息记录到日志中,而不是保留给用户。
我们在捕获了异常之后,需要把异常的相关信息记录到日志当中。
反例:
try { double b = 1/0; } catch(ArithmeticException e) { log.error("处理失败,原因:",e.getMessage()); }
这个例子中使用e.getMessage()方法返回异常信息。
但执行结果为:
doSomething处理失败,原因:
这种情况异常信息根本没有打印出来。
我们应该把异常信息和堆栈都打印出来。
正例:
try { double b = 1/0; } catch(ArithmeticException e) { log.error("处理失败,原因:",e); }
执行结果:
doSomething处理失败,原因: java.lang.ArithmeticException: / by zero at cn.net.susan.service.Test.main(Test.java:16)
将具体的异常,出现问题的代码和具体行数都打印出来。
有时候,我们为了记录日志,可能会对异常进行捕获,然后又抛出。
反例:
try { doSomething(); } catch(ArithmeticException e) { log.error("doSomething处理失败,原因:",e) throw e; }
在调用doSomething方法时,如果出现了ArithmeticException异常,则先使用catch捕获,记录到日志中,然后使用throw关键抛出这个异常。
这个骚操作纯属是为了记录日志。
但最后发现日志记录两次。
因为在后续的处理中,可能会将这个ArithmeticException异常又记录一次。
这样就会导致日志重复记录了。
在Java中已经定义了许多比较常用的标准异常,比如下面这张图中列出的这些异常:
反例:
public void checkValue(int value) { if (value < 0) { throw new MyIllegalArgumentException("值不能为负"); } }
自定义了一个异常表示参数错误。
其实,我们可以直接复用已有的标准异常。
正例:
public void checkValue(int value) { if (value < 0) { throw new IllegalArgumentException("值不能为负"); } }
我们在写代码的过程中,有一个好习惯是给方法、参数和返回值,增加文档说明。
反例:
/* * 处理用户数据 * @param value 用户输入参数 * @return 值 */ public int doSomething(String value) throws BusinessException { //业务逻辑 return 1; }
这个doSomething方法,把方法、参数、返回值都加了文档说明,但异常没有加。
正解:
/* * 处理用户数据 * @param value 用户输入参数 * @return 值 * @throws BusinessException 业务异常 */ public int doSomething(String value) throws BusinessException { //业务逻辑 return 1; }
抛出的异常,也需要增加文档说明。
我们有时候,在程序中使用异常来控制了程序的流程,这种做法其实是不对的。
反例:
Long id = null; try { id = Long.parseLong(idStr); } catch(NumberFormatException e) { id = 1001; }
如果用户输入的idStr是Long类型,则将它转换成Long,然后赋值给id,否则id给默认值1001。
每次都需要try/catch还是比较影响系统性能的。
正例:
Long id = checkValueType(idStr) ? Long.parseLong(idStr) : 1001;
我们增加了一个checkValueType方法,判断idStr的值,如果是Long类型,则直接转换成Long,否则给默认值1001。
如果标准异常无法满足我们的业务需求,我们可以自定义异常。
例如:
/** * 业务异常 * * @author 苏三 * @date 2024/1/9 下午1:12 */ @AllArgsConstructor @Data public class BusinessException extends RuntimeException { public static final long serialVersionUID = -6735897190745766939L; /** * 异常码 */ private int code; /** * 具体异常信息 */ private String message; public BusinessException() { super(); } public BusinessException(String message) { this.code = HttpStatus.INTERNAL_SERVER_ERROR.value(); this.message = message; } }
对于这种自定义的业务异常,我们可以增加code和message这两个字段,code表示异常码,而message表示具体的异常信息。
BusinessException继承了RuntimeException运行时异常,后面处理起来更加灵活。
提供了多种构造方法。
定义了一个序列化ID(serialVersionUID)。