瞧瞧別人家的異常處理,那叫一個優雅
前言
在我們日常工作中,經常會遇到一些異常,比如:NullPointerException、NumberFormatException、ClassCastException等等。
那么問題來了,我們該如何處理異常,讓代碼變得更優雅呢?
1.不要忽略異常
不知道你有沒有遇到過下面這段代碼:
反例:
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))
}
后面如果數據轉換出現問題,從日志中我們一眼就可以查到具體原因了。
2.使用全局異常處理器
有些小伙伴,經常喜歡在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代碼可以去掉。
如果在接口中出現異常,全局的異常處理器會幫我們封裝結果,返回給用戶。
3.盡可能捕獲具體異常
在你的業務邏輯方法中,有可能需要去處理多種不同的異常。
你可能你會覺得比較麻煩,而直接捕獲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);
}
這樣如果后面出現了上面的異常,我們就非常方便知道是什么原因了。
4.在finally中關閉IO流
我們在使用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處理。
5.多用try-catch-resource
前面在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就沒有這個問題。
6.不在finally中return
我們在某個方法中,可能會有返回數據。
反例:
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。
7.少用e.printStackTrace()
我們在本地開發中,喜歡使用e.printStackTrace()方法,將異常的堆棧跟蹤信息輸出到標準錯誤流中。
反例:
try {
doSomething();
} catch(IOException e) {
e.printStackTrace();
}
這種方式在本地確實容易定位問題。
但如果代碼部署到了生產環境,可能會帶來下面的問題:
- 可能會暴露敏感信息,如文件路徑、用戶名、密碼等。
- 可能會影響程序的性能和穩定性。
正解:
try {
doSomething();
} catch(IOException e) {
log.error("doSomething處理失敗,原因:",e);
}
我們要將異常信息記錄到日志中,而不是保留給用戶。
8.異常打印詳細一點
我們在捕獲了異常之后,需要把異常的相關信息記錄到日志當中。
反例:
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)
將具體的異常,出現問題的代碼和具體行數都打印出來。
9.別捕獲了異常又馬上拋出
有時候,我們為了記錄日志,可能會對異常進行捕獲,然后又拋出。
反例:
try {
doSomething();
} catch(ArithmeticException e) {
log.error("doSomething處理失敗,原因:",e)
throw e;
}
在調用doSomething方法時,如果出現了ArithmeticException異常,則先使用catch捕獲,記錄到日志中,然后使用throw關鍵拋出這個異常。
這個騷操作純屬是為了記錄日志。
但最后發現日志記錄兩次。
因為在后續的處理中,可能會將這個ArithmeticException異常又記錄一次。
這樣就會導致日志重復記錄了。
10.優先使用標準異常
在Java中已經定義了許多比較常用的標準異常,比如下面這張圖中列出的這些異常:
反例:
public void checkValue(int value) {
if (value < 0) {
throw new MyIllegalArgumentException("值不能為負");
}
}
自定義了一個異常表示參數錯誤。
其實,我們可以直接復用已有的標準異常。
正例:
public void checkValue(int value) {
if (value < 0) {
throw new IllegalArgumentException("值不能為負");
}
}
11.對異常進行文檔說明
我們在寫代碼的過程中,有一個好習慣是給方法、參數和返回值,增加文檔說明。
反例:
/*
* 處理用戶數據
* @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;
}
拋出的異常,也需要增加文檔說明。
12.別用異常控制程序的流程
我們有時候,在程序中使用異常來控制了程序的流程,這種做法其實是不對的。
反例:
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。
13.自定義異常
如果標準異常無法滿足我們的業務需求,我們可以自定義異常。
例如:
/**
* 業務異常
*
* @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)。