Skip to content

錯誤處理

在 Go 中的異常有三種級別:

  • error:正常的流程出錯,需要處理,直接忽略掉不處理程序也不會崩潰
  • panic:很嚴重的問題,程序應該在處理完問題後立即退出
  • fatal:非常致命的問題,程序應該立即退出

准確的來說,Go 語言並沒有異常,它是通過錯誤來體現,同樣的,Go 中也並沒有try-catch-finally這種語句,Go 創始人希望能夠將錯誤可控,他們不希望干什麼事情都需要嵌套一堆try-catch,所以大多數情況會將其作為函數的返回值來返回,例如下方代碼例子:

go
func main() {
  // 打開一個文件
  if file, err := os.Open("README.txt"); err != nil {
    fmt.Println(err)
        return
  }
    fmt.Println(file.Name())
}

這段代碼的意圖很明顯,打開一個名為README.txt的文件,如果打開失敗函數將會返回一個錯誤,輸出錯誤信息,如果錯誤為nil的話那麼就是打開成功,輸出文件名。

看起來似乎是要比try-catch簡潔一些,那如果有特別多的函數調用,將會到處都充斥著if err != nil 這種判斷語句,比如下面的例子,這是一個計算文件哈希值的 demo,在這一小段代碼中總共出現了三次if err != nil

go
func main() {
  sum, err := checksum("main.go")
  if err != nil {
    fmt.Println(err)
    return
  }
  fmt.Println(sum)
}

func checksum(path string) (string, error) {
  file, err := os.Open(path)
  if err != nil {
    return "", err
  }
  defer file.Close()

  hash := sha256.New()
  _, err = io.Copy(hash, file)
  if err != nil {
    return "", err
  }

  var hexSum [64]byte
  sum := hash.Sum(nil)
  hex.Encode(hexSum[:], sum)

  return string(hexSum[:]), nil
}

正因如此,外界對於 Go 最詬病的點就在錯誤處理上,Go 源代碼裡if err != nil就佔了相當一部分。Rust 同樣也是返回錯誤值,但沒有人會去說它這一點,因為它通過語法糖的方式解決了這類問題,與 Rust 相比之下,Go 的語法糖不能說很多,只能說是幾乎沒有。

不過我們看待事物要辯證的來看,凡事都是有好有壞的,Go 的錯誤處理的優點有幾個

  • 心智負擔小:有錯誤就處理,不處理就返回
  • 可讀性:因為處理的方式非常簡單,大部分情況下都很容易讀懂代碼
  • 易於調試:每一個錯誤都是通過函數調用的返回值產生的,可以一層一層往回找到,很少會出現突然冒出一個錯誤卻不知道是從哪裡來的這種情況

不過缺點也不少

  • 錯誤中沒有堆棧信息(需要第三方包解決或者自己封裝)
  • 丑陋,重復代碼多(看個人喜好)
  • 自定義錯誤是通過var來聲明的,它是一個變量而不是常量(確實不應該)
  • 變量遮蔽問題

社區中有關於 Go 錯誤處理的提案和討論自從 Go 誕生以來就從未停止過,有這麼一句玩笑話:如果你能接受 Go 的錯誤處理,那麼你才是個合格的 Gopher 了。

TIP

這裡有兩篇 Go 團隊關於錯誤處理的文章,感興趣可以看看

error

error 屬於是一種正常的流程錯誤,它的出現是可以被接受的,大多數情況下應該對其進行處理,當然也可以忽略不管,error 的嚴重級別不足以停止整個程序的運行。error本身是一個預定義的接口,該接口下只有一個方法Error(),該方法的返回值是字符串,用於輸出錯誤信息。

go
type error interface {
   Error() string
}

error 在歷史上也有過大改,在 1.13 版本時 Go 團隊推出了鏈式錯誤,且提供了更加完善的錯誤檢查機制,接下來都會一一介紹。

創建

創建一個 error 有以下幾種方法,第一種是使用errors包下的New函數。

go
err := errors.New("這是一個錯誤")

第二種是使用fmt包下的Errorf函數,可以得到一個格式化參數的 error。

go
err := fmt.Errorf("這是%d個格式化參數的的錯誤", 1)

下面是一個完整的例子

go
func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("必須是正整數")
   }
   return i + j, nil
}

大部分情況,為了更好的維護性,一般都不會臨時創建 error,而是會將常用的 error 當作全局變量使用,例如下方節選自os\erros.go文件的代碼

go
var (
  ErrInvalid = fs.ErrInvalid // "invalid argument"

  ErrPermission = fs.ErrPermission // "permission denied"
  ErrExist      = fs.ErrExist      // "file already exists"
  ErrNotExist   = fs.ErrNotExist   // "file does not exist"
  ErrClosed     = fs.ErrClosed     // "file already closed"

  ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
  ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

可以看到它們都是被var定義的變量

自定義錯誤

通過實現Error()方法,可以很輕易的自定義 error,例如erros包下的errorString就是一個很簡單的實現。

go
func New(text string) error {
   return &errorString{text}
}

// errorString結構體
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

因為errorString實現太過於簡單,表達能力不足,所以很多開源庫包括官方庫都會選擇自定義 error,以滿足不同的錯誤需求。

傳遞

在一些情況中,調用者調用的函數返回了一個錯誤,但是調用者本身不負責處理錯誤,於是也將錯誤作為返回值返回,拋給上一層調用者,這個過程叫傳遞,錯誤在傳遞的過程中可能會層層包裝,當上層調用者想要判斷錯誤的類型來做出不同的處理時,可能會無法判別錯誤的類別或者誤判,而鏈式錯誤正是為了解決這種情況而出現的。

go
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrappError同樣實現了error接口,也多了一個方法Unwrap,用於返回其內部對於原 error 的引用,層層包裝下就形成了一條錯誤鏈表,順著鏈表上尋找,很容易就能找到原始錯誤。由於該結構體並不對外暴露,所以只能使用fmt.Errorf函數來進行創建,例如

go
err := errors.New("這是一個原始錯誤")
wrapErr := fmt.Errorf("錯誤,%w", err)

使用時,必須使用%w格式動詞,且參數只能是一個有效的 error。

處理

錯誤處理中的最後一步就是如何處理和檢查錯誤,errors包提供了幾個方便函數用於處理錯誤。

go
func Unwrap(err error) error

errors.Unwrap()函數用於解包一個錯誤鏈,其內部實現也很簡單

go
func Unwrap(err error) error {
   u, ok := err.(interface { // 類型斷言,是否實現該方法
      Unwrap() error
   })
   if !ok { //沒有實現說明是一個基礎的error
      return nil
   }
   return u.Unwrap() // 否則調用Unwrap
}

解包後會返回當前錯誤鏈所包裹的錯誤,被包裹的錯誤可能依舊是一個錯誤鏈,如果想要在錯誤鏈中找到對應的值或類型,可以遞歸進行查找匹配,不過標准庫已經提供好了類似的函數。

go
func Is(err, target error) bool

errors.Is函數的作用是判斷錯誤鏈中是否包含指定的錯誤,例子如下

go
var originalErr = errors.New("this is an error")

func wrap1() error { // 包裹原始錯誤
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 原始錯誤
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) { // 如果使用if err == originalErr 將會是false
      fmt.Println("original")
   }
}

所以在判斷錯誤時,不應該使用==操作符,而是應該使用errors.Is()

go
func As(err error, target any) bool

errors.As()函數的作用是在錯誤鏈中尋找第一個類型匹配的錯誤,並將值賦值給傳入的err。有些情況下需要將error類型的錯誤轉換為具體的錯誤實現類型,以獲得更詳細的錯誤細節,而對一個錯誤鏈使用類型斷言是無效的,因為原始錯誤是被結構體包裹起來的,這也是為什麼需要As函數的原因。例子如下

go
type TimeError struct { // 自定義error
   Msg  string
   Time time.Time //記錄發生錯誤的時間
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // 包裹原始錯誤
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 原始錯誤
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // 檢查錯誤鏈中是否有*TimeError類型的錯誤
   if errors.As(err, &myerr) { // 輸出TimeError的時間
      fmt.Println("original", myerr.Time)
   }
}

target必須是指向error的指針,由於在創建結構體時返回的是結構體指針,所以error實際上*TimeError類型的,那麼target就必須是**TimeError類型的。

不過官方提供的errors包其實並不夠用,因為它沒有堆棧信息,不能定位,一般會比較推薦使用官方的另一個增強包

github.com/pkg/errors

例子

go
import (
  "fmt"
  "github.com/pkg/errors"
)

func Do() error {
  return errors.New("error")
}

func main() {
  if err := Do(); err != nil {
    fmt.Printf("%+v", err)
  }
}

輸出

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

通過格式化輸出,就可以看到堆棧信息了,默認情況下是不會輸出堆棧的。這個包相當於是標准庫errors包的加強版,同樣都是官方寫的,不知道為什麼沒有並入標准庫。

panic

panic中文譯為恐慌,表示十分嚴重的程序問題,程序需要立即停止來處理該問題,否則程序立即停止運行並輸出堆棧信息,panic是 Go 是運行時異常的表達形式,通常在一些危險操作中會出現,主要是為了及時止損,從而避免造成更加嚴重的後果。不過panic在退出之前會做好程序的善後工作,同時panic也可以被恢復來保證程序繼續運行。

下方是一個向nil的 map 寫入值的例子,肯定會觸發 panic

go
func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

TIP

當程序中存在多個協程時,只要任一協程發生panic,如果不將其捕獲的話,整個程序都會崩潰

創建

顯式的創建panic十分簡單,使用內置函數panic即可,函數簽名如下

go
func panic(v any)

panic函數接收一個類型為 any的參數v,當輸出錯誤堆棧信息時,v也會被輸出。使用例子如下

go
func main() {
  initDataBase("", 0)
}

func initDataBase(host string, port int) {
  if len(host) == 0 || port == 0 {
    panic("非法的數據鏈接參數")
  }
    // ...其他的邏輯
}

當初始化數據庫連接失敗時,程序就不應該啟動,因為沒有數據庫程序就運行的毫無意義,所以此處應該拋出panic

panic: 非法的數據鏈接參數

善後

程序因為panic退出之前會做一些善後工作,例如執行defer語句。

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

輸出為

C
B
A
panic: panic

並且上游函數的defer語句同樣會執行,例子如下

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

輸出

C
2
1
B
A
panic: panic

defer中也可以嵌套panic,下面是一個比較復雜的例子

go
func main() {
  defer fmt.Println("A")
  defer func() {
    func() {
      panic("panicA")
      defer fmt.Println("E")
    }()
  }()
  fmt.Println("C")
  dangerOp()
  defer fmt.Println("D")
}

func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

defer中嵌套的panic 執行順序依舊一致,發生panic時後續的邏輯將無法執行。

C
2
1
A
panic: panicB
        panic: panicA

綜上所述,當發生panic時,會立即退出所在函數,並且執行當前函數的善後工作,例如defer,然後層層上拋,上游函數同樣的也進行善後工作,直到程序停止運行。

當子協程發生panic時,不會觸發當前協程的善後工作,如果直到子協程退出都沒有恢復panic,那麼程序將會直接停止運行。

go
var waitGroup sync.WaitGroup

func main() {
  demo()
}

func demo() {
  waitGroup.Add(1)
  defer func() {
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  waitGroup.Wait() // 父協程阻塞等待子協程執行完畢
  defer fmt.Println("D")
}
func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
  waitGroup.Done()
}

輸出為

C
2
1
panic: panicB

可以看到demo()中的defer語句一個都沒有執行,程序就直接退出了。需要注意的是,如果沒有waitGroup來阻塞父協程的話,demo()的執行速度可能會快於子協程的執行速度,輸出的結果就會變得非常有迷惑性,下面稍微修改一下代碼

go
func main() {
  demo()
}

func demo() {
  defer func() {
    // 父協程善後工作要花費20ms
    time.Sleep(time.Millisecond * 20)
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  defer fmt.Println("D")
}
func dangerOp() {
  // 子協程要執行一些邏輯,要花費1ms
  time.Sleep(time.Millisecond)
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

輸出為

C
D
2
1
panic: panicB

在本例中,當子協程發生panic時,父協程早已完成的函數的執行,進入了善後工作,在執行最後一個defer時,碰巧遇到了子協程發生panic,所以程序就直接退出運行。

恢復

當發生panic時,使用內置函數recover()可以及時的處理並且保證程序繼續運行,必須要在defer語句中運行,使用示例如下。

go
func main() {
   dangerOp()
   fmt.Println("程序正常退出")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic恢復")
      }
   }()
   panic("發生panic")
}

調用者完全不知道dangerOp()函數內部發生了panic,程序執行剩下的邏輯後正常退出,所以輸出如下

發生panic
panic恢復
程序正常退出

但事實上recover()的使用有許多隱含的陷阱。例如在defer中再次閉包使用recover

go
func main() {
  dangerOp()
  fmt.Println("程序正常退出")
}

func dangerOp() {
  defer func() {
    func() {
      if err := recover(); err != nil {
        fmt.Println(err)
        fmt.Println("panic恢復")
      }
    }()
  }()
  panic("發生panic")
}

閉包函數可以看作調用了一個函數,panic是向上傳遞而不是向下,自然閉包函數也就無法恢復panic,所以輸出如下。

panic: 發生panic

除此之外,還有一種很極端的情況,那就是panic()的參數是nil

go
func main() {
   dangerOp()
   fmt.Println("程序正常退出")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic恢復")
      }
   }()
   panic(nil)
}

這種情況panic確實會恢復,但是不會輸出任何的錯誤信息。

輸出

程序正常退出

總的來說recover函數有幾個注意點

  1. 必須在defer中使用
  2. 多次使用也只會有一個能恢復panic
  3. 閉包recover不會恢復外部函數的任何panic
  4. panic的參數禁止使用nil

fatal

fatal是一種極其嚴重的問題,當發生fatal時,程序需要立刻停止運行,不會執行任何善後工作,通常情況下是調用os包下的Exit函數退出程序,如下所示

go
func main() {
  dangerOp("")
}

func dangerOp(str string) {
  if len(str) == 0 {
    fmt.Println("fatal")
    os.Exit(1)
  }
  fmt.Println("正常邏輯")
}

輸出

fatal

fatal級別的問題一般很少會顯式的去觸發,大多數情況都是被動觸發。

Golang學習網由www.golangdev.cn整理維護