我為什麼反對用異常做流程式控制制?

  • 2019 年 10 月 27 日
  • 筆記

「懶」是驅動程式設計師前進的原動力,亦是原罪。

像SSH/M這種基礎框架的出現,讓不少程式設計師「癱瘓」成了流水線工人。以前小心翼翼方能寫就的邏輯分支判斷,演變成了直接丟個異常然後坐等AOP攔截處理,此時的攔截器就是個垃圾處理廠。這種似乎失控的編碼方式,讓我想到了邪惡的「GoTo」語法,很多程式語言里都有它, 但是都不建議你用它。因為邪惡的不是GoTo本身,而是濫用GoTo的我們。

題眼基本表達了我的論點,隨著本文的深入會對該論點做加一個約束條件。現在容我開始論證它~

都說拋異常很重,到底重在哪裡?

不整虛的,我們用測試數據來說話。採用OpenJDK的JMH基準測試框架實現,設計如下6種測試場景:

  1. New一個普通的Exception
  2. New一個普通的不包含堆棧資訊的Exception
  3. New一個普通的自定義對象
  4. Throw一個普通的Exception
  5. Throw一個普通的不包含堆棧資訊的Exception
  6. 獲取/列印異常的堆棧資訊

6個場景的benchmark測試報告如上圖。從結果數字可以看出:耗時最短的是創建自定義對象,耗時最長的是獲取異常的堆棧資訊。詳細說明幾個要點:

&創建對象:自定義對象 VS 無堆棧異常 VS 普通異常

三者的耗時依次遞增,自定義對象的創建作為基準參照耗時,無堆棧異常創建的耗時是其5倍,普通異常創建的耗時是其250倍。所以異常從出生就死在起跑線。雖然我們的測試耗時是納秒級別,若從系統介面通常的秒為單位,就算30倍也可以忽略不計。但是在這裡已經可以凸顯出異常本身的沉重。

&異常的創建到拋出到捕獲

異常的創建 和 疊加異常的拋出捕獲 前後並沒有特別明顯的性能損耗,拋異常的耗時可以忽略不計。

明確概念1:Java中如果不發生異常,try/catch基本是不會造成任何性能損失的(查看位元組碼了解異常表)。而一旦發生異常,除了昂貴的異常填充堆棧成本,也就是確認下try block對應異常表記錄的起止程式碼行和異常名稱是否一致。上測試結果也表明確實會有性能波動,但其實很小。

明確概念2:對於try block內的程式碼,Java會阻止指令重排序一類的記憶體優化手段。所以即使try的性能損耗很小,但是我們仍舊建議try block的邊界越窄越好。

明確概念3:try block的範圍即使很寬,對於堆棧深度來說並無特別影響。因為棧幀的深度取決於不同方法之間的調用關係和次數。

&異常堆棧的獲取/列印

現實喜歡狠狠的打人臉,原以為測試出真相了,結果數據告訴我們最耗時的操作竟是讀取堆棧操作。

Thread::getStackTrace()做個簡單說明。大家可以看一下JDK源碼,在當前執行緒里它等同於

(new Exception()).getStackTrace()

實例化一個異常對象已經夠慢了,獲取異常堆棧數據的耗時竟然達到10倍以上。大家想一想不管是自己寫的try/catch程式碼塊,還是AOP的攔截器,是不是都會讀取堆棧,然後列印到日誌里用於排障?

所以異常重不重已經很明確了吧?再貼一遍測試數據感受一下,所有的真相都在此圖了。

程式碼示例已上傳Github

https://github.com/NicholasQu/snippets

介面設計如何定義異常的邊界?

傳統的介面設計規範說明會包含幾個基本要素:介面名/地址、版本號、請求參數,響應參數。其中應答的響應碼基本都會一一列舉並詳細說明,讓調用方簡單直觀的理解到此介面的服務能力。

當把控制流程的異常嵌入到介面設計里,隨之問題就來了:

  1. 甚少看到有人能夠在Javadoc里使用@exception將介面內的異常標註清楚;
  2. 如何權衡選擇正常的應答返回還是拋異常?當介面應答只是true/false的時候,拋異常會是個很匪夷所思的設計;
  3. 當下層方法不斷的拋出各種異常,然後匯總到攔截器里處理時,或者需要對異常拆開做判斷,再自定義成合理的應答話術;或者將好不容易區分開的不同異常,被整合成了「通用系統異常」無法分辨;這時候的攔截器就是個異常中央處理池,拆就是hardcode,不拆就可能是浪費了之前的異常細顆粒度;
  4. 為了讓程式碼不那麼醜陋,自定義的異常通常繼承自RuntimeException。在介面提供方和調用方沒有通過介質(介面設計文檔/對話…)充分溝通清楚的情況下,一個神不知鬼不覺的Runtime異常完全可能造成自身業務邏輯的無法自恰;
  5. 異常具有正常應答無法比擬的分層穿透性,也就是不管間隔多少層,都可以直接穿透到接入層。對於某些異常場景下的程式碼實現確實有很好的支撐,反之,成也蕭何敗蕭何,這種強力的穿透能力是否會讓邏輯失控,totally count on you and your team;

綜合上面的這些點,異常作為非常規的設計路數,在沒有足夠的把控能力下,千萬不要無盡蔓延,這種類似「GoTo」式的程式碼實現,可能會讓你的系統支離破碎。

我的態度

任何的系統架構設計,都是在不斷的在做天人交戰,利弊權衡。鮮有絕對的對與錯,只有在當前組織環境內相對的合理與不合理。對於異常用作流程式控制制這件事,我是投反對票。因為即使異常的性能損耗對我們大部分的業務場景可以忽略不計的,但異常在介面中的易被忽視性、不可控的穿透性,就算是高素質的團隊也不一定能完全消除這種風險。既然風險如此大,寧肯讓團隊按部就班老老實實的寫好每一種應答。

承篇頭的論點,重新展開再抽象歸納一下:

任何邏輯判斷的流程式控制制都不應該用異常來實現,除非那些能明確導致程式中斷/終止的節點。異常務必要明確拋checked還是unchecked,對調用者負責。