拒絕千篇一律,這套Go錯誤處理的完整解決方案值得一看!
導(dǎo)語 | 在使用Go開發(fā)的后臺服務(wù)中,對于錯誤處理,一直以來都有多種不同的方案,本文探討并提出一種從服務(wù)內(nèi)到服務(wù)外的一個統(tǒng)一的傳遞、返回和回溯的完整方案,拋磚引玉,希望與大家一起討論分享。
一、問題提出
在后臺開發(fā)中,針對錯誤處理,有三個維度的問題需要解決:
函數(shù)內(nèi)部的錯誤處理: 這是一個函數(shù)在執(zhí)行過程中遇到各種錯誤時的錯誤處理。這是一個語言級的問題。
函數(shù)/模塊的錯誤信息返回: 一個函數(shù)在操作錯誤之后,要怎么將這個錯誤信息優(yōu)雅地返回,方便調(diào)用方(也要優(yōu)雅地)處理。這也是一個語言級的問題。
服務(wù)/系統(tǒng)的錯誤信息返回: 微服務(wù)/系統(tǒng)在處理失敗時,如何返回一個友好的錯誤信息,依然是需要讓調(diào)用方優(yōu)雅地理解和處理。這是一個服務(wù)級的問題,適用于任何語言。
二、函數(shù)內(nèi)部的錯誤處理
一個面向過程的函數(shù),在不同的處理過程中需要handle不同的錯誤信息;一個面向?qū)ο蟮暮瘮?shù),針對一個操作所返回的不同類型的錯誤,有可能需要進(jìn)行不同的處理。此外,在遇到錯誤時,也可以使用斷言的方式,快速中止函數(shù)流程,大大提高代碼的可讀性。
在許多高級語言中都提供了try...catch的語法,函數(shù)內(nèi)部可以通過這種方案,實現(xiàn)一個統(tǒng)一的錯誤處理邏輯。而即便是C這種“中級語言”雖然沒有,但是程序員也可以使用宏定義的方式,來實現(xiàn)某種程度上的錯誤斷言。但是,對于Go的情況就比較尷尬了。
(一)Go的錯誤斷言
我們先來看斷言,我們的目的是,僅使用一行代碼就能夠檢查錯誤并終止當(dāng)前函數(shù)。由于沒有throw,沒有宏,如果要實現(xiàn)一行斷言,有兩種方法。
第一種是把if的錯誤判斷寫在一行內(nèi),比如:
if err != nil { return err }第二種方法是借用panic函數(shù),結(jié)合recover來實現(xiàn):
func SomeProcess() (err error)defer func() {if e := recover(); e != nil {err = e.(error)}}()assert := func(cond bool, f string, a ...interface{}) {if !cond {panic(fmt.Errorf(f, a...))}}// ...err = DoSomething()assert(err == nil, "DoSomething() error: %w", err)// ...}
這兩種方法都值得商榷。
首先,將if寫在同一行內(nèi)的問題有:
這種寫法,雖然理論上符合Go的代碼規(guī)范,但是在實操中,花括號不換行這一點還是有點爭議的,筆者在實際代碼中也很少見到過。
不夠直觀,而且在花括號中也不方便寫其他語句,原因是Go的規(guī)范中強(qiáng)烈不建議使用;來分隔代碼語句(if 判斷除外)
至于第二種方法,我們要分情況看:
首先panic的設(shè)計原意,是在當(dāng)程序或協(xié)程遇到嚴(yán)重錯誤,完全無法繼續(xù)運行下去的時候,才會調(diào)用(比如段錯誤、共享資源競爭錯誤)。這相當(dāng)于Linux中FATAL級別的錯誤日志。僅僅用來進(jìn)行普通的錯誤處理(ERROR級別),殺雞用牛刀了。
panic調(diào)用本身,相比于普通的業(yè)務(wù)邏輯,的系統(tǒng)開銷是比較大的。而錯誤處理這種事情,可能是常態(tài)化邏輯,頻繁的panic-recover操作,也會大大降低系統(tǒng)的吞吐。
不過使用panic來斷言的方案,雖然在業(yè)務(wù)邏輯中基本上不用,但在測試場景下則是非常常見的。測試嘛,用牛刀有何不可?稍微大一點的系統(tǒng)開銷也沒啥問題。對于Go來說,非常熱門的單元測試框架goconvey就是使用panic機(jī)制來實現(xiàn)單元測試中的斷言,用的人都說好。
綜上,在Go中,對于業(yè)務(wù)代碼,筆者不建議采用斷言,遇到錯誤的時候建議還是老老實實采用這種格式:
if err := DoSomething(); err != nil {// ...}
而在單測代碼中,則完全可以大大方方地采用類似于goconvey之類基于panic機(jī)制的斷言。
(二)Go的try...catch
眾所周知Go是沒有try...catch的,而且從官方的態(tài)度來看,短時間內(nèi)也沒有考慮的計劃。但程序員有這個需求呀。筆者采用的方法,是將需要返回的err變量在函數(shù)內(nèi)部全局化,然后結(jié)合defer統(tǒng)一處理:
func SomeProcess() (err error) { // <-- 注意,err 變量必須在這里有定義defer func() {if err == nil {return}// 這下面的邏輯,就當(dāng)作 catch 作用了if errors.Is(err, somepkg.ErrRecordNotExist) {err = nil // 這里是舉一個例子,有可能捕獲到某些錯誤,對于該函數(shù)而言不算錯誤,因此 err = nil} else if errors.Like(err, somepkg.ErrConnectionClosed) {// ... // 或者是說遇到連接斷開的操作時,可能需要做一些重連操作之類的;甚至乎還可以在這里重連成功之后,重新拉起一次請求} else {// ...}}()// ...if err = DoSomething(); err != nil {return}// ...}
這種方案要特別注意變量作用域問題,比如前面if err=DoSomething();err!=nil{行,如果我們將err=...改為err:=...,那么這一行中的err變量和函數(shù)最前面定義的 (err error) 不是同一個變量,因此即便在此處發(fā)生了錯誤,但是在defer函數(shù)中無法捕獲到err變量了。
在try...catch方面,筆者其實沒有特別好的方法來模擬,即便是上面的方法也有一個很讓人頭疼的問題:defer寫法導(dǎo)致錯誤處理前置,而正常邏輯后置了,從可讀性的角度來說非常不友好。因此也希望讀者能夠指教。同時還是希望Go官方能夠繼續(xù)迭代,支持這種語法。
三、函數(shù)/模塊的錯誤信息返回
這一點在Go里面,一開始看起來還是比較統(tǒng)一的,這就是Go最開始就定義的error類型,以系統(tǒng)標(biāo)準(zhǔn)的方式,統(tǒng)一了進(jìn)程內(nèi)函數(shù)級的錯誤返回模式。調(diào)用方使用if err!=nil的統(tǒng)一模式,來判斷一個調(diào)用是不是成功了。
但是隨著Go的逐步推廣,由于error接口的高自由度,程序員們對于“如何判斷該錯誤是什么錯誤”的時候,出現(xiàn)了分歧。
(一)Go1.13之前
在Go1.13之前,對于error類型的傳遞,有三種常見的模式:
流派
這個流派很簡單,就是將各種錯誤信息直接定義為一個類枚舉值的模式,比如:
var (ErrRecordNotExist = errors.New("record not exist")ErrConnectionClosed = errors.New("connection closed")// ...)
當(dāng)遇到相應(yīng)的錯誤信息時,直接返回對應(yīng)的error類枚舉值就行了。對于調(diào)用方也非常方便,可以采用switch-case來判斷錯誤類型:
switch err {case nil:// ...case ErrRecordNotExist:// ...default:// ...}
個人覺得這種設(shè)計模式本質(zhì)上還是C error code模式。
類型斷言流派
這種流派則是充分使用了“error是一個interface”的特性,重新自定義一個error類型。一方面是用不同的類型來表示不同的錯誤分類,另一方面則能夠?qū)崿F(xiàn)對于同一錯誤類型,能夠給調(diào)用方提供更佳詳盡的信息。舉個例子,我們可以定義多個不同的錯誤類型如下:
type ErrRecordNotExist errImpltype ErrPermissionDenined errImpltype ErrOperationTimeout errImpltype errImpl struct {msg string}func (e *errImpl) Error() string {return e.msg}
對于調(diào)用方,則通過以下代碼來判斷不同的錯誤:
if err == nil {// OK} else if _, ok := err.(*ErrRecordNotExist); ok {// 處理記錄不存在的錯誤} else if _, ok := err.(*ErrPermissionDenined); ok {// 處理權(quán)限錯誤} else {// 處理其他類型的錯誤}
fmt.Errorf流派
if err := DoSomething(); err != nil {return fmt.Errorf("DoSomething() error: %v", err)}
這種模式,一方面可以透傳底層錯誤,另一方面又可以添加自定義的信息。但對于調(diào)用方而言,災(zāi)難在于如果要判斷某一個錯誤的具體類型,只能用strings.Contains()來實現(xiàn),而錯誤的具體描述文字是不可靠的,同一類型的信息可能會有不同的表達(dá);而在fmt.Errorf的過程中,各個業(yè)務(wù)添加的額外信息也可能會有不同的文字,這帶來了極大的不可靠性,提高了模塊之間的耦合度。
(二)Go1.13之后
在Go1.13版本發(fā)布之后,針對fmt.Errorf增加了wraping功能,并在errors包中添加了Is()和As()函數(shù)。關(guān)于這個模式的原理和使用已經(jīng)有很多文章了,本文就不再贅述。
這個功能,合并并改造了前文的所謂“==流派”和“fmt.Errorf”流派,統(tǒng)一使用errors.Is()函數(shù);此外,也算是官方對類型斷言流派的認(rèn)可(專門用As()函數(shù)來支持)。
在實際應(yīng)用中,函數(shù)/模塊透傳錯誤時,應(yīng)該采用Go的error wrapping 模式,也就是fmt.Errorf()配合%w使用,業(yè)務(wù)方可以放心地添加自己的錯誤信息,只要調(diào)用方統(tǒng)一采用errors.Is()和errors.As()即可。
四、服務(wù)/系統(tǒng)的錯誤信息返回
(一)傳統(tǒng)方案
服務(wù)/系統(tǒng)層面的錯誤信息返回,大部分協(xié)議都可以看成是code-message模式或者是其變體:
code是數(shù)字或者預(yù)定義的字符串,可以視為整型或者是字符串類型的枚舉值。
如果是數(shù)字的話,大部分情況下是使用0表示成功,小部分則采用一個比較規(guī)整的十進(jìn)制數(shù)字表示成功,比如1000、10000等。
如果是預(yù)定義的字符串,那么是使用“success”、“OK”等字符串表示成功,或者是直接以空字符串、甚至是不返回字符串字段來表示成功。
message字段則是錯誤信息的具體描述,大部分情況下都是一個人類可讀的句子。
一般而言,只有當(dāng)code表示錯誤的時候,這個message字段才有返回的必要。
這種模式的特點是:code是給程序代碼使用的,代碼判斷這是一個什么類型的錯誤,進(jìn)入相應(yīng)的分支處理;而message是給人看的,程序可以以某種形式拋出或者記錄這個錯誤信息,供用戶查看。
(二)存在問題
在這一層面有什么問題呢?code for computer,message for user,好像挺好的。
但有時候,我們可能會收到用戶/客戶反饋一個問題:“XXX報錯了,幫忙看看什么問題?”用戶看不懂我們的錯誤提示嗎?
在筆者的經(jīng)驗中,我們在使用code-message機(jī)制的時候,特別是業(yè)務(wù)初期,難以避免的是前后端的設(shè)計文案沒能完整地覆蓋所有的錯誤用例,或者是錯誤極其罕見。因此當(dāng)出現(xiàn)錯誤時,提示曖昧不清(甚至是直接提示錯誤信息),導(dǎo)致用戶從錯誤信息中找到解決方案。
在這種情況下,盡量覆蓋所有錯誤路徑肯定是最完美的方法。不過在做到這一點之前,碼農(nóng)們往往有下面的解決方案:
遇到未定義錯誤時,后端在code中返回一個統(tǒng)一的錯誤碼,并且將詳細(xì)的錯誤信息記錄在message中。不過這個模式有下面的問題:
客戶端提示此類信息時,如果將message信息直接展示,可能會展示很多讓用戶看不懂(也沒必要看懂)的文字,而且文字可能會很長(萬一是一個panic信息),這對用戶來說非常不友好。
如果開發(fā)者不注意,message信息可能會暴露程序細(xì)節(jié),比如連接DB失敗的信息里可能會涉及數(shù)據(jù)庫的用戶名、IP。敏感信息一旦暴露,輕則安全教育,重則高壓線伺候。
還是類似上面的方法,返回統(tǒng)一的錯誤碼,message則直接用一個通用的“unknown error”或“未知錯誤,請聯(lián)系XXX”之類的提示信息。但是這個時候,我們要怎么查錯呢?
如果主調(diào)方是另一個模塊的話還好,用戶肯定是個程序員,這個時候只要對對方提供requestID/trackID過來就行了。
如果對方是個普通用戶,難道讓用戶F12看控制臺嗎?如果是移動端,那可一點看的機(jī)會都沒;如果將traceID暴露給用戶,那么長的ID,誰記得住啊。
既要隱藏信息,又要暴露信息,這也太難了……
(三)解決方案
這里,筆者從日益普及的短信驗證碼有了個靈感——人的短期記憶對4個字符還是比較強(qiáng)的,因此我們可以考慮把錯誤代碼縮短到4個字符——不區(qū)分大小寫,因為如果人在記憶時還要記錄大小寫的話,難度會增加不少。
怎么用4個字符表示盡量多的數(shù)據(jù)呢?數(shù)字+字母總共有36個字符,理論上使用4位36進(jìn)制可以表示36x36x36x36=1679616個值。因此我們只要找到一個針對錯誤信息字符串的哈希算法,把輸出值限制在1679616范圍內(nèi)就行了。
這里我采用的是MD5作為例子。MD5的輸出是128位,理論上我可以取MD5的輸出,模1679616就可以得到一個簡易的結(jié)果。實際上為了減少除法運算,我采用的是取高20位(0xFFFFF)的簡易方式(20位二進(jìn)制的最大值為1048575),然后將這個數(shù)字轉(zhuǎn)成36進(jìn)制的字符串輸出。
當(dāng)出現(xiàn)異常錯誤時,我們可以將message的提示信息如下展示:“未知錯誤,錯誤代碼30EV,如需協(xié)助,請聯(lián)系XXX”。順帶一提,30EV是“Access denied for user'db_user'@'127.0.0.1'”的計算結(jié)果,這樣一來,我就對調(diào)用方隱藏了敏感信息。
至于后臺側(cè),還是需要實實在在地將這個哈希值和具體的錯誤信息記錄在日志或者其他支持搜索的渠道里。當(dāng)用戶提供該代碼時,可以快速定位。
這種方案的優(yōu)點很明顯:
能夠提供足夠的信息,用戶可以記住代碼,從而反饋給開發(fā)側(cè)進(jìn)行debug。
對于同一個錯誤,由于哈希的特點,計算結(jié)果是相同的。即便出現(xiàn)了碰撞,那么只要輸入的數(shù)據(jù)不至于太多,還是能夠快速區(qū)分的。
由于不論多長的錯誤信息,反饋到前端都只有四個字符,因此后端在記錄錯誤信息的時候,可以放心地基于Go1.13的error wraping機(jī)制進(jìn)行嵌套,從而記錄足夠的錯誤信息。
簡易的錯誤碼生成代碼如下:
import (// ..."github.com/martinlindhe/base36")var (replacer = strings.NewReplacer(" ", "0","O", "0","I", "1",))// ...func Err2Hashcode(err error) (uint64, string) {u64 := hash(err.Error())codeStr := encode(u64)u64, _ = decode(codeStr)return u64, codeStr}func encode(code uint64) string {s := fmt.Sprintf("%4s", base36.Encode(code))return replace.Replace(s)}func decode(s string) (uint64, bool) {if len(s) != 4 {return 0, false}s = strings.Replace(s, "l", "1", -1)s = strings.ToUpper(s)s = replace.Replace(s)code := base36.Decode(s)return code, code > 0}// hash 函數(shù)可以自定義func hash(s string) uint64 {h := md5.Sum([]byte(s))u := binary.BigEndian.Uint32(h[0:16])return uint64(u & 0xFFFFF)}
當(dāng)然這種方案也有局限性,筆者能想到的是需要注意以下兩點:
生成error時要避免記錄隨機(jī)數(shù)據(jù)、不可重放數(shù)據(jù)、千人千面的數(shù)據(jù),比如說時間、賬戶號、流水ID等等信息,盡可能使用戶進(jìn)行統(tǒng)一操作時,能夠生成相同的錯誤碼。
由于數(shù)字1和字母I、數(shù)字0和字母O很類似,因此需要進(jìn)行統(tǒng)一轉(zhuǎn)換,避免出現(xiàn)歧義。這就是為什么在Err2Hashcode中,對hash結(jié)果encode之后要重新decode一次再返回的原因。
此外,筆者需要再強(qiáng)調(diào)的是:在開發(fā)中,針對各種不同的、正式的錯誤code和message用例依然需要完整覆蓋,盡可能通過已有的code-message機(jī)制將足夠清晰的信息告知主調(diào)方。這種hashcode的錯誤代碼生成方法,僅適用于錯誤用例遺漏、或者是快遞迭代過程中,用于發(fā)現(xiàn)和調(diào)試遺漏的錯誤用例的臨時方案。
作者簡介
張敏
騰訊高級后臺工程師
騰訊高級后臺工程師,在電子和互聯(lián)網(wǎng)行業(yè)深耕多年,擁有豐富的嵌入式和云服務(wù)后臺開發(fā)經(jīng)驗,個人博客共有過百篇文章,云+社區(qū)Top50原創(chuàng)作者,技術(shù)創(chuàng)作101第二季講師,現(xiàn)負(fù)責(zé)騰訊產(chǎn)品后臺開發(fā)。
推薦閱讀
