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