錯誤處理是任何程式語言都繞不開的話題。一直以來,程式語言的錯誤處理機制有兩大流派:基於異常的結構化 try-catch-finally 處理機制和基於值的處理機制。前者的成員包括 C++、Java、Python、PHP 等主流程式語言,後者的代表則是 C 語言。Go 的設計追求簡單,採用的是後一種處理機制:錯誤就是值,而錯誤處理就是基於值比較後的決策。
認識 error
在 Go 語言中,錯誤是值,不過是一個介面值,也即我們平時常用的 error:
// $GOROOT/src/builtin/builtin.go
type interface error {
Error() string
}
error 介面很簡單,只宣告瞭一個 Error() 方法。在標準庫中提供了構造錯誤值的兩種基本方法:errors.New() 和 fmt.Errorf(),在 Go 1.13 版本之前,這兩種方法實際上返回的是一個未匯出型別 errors.errorString:
// $GOROOT/src/errors/errors.go
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
// $GOROOT/src/fmt/errors.go
// 1.13 版本之前
func Errorf(format string, a ...interface{}) error {
return errors.New(Sprintf(format, a...))
}
fmt.Errorf() 適用於需要格式化輸出字串的場景,如果不需要格式化字串,則建議使用 errors.New()。因為 fmt.Errof() 在生成格式化字串時需要遍歷所有字元,會有一定的效能損失。
錯誤處理基本策略
瞭解了錯誤值後,我們來看一下 Go 語言錯誤處理的幾種慣用策略。
透明策略
透明處理策略是最簡單的策略,它完全不關心返回錯誤值攜帶的具體上下文資訊,只要發生錯誤就進入唯一的錯誤處理執行路徑。這也是 Go 語言中最常見的錯誤處理策略,絕大部分的錯誤處理情形可以歸類到這種策略下。
err := doSomething()
if err != nil {
// 不關心err變數底層錯誤值所攜帶的具體上下文資訊
// 執行簡單錯誤處理邏輯並返回
...
return err
}
“哨兵”處理策略
“哨兵”策略通過特定值來表示成功和不同的錯誤,依靠呼叫方對錯誤進行檢查來處理錯誤。如果採用這種處理策略,錯誤值構造方通常會定義一系列匯出的“哨兵”錯誤值,用來輔助錯誤處理方檢視錯誤值並做出錯誤處理分支的決策。
// $GOROOT/src/bufio/bufio.go
var (
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
ErrBufferFull = errors.New("bufio: buffer full")
ErrNegativeCount = errors.New("bufio: negative count")
)
// 錯誤處理程式碼
data, err := b.Peek(1)
if err != nil {
switch err {
case bufio.ErrNegativeCount:
// ...
return
case bufio.ErrBufferFull:
// ...
return
case bufio.ErrInvalidUnreadByte:
// ...
return
default:
// ...
return
}
}
與透明錯誤策略相比,“哨兵”策略讓錯誤處理方可以更靈活地處理錯誤。不過對於包的開發者而言,暴露“哨兵”錯誤值意味著這些錯誤值和包的公共函式一起成為包的一部分,會讓錯誤處理方對其產生依賴。
型別檢視策略
型別檢視策略又被稱為自定義錯誤策略,顧名思義,這種錯誤處理方式通過自定義的錯誤型別來表示特定的錯誤,同樣依賴上層程式碼對錯誤值進行檢查,不同的是需要使用型別斷言機制(type assertion)或型別選擇機制(type switch)對錯誤進行檢查。
來看一個標準庫的例子:
// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
Value string
Type reflect.Type
Offset int64
Struct string
Field string
}
// $GOROOT/src/encoding/json/decode_test.go
// 通過型別斷言機制獲取
func TestUnmarshalTypeError(t *testing.T) {
for _, item := range decodeTypeErrorTests {
err := Unmarshal([]byte(item.src), item.dest)
if _, ok := err.(*UnmarshalTypeError); !ok {
t.Errorf("expected type error for Unmarshal(%q, type %T): got %T",
item.src, item.dest, err)
}
}
}
// $GOROOT/src/encoding/json/decode.go
// 通過型別選擇機制獲取
func (d *decodeState) addErrorContext(err error) error {
if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {
switch err := err.(type) {
case *UnmarshalTypeError:
err.Struct = d.errorContext.Struct.Name()
err.Field = strings.Join(d.errorContext.FieldStack, ".")
return err
}
}
return err
}
這種錯誤處理的好處在於可以將錯誤包裝起來,提供更多的上下文資訊,但實現方必須向上層公開實現的錯誤型別,與使用方之間同樣需要產生依賴關係。
鏈式 error
在 1.13 版本之前,使用上述“哨兵”和型別檢視策略帶來的最大的一個問題是 error 經過函式或方法進行自定義處理後,原始的 error 會被丟棄。
// example 1
func main() {
err := WriteFile("")
if err == os.ErrPermission {
fmt.Println("permission denied")
}
}
func WriteFile(filename string) error {
if filename == "" {
return fmt.Errorf("write file error: %v", os.ErrPermission)
}
return nil
}
// example 2
func main() {
err := WriteFile("")
if _, ok := err.(*os.PathError); ok {
fmt.Println("permission denied")
}
}
func WriteFile(filename string) error {
if filename == "" {
return fmt.Errorf("write file error: %v", &os.PathError{})
}
return nil
}
通過上面兩個示例我們可以看到,原始的 error 經過函式的自定義包裝後,它的值或者型別就可能被“淹沒”了,使用方不能很容易地獲取到它,給錯誤處理帶來了不必要的麻煩。
為了解決這個問題,Go 1.13 版本中引入了一套稱為鏈式 error 的解決方案,error 在函式間傳遞時資訊並不會丟失,而是像鏈條一樣被串連起來。
wrapError
wrapError 是鏈式 error 的核心資料結構,其他相關優化都是圍繞它展開的:
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}
wrapError 與傳統的 errorString 相比,額外實現了 Unwrap 方法,用於返回原始 error。
生成鏈式 error
在 Go 1.13 版本之後,我們可以使用 fmt.Errorf 函式配合格式動詞 %w 來生成鏈式 error,原始碼如下:
func Errorf(format string, a ...interface{}) error {
p := newPrinter()
p.wrapErrs = true
// 解析格式,如果發現格式動詞 %w 且提供了合法的 error 引數,則把 p.wrappedErr 置為 error
p.doPrintf(format, a)
s := string(p.buf)
var err error
if p.wrappedErr == nil {
// 一般情況下生成 errorString
err = errors.New(s)
} else {
// 存在 %w 動詞生成 wrapError
err = &wrapError{s, p.wrappedErr}
}
p.free()
return err
}
生成 wrapError 有兩點需要記住:
- 每次只能使用一次 %w 動詞;
- %w 動詞只能匹配實現 error 介面的引數。
errors.Is
errors 包提供了 Is 方法用於錯誤處理方對錯誤值進行比較,Is 支援錯誤在包裝過多層後的等值判斷。
func Is(err, target error) bool {
if target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
for {
// 如果 target 是可比較的,則直接進行比較
if isComparable && err == target {
return true
}
// 如果 err 實現了 Is 方法,則呼叫該方法繼續進行判斷
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 否則,對 err 進行 Unwrap(也即返回 wrapError 的 err 欄位)
if err = Unwrap(err); err == nil {
return false
}
}
}
errors.As
As 方法類似於通過型別斷言判斷一個 error 型別變數是否為特定的自定義錯誤型別。不同的是,如果 error 型別變數的底層錯誤值是一個鏈式 error,那麼 As 方法會沿著錯誤鏈進行型別比較,直至找到一個匹配的錯誤型別。
func As(err error, target interface{}) bool {
if target == nil {
panic("errors: target cannot be nil")
}
// 通過反射獲取 target 的值和型別,並進行相關判斷
val := reflectlite.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflectlite.Ptr || val.IsNil() {
panic("errors: target must be a non-nil pointer")
}
targetType := typ.Elem()
if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) {
panic("errors: *target must be interface or implement error")
}
for err != nil {
// 如果 err 的型別與 target 匹配,直接賦值給 target
if reflectlite.TypeOf(err).AssignableTo(targetType) {
val.Elem().Set(reflectlite.ValueOf(err))
return true
}
// 判斷 err 是否實現 As 方法,若已實現則呼叫該方法進一步匹配
if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
return true
}
// 否則,對 err 進行 Unwrap
err = Unwrap(err)
}
return false
}
錯誤處理建議
關於錯誤處理的討論有很多,但沒有哪一種錯誤處理策略適用於所有專案或場合。綜合上述的構造錯誤值方法及錯誤處理策略,有以下幾點建議:
- 優先使用透明錯誤處理策略,降低錯誤處理方與錯誤值構造方之間的耦合;
- 其次儘量使用型別檢視策略;
- 在上述兩種策略無法實施的情況下,再用“哨兵”策略;
- 在 Go 1.13 及後續版本中,儘量用 errors.Is 和 errors.As 方法替換原先的錯誤處理語句。
優化 if err != nil
因為 Go 語言的錯誤處理機制,會在程式碼中產生大量的 if err != nil
,十分繁瑣且不美觀,這也是 Go 語言經常被其他主流語言開發者吐槽的地方。那麼有什麼辦法可以優化?
首先能想到的就是視覺上的優化,將多個判斷語句放置在一起,但這種方法也只不過是“表面功夫”,而且有很大的侷限性。
第二種就是模仿其他語言用 panic 和 recover 來模擬異常捕獲來替換錯誤值判斷,不過這是一種反模式,並不推薦使用。首先,錯誤是正常的程式設計邏輯,而異常是意料之外的錯誤,二者不能畫等號,而且如果異常沒有得到捕獲將會導致整個程式退出,個別情況下後果很嚴重。還有一點,使用異常代替錯誤機制會大幅影響程式的執行速度。
在這裡提供兩種優化思路以供參考。
封裝多個 error
這個方法就是將多個 if err != nil
語句封裝到一個函式或方法中,這樣外部呼叫的時候只需要額外判斷一次就可以了。下面看一個例子:
func openBoth(src, dst string) (*os.File, *os.File, error) {
var r, w *os.File
var err error
if r, err = os.Open(src); err != nil {
return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if w, err = os.Create(dst); err != nil {
r.Close()
return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
}
return r, w, nil
}
func CopyFile(src, dst string) error {
var err error
var r, w *os.File
if r, w, err = openBoth(src, dst); err != nil {
return err
}
defer func() {
r.Close()
w.Close()
if err != nil {
os.Remove(dst)
}
}()
if _, err = io.Copy(w, r); err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
return nil
}
為了減少 CopyFile 函式中 if err != nil
的重複次數,以上程式碼引入了一個 openBoth 函式,我們將開啟原始檔、建立目的檔案和相關的錯誤處理工作轉移到了 openBoth 函式中。這種方法的優點是比較簡單,缺點是效果有時並不顯著。
內建 error
我們先粗略看一下 bufio 包的 Writer 實現:
// $GOROOT/src/bufio/bufio.go
type Writer struct {
err error
buf []byte
n int
wr io.Writer
}
func (b *Writer) WriteByte(c byte) error {
if b.err != nil {
return b.err
}
if b.Available() <= 0 && b.Flush() != nil {
return b.err
}
b.buf[b.n] = c
b.n++
return nil
}
可以看到,Writer 定義了一個 err 欄位作為內部錯誤狀態值,它與 Writer 的例項繫結在了一起,並且在 WriteByte 方法的入口判斷是否為 nil。一旦不為 nil,WriteByte 就直接返回內建的 err。我們來使用這種思路來重構一下前面例子中的程式碼:
type FileCopier struct {
w *os.File
r *os.File
err error
}
func (f *FileCopier) open(path string) (*os.File, error) {
if f.err != nil {
return nil, f.err
}
h, err := os.Open(path)
if err != nil {
f.err = err
return nil, err
}
return h, nil
}
func (f *FileCopier) openSrc(path string) {
if f.err != nil {
return
}
f.r, f.err = f.open(path)
return
}
func (f *FileCopier) createDst(path string) {
if f.err != nil {
return
}
f.w, f.err = os.Create(path)
return
}
func (f *FileCopier) copy() {
if f.err != nil {
return
}
if _, err := io.Copy(f.w, f.r); err != nil {
f.err = err
}
}
func (f *FileCopier) CopyFile(src, dst string) error {
if f.err != nil {
return f.err
}
defer func() {
if f.r != nil {
f.r.Close()
}
if f.w != nil {
f.w.Close()
}
if f.err != nil {
if f.w != nil {
os.Remove(dst)
}
}
}()
f.openSrc(src)
f.createDst(dst)
f.copy()
return f.err
}
func main() {
var fc FileCopier
err := fc.CopyFile("foo.txt", "bar.txt")
if err != nil {
fmt.Println("copy file error:", err)
return
}
fmt.Println("copy file ok")
}
我們將原 CopyFile 函式徹底拋棄,將其邏輯封裝到 FileCopier 結構的 CopyFile 方法中。FileCopier 結構內建了一個 err 欄位用於儲存內部的錯誤狀態,這樣在 CopyFile 方法中,我們只需按照正常業務邏輯,順序執行 openSrc、createDst 和 copy 即可,正常業務邏輯的視覺連續性就這樣被很好地實現了。