Golang錯誤和異常處理的正確姿勢

序言

錯誤和異常是兩個不同的概念,非常容易混淆。很多程序員習慣將一切非正常情況都看做錯誤,而不區(qū)分錯誤和異常,即使程序中可能有異常拋出,也將異常及時捕獲并轉(zhuǎn)換成錯誤。從表面上看,一切皆錯誤的思路更簡單,而異常的引入僅僅增加了額外的復雜度。
但事實并非如此。眾所周知,Golang遵循“少即是多”的設計哲學,追求簡潔優(yōu)雅,就是說如果異常價值不大,就不會將異常加入到語言特性中。

錯誤和異常處理是程序的重要組成部分,我們先看看下面幾個問題:

  1. 錯誤和異常如何區(qū)分?
  2. 錯誤處理的方式有哪幾種?
  3. 什么時候需要使用異常終止程序?
  4. 什么時候需要捕獲異常?
  5. ...

如果你對這幾個問題的答案不是太清楚,那么就抽一點時間看看本文,或許能給你一些啟發(fā)。

face-to-exception.png

基礎(chǔ)知識

錯誤指的是可能出現(xiàn)問題的地方出現(xiàn)了問題,比如打開一個文件時失敗,這種情況在人們的意料之中 ;而異常指的是不應該出現(xiàn)問題的地方出現(xiàn)了問題,比如引用了空指針,這種情況在人們的意料之外??梢?,錯誤是業(yè)務過程的一部分,而異常不是 。

Golang中引入error接口類型作為錯誤處理的標準模式,如果函數(shù)要返回錯誤,則返回值類型列表中肯定包含error。error處理過程類似于C語言中的錯誤碼,可逐層返回,直到被處理。

Golang中引入兩個內(nèi)置函數(shù)panic和recover來觸發(fā)和終止異常處理流程,同時引入關(guān)鍵字defer來延遲執(zhí)行defer后面的函數(shù)。
一直等到包含defer語句的函數(shù)執(zhí)行完畢時,延遲函數(shù)(defer后的函數(shù))才會被執(zhí)行,而不管包含defer語句的函數(shù)是通過return的正常結(jié)束,還是由于panic導致的異常結(jié)束。你可以在一個函數(shù)中執(zhí)行多條defer語句,它們的執(zhí)行順序與聲明順序相反。
當程序運行時,如果遇到引用空指針、下標越界或顯式調(diào)用panic函數(shù)等情況,則先觸發(fā)panic函數(shù)的執(zhí)行,然后調(diào)用延遲函數(shù)。調(diào)用者繼續(xù)傳遞panic,因此該過程一直在調(diào)用棧中重復發(fā)生:函數(shù)停止執(zhí)行,調(diào)用延遲執(zhí)行函數(shù)等。如果一路在延遲函數(shù)中沒有recover函數(shù)的調(diào)用,則會到達該攜程的起點,該攜程結(jié)束,然后終止其他所有攜程,包括主攜程(類似于C語言中的主線程,該攜程ID為1)。

錯誤和異常從Golang機制上講,就是error和panic的區(qū)別。很多其他語言也一樣,比如C++/Java,沒有error但有errno,沒有panic但有throw。

Golang錯誤和異常是可以互相轉(zhuǎn)換的:

  1. 錯誤轉(zhuǎn)異常,比如程序邏輯上嘗試請求某個URL,最多嘗試三次,嘗試三次的過程中請求失敗是錯誤,嘗試完第三次還不成功的話,失敗就被提升為異常了。
  2. 異常轉(zhuǎn)錯誤,比如panic觸發(fā)的異常被recover恢復后,將返回值中error類型的變量進行賦值,以便上層函數(shù)繼續(xù)走錯誤處理流程。

一個啟示

regexp包中有兩個函數(shù)Compile和MustCompile,它們的聲明如下:

func Compile(expr string) (*Regexp, error)
func MustCompile(str string) *Regexp

同樣的功能,不同的設計:

  1. Compile函數(shù)基于錯誤處理設計,將正則表達式編譯成有效的可匹配格式,適用于用戶輸入場景。當用戶輸入的正則表達式不合法時,該函數(shù)會返回一個錯誤。
  2. MustCompile函數(shù)基于異常處理設計,適用于硬編碼場景。當調(diào)用者明確知道輸入不會引起函數(shù)錯誤時,要求調(diào)用者檢查這個錯誤是不必要和累贅的。我們應該假設函數(shù)的輸入一直合法,當調(diào)用者輸入了不應該出現(xiàn)的輸入時,就觸發(fā)panic異常。

于是我們得到一個啟示:什么情況下用錯誤表達,什么情況下用異常表達,就得有一套規(guī)則,否則很容易出現(xiàn)一切皆錯誤或一切皆異常的情況。

在這個啟示下,我們給出異常處理的作用域(場景):

  1. 空指針引用
  2. 下標越界
  3. 除數(shù)為0
  4. 不應該出現(xiàn)的分支,比如default
  5. 輸入不應該引起函數(shù)錯誤

其他場景我們使用錯誤處理,這使得我們的函數(shù)接口很精煉。對于異常,我們可以選擇在一個合適的上游去recover,并打印堆棧信息,使得部署后的程序不會終止。

說明: Golang錯誤處理方式一直是很多人詬病的地方,有些人吐槽說一半的代碼都是"if err != nil { / 打印 && 錯誤處理 / }",嚴重影響正常的處理邏輯。當我們區(qū)分錯誤和異常,根據(jù)規(guī)則設計函數(shù),就會大大提高可讀性和可維護性。

錯誤處理的正確姿勢

姿勢一:失敗的原因只有一個時,不使用error

我們看一個案例:

func (self *AgentContext) CheckHostType(host_type string) error {
    switch host_type {
    case "virtual_machine":
        return nil
    case "bare_metal":
        return nil
    }
    return errors.New("CheckHostType ERROR:" + host_type)
}

我們可以看出,該函數(shù)失敗的原因只有一個,所以返回值的類型應該為bool,而不是error,重構(gòu)一下代碼:

func (self *AgentContext) IsValidHostType(hostType string) bool {
    return hostType == "virtual_machine" || hostType == "bare_metal"
}

說明:大多數(shù)情況,導致失敗的原因不止一種,尤其是對I/O操作而言,用戶需要了解更多的錯誤信息,這時的返回值類型不再是簡單的bool,而是error。

姿勢二:沒有失敗時,不使用error

error在Golang中是如此的流行,以至于很多人設計函數(shù)時不管三七二十一都使用error,即使沒有一個失敗原因。
我們看一下示例代碼:

func (self *CniParam) setTenantId() error {
    self.TenantId = self.PodNs
    return nil
}

對于上面的函數(shù)設計,就會有下面的調(diào)用代碼:

err := self.setTenantId()
if err != nil {
    // log
    // free resource
    return errors.New(...)
}

根據(jù)我們的正確姿勢,重構(gòu)一下代碼:

func (self *CniParam) setTenantId() {
    self.TenantId = self.PodNs
}

于是調(diào)用代碼變?yōu)椋?/p>

self.setTenantId()

姿勢三:error應放在返回值類型列表的最后

對于返回值類型error,用來傳遞錯誤信息,在Golang中通常放在最后一個。

resp, err := http.Get(url)
if err != nil {
    return nill, err
}

bool作為返回值類型時也一樣。

value, ok := cache.Lookup(key) 
if !ok {
    // ...cache[key] does not exist… 
}

姿勢四:錯誤值統(tǒng)一定義,而不是跟著感覺走

很多人寫代碼時,到處return errors.New(value),而錯誤value在表達同一個含義時也可能形式不同,比如“記錄不存在”的錯誤value可能為:

  1. "record is not existed."
  2. "record is not exist!"
  3. "###record is not existed!??!"
  4. ...

這使得相同的錯誤value撒在一大片代碼里,當上層函數(shù)要對特定錯誤value進行統(tǒng)一處理時,需要漫游所有下層代碼,以保證錯誤value統(tǒng)一,不幸的是有時會有漏網(wǎng)之魚,而且這種方式嚴重阻礙了錯誤value的重構(gòu)。

于是,我們可以參考C/C++的錯誤碼定義文件,在Golang的每個包中增加一個錯誤對象定義文件,如下所示:

var ERR_EOF = errors.New("EOF")
var ERR_CLOSED_PIPE = errors.New("io: read/write on closed pipe")
var ERR_NO_PROGRESS = errors.New("multiple Read calls return no data or error")
var ERR_SHORT_BUFFER = errors.New("short buffer")
var ERR_SHORT_WRITE = errors.New("short write")
var ERR_UNEXPECTED_EOF = errors.New("unexpected EOF")

說明:筆者對于常量更喜歡C/C++的“全大寫+下劃線分割”的命名方式,讀者可以根據(jù)團隊的命名規(guī)范或個人喜好定制。

姿勢五:錯誤逐層傳遞時,層層都加日志

根據(jù)筆者經(jīng)驗,層層都加日志非常方便故障定位。

說明:至于通過測試來發(fā)現(xiàn)故障,而不是日志,目前很多團隊還很難做到。如果你或你的團隊能做到,那么請忽略這個姿勢:)

姿勢六:錯誤處理使用defer

我們一般通過判斷error的值來處理錯誤,如果當前操作失敗,需要將本函數(shù)中已經(jīng)create的資源destroy掉,示例代碼如下:

func deferDemo() error {
    err := createResource1()
    if err != nil {
        return ERR_CREATE_RESOURCE1_FAILED
    }
    err = createResource2()
    if err != nil {
        destroyResource1()
        return ERR_CREATE_RESOURCE2_FAILED
    }

    err = createResource3()
    if err != nil {
        destroyResource1()
        destroyResource2()
        return ERR_CREATE_RESOURCE3_FAILED
    }

    err = createResource4()
    if err != nil {
        destroyResource1()
        destroyResource2()
        destroyResource3()
        return ERR_CREATE_RESOURCE4_FAILED
    }
    return nil
}

當Golang的代碼執(zhí)行時,如果遇到defer的閉包調(diào)用,則壓入堆棧。當函數(shù)返回時,會按照后進先出的順序調(diào)用閉包。
對于閉包的參數(shù)是值傳遞,而對于外部變量卻是引用傳遞,所以閉包中的外部變量err的值就變成外部函數(shù)返回時最新的err值。
根據(jù)這個結(jié)論,我們重構(gòu)上面的示例代碼:

func deferDemo() error {
    err := createResource1()
    if err != nil {
        return ERR_CREATE_RESOURCE1_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource1()
        }
    }()
    err = createResource2()
    if err != nil {
        return ERR_CREATE_RESOURCE2_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource2()
        }
    }()

    err = createResource3()
    if err != nil {
        return ERR_CREATE_RESOURCE3_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource3()
        }
    }()

    err = createResource4()
    if err != nil {
        return ERR_CREATE_RESOURCE4_FAILED
    }
    return nil
}

姿勢七:當嘗試幾次可以避免失敗時,不要立即返回錯誤

如果錯誤的發(fā)生是偶然性的,或由不可預知的問題導致。一個明智的選擇是重新嘗試失敗的操作,有時第二次或第三次嘗試時會成功。在重試時,我們需要限制重試的時間間隔或重試的次數(shù),防止無限制的重試。

兩個案例:

  1. 我們平時上網(wǎng)時,嘗試請求某個URL,有時第一次沒有響應,當我們再次刷新時,就有了驚喜。
  2. 團隊的一個QA曾經(jīng)建議當Neutron的attach操作失敗時,最好嘗試三次,這在當時的環(huán)境下驗證果然是有效的。

姿勢八:當上層函數(shù)不關(guān)心錯誤時,建議不返回error

對于一些資源清理相關(guān)的函數(shù)(destroy/delete/clear),如果子函數(shù)出錯,打印日志即可,而無需將錯誤進一步反饋到上層函數(shù),因為一般情況下,上層函數(shù)是不關(guān)心執(zhí)行結(jié)果的,或者即使關(guān)心也無能為力,于是我們建議將相關(guān)函數(shù)設計為不返回error。

姿勢九:當發(fā)生錯誤時,不忽略有用的返回值

通常,當函數(shù)返回non-nil的error時,其他的返回值是未定義的(undefined),這些未定義的返回值應該被忽略。然而,有少部分函數(shù)在發(fā)生錯誤時,仍然會返回一些有用的返回值。比如,當讀取文件發(fā)生錯誤時,Read函數(shù)會返回可以讀取的字節(jié)數(shù)以及錯誤信息。對于這種情況,應該將讀取到的字符串和錯誤信息一起打印出來。

說明:對函數(shù)的返回值要有清晰的說明,以便于其他人使用。

異常處理的正確姿勢

姿勢一:在程序開發(fā)階段,堅持速錯

去年學習Erlang的時候,建立了速錯的理念,簡單來講就是“讓它掛”,只有掛了你才會第一時間知道錯誤。在早期開發(fā)以及任何發(fā)布階段之前,最簡單的同時也可能是最好的方法是調(diào)用panic函數(shù)來中斷程序的執(zhí)行以強制發(fā)生錯誤,使得該錯誤不會被忽略,因而能夠被盡快修復。

姿勢二:在程序部署后,應恢復異常避免程序終止

在Golang中,雖然有類似Erlang進程的Goroutine,但需要強調(diào)的是Erlang的掛,只是Erlang進程的異常退出,不會導致整個Erlang節(jié)點退出,所以它掛的影響層面比較低,而Goroutine如果panic了,并且沒有recover,那么整個Golang進程(類似Erlang節(jié)點)就會異常退出。所以,一旦Golang程序部署后,在任何情況下發(fā)生的異常都不應該導致程序異常退出,我們在上層函數(shù)中加一個延遲執(zhí)行的recover調(diào)用來達到這個目的,并且是否進行recover需要根據(jù)環(huán)境變量或配置文件來定,默認需要recover。
這個姿勢類似于C語言中的斷言,但還是有區(qū)別:一般在Release版本中,斷言被定義為空而失效,但需要有if校驗存在進行異常保護,盡管契約式設計中不建議這樣做。在Golang中,recover完全可以終止異常展開過程,省時省力。

我們在調(diào)用recover的延遲函數(shù)中以最合理的方式響應該異常:

  1. 打印堆棧的異常調(diào)用信息和關(guān)鍵的業(yè)務信息,以便這些問題保留可見;
  2. 將異常轉(zhuǎn)換為錯誤,以便調(diào)用者讓程序恢復到健康狀態(tài)并繼續(xù)安全運行。

我們看一個簡單的例子:

func funcA() error {
    defer func() {
        if p := recover(); p != nil {
            fmt.Printf("panic recover! p: %v", p)
            debug.PrintStack()
        }
    }()
    return funcB()
}

func funcB() error {
    // simulation
    panic("foo")
    return errors.New("success")
}

func test() {
    err := funcA()
    if err == nil {
        fmt.Printf("err is nil\\n")
    } else {
        fmt.Printf("err is %v\\n", err)
    }
}

我們期望test函數(shù)的輸出是:

err is foo

實際上test函數(shù)的輸出是:

err is nil

原因是panic異常處理機制不會自動將錯誤信息傳遞給error,所以要在funcA函數(shù)中進行顯式的傳遞,代碼如下所示:

func funcA() (err error) {
    defer func() {
        if p := recover(); p != nil {
            fmt.Println("panic recover! p:", p)
            str, ok := p.(string)
            if ok {
                err = errors.New(str)
            } else {
                err = errors.New("panic")
            }
            debug.PrintStack()
        }
    }()
    return funcB()
}

姿勢三:對于不應該出現(xiàn)的分支,使用異常處理

當某些不應該發(fā)生的場景發(fā)生時,我們就應該調(diào)用panic函數(shù)來觸發(fā)異常。比如,當程序到達了某條邏輯上不可能到達的路徑:

switch s := suit(drawCard()); s {
    case "Spades":
    // ...
    case "Hearts":
    // ...
    case "Diamonds":
    // ... 
    case "Clubs":
    // ...
    default:
        panic(fmt.Sprintf("invalid suit %v", s))
}

姿勢四:針對入?yún)⒉粦撚袉栴}的函數(shù),使用panic設計

入?yún)⒉粦撚袉栴}一般指的是硬編碼,我們先看“一個啟示”一節(jié)中提到的兩個函數(shù)(Compile和MustCompile),其中MustCompile函數(shù)是對Compile函數(shù)的包裝:

func MustCompile(str string) *Regexp {
    regexp, error := Compile(str)
    if error != nil {
        panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
    }
    return regexp
}

所以,對于同時支持用戶輸入場景和硬編碼場景的情況,一般支持硬編碼場景的函數(shù)是對支持用戶輸入場景函數(shù)的包裝。
對于只支持硬編碼單一場景的情況,函數(shù)設計時直接使用panic,即返回值類型列表中不會有error,這使得函數(shù)的調(diào)用處理非常方便(沒有了乏味的"if err != nil {/ 打印 && 錯誤處理 /}"代碼塊)。

小結(jié)

本文以Golang為例,闡述了錯誤和異常的區(qū)別,并且分享了很多錯誤和異常處理的正確姿勢,這些姿勢可以單獨使用,也可以組合使用,希望對大家有一點啟發(fā)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內(nèi)容

  • 序言 Golang遵循“少即是多”的設計哲學,同時又支持閉包(Closure),那么閉包對于Golang來說肯定有...
    _張曉龍_閱讀 4,482評論 7 26
  • 前言 本規(guī)范是針對 Go 語言的編碼規(guī)范,目的是為了統(tǒng)一項目的編碼風格,提高源程序的可讀性、可靠性和可重用性,從而...
    _張曉龍_閱讀 1,979評論 5 21
  • 能力模型 選擇題 [primary] 下面屬于關(guān)鍵字的是()A. funcB. defC. structD. cl...
    _張曉龍_閱讀 24,861評論 14 224
  • 概述 golang is a better C and a simple C++ golang主要特性 1、語法簡...
    zengfan閱讀 17,198評論 4 30
  • error code(錯誤代碼)=0是操作成功完成。error code(錯誤代碼)=1是功能錯誤。error c...
    Heikki_閱讀 3,422評論 1 9