Go 語言的錯誤處理機制是一個優秀的設計嗎?
很多 API 中都會把 Error Code 作為返回值,怎樣才能避免代碼中大量的 Error Check?
這個問題說來話長,我先表達一下我的觀點,Go語言從語法層面提供區分錯誤和異常的機制是很好的做法,比自己用單個返回值做值判斷要方便很多。上面看到很多知乎大牛把異常和錯誤混在一起說,有認為Go沒有異常機制的,有認為Go純粹只有異常機制的,我覺得這些觀點都太片面了。
具體對於錯誤和異常的討論,我轉發一下前陣子寫的一篇日誌拋磚引玉吧。
============================最近連續遇到朋友問我項目里錯誤和異常管理的事情,之前也多次跟團隊強調過錯誤和異常管理的一些概念,所以趁今天有動力就趕緊寫一篇Go語言項目錯誤和異常管理的經驗分享。
首先我們要理清:什麼是錯誤、什麼是異常、為什麼需要管理。然後才是怎樣管理。
錯誤和異常從語言機制上面講,就是error和panic的區別,放到別的語言也一樣,別的語言沒有error類型,但是有錯誤碼之類的,沒有panic,但是有throw之類的。
在語言層面它們是兩種概念,導致的是兩種不同的結果。如果程序遇到錯誤不處理,那麼可能進一步的產生業務上的錯誤,比如給用戶多扣錢了,或者進一步產生了異常;如果程序遇到異常不處理,那麼結果就是進程異常退出。
在項目裡面是不是應該處理所有的錯誤情況和捕捉所有的異常呢?我只能說,你可以這麼做,但是估計效果不會太好。我的理由是:
- 如果所有東西都處理和記錄,那麼重要信息可能被淹沒在信息的海洋里。
- 不應該處理的錯誤被處理了,很容易導出BUG暴露不出來,直到出現更嚴重錯誤的時候才暴露出問題,到時候排查就很困難了,因為已經不是錯誤的第一現場。
所以錯誤和異常最好能按一定的規則進行分類和管理,在第一時間能暴露錯誤和還原現場。
對於錯誤處理,Erlang有一個很好的概念叫速錯,就是有錯誤第一時間暴露它。我們的項目從Erlang到Go一直是沿用這一設計原則。但是應用這個原則的前提是先得區分錯誤和異常這兩個概念。
錯誤和異常上面已經提到了,從語言機制層面比較容易區分它們,但是語言取決於人為,什麼情況下用錯誤表達,什麼情況下用異常表達,就得有一套規則,否則很容易出現全部靠異常來做錯誤處理的情況,似乎Java項目特別容易出現這樣的設計。
這裡我先假想有這樣一個業務:遊戲玩家通過購買按鈕,用銅錢購買寶石。
在實現這個業務的時候,程序邏輯會進一步分化成客戶端邏輯和服務端邏輯,客戶端邏輯又進一步因為設計方式的不同分化成兩種結構:胖客戶端結構、瘦客戶端結構。
胖客戶端結構,有更多的本地數據和懂得更多的業務邏輯,所以在胖客戶端結構的應用中,以上的業務會實現成這樣:客戶端檢查緩存中的銅錢數量,銅錢數量足夠的時候購買按鈕為可用的亮起狀態,用戶點擊購買按鈕後客戶端發送購買請求到服務端;服務端收到請求後校驗用戶的銅錢數量,如果銅錢數量不足就拋出異常,終止請求過程並斷開客戶端的連接,如果銅錢數量足夠就進一步完成寶石購買過程,這裡不繼續描述正常過程。
因為正常的客戶端是有一步數據校驗的過程的,所以當服務端收到不合理的請求(銅錢不足以購買寶石)時,拋出異常比返回錯誤更為合理,因為這個請求只可能來自兩種客戶端:外掛或者有BUG的客戶端。如果不通過拋出異常來終止業務過程和斷開客戶端連接,那麼程序的錯誤就很難被第一時間發現,攻擊行為也很難被發現。
我們再回頭看瘦客戶端結構的設計,瘦客戶端不會存有太多狀態數據和用戶數據也不清楚業務邏輯,所以客戶端的設計會是這樣:用戶點擊購買按鈕,客戶端發送購買請求;服務端收到請求後檢查銅錢數量,數量不足就返回數量不足的錯誤碼,數量足夠就繼續完成業務並返回成功信息;客戶端收到服務端的處理結果後,在界面上做出反映。
在這種結構下,銅錢不足就變成了業務邏輯範圍內的一種失敗情況,但不能提升為異常,否則銅錢不足的用戶一點購買按鈕都會出錯掉線。
所以,異常和錯誤在不同程序結構下是互相轉換的,我們沒辦法一句話的給所有類型所有結構的程序一個統一的異常和錯誤分類規則。
但是,異常和錯誤的分類是有跡可循的。比如上面提到的痩客戶端結構,銅錢不足是業務邏輯範圍內的一種失敗情況,它屬於業務錯誤,再比如程序邏輯上嘗試請求某個URL,最多三次,重試三次的過程中請求失敗是錯誤,重試到第三次,失敗就被提升為異常了。
所以我們可以這樣來歸類異常和錯誤:不會終止程序邏輯運行的歸類為錯誤,會終止程序邏輯運行的歸類為異常。
因為錯誤不會終止邏輯運行,所以錯誤是邏輯的一部分,比如上面提到的瘦客戶端結構,銅錢不足的錯誤就是業務邏輯處理過程中需要考慮和處理的一個邏輯分支。而異常就是那些不應該出現在業務邏輯中的東西,比如上面提到的胖客戶端結構,銅錢不足已經不是業務邏輯需要考慮的一部分了,所以它應該是一個異常。
錯誤和異常的分類需要通過一定的思維訓練來強化分類能力,就類似於面向對象的設計方式一樣的,技術實現就擺在那邊,但是要用好需要不斷的思維訓練不斷的歸類和總結,以上提到的歸類方式希望可以作為一個參考,期待大家能發現更多更有效的歸類方式。
接下來我們講一下速錯和Go語言裡面怎麼做到速錯。
速錯我最早接觸是在做http://ASP.NET的時候就體驗到的,當然跟Erlang的速錯不完全一致,那時候也沒有那麼高大上的一個名字,但是對待異常的理念是一樣的。
在.NET項目開發的時候,有經驗的程序員都應該知道,不能隨便re-throw,就是catch錯誤再拋出,原因是異常的第一現場會被破壞,堆棧跟蹤信息會丟失,因為外部最後拿到異常的堆棧跟蹤信息,是最後那次throw的異常的堆棧跟蹤信息;其次,不能隨便try catch,隨便catch很容易導出異常暴露不出來,升級為更嚴重的業務漏洞。
到了Erlang時期,大家學到了速錯概念,簡單來講就是:讓它掛。只有掛了你才會第一時間知道錯誤,但是Erlang的掛,只是Erlang進程的異常退出,不會導致整個Erlang節點退出,所以它掛的影響層面比較低。
在Go語言項目中,雖然有類似Erlang進程的Goroutine,但是Goroutine如果panic了,並且沒有recover,那麼整個Go進程就會異常退出。所以我們在Go語言項目中要應用速錯的設計理念,就要對Goroutine做一定的管理。
在我們的遊戲服務端項目中,我把Goroutine按掛掉後的結果分為兩類:1、掛掉後不影響其他業務或功能的;2、掛掉後業務就無法正常進行的。
第一類Goroutine典型的有:處理各個玩家請求的Goroutine,因為每個玩家連接各自有一個Goroutine,所以掛掉了只會影響單個玩家,不會影響整體業務進行。
第二類Goroutine典型的有:資料庫同步用的Goroutine,如果它掛了,數據就無法同步到資料庫,遊戲如果繼續運行下去只會導致數據回檔,還不如讓整個遊戲都異常退出。
這樣一分類,就可以比較清楚哪些Goroutine該做recover處理,哪些不該做recover處理了。
那麼在做recover處理時,要怎樣才能盡量保留第一現場來幫組開發者排查問題原因呢?我們項目中通常是會在最外層的recover中把錯誤和堆棧跟蹤信息記進日誌,同時把關鍵的業務信息,比如:用戶ID、來源IP、請求數據等也一起記錄進去。
為此,我們還特地設計了一個庫,用來格式化輸出堆棧跟蹤信息和對象信息,項目地址:funny/debug · GitHub
通篇寫下來發現比我預期的長很多,所以這裡我做一下歸納總結,幫組大家理解這篇文章所要表達的:
- 錯誤和異常需要分類和管理,不能一概而論
- 錯誤和異常的分類可以以是否終止業務過程作為標準
- 錯誤是業務過程的一部分,異常不是
- 不要隨便捕獲異常,更不要隨便捕獲再重新拋出異常
- Go語言項目需要把Goroutine分為兩類,區別處理異常
- 在捕獲到異常時,需要儘可能的保留第一現場的關鍵數據
以上僅為一家之言,拋磚引玉,希望對大家有所幫助。
是的是的,取消異常是非常正確的。由於沒有異常,分析代碼的時候可以線性一行一行思考,完全不需要像有異常的系統那樣每一行都擔心跳出異常怎麼辦,大大減輕了思考負擔
對於有異常的編程系統,假如對程序的核心狀態需要多步update語句,如果中間被異常中斷,程序的核心狀態就會失去完整性,這是絕大多數bug的來源請注意,Golang 的錯誤處理機制:
- 返回 error(這個不用多說)
- panic、recover、defer,類似 try catch,出錯你不捕獲,就向上拋。具體的例子可以看 Golang 的 json 包源碼中對 JSON 的處理的做法
請不要誤解 Golang 的錯誤處理機制。
個人認為這是Go最出色的設計之一,不遜於非侵入性介面和goroutine。這樣可以最大限度避免濫用異常,而濫用異常無論從性能還是可維護性上看都是大忌。雖然這樣會使代碼顯得繁瑣,不過兩害相衡取其輕嘛!
我認為是個優秀的設計,也不能說優秀,應該是適中,畢竟有好有壞處,看你能不能理解駕馭了。
提個問題,想知道大家在批量處理100條秋褲的時候,庫的處理不會panic 只會return int ,err
這個時候一條掛了,你們怎麼處理?現在的我,做了一個全局的errCheck方法,接收err,非空panic還有一些類似其他功能函數。這樣意味著,掛了一條,拋出異常,或者可以說一個簡單的assert機制,終止後續代碼,畢竟掛了一條,在我的業務里要整體撤銷,或者說不能提交事物。大家呢?會如何處理?看了所有回答,發現:覺得是問題的基本上都贊同Java的將異常和錯誤混為一談的方式;覺得不是問題的大都了解幾種語言,或者很希望將錯誤和異常分清楚的。我的結論是:go的設計者一定很清楚錯誤和異常,也很清楚Java異常的問題,所以他們做的很棒,目前也想不到更好的方案。我在做Android應用時看到動輒上百行的異常信息恨不得拿Lua重寫一遍Android SDK。
本來也以為是問題的,用了一段時間發現:
程序員總是想省掉錯誤處理部分,而大量錯誤處理是必須面對的,golang只是讓你正視這種情況。現在覺得挺好,或沒發現更好的方案。只是Go裡面的Error Check比較不直觀而已,其實可以封裝一層讓它更好看的。比如Rust的Result&
someFunctionMayFail().unwrap(); // 要是出錯了,就直接掛掉
或者在掛掉的時候,帶一個自己的出錯信息
someFunctionMayFail().ok().expect("Expecting a xxxxx");
或者自己處理
let return_val = match someFunctionMayFail() {
Ok(v) =&> v,
Err(err) =&> {
// Deal with the error
// for example:
// fail!("Fail!!!! {}", err);
}
};
當然,我不是說它和Go的那個Error Check有什麼本質的區別,反正都是處理錯誤,但是我個人覺得Rust這樣寫更舒服。
沒有碰到第三方庫直接 log.Fatal 導致根本無法找到出錯點情況的就不要給沒有 exception 洗地了。
在某些方面 Go 的設計比 Java 更偏向於無腦人士,也難怪上面某些人會如此讚賞。這篇文章說得挺好Cleaner, more elegant, and harder to recognize
很久很久以前,我寫c。錯誤處理寫得想死。
後來有了java , python等等, try catch用得不亦樂乎。後來我用了go,本來挺爽的,錯誤處理又寫得想死了。不經吐槽:不愧是遺老的玩意兒,特么的錯誤處理這種東西也繼承了,媽蛋。直到有個人跟我說了一句話:
伺服器跑掛了你為什麼還想要讓他繼續跑下去……是啊,伺服器出了你沒有handle的異常,就應該進入未知狀態,不應該讓他跑下去才是最好的,讓伺服器在異常的地方crash掉是最好的解決方法。
我們總想要銀彈來解決所有的錯誤處理,然而……這是不可能的。go的設計強迫用戶正視了無窮無盡的錯誤處理的問題,是個挺好的設計。有error你就應該check啊,不要問怎麼避免,沒有異常處理的語言你是沒法避免的,好好一個一個check。
Go 的錯誤處理不是好設計,原因是「表意能力太差」,而不是需要反覆檢查錯誤返回值。
Go 官方 FAQ 指出,只應使用標準 error 類型作為函數返回值,而這嚴重損害了錯誤返回值的表意能力,例如:
官方 net 包中的 TcpListener.Accept 方法:
func (l *TCPListener) Accept() (Conn, error)
它返回的 error 有可能是 net.Error 類型,然而從函數聲明甚至官方文檔中都完全看不出來!
那麼當調用該函數時,如何知道需要處理哪些錯誤類型?怎麼處理這些錯誤?如果漏處理一些類型的錯誤,編譯時不會有任何提示,甚至運行時你都意識不到自己沒有完善地處理錯誤。
作為對照,看一下 Java 的 ServerSocket.accept() 方法:
public Socket accept() throws IOException
從方法聲明即可看出,調用該函數必須處理 IOException 類型的錯誤。
同時,Java 允許函數(方法)拋出多種異常。調用函數的程序員很清楚,自己該處理哪些錯誤。並且無論忘記處理哪一種,編譯器都會給出明確的提示。
結論:Go 的錯誤處理設計相對於 C 當然是有進步,但和 Java 一比就知道,不是一個很好的設計。
題外話:當然 Java 的異常機制也並不完美,最大的缺點是拋出異常時開銷較大,所以只應該用在真正出錯的地方。
最後,可能有人奇怪,為什麼不建議用自定義類型做錯誤返回值,官方有解釋:https://golang.org/doc/faq#nil_error
看了上面的 FAQ 就知道 Go 的設計槽點真是多,處處都是坑。亟需一本《Go 陷阱與缺陷》來拯救廣大程序員^_^
錯誤處理要做好是相當困難的,並且相當有藝術性。如果嫌if-err過多,那麼是沒意識到錯誤處理的重要性,或者更多的,是代碼不在一個抽象層次。
可以說說個人的例子。先是寫c++代碼,它的異常處理就不說了,沒人喜歡;然後是寫各種框架下的java代碼,比c++舒服很多,但也為處理好異常下了很大一番功夫;而這幾年基本只寫go代碼,感覺用if-err處理異常更直白簡單。
另外,還要考慮語言的使用場景。比如web程序,就適合用php或ruby來寫,如果用go寫就會有一串串的if-err判斷,這樣就不好看了。為什麼大家不看Rob Pike寫的這篇go怎麼寫錯誤處理的博客呢?Errors are values看了之後,大部分估計會有「原來也可以這樣寫」的感覺?
怎樣才能避免代碼中大量的 Error Check?
本就需要考慮的那些情形,為什麼需要被避免?為什麼你膽敢避免?
不是,寫一次if鄙視一次這設計...
不check也是可以的,遇到實在跑不下去的情況依然會像其他語言一樣終止進程,把error負值給_就好
py 寫了一個 rpc調用的封裝類 call_rpc 返回 err, result 沒覺得有太多的 不妥
推薦閱讀:
※各種編程語言中的「錯誤/異常處理」有哪些成熟的,優雅的或是熱門的機制/思想?
※為什麼C++中,析構函數、operator delete、以及operator delete []按照慣例不會拋出異常?
※c++ 程序運行時異常處理,怎麼定位到出錯代碼行?
※如何優雅的處理(或忽略)c++函數返回值代表的錯誤?
※python程序報錯後除了try except之外有沒有好的辦法再次啟動?