天天看點

如何寫出健壯的代碼?

阿裡妹導讀:關于代碼的健壯性,其重要性不言而喻。那麼如何才能寫出健壯的代碼?阿裡文娛技術專家長統将從防禦式程式設計、如何正确使用異常和 DRY 原則等三個方面,并結合代碼執行個體,分享自己的看法心得,希望對同學們有所啟發。

你不可能寫出完美的軟體。因為它不曾出現,也不會出現。

每一個司機都認為自己是最好的司機,我們在鄙視那些闖紅燈、亂停車、胡亂變道不遵守規則的司機同時,更應該在行駛的過程中防衛性的駕駛,小心那些突然沖出來的車輛,在他們給我們造成麻煩的時候避開他。這跟程式設計有極高的相似性,我們在程式的世界裡要不斷的跟他人的代碼接合(那些不符合你的高标準的代碼),并處理可能有效也可能無效的輸入。無效的的輸入就好像一輛橫沖直撞的大卡車,這樣的世界防禦式程式設計也是必須的,但要駛得萬年船我們可能連自己都不能信任,因為你不知道沖出去的那輛是不是你自己的車。關于這點我們将在防禦式程式設計中讨論。

沒人能否認異常處理在 Java 中的重要性,但如果不能正确使用異常處理那麼它帶來的危害可能比好處更多。我将在正确使用異常中讨論這個問題。

DRY,Don't Repeat Yourself. 不要重複你自己。我們都知道重複的危害性,但重複時常還會出現在我們的工作中、代碼中、文檔中。有時重複感覺上是不得不這麼做,有時你并沒有意識到是在重複,有時卻是因為懶惰而重複。

好借好還再借不難。這句俗話在程式設計世界中同樣也是至理名言。隻要在程式設計,我們都要管理資源:記憶體、事物、線程、檔案、定時器,所有數量有限的事物都稱為資源。資源使用一般遵循的模式:你配置設定、你使用、你回收。

防禦式程式設計

防禦式程式設計是提高軟體品質技術的有益輔助手段。防禦式程式設計的主要思想是:程式/方法不應該因傳入錯誤資料而被破壞,哪怕是其他由自己編寫方法和程式産生的錯誤資料。這種思想是将可能出現的錯誤造成的影響控制在有限的範圍内。

一個好程式,在非法輸入的情況下,要麼什麼都不輸出,要麼輸出錯誤資訊。我們往往會檢查每一個外部的輸入(一切外部資料輸入,包括但不僅限于資料庫和配置中心),我們往往也會檢查每一個方法的入參。我們一旦發現非法輸入,根據防禦式程式設計的思想一開始就不引入錯誤。

使用衛語句

對于非法輸入的檢查我們通常會使用 if…else 去做判斷,但往往在判斷過程中由于參數對象的層次結構會一層一層展開判斷。

public void doSomething(DomainA a) {  if (a != null) {        assignAction;    if (a.getB() != null) {      otherAction;      if (a.getB().getC() instanceof DomainC) {        doSomethingB();        doSomethingA();        doSomthingC();      }    }  }}      

上邊的嵌套判斷的代碼我相信很多人都見過或者寫過,這樣做雖然做到了基本的防禦式程式設計,但是也把醜陋引了進來。《Java 開發手冊》中建議我們碰到這樣的情況使用衛語句的方式處理。什麼是衛語句?我們給出個例子來說明什麼是衛語句。

public void doSomething(DomainA a) {    if (a == null) {        return ; //log some errorA    }    if (a.getB() == null) {        return ; //log some errorB    }    if (!(a.getB().getC instanceof DomainC)) {        return ;//log some errorC    }    assignAction;    otherAction;    doSomethingA();    doSomethingB();    doSomthingC();}      

方法中的條件邏輯使人難以看清正常的分支執行路徑,所謂的衛語句的做法就是将複雜的嵌套表達式拆分成多個表達式,我們使用衛語句表現所有特殊情況。

使用驗證器 (validator)

驗證器是我在開發中的一種實踐,将合法性檢查與 OOP 結合是一種非常奇妙的體驗。

public List<DemoResult> demo(DemoParam dParam) {    Assert.isTrue(dParam.validate(),()-> new SysException("參數驗證失敗-" + DemoParam.class.getSimpleName() +"驗證失敗:" + dParam));    DemoResult demoResult = doBiz();    doSomething();    return demoResult;}      

在這個示例中,方法的第一句話就是對驗證器的調用,以獲得目前參數是否合法。

在參數對象中實作驗證接口,為字段配置驗證注解,如果需要組合驗證複寫 validate0 方法。這樣就把合法性驗證邏輯封裝到了對象中。

public class DemoParam extends BaseDO implements ValidateSubject {    @ValidateString(strMaxLength = 128)    private String aString;    @ValidateObject(require = true)    private List<SubjectDO> bList;    @ValidateString(require = true,strMaxLength = 128)    private String cString;    @ValidateLong(require = true)    private Long dLong;    @Override    public boolean validate0(ValidateSubject validateSubject) throws ValidateException {        if (validateSubject instanceof DemoParam) {            DemoParam param = (DemoParam)validateSubject;            return StringUtils.isNotBlank(param.getAString())                   && SubjectDO.allValidate(param.getBList());        }        return false;    }}      

使用斷言

當出現了一個突如其來的線上問題,我相信很多夥伴的心中一定閃現過這樣一個念頭。"這不科學啊...這不可能發生啊…","計數器怎麼可能為負數","這個對象不可為null",但它就是真實的發生了,它就在那。我們不要這樣騙自己,特别是在編碼時。如果它不可能發生,用斷言確定它不會發生。

使用斷言的重要原則就是,斷言不能有副作用,也絕不能把必須執行的代碼放入斷言。

斷言不能有副作用,如果我每年增加錯誤檢查代碼卻制造了新的錯誤,那是一件令人尴尬的事情。舉一個反面例子:

while (iter.next() != null) {    assert(iter.next()!=null);    Object next = iter.next();    //...}      

必須執行的代碼也不能放入斷言,因為生産環境很可能是關閉 Java 斷言的。是以我更喜歡使用 Spring 提供的 Assert 工具,這個工具提供的斷言隻會傳回 IllegalStateException,如果需要這個異常不能滿足我們的業務需求,我們可以重新建立一個 Assert 類并繼承org.springframework.util.Assert,在新類中新增斷言方法以支援自定義異常的傳入。

public class Assert extends org.springframework.util.Assert {    public static <T extends RuntimeException> void isTrue(boolean expression, Supplier<T> tSupplier) {        if (!expression) {            if (tSupplier != null) {                throw tSupplier.get();            }            throw new IllegalArgumentException();        }    }}      
Assert.isTrue(crParam.validate(),()-> new SysException("參數驗證失敗-" + Calculate.class.getSimpleName() +"驗證失敗:" + crParam));      

有人認為斷言僅是一種調試工具,一旦代碼釋出後就應該關閉斷言,因為斷言會增加一些開銷(微小的 CPU 時間)。是以在很多工程實踐中斷言确實是關閉的,也有不少大 V 有過這樣的建議。Dndrew Hunt 和 David Thomas 反對這樣的說法,在他們書裡有一個比喻我認為很形象。

在你把程式傳遞使用時關閉斷言就像是因為你曾經成功過,就不用保護網取走鋼絲。

——《The pragmatic Programmer》

處理錯誤時的關鍵選擇

防禦式程式設計會預設錯誤處理。

在錯誤發生後的後續流程上通常會有兩種選擇,終止程式和繼續運作。

  • 終止程式,如果出現了非常嚴重的錯誤,那最好終止程式或讓使用者重新開機程式。比如,銀行的 ATM 機出現了錯誤,那就關閉裝置,以防止取 100 塊吐出 10000 塊的悲劇發生。
  • 繼續運作,通常也是有兩種選擇,本地處理和抛出錯誤。本地處理通常會使用預設值的方式處理,抛出錯誤會以異常或者錯誤碼的形式傳回。

在處理錯誤的時候我們還面臨着另外一種選擇,正确性和健壯性的選擇。

  • 正确性,選擇正确性意味着結果永遠是正确的,如果出錯,甯願不給出結果也不要給定一個不準确的值。比如使用者資産類的業務。
  • 健壯性,健壯性意味着通過一些措施,保證軟體能夠正常運作下去,即使有時候會有一些不準确的值出現。比如産品介紹超過頁面展示範圍

無論是使用衛語、斷言還是預設錯誤處理都是在用抱着對程式世界的敬畏态度在小心的駕駛,時刻提防着他人更提防着自己。

北京第三區交通委提醒您,道路千萬條,安全第一條,行車不規範,親人兩行淚。

正确使用異常

檢查每一個可能的錯誤是一種良好的實踐,特别是那些意料之外的錯誤。

非常棒的是,Java 為我們提供了異常機制。如果充分發揮異常的優點,可以提高程式的可讀性、可靠性和可維護性,但如果使用不當,異常帶來的負面影響也是非常值得我們注意并避免的。

隻在異常情況下使用異常

在《The pragmatic Programmer》和《Effective Java》中作者都有這樣的觀點。

我認為這有兩重意思。一重意思如何處理識别異常情況并處理他,另一重意思是隻在異常情況下使用異常流程。

那什麼是異常情況,又該如何處理?這個問題無法在代碼模式上給出标準的答案,完全看實際情況,要對每一個錯誤了然于胸并檢查每一個可能發生的錯誤,并區分錯誤和異常。

即便同樣是打開檔案操作,讀取"/etc/passwd"和讀取一個使用者上傳的檔案,同樣是 FileNotFoundException,如何處理完全取決于實際情況,Surprise!前者直接讀取檔案出現異常直接抛出讓程式盡早崩潰,而後者應該先判斷檔案是否存在,如果存在但出現了 FileNotFoundException 則再抛出。

public static void openPasswd() throws FileNotFoundException {        FileInputStream fs = new FileInputStream("/etc/passwd");    }      

讀取"/etc/passwd"失敗,Surprise!

public static boolean openUserFile(String path) throws FileNotFoundException {        File f = new File(path);        if (!f.exists()) {            return false;        }        FileInputStream fs = new FileInputStream(path);        return true;    }      

在檔案存在的情況下讀取檔案失敗,Surprise!

再啰嗦一遍,是不是異常情況關鍵在于它是不是給我們一記 Surprise!,這就是本節開頭檢查每一個錯誤是一種良好的實踐想要表達的。

使用異常來控制正常流程的反面例子我就偷懶借用一下《Effective Java Second Edition》裡的例子來說明好了。

Integer[] range ={1,2,3};//Horrible abuse of exceptions.Don't ever do this!try {  int i=0;  println(range[i++].intValue());} catch (ArrayIndexOutOfBoundsException e) {}      

這個例子看起來根本不知道在幹什,這段代碼其實是在用數組越界異常來控制周遊數組,這個腦洞開的非常拙劣。如何正确周遊一個數組我想不需要再給出例子,那是對讀者的亵渎。

那為什麼有人這麼開腦洞呢?因為這個做法企圖使用 Java 錯誤判斷機制來提高性能,因為 JVM 對每一次數組通路都會檢查越界情況,是以他們認為檢查到的錯誤才應該是循環終止的條件,然而 for-each 循環對已經檢查到的錯誤視而不見,将其隐藏了,是以用應該避免使用 for-each。

對于這個腦洞的原因 Joshua Bloch 給出了三點反駁:

  • 因為異常機制的設計初衷是用于不正常的情形,是以很少會有 JVM 實作試圖對它們進行優化,使得與顯示測試一樣快速。
  • 把代碼放在 try-catch 塊中反而阻止了現代 JVM 實作本來可能要執行的某些特定優化。
  • 對數組進行周遊的标準模式并不會導緻備援的檢查。有些現代的 JVM 實作會将他們優化掉。

還有一個例子是我曾經遇到的,但是由于年代久遠已經找不到項目位址了。我一個朋友曾經給我看過一個 github 上的 MVC 架構項目,雖然時隔多年但令我印象深刻的是這個項目使用自定義異常和異常碼來控制 Dispatcher,把異常當成一種友善的結果傳遞方式來使用,當成 goto 來使用,這太可怕了。不過 try-catch 方式從位元組碼表述上來看,确實是一種 goto 的表述。這樣的方式我們最好想都不要想。

這兩個例子主要就是為了說明,異常應該隻用于異常的情況下;永遠不應該用在正常的流程中,不管你的理由看着多麼的聰明。這樣做往往會弄巧成拙,使得代碼可讀性大大下降。

受檢異常和非受檢異常

曾經不止一次的見過有人提倡将系統中的受檢異常都包裝成非受檢異常,對于這個建議我并不以為然。因為 Java 的設計者其實是希望通過區分異常種類來指導我們程式設計的。

Java 一共提供了三類可抛出結構 (throwable),受檢異常、非受檢異常(運作時異常)和錯誤 (error)。他們的界限我也經常傻傻的分不清,不過還是有迹可循的。

  • 受檢異常:如果期望調用者能夠适當的恢複,比如 RMI 每次調用必須處理的異常,設計者是期望調用者可以重試或别的方式來嘗試恢複;比如上邊提到的 FileInputStream 的構造方法,會抛出 FileNotFoundException,設計者或許希望調用者嘗試從其他目錄來讀取該檔案,使得程式可以繼續執行下去。
  • 非受檢異常和錯誤:表明是程式設計錯誤,往往屬于不可恢複的情景,而且是不應該被提前捕獲的,應該快速抛出到頂層處理器,比如在服務接口的基類方法中統一處理非受檢異常。這種非受檢異常往往也說明了在程式設計中違反了某些約定。比如數組越界異常,說明違反了通路數組不能越界的前提約定。

總而言之,對于可恢複的情況使用受檢異常;對于程式錯誤使用非受檢異常。是以你自己程式内部定義的異常都應該是非受檢異常;在面向接口或面向二方/三方庫的方法盡量使用受檢異常。

說到面向接口或面向二/三方庫,你可能碰到的就是一輛失控的汽車。搞清楚你所調用的接口或者庫裡的異常情況也是我們能夠碼出健壯代碼的一個強力保證。

不要忽略異常

這個建議顯而易見,但卻常常被違反。當一個 API 的設計者聲明一個方法将抛出異常的時候,通常都是想要說明某件事發生了。忽略異常就是我們通常說的吃掉異常,try-catch 但什麼也不做。吃掉一個異常就好比破壞了一個報警器,當災難真正來臨沒人搞清楚發生了什麼。

對于每一個 catch 塊至少列印一條日志,說明異常情況或者說明為什麼不處理。

這個顯而易見的建議同時适用于受檢異常和非受檢異常。

DRY (Don't Repeat Yourself)

DRY 原則最先在《The pragmatic Programmer》被提出,如今已經被業界廣泛的認知,我相信每個軟體工程師都認識它。我想有很多人對它的認識含混不清僅僅是不要有重複的代碼;也有些人對此原則不屑一顧抽象什麼的都是浪費時間快速上線是大義;也有人誓死捍衛這個原則不能忍受任何重複。今天我們來談談這個熟悉又陌生的話題。

DRY 是什麼

DRY 的原則是“系統中的每一部分,都必須有一個單一的、明确的、權威的代表”,指的是(由人編寫而非機器生成的)代碼和測試所構成的系統,必須能夠表達所應表達的内容,但是不能含有任何重複代碼。當 DRY 原則被成功應用時,一個系統中任何單個元素的修改都不需要與其邏輯無關的其他元素發生改變。此外,與之邏輯上相關的其他元素的變化均為可預見的、均勻的,并如此保持同步。

這段定義來自于中文維基百科,但這個定義似乎與 Andrew Hunt 和 David Thomas 給出的定義有所出入。尋根溯源在《The pragmatic Programmer》作者是這樣定義這個原則的:

EVERY PIECE OF KNOWLEDGE MUST HAVE A SINGLE, UNAMBIGUOUS, AUTHORITATIVE REPRESENTATION WITHIN A SYSTEM.

系統中的每一項知識都必須具有單一、無歧義、權威的表示。

作者所提倡禁止的是知識 (knowledge) 的重複而不是單純的代碼上的重複。那什麼是知識呢?我鬥膽給一個自己的了解,知識就是系統中對于一個邏輯的解釋/定義,系統中的邏輯都是要對外輸出或者讓外界感覺的。邏輯的定義/解釋包括代碼和寫在代碼上的文檔還有宏觀上實作。我們要避免的是在改動時的一個邏輯的時候需要去修改十處,如果漏掉了任何一處就會造成 bug 甚至線上故障。變更在軟體開發中又是一個常态,在網際網路行業中更是如此,而在一個到處是重複的系統中維護變更是非常艱難的。

沒有文檔比錯誤的文檔更好

編寫代碼時同時編寫文檔在多數程式員看來是一個好習慣,但有相當一部分程式開發人員又沒有這樣的習慣,這一點反而使得代碼更幹 (dry)——有點好笑。因為底層知識應該放在代碼中,底層代碼應該是職責單一、邏輯簡單的代碼,在底層代碼上添加注釋就是在做重複的事情,就有可能因為對于知識的過期解釋,而讀注釋比讀代碼更容易,可怕的事情往往就這樣發生;把注釋放在更上層的複雜的複雜邏輯中。滿篇的注釋并不是好代碼,也不是好習慣,好的代碼是不需要注釋的。

CP 大法,禁止!

每個項目都有時間壓力,這往往是誘惑我們使用 CP 大法最重要原因。但是"欲速則不達",你也許現在省了十分鐘,以後卻需要花幾個小時處理各種各樣的線上問題。因為變更是常态,我們當初留下的一個坑隊友可能會幫你挖的更深更大一些,然後我們就掉進了自己挖的坑,我們還會埋怨豬隊友,到底誰才是豬隊友。這其實是我帶過的一個團隊裡真實發生的事情。

把知識的解釋/定義放在一處!

PS:感受一下程式員的冷幽默。違背 DRY 原則的代碼,程式員稱之為 WET 的,可以了解為 Write Everything Twice(任何東西寫兩遍),We Enjoying Typing(我們享受敲鍵盤)或 Waste Everyone’s Time(浪費所有人的時間)。

關于 DRY 原則的争論

DRY 原則提出以來一直以來都存在着一些争議和讨論,有粉也有黑。如果有一個百分比,對于這條原則我會選擇 95% 服從。

《The pragmatic Programmer》告訴我們 Once and only once。

《Extreme Programing》又告訴我們 You aren't gonna need it (YAGNI),指的是你自以為有用的功能,實際上都是用不到的。這裡好像出現了一個問題,DRY 與 YAGNI 不完全相容。DRY 要求花精力去抽象追求通用,而 YAGNI 要求快和省,你花精力做的抽象很可能用不到。

這個時候我們的第三選擇是什麼?《Refactoring》提出的 Rule Of Three 像是一個很好的折中方案。它的涵義是,第一次用到某個功能時,你寫一個特定的解決方法;第二次又用到的時候,你拷貝上一次的代碼;第三次出現的時候,你才着手"抽象化",寫出通用的解決方法。這樣做有幾個理由:

省事

如果一種功能隻有一到兩個地方會用到,就不需要在"抽象化"上面耗費時間了。

容易發現模式

"抽象化"需要找到問題的模式,問題出現的場合越多,就越容易看出模式,進而可以更準确地"抽象化"。比如,對于一個數列來說,兩個元素不足以判斷出規律:

1,2,_,_,_,_      

第三個元素出現後,規律就變得較清晰了:

1,2,4,_,_,_      

防止過度備援

如果一種功能同時有多個實作,管理起來非常麻煩,修改的時候需要修改多處。在實際工作中,重複實作最多可以容忍出現一次,再多就無法接受了。

我認為以上三個原則都不能當做銀彈,還是要根據實際情況做出正确的選擇。

DRY 原則理論上來說是沒有問題的,但在實際應用時切忌死搬教條。它隻能起指導作用,沒有量化标準,否則的話理論上一個程式每一行代碼都隻能出現一次才行,這是非常荒謬的。

Rule of Three 不是重複的代碼一定要出現三次才可以進行抽象,我認為三次不應該成為一個度量标準,對于未來的預判和對于項目走向等因素也應該放在是否抽象的考慮中。

PS:王垠曾經寫過一篇《DRY 原則的危害》有興趣的朋友可以讀一讀:如何評價王垠最新文章,《DRY 原則的危害》?

(https://www.zhihu.com/question/31278077)

後記

原則不是銀彈,原則是沙漠中的綠洲亦或是沙漠中海市蜃樓中的綠洲。面對所謂的原則要求我們每一個人都有辨識能力,不盲目遵從先哲大牛,要具有獨立思考的能力。具備辨識和思考能力首先就需要有足夠多的輸入和足夠多的實踐。

參考[1]《The pragmatic Programmer:From Journeyman to Master》 作者:Andrew Hunt、David Thomas[2]《Effective Java Second Edition》 作者 :Joshua Bloch[3]《Java 開發手冊》[4]中文維基百科[5]代碼的抽象三原則-阮一峰http://www.ruanyifeng.com/blog/2013/01/abstraction_principles.html
public void doSomething(DomainA a) {  if (a != null) {        assignAction;    if (a.getB() != null) {      otherAction;      if (a.getB().getC() instanceof DomainC) {        doSomethingB();        doSomethingA();        doSomthingC();      }    }  }}      
public void doSomething(DomainA a) {    if (a == null) {        return ; //log some errorA    }    if (a.getB() == null) {        return ; //log some errorB    }    if (!(a.getB().getC instanceof DomainC)) {        return ;//log some errorC    }    assignAction;    otherAction;    doSomethingA();    doSomethingB();    doSomthingC();}      
public List<DemoResult> demo(DemoParam dParam) {    Assert.isTrue(dParam.validate(),()-> new SysException("參數驗證失敗-" + DemoParam.class.getSimpleName() +"驗證失敗:" + dParam));    DemoResult demoResult = doBiz();    doSomething();    return demoResult;}      
public class DemoParam extends BaseDO implements ValidateSubject {    @ValidateString(strMaxLength = 128)    private String aString;    @ValidateObject(require = true)    private List<SubjectDO> bList;    @ValidateString(require = true,strMaxLength = 128)    private String cString;    @ValidateLong(require = true)    private Long dLong;    @Override    public boolean validate0(ValidateSubject validateSubject) throws ValidateException {        if (validateSubject instanceof DemoParam) {            DemoParam param = (DemoParam)validateSubject;            return StringUtils.isNotBlank(param.getAString())                   && SubjectDO.allValidate(param.getBList());        }        return false;    }}      
while (iter.next() != null) {    assert(iter.next()!=null);    Object next = iter.next();    //...}      
public class Assert extends org.springframework.util.Assert {    public static <T extends RuntimeException> void isTrue(boolean expression, Supplier<T> tSupplier) {        if (!expression) {            if (tSupplier != null) {                throw tSupplier.get();            }            throw new IllegalArgumentException();        }    }}      
Assert.isTrue(crParam.validate(),()-> new SysException("參數驗證失敗-" + Calculate.class.getSimpleName() +"驗證失敗:" + crParam));      
public static void openPasswd() throws FileNotFoundException {        FileInputStream fs = new FileInputStream("/etc/passwd");    }      
public static boolean openUserFile(String path) throws FileNotFoundException {        File f = new File(path);        if (!f.exists()) {            return false;        }        FileInputStream fs = new FileInputStream(path);        return true;    }      
Integer[] range ={1,2,3};//Horrible abuse of exceptions.Don't ever do this!try {  int i=0;  println(range[i++].intValue());} catch (ArrayIndexOutOfBoundsException e) {}      
1,2,_,_,_,_      
1,2,4,_,_,_