錯誤處理
在 Go 中的異常有三種級別:
error:正常的流程出錯,需要處理,直接忽略掉不處理程序也不會崩潰panic:很嚴重的問題,程序應該在處理完問題後立即退出fatal:非常致命的問題,程序應該立即退出
准確的來說,Go 語言並沒有異常,它是通過錯誤來體現,同樣的,Go 中也並沒有try-catch-finally這種語句,Go 創始人希望能夠將錯誤可控,他們不希望干什麼事情都需要嵌套一堆try-catch,所以大多數情況會將其作為函數的返回值來返回,例如下方代碼例子:
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。
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 了。
error
error 屬於是一種正常的流程錯誤,它的出現是可以被接受的,大多數情況下應該對其進行處理,當然也可以忽略不管,error 的嚴重級別不足以停止整個程序的運行。error本身是一個預定義的接口,該接口下只有一個方法Error(),該方法的返回值是字符串,用於輸出錯誤信息。
type error interface {
Error() string
}error 在歷史上也有過大改,在 1.13 版本時 Go 團隊推出了鏈式錯誤,且提供了更加完善的錯誤檢查機制,接下來都會一一介紹。
創建
創建一個 error 有以下幾種方法,第一種是使用errors包下的New函數。
err := errors.New("這是一個錯誤")第二種是使用fmt包下的Errorf函數,可以得到一個格式化參數的 error。
err := fmt.Errorf("這是%d個格式化參數的的錯誤", 1)下面是一個完整的例子
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文件的代碼
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就是一個很簡單的實現。
func New(text string) error {
return &errorString{text}
}
// errorString結構體
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}因為errorString實現太過於簡單,表達能力不足,所以很多開源庫包括官方庫都會選擇自定義 error,以滿足不同的錯誤需求。
傳遞
在一些情況中,調用者調用的函數返回了一個錯誤,但是調用者本身不負責處理錯誤,於是也將錯誤作為返回值返回,拋給上一層調用者,這個過程叫傳遞,錯誤在傳遞的過程中可能會層層包裝,當上層調用者想要判斷錯誤的類型來做出不同的處理時,可能會無法判別錯誤的類別或者誤判,而鏈式錯誤正是為了解決這種情況而出現的。
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函數來進行創建,例如
err := errors.New("這是一個原始錯誤")
wrapErr := fmt.Errorf("錯誤,%w", err)使用時,必須使用%w格式動詞,且參數只能是一個有效的 error。
處理
錯誤處理中的最後一步就是如何處理和檢查錯誤,errors包提供了幾個方便函數用於處理錯誤。
func Unwrap(err error) errorerrors.Unwrap()函數用於解包一個錯誤鏈,其內部實現也很簡單
func Unwrap(err error) error {
u, ok := err.(interface { // 類型斷言,是否實現該方法
Unwrap() error
})
if !ok { //沒有實現說明是一個基礎的error
return nil
}
return u.Unwrap() // 否則調用Unwrap
}解包後會返回當前錯誤鏈所包裹的錯誤,被包裹的錯誤可能依舊是一個錯誤鏈,如果想要在錯誤鏈中找到對應的值或類型,可以遞歸進行查找匹配,不過標准庫已經提供好了類似的函數。
func Is(err, target error) boolerrors.Is函數的作用是判斷錯誤鏈中是否包含指定的錯誤,例子如下
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()。
func As(err error, target any) boolerrors.As()函數的作用是在錯誤鏈中尋找第一個類型匹配的錯誤,並將值賦值給傳入的err。有些情況下需要將error類型的錯誤轉換為具體的錯誤實現類型,以獲得更詳細的錯誤細節,而對一個錯誤鏈使用類型斷言是無效的,因為原始錯誤是被結構體包裹起來的,這也是為什麼需要As函數的原因。例子如下
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例子
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
func main() {
var dic map[string]int
dic["a"] = 'a'
}panic: assignment to entry in nil mapTIP
當程序中存在多個協程時,只要任一協程發生panic,如果不將其捕獲的話,整個程序都會崩潰
創建
顯式的創建panic十分簡單,使用內置函數panic即可,函數簽名如下
func panic(v any)panic函數接收一個類型為 any的參數v,當輸出錯誤堆棧信息時,v也會被輸出。使用例子如下
func main() {
initDataBase("", 0)
}
func initDataBase(host string, port int) {
if len(host) == 0 || port == 0 {
panic("非法的數據鏈接參數")
}
// ...其他的邏輯
}當初始化數據庫連接失敗時,程序就不應該啟動,因為沒有數據庫程序就運行的毫無意義,所以此處應該拋出panic
panic: 非法的數據鏈接參數善後
程序因為panic退出之前會做一些善後工作,例如執行defer語句。
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語句同樣會執行,例子如下
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: panicdefer中也可以嵌套panic,下面是一個比較復雜的例子
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,那麼程序將會直接停止運行。
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()的執行速度可能會快於子協程的執行速度,輸出的結果就會變得非常有迷惑性,下面稍微修改一下代碼
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語句中運行,使用示例如下。
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。
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。
func main() {
dangerOp()
fmt.Println("程序正常退出")
}
func dangerOp() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("panic恢復")
}
}()
panic(nil)
}這種情況panic確實會恢復,但是不會輸出任何的錯誤信息。
輸出
程序正常退出總的來說recover函數有幾個注意點
- 必須在
defer中使用 - 多次使用也只會有一個能恢復
panic - 閉包
recover不會恢復外部函數的任何panic panic的參數禁止使用nil
fatal
fatal是一種極其嚴重的問題,當發生fatal時,程序需要立刻停止運行,不會執行任何善後工作,通常情況下是調用os包下的Exit函數退出程序,如下所示
func main() {
dangerOp("")
}
func dangerOp(str string) {
if len(str) == 0 {
fmt.Println("fatal")
os.Exit(1)
}
fmt.Println("正常邏輯")
}輸出
fatalfatal級別的問題一般很少會顯式的去觸發,大多數情況都是被動觸發。
