認真一點學 Go:16. 錯誤與異常

老苗發表於2021-10-22

>> 原文地址

學到什麼

  1. 什麼是錯誤?

  2. 如何建立錯誤?

  3. 如何處理錯誤?

  4. errors 包的使用?

  5. 什麼是異常?

  6. 如何處理異常?

  7. defer 關鍵字的作用?

  8. recover 函式的使用?

什麼是錯誤

在寫程式碼時,不是所有情況都能處理,對於不能處理的邏輯,就需要使用錯誤機制告訴上層呼叫者。

在 Go 語言中,錯誤是被作為一個介面型別對待,它不像其它語言一樣使用 try/catch 去捕捉,只需在函式或方法之間使用一個錯誤型別變數去傳遞。

建立錯誤

這裡所說的建立錯誤,實際上就是去實現錯誤介面,介面如下:


type  error  interface {

 Error() string

}

該介面是 Go 標準包內建的,所有建立的錯誤型別都需要實現此介面,怎麼去實現介面,不懂的看看上篇文章 《介面》

1. errors.New

Go 語言中內建了一個處理錯誤的標準包,你不需要自己去實現 error 介面,它有函式幫你處理,如下:


import  "errors"

var  ErrNotFound = errors.New("not found")

匯入 errors 包,呼叫 New 函式建立了一個錯誤並儲存到 ErrNotFound 變數,該錯誤資訊為 not found

2. fmt.Errorf

fmt 標準包內也有一個建立錯誤的函式 Errorf ,該函式可以使用佔位符設定錯誤資訊,比 errors.New 函式更靈活。


import  "fmt"

var  ErrHuman = fmt.Errorf("%s不符合我們人類要求", "老苗")

3. 自定義錯誤型別

如果上述兩種方式你覺得還不夠靈活,那可以自定義錯誤型別。


type  ErrorPathNotExist  struct {

Filename string

}

func (*ErrorPathNotExist) Error() string {

   return  "檔案路徑不存在"

}

var  ErrNotExist  error = &ErrorPathNotExist{

Filename: "./main.go",

}
  • 自定義了一個ErrorPathNotExist 結構體,該結構體實現了 error 介面。

  • 建立了一個 ErrNotExist 錯誤型別變數。

這種如果不明白具體怎麼應用,不著急,往下看。

補充知識點:如果方法的接收者沒有被使用可以直接省略掉,例:func (*ErrorPathNotExist) ,不省略的話就是這樣:func (e *ErrorPathNotExist) ,當然也可以使用下劃線”_”代替 “e” 只是沒有必要性。

列印錯誤

在專案開發中,錯誤常常通過函式或方法的返回值攜帶,返回的位置也通常被放置在最後一位。


// error/file.go

package main

import  "io/ioutil"

// 讀取檔案內容

func  LoadConfig() (string, error) {

 filename := "./config.json"

 b, err := ioutil.ReadFile(filename)

 if err != nil {

 return  "", err

    }

 content := string(b)

 if  len(content) == 0 {

 return  "", errors.New("內容為空")

    }

 return content, nil

}
  • ReadFile 函式讀取 “config.json” 檔案內容。

  • (string, error) 返回兩個值,第一個為檔案內容,第二個為錯誤。

  • err != nil 用於判斷是否有錯誤,如果有 return 直接返回。

  • string(b) 變數 b 的型別為 []byte ,該操作是將 []byte 型別轉為 string

  • 增加了一個“內容為空”的錯誤判斷,該錯誤也可以直接儲存到變數中返回。


var  ErrEmpty = errors.New("內容為空")

func  LoadConfig() (string, error) {

// ...

 return  "", ErrEmpty

// ...

}

現在假設 “config.json” 檔案不存在,呼叫 LoadConfig 函式看看結果。


package main

import (

 "fmt"

 "log"

)

func  main() {

 content, err := LoadConfig()

 if err != nil {

        log.Fatal(err)

    }

    fmt.Println("內容:", content)

}

// 輸出

2021/09/23  16:57:25 open ./config.json: The system cannot find the file specified.
  • err 不等於 nil 時,列印錯誤,並退出程式。

  • log 標準包包含列印日誌的函式集。

  • log.Fatal 函式列印日誌,並終止程式向下執行。

  • 輸出的錯誤訊息顯示沒有找到指定的檔案。

  • 列印錯誤時,也可以使用 fmt 包,例如:fmt.Println(err) ,只是輸出資訊沒 log 包多。

os.Exit

該函式通知程式退出,並且該函式之後的邏輯將不會被執行。在呼叫時需要指定退出碼,為 0 時,表示正常退出程式。


os.Exit(0)

不主動呼叫該函式,即程式從 main 函式自然結束時,預設的退出碼為 0。在使用編寫工具時或許能看到成功的退出碼資訊,例如我使用的是 Goland,執行程式碼後輸出的結果末尾會顯示如下資訊。


Process finished with exit code 0

如果不正常退出,退出碼則為非 0,通常使用 1 表示未知錯誤。


os.Exit(1)

在使用 log.Fatal 函式時,內部就呼叫了 os.Exit(1)

錯誤加工

1. 錯誤拼接

在返回錯誤時,如果想攜帶附加的錯誤訊息時,可以使用 fmt.Errorf ,現在修改 LoadConfig 函式。


func  LoadConfig() (string, error) {

 filename := "./config.json"

 b, err := ioutil.ReadFile(filename)

 if err != nil {

 return  "", fmt.Errorf("讀取檔案出錯:%v", err)

    }

 // ...

}

%v 佔位符表示獲取資料的值,在這塊表示錯誤訊息,後續會詳細講解佔位符的使用。

現在重新執行上面的 main 函式,還是假設 “config.json” 檔案不存在。


// ...

content, err := LoadConfig()

if err != nil {

    log.Fatal(err)

}

//...

// 輸出

2021/09/24  11:37:33 讀取檔案出錯:open ./config.json: The system cannot find the file specified.

LoadConfig 函式返回的 err 變數中攜帶了附加的錯誤訊息,但這樣有個問題,附加的錯誤和原始錯誤訊息雜糅在一塊導致不能分離。

2. 錯誤巢狀和 errors.Unwrap

上面所說的”錯誤訊息雜糅在一塊導致不能分離“問題,如果還沒有明白的話,可以再看看這塊,你應該就豁然開朗了。

Go

錯誤巢狀就類似上圖,err1 巢狀類 err2err2 也可以繼續巢狀。如果想從 err1 中獲取 err2 就剝一層,類似洋蔥一樣,一層一層往裡找。

那怎麼實現這種巢狀關係呢,還是使用 fmt.Errorf 函式,只是使用另外一個佔位符%ww 的英文全名就是 wrap

繼續修改 LoadConfig 函式,如下:


func  LoadConfig() (string, error) {

 filename := "./config.json"

 b, err := ioutil.ReadFile(filename)

 if err != nil {

 return  "", fmt.Errorf("讀取檔案出錯:%w", err)

    }

 // ...

}

現在再執行 main 函式,即呼叫 LoadConfig 該函式,並列印錯誤。


2021/09/24  18:07:14 讀取檔案出錯:open ./config.json: The system cannot find the file specified.

是不是發現錯誤結果沒有變化,那修改下 main 函式。


package main

import (

 "errors"

 "fmt"

 "log"

)

func  main() {

   content, err := LoadConfig()

   if err != nil {

      log.Fatal(errors.Unwrap(err))

   }

   fmt.Println("內容:", content)

}

// 輸出

2021/09/24  18:11:09 open ./config.json: The system cannot find the file specified.

在列印錯誤時,增加了一個 errors.Unwrap 函式,該函式就是用來取出巢狀的錯誤,再看看輸出的結果,附加的錯誤資訊”讀取檔案出錯:“已經沒有了。

3. 自定義錯誤型別

在上面講過了如何自定義錯誤型別,現在講講如何應用你自定義的錯誤,接下來將 LoadConfig 函式中的內容為空的錯誤改為自定義錯誤型別。


func  LoadConfig() (string, error) {

 filename := "./config.json"

 // ...

 if  len(content) == 0 {

 return  "", &FileEmptyError{

            Filename: filename,

            Err:      errors.New("內容為空"),

        }

    }

 return content, nil

}

FileEmptyError 是自定義的錯誤型別,同樣的實現 error 介面。


type  FileEmptyError  struct {

Filename string

    Err      error

}

func (e *FileEmptyError) Error() string {

 return fmt.Sprintf("%s  %v", e.Filename, e.Err)

}

現在呼叫 LoadConfig 函式,現在假設 “config.json” 檔案存在,但內容為空,結果如下:


content, err := LoadConfig()

if err != nil {

 if  v, ok := err.(*FileEmptyError); ok {

        fmt.Println("Filename:", v.Filename)

    }

    log.Fatal(err)

}

// 輸出

Filename: ./config.json

2021/09/27  14:36:40 ./config.json 內容為空
  • err 變數的介面型別推斷為*FileEmptyError 型別,並輸出 Filename 欄位。

  • 列印自定義錯誤內容。

如果想使用 errors.Unwrap 函式 , 就需要實現 Wrapper 介面,fmt.Errorf 函式中的 %w 佔位符底層實現好了此介面。


type  Wrapper  interface {

   // Unwrap returns the next error in the error chain.

   // If there is no next error, Unwrap returns nil.

   Unwrap() error

}

只需要我們實現了 Unwrap 方法就可以。


func (e *FileEmptyError) Unwrap() error {

 return e.Err

}

下來自行去實驗,我在這就不羅嗦了。

錯誤判斷

對於一個函式或方法,返回的錯誤常常不止一個錯誤結果,如果對於不同的錯誤結果你想有不同的處理邏輯,那這個時候就要對錯誤結果進行判斷。


// ...

var (

 ErrNotFoundRequest = errors.New("404")

 ErrBadRequest = errors.New("請求異常")

)

func  GetError() error {

 // ...

 // 錯誤 1

 return ErrNotFoundRequest

 // ...

 // 錯誤 2

 return ErrBadRequest

 // ...

 // 錯誤 3

 path := "https://printlove.com"

 return fmt.Errorf("%s:%w", path, ErrNotFoundRequest)

 // ...

}

GetError 函式沒有寫具體邏輯,只展示了 3 個錯誤的返回,下來看看如何對這幾種情況進行判斷。

1. 最簡單”==”

最簡單的就是使用”==“判斷錯誤結果。


err := GetError()

if err == ErrNotFoundRequest {

 // 錯誤 1

} else  if err == ErrBadRequest {

 // 錯誤 2

}

對於這種判斷方式有個問題,“錯誤 3”是不符合這兩個 if 判斷的,但從錯誤分類上說,它屬於 ErrNotFoundRequest 錯誤,只是拼接了請求地址資料 path ,下來往下看另外一種判斷方式。

2. errors.Is

使用 errors.Is 函式解決“錯誤 3”的判斷問題,下來分析為什麼?

“錯誤 3”中使用了佔位符%w,它就是將 ErrNotFoundRequest 錯誤嵌入其中,**Is 函式的作用就是一層層的對錯誤進行剝離判斷**,直到成功或沒有巢狀的錯誤為止,沒明白的話可以結合上面的圖。

函式定義如下:


func  Is(err, target error) bool
  • err 引數為要判斷的錯誤。

  • target 引數為要比對的錯誤物件。

使用如下:


err := GetError()

if errors.Is(err, ErrNotFoundRequest) {

 // 錯誤 1,錯誤3

} else  if errors.Is(err, ErrBadRequest) {

 // 錯誤 2

}
  • 在具體專案中,呼叫函式或方法時,我們不知道底層是否進行了錯誤巢狀,如果不明確的話就統一使用 Is 函式。

  • 錯誤 1錯誤 2 沒有巢狀也可以判斷。

3. errors.As

這個和 errors.Is 函式類似,不同點就是該函式只判斷錯誤型別,而 errors.Is 函式不僅判斷型別,也要判斷值(錯誤訊息)。


// error/as.go

package main

import (

 "errors"

 "fmt"

)

type  ErrorString  struct {

s string

}

func (e *ErrorString) Error() string {

 return e.s

}

func  main() {

 var  targetErr *ErrorString

 err := fmt.Errorf("new error:[%w]", &ErrorString{s: "target err"})

    fmt.Println(errors.As(err, &targetErr))

}

// 輸出

true

targetErr 變數有幾點要求:

  • 無需初始化。

  • 必須是指標型別,並且實現了 error 介面。

  • As 函式不接受nil ,因此不能直接使用targetErr 變數,要使用其引用&targetErr

什麼是異常

錯誤就是上述所講的,它的出現不會導致程式異常退出。

那什麼情況會異常退出呢?比如:

  • 下標越界

  • 除數為 0

  • 等等

通常該異常是你沒留意到程式碼問題而丟擲,當然你可以可以主動丟擲。

panic

使用 panic 函式可以主動丟擲異常,該函式格式如下:


func  panic(v interface{})
  • v 引數為空介面型別,那就說明可以接受任意型別資料。

例:


panic("我是異常")

// 輸出

panic: 我是異常

goroutine 1 [running]:

main.main()

        C:/workspace/go/src/gobasic/panic/panic.go:4 +0x45

exit status 2

從輸出的結果上可以看出幾點:

  • 列印出具體的異常位置,這些資訊稱作堆疊資訊。

  • 程式終止,退出碼為 2。

處理異常

不管是主動丟擲異常,還是你程式哪塊有 bug 被動丟擲異常,這些在我們寫專案時都是很嚴重的問題,因為它會導致我們的程式異常退出。

在其它語言中,通過try/catch 機制可以捕捉異常來保證程式的正常執行,那在 Go 語言中使用 recover 函式捕捉異常。

在學習這個函式前,先要了解另外一個關鍵字 defer

1. defer

它不是函式,只是一個關鍵字。該關鍵字後面所跟的語句將延遲執行,在所在函式或方法正常結束時或出現異常中斷時,再提前執行。


package main

import  "fmt"

func  main() {

 defer  func() {

        fmt.Println("defer")

    }()

    fmt.Println("main")

 panic("panic")

}

// 輸出

main

defer

panic: panic

...
  • defer 關鍵字後面跟了一個匿名函式呼叫,有名字的函式當然也可以。

  • 遇到 defer 關鍵字,後面的語句會延遲執行,因此先輸出”main”字串。

  • panic 丟擲異常,因此在退出前先執行 defer 語句。

  • 如果呼叫了 os.Exit 函式,defer 後的語句將不會執行。

如果在一個函式或方法中出現了多個 defer 語句,那會採用先進後出原則,即先出現的 defer 語句後執行,後出現的先執行。

例:


package main

import  "fmt"

func  Fun1() {

    fmt.Println("猜我啥時候輸出?")

}

func  main() {

 defer  func() {

        fmt.Println("defer")

    }()

 defer  Fun1()

    fmt.Println("main")

 panic("panic")

}

// 輸出

main

猜我啥時候輸出?

defer

panic: panic

...

現在講了 defer 關鍵字的概念,可能還不知道實際中什麼地方用到,我現在舉個例子。


// defer/main.go

// 拷貝檔案

// srcName 路徑檔案拷貝到 dstName 路徑檔案

func  CopyFile(srcName, dstName string) (written int64, err error) {

 src, err := os.Open(srcName)

 if err != nil {

 return

    }

 defer src.Close()

 dst, err := os.Create(dstName)

 if err != nil {

 return

    }

 defer dst.Close()

 return io.Copy(dst, src)

}

程式碼中有兩個 defer 關鍵字,後面的語句用來關閉檔案釋放資源。

如果不用此關鍵字,關閉檔案的Close函式就必須寫在 io.Copy 函式後,因為該函式還要使用檔案資源,提前關閉了,就完蛋了。所以,使用 defer 關鍵字後就會延遲執行,因此就不需要考慮檔案什麼時候被使用。

當然也不是隻有這一種情況應用,只要記住,在函式或方法結束前你才想處理的語句都可以使用 defer 關鍵字。

2. recover

瞭解了 defer 關鍵字後就明白了,在程式出現異常之前 defer 語句先被執行,因此在 defer 後的語句就可以提前攔截異常。


// panic/recover.go

package main

import  "fmt"

func  main() {

 defer  func() {

 if  err := recover(); err != nil {

            fmt.Println("我捕捉的:", err)

            fmt.Println("我好好的")

        }

    }()

 panic("我是異常")

}

// 輸出

我捕捉的: 我是異常

我好好的
  • recover 返回異常資訊。

不管是主動丟擲異常,還是被動的,recover 函式都能捕捉,這樣以保證程式的正常進行。

假如函式 A 呼叫了很多函式,這些函式又呼叫了很多,只要下面被呼叫的函式出現異常,函式 A 中的 recover 函式將都可以捕捉到。但其中還是有個特例, Goroutine 中出現的異常是無法被捕捉到的,必須在新的 Goroutine 中重新使用 recover 函式,這個讓人期待的知識點後續會講。

總結

本篇講解了兩個概念,”錯誤“和”異常“ ,這兩個概念很重要,一定要掌握明白。還有一點,在處理錯誤時,有時候發覺錯誤結果對於我們沒用,那這個時候就可以忽略掉。

我寫的時候也想盡可能的講明白,有些地方就會囉嗦些。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章