如何寫出健壯的代碼?

簡介: 關於代碼的健壯性,其重要性不言而喻。那麼如何才能寫出健壯的代碼?阿里文娛技術專家長統將從防護式編程、如何正確使用異常和 DRY 原則等三個方面,並結合代碼實例,分享本身的見解心得,但願對同窗們有所啓發。html

image.png
你不可能寫出完美的軟件。由於它未曾出現,也不會出現。git

每個司機都認爲本身是最好的司機,咱們在鄙視那些闖紅燈、亂停車、胡亂變道不遵照規則的司機同時,更應該在行駛的過程當中防衛性的駕駛,當心那些忽然衝出來的車輛,在他們給咱們形成麻煩的時候避開他。這跟編程有極高的類似性,咱們在程序的世界裏要不斷的跟他人的代碼接合(那些不符合你的高標準的代碼),並處理可能有效也可能無效的輸入。無效的的輸入就好像一輛橫衝直撞的大卡車,這樣的世界防護式編程也是必須的,但要駛得萬年船咱們可能連本身都不能信任,由於你不知道衝出去的那輛是否是你本身的車。關於這點咱們將在防護式編程中討論。程序員

沒人可否認異常處理在 Java 中的重要性,但若是不能正確使用異常處理那麼它帶來的危害可能比好處更多。我將在正確使用異常中討論這個問題。github

DRY,Don't Repeat Yourself. 不要重複你本身。咱們都知道重複的危害性,但重複時常還會出如今咱們的工做中、代碼中、文檔中。有時重複感受上是不得不這麼作,有時你並無意識到是在重複,有時倒是由於懶惰而重複。spring

好借好還再借不難。這句俗話在編程世界中一樣也是至理名言。只要在編程,咱們都要管理資源:內存、事物、線程、文件、定時器,全部數量有限的事物都稱爲資源。資源使用通常遵循的模式:你分配、你使用、你回收。數據庫

防護式編程

防護式編程是提升軟件質量技術的有益輔助手段。防護式編程的主要思想是:程序/方法不該該因傳入錯誤數據而被破壞,哪怕是其餘由本身編寫方法和程序產生的錯誤數據。這種思想是將可能出現的錯誤形成的影響控制在有限的範圍內。express

一個好程序,在非法輸入的狀況下,要麼什麼都不輸出,要麼輸出錯誤信息。咱們每每會檢查每個外部的輸入(一切外部數據輸入,包括但不只限於數據庫和配置中心),咱們每每也會檢查每個方法的入參。咱們一旦發現非法輸入,根據防護式編程的思想一開始就不引入錯誤。編程

使用衛語句

對於非法輸入的檢查咱們一般會使用 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.

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

做者所提倡禁止的是知識 (knowledge) 的重複而不是單純的代碼上的重複。那什麼是知識呢?我斗膽給一個本身的理解,知識就是系統中對於一個邏輯的解釋/定義,系統中的邏輯都是要對外輸出或者讓外界感知的。邏輯的定義/解釋包括代碼和寫在代碼上的文檔還有宏觀上實現。咱們要避免的是在改動時的一個邏輯的時候須要去修改十處,若是漏掉了任何一處就會形成 bug 甚至線上故障。變動在軟件開發中又是一個常態,在互聯網行業中更是如此,而在一個處處是重複的系統中維護變動是很是艱難的。

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

編寫代碼時同時編寫文檔在多數程序員看來是一個好習慣,但有至關一部分程序開發人員又沒有這樣的習慣,這一點反而使得代碼更幹 (dry)——有點可笑。由於底層知識應該放在代碼中,底層代碼應該是職責單1、邏輯簡單的代碼,在底層代碼上添加註釋就是在作重複的事情,就有可能由於對於知識的過時解釋,而讀註釋比讀代碼更容易,可怕的事情每每就這樣發生;把註釋放在更上層的複雜的複雜邏輯中。滿篇的註釋並非好代碼,也不是好習慣,好的代碼是不須要註釋的。

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
相關文章
相關標籤/搜索