異常處理的原則
1.拋出異常,要針對具體問題來拋出異常,拋出的異常要足夠具體詳細(xì);
- 拋出的異常,應(yīng)能通過異常類名和message準(zhǔn)確說明異常的類型和產(chǎn)生的原因。
2.捕獲異常是為了處理它,不要捕獲了卻什么都不處理而拋棄之;
- 如果不想處理它,應(yīng)將該異常拋給它的調(diào)用者;永遠(yuǎn)不要在沒有充分理由的情況下吞掉異常。即要么處理,要么向上拋,決不能吃掉:You either handle it, or throw it. You don’t eat it.
- 最外層的業(yè)務(wù)使用者,必須處理異常,將其轉(zhuǎn)化為用戶可以理解的內(nèi)容。
3.盡早拋出,延遲捕獲
- 盡早拋出的基本目的是為了防止問題擴(kuò)散,這樣就給排查問題增加了難度。
- 延遲捕獲說的是對異常的捕獲和處理需要根據(jù)當(dāng)前代碼的能力來做,如果當(dāng)前方法內(nèi)無法對異常做處理就拋給調(diào)用者;如果調(diào)用者也無法處理理論上它也應(yīng)該繼續(xù)上拋,這樣異常最終會在一個適當(dāng)?shù)奈恢帽籧atch下來,而比起異常出現(xiàn)的位置,異常的捕獲和處理是延遲了很多。但是也避免了不恰當(dāng)?shù)奶幚怼?/li>
最佳實(shí)踐
1.Don’t log and rethrow Java exceptions
日志記錄和異常處理就像一個豆莢里的兩顆豌豆。當(dāng)你的 Java 代碼中出現(xiàn)問題時,這通常意味著你有一個需要處理的異常,當(dāng)然,任何時候發(fā)生錯誤或意外事件時,都應(yīng)該適當(dāng)?shù)赜涗浽撌录?br>
在Java 中執(zhí)行異常處理時,開發(fā)人員實(shí)際上有兩種選擇:
- 在拋出異常時處理異常,并在錯誤發(fā)生時從錯誤中恢復(fù)。
- 重新拋出異常,以便應(yīng)用程序的另一部分可以處理該問題。
在分層架構(gòu)的應(yīng)用中,第二個選項(xiàng)特別常見,因?yàn)閳?zhí)行堆棧的頂部通常只有一個層,專門用于處理異常和從異常中恢復(fù)的任務(wù)。
但是,開發(fā)者最常犯的錯誤之一是在重新拋出異常之前記錄異常。這種做法必須被視為最高級別的bad case。
比如下面的代碼:
/* log and rethrow exception example */
try {
Class.forName("com.mcnz.Example");
} catch (ClassNotFoundException ex) {
log.warning("Class was not found.");
throw ex;
}
異常最終會在多層中多次記錄。當(dāng)通過日志文件進(jìn)行跟蹤故障時,排查過程令人窒息,排除人員不知道從哪里開始以及何時結(jié)束。
這樣做會導(dǎo)致代碼重復(fù),并在日志文件中散布重復(fù)的記錄,這使得對代碼進(jìn)行故障排除變得更加困難。
正確的做法是:僅在真正處理異常時記錄異常log;真正處理異常意味著不再將異常上拋,或再往上已沒有調(diào)用者。
2.在流量出口處設(shè)置全局異常處理器
上一條提到:僅在真正處理異常時記錄異常log;在業(yè)務(wù)系統(tǒng)中,通常能真正處理異常的地方,就是在流量出口,即最外層的使用層。
因此,捕獲異常的處理邏輯,應(yīng)該盡量放在流量出口的末尾。 這會在業(yè)務(wù)工程中放置更少的 catch 塊,并使工程代碼更易于閱讀和維護(hù)。
使用全局異常處理器,因?yàn)榭倳形床东@的異常潛入到代碼中。始終包含一個全局異常處理程序來處理任何未捕獲的異常,這不僅可以讓你記錄并處理可能發(fā)生的異常,還可以防止你的應(yīng)用程序在運(yùn)行時崩潰。
最外層的業(yè)務(wù)使用者,必須處理異常,并將其轉(zhuǎn)化為用戶可以理解的內(nèi)容。
3.檢查suppressed exception,防止異常被覆蓋
Suppressed Exception是一種相對較新的語言特性,并非所有開發(fā)人員都知道。
上篇 別被坑在finally代碼塊上 提到:如果程序執(zhí)行try塊出現(xiàn)異常,且進(jìn)入執(zhí)行finally 塊也拋出異常,則最后拋出給調(diào)用方的異常是finally中的,try/catch中的不會再拋出,因?yàn)楸桓采w掉了,在Java中,對上層調(diào)用方只能拋出一個異常。沒有拋出的異常被稱為“被屏蔽”的異常(suppressed exception)。
suppressed exception其實(shí)并不好翻譯。有些平臺譯為“被壓制的異?!??!皦褐啤边@個詞的含義是“使某物變小”,延伸到在 Java 中,被壓制的異常指的是在 try-with-resources 語句塊中,因?yàn)?try 塊和 finally 塊都拋出了異常,導(dǎo)致 finally 塊中的異常被“壓制”了,沒有被正確捕獲和處理。因此,我們可以稱這些未被正確處理的異常為被壓制的異常。
Throwable#suppressedExceptions
- 被屏蔽的異常,可通過Throwable.getSuppressed()獲?。?/li>
- 可以通過addSuppressed(Throwable exception)添加,這個函數(shù)一般是在try-with-resources語句中由自動調(diào)用的;因?yàn)閠ry-with-resources結(jié)構(gòu)會自動回收資源,通常不需要顯示添加finally塊;因此try-with-resources自動回收資源時出現(xiàn)異常,會自動調(diào)用addSuppressed。
想要同時保留 finally 塊和 try 塊中的異常信息,上篇給出了一種方法:用變量保存try塊的原始異常,在finally也出現(xiàn)異常時進(jìn)行取舍。
下面給出采用suppressed exception的一種新方式。
try {
//可能拋出 IOException 異常
BufferedReader br = new BufferedReader(new FileReader(path));
String line = br.readLine();
throw new IOException("mock IOException");
} catch (IOException e) {
//try塊里拋出的異常是e
System.out.println("try塊里拋出的異常是: " + e.getMessage());
//finally塊里拋出的異常是e.getSuppressed()
Throwable[] suppressed = e.getSuppressed();
String suppressedException = Arrays.stream(suppressed).map(Throwable::getMessage).collect(Collectors.joining(","));
System.out.println("finally塊里拋出的異常是: " + suppressedException);
} finally {
//此示例是為了讓finally塊產(chǎn)生異常,從而出現(xiàn)“被屏蔽”的異常
throw new RuntimeException("mock throwExceptionInFinally");
}
//執(zhí)行結(jié)果
//try塊里拋出的異常是: mock IOException
//finally塊里拋出的異常是:
//Exception in thread "main" java.lang.RuntimeException: mock throwExceptionInFinally
// at FinallyTest.main(……
需要注意的是,由于在Java中對上層調(diào)用方只能拋出一個異常;因此兩種方式,都是只能同時保留 finally 塊和 try 塊中的異常信息,最終還需取舍,一個記錄到日志文件,一個向上拋出。
4.通過前置防御規(guī)避非受檢異常,而非通過catch去捕獲處理
重學(xué)Java異常體系提到受檢異常 vs 非受檢異常的差異;
- 非受檢異常的發(fā)生通常是由于程序 bug 所致,應(yīng)該盡量通過預(yù)先檢查進(jìn)行規(guī)避;比如在面對可能拋NullPointerException的地方時主動判斷是不是null 并處理、循環(huán)處理時要檢查下標(biāo)邊界防止出現(xiàn)IndexOutOfBounds。
- 這種異常源于開發(fā)者的疏忽,很多開發(fā)人員將其跟真正需要處理的異常混為一談,然后統(tǒng)一的在catch里忽略掉這種異常,是對自己的“放縱”。
對于非受檢異常,我們應(yīng)該修正代碼,而不是去通過異常處理器去處理。
具體來說,就是RuntimeException及其子類(JDK內(nèi)置的大多數(shù)RuntimeException子類), 盡量通過預(yù)先檢查進(jìn)行規(guī)避,而不應(yīng)該通過 catch 來處理。
5.保留異常鏈
- 丟失異常的另一種場景是丟失異常鏈
- 永遠(yuǎn)要記得:包裝異常但不要丟棄原始異常
異常鏈?zhǔn)且环N面向?qū)ο缶幊碳夹g(shù),指將捕獲的異常包裝進(jìn)一個新的異常中并重新拋出的異常處理方式。原異常被保存為新異常的一個屬性(比如cause)。這個想法是指一個方法應(yīng)該拋出定義在相同的抽象層次上的異常,但不會丟棄更低層次的信息。
通過把原始異常傳遞給新的異常,使得即使在當(dāng)前位置創(chuàng)建并拋出了新的異常,也能通過這個異常鏈追蹤到異常最初發(fā)生的位置。
6.對異常進(jìn)行文檔說明
- 為你的異常生成足夠的文檔說明,至少要有 Javadoc
當(dāng)在方法上聲明拋出異常時,也需要進(jìn)行文檔說明。目的是為了給調(diào)用者提供盡可能多的信息,從而可以更好地避免或處理異常。
在 Javadoc 添加 @throws 聲明,并且描述拋出異常的場景(when and why拋出異常)。
/**
* 方法描述
*
* @throws MyBusinessException - when and why拋出異常
*/
public void doSomething(String input) throws MyBusinessException {
// ...
}
7.如無必要,勿增自定義異常
- 優(yōu)先使用Java內(nèi)置異常
- 自定義的異常數(shù)量需要控制
在Java中,我們可以使用內(nèi)置的異常類,也可以自定義異常類。
- 對于重要的通用異常類型——例如空指針異常、數(shù)組下標(biāo)越界異常、類型轉(zhuǎn)換異常等等,Java語言內(nèi)置了相應(yīng)的異常類型。當(dāng)我們需要捕捉這些異常時,可以直接使用內(nèi)置的異常類型。這樣可以簡化代碼,提高可讀性。此外,內(nèi)置異常類還具有特定的語義,可以幫助開發(fā)者更加準(zhǔn)確地進(jìn)行異常處理。
- 當(dāng)我們需要處理某些特定的異常類型時,為了便于開發(fā)者的理解和使用,我們可能需要創(chuàng)建自定義異常類。自定義異常類的命名應(yīng)具有一定的描述性,可以在異常拋出時幫助開發(fā)者快速理解異常的含義和產(chǎn)生原因。
對于業(yè)務(wù)系統(tǒng)而言,由于自定義異常通常設(shè)計成非受檢異常(Unchecked Exception,即RuntimeException及其子類)以免強(qiáng)制捕獲處理異常,當(dāng)系統(tǒng)拋出太多RuntimeException子類,到了流量出口的全局異常處理器時,可能根本不知道應(yīng)該捕獲什么!
- 如果只捕獲你知道拋出的異常,那么你怎么知道拋出了哪些異常呢?當(dāng)其他開發(fā)者拋出一個新的RuntimeException子類型并忘記在全局異常處理器捕獲它時,可能會發(fā)生危險情況。
- 如果直接捕獲RuntimeException類型,那定義不同的異常類型還有什么意義,因?yàn)楫惓L幚砥鲗λ鼈円灰曂?。因此,自定義的異常數(shù)量不宜過度、需要控制。
對于非業(yè)務(wù)系統(tǒng),即Java中間件、組件、庫等,需要單獨(dú)考慮。
8.不要在finally塊拋出異常
在 finally 中拋出異常同樣會導(dǎo)致程序出現(xiàn)預(yù)期之外的行為。如果 finally 塊中的代碼拋出了異常,而且未在finally塊捕捉處理,那么該異常將被拋出到上一級調(diào)用者,并且 try 或 catch 塊中拋出的異常將會被覆蓋(丟失)。因此,在 finally 代碼塊中拋出異常可能會對程序的邏輯造成混亂,不利于代碼的維護(hù)和調(diào)試。
詳見 finally最佳實(shí)踐
9.不要使用異??刂瞥绦虻牧鞒?/strong>
在程序設(shè)計中,異常機(jī)制的作用是用來處理錯誤或者異常情況,而不是用來控制程序的流程。在程序的正常執(zhí)行流程中,異常應(yīng)該是意外情況才會出現(xiàn)的,而不是作為正常流程中可預(yù)知的分支。
使用異??刂瞥绦虻牧鞒蹋赡軙?dǎo)致以下問題:
- 代碼可讀性差:使用異常來控制程序流程,可能會使代碼結(jié)構(gòu)和邏輯變得復(fù)雜,難以閱讀和理解。
- 難以調(diào)試:異常的跳轉(zhuǎn)會擾亂程序的執(zhí)行流程并難以判斷,給代碼調(diào)試和維護(hù)增加難度。
- 性能問題:拋出和捕獲異常會消耗比較大的時間和資源。如果大量使用異常來控制程序的流程,可能會導(dǎo)致性能問題,降低程序的運(yùn)行效率。
因此,不用使用異常來管理業(yè)務(wù)邏輯,應(yīng)該使用條件語句。如果一個控制邏輯可通過 if-else 語句來簡單完成的,那就不用使用異常。
主要影響性能的地方是往異常填充堆棧信息,在確定不需要堆棧信息的異常,可以重寫fillInStackTrace方法,重寫該方法不填充堆??梢蕴嵘阅?。
之所以將這條最佳實(shí)踐放在最后,是因?yàn)橐昝缹?shí)施它其實(shí)很難。一是正常的業(yè)務(wù)主流程(normal),跟“異常情況”(abnormal)有時候很難界定。比如用戶注冊,用戶想注冊的id一般來說都是被別人先注冊了的。那么catch UserexistException的機(jī)會將會多于try中的主流程。二是在try-catch結(jié)構(gòu)中,catch塊對異常的處理,很容易包含特定異常情況下的處理邏輯。
10.優(yōu)先捕獲最具體的異常Catch the most specific exception first
在Java中,出現(xiàn)異常從異常表查找異常處理程序時,會根據(jù)catch聲明的順序來依次匹配,只有匹配異常的第一個 catch 塊會被執(zhí)行。因此,如果首先捕獲 IllegalArgumentException ,則永遠(yuǎn)不會到達(dá)應(yīng)該處理更具體的 NumberFormatException 的 catch 塊,因?yàn)樗?IllegalArgumentException 的子類。
總是優(yōu)先捕獲最具體的異常類,子類必須放在父類的前面,并將不太具體的 catch 塊添加到列表的末尾。
大多數(shù) IDE 都可以輔助實(shí)現(xiàn)這個最佳實(shí)踐。當(dāng)你嘗試首先捕獲較不具體的異常時,IDE會報告無法訪問的代碼塊。