Go36-19,20-錯誤處理

錯誤處理(上)

錯誤處理到現在爲止應該已經接觸過幾次了。比如,聲明error類型的變量err,或是調用errors包中的New函數。

error類型

error類型是一個接口類型,是一個Go語言的內建類型。在這個接口類型的聲明中只包含了一個方法Error。這個方法不接受任何參數,但是會返回一個string類型的結果。它的作用是返回錯誤信息的字符串表示形式。使用error類型的方式通常是,在函數聲明的結果列表的最後,聲明一個該類型的結果,同時在調用這個函數之後,先判斷它返回的最後一個結果值是否“不爲nil”。如果值“不爲nil”,就需要進入錯誤處理。否則就是繼續正常的流程。示例如下:

package main

import "fmt"

func echo(request string) (response string, err error) {
    if request == "" {
        err = fmt.Errorf("空字符串")  // 這裏底層也是調用下面的New,但是支持字符串格式化
        // 如果是純字符串,可以直接調用errors包裏的New函數
        // err = errors.New("empty request")
        return
    }
    response = fmt.Sprintf("echo:%s", request)
    return
}

func main() {
    for _, req := range []string{"", "Hello"} {
        fmt.Printf("request: %s\n", req)
        resp, err := echo(req)
        if err != nil {
            fmt.Printf("error: %s\n", err)
            continue
        }
        fmt.Printf("response: %s\n", resp)
    }
}

在echo函數和main函數中,我都使用到了衛述語句。衛述語句,就是被用來檢查後續操作的前置條件並進行相應處理的語句。在進行錯誤處理的時候經常會用到衛述語句,以至於“我的程序滿屏都是衛述語句,簡直是太難看了!”(這裏我有同感)。

錯誤判斷

由於error是一個接口類型,所以即使同爲error類型的錯誤值,它們的實際類型也可能不同。錯誤判斷的做法一般是如下的3種:

  1. 對於類型在已知範圍內的一系列錯誤值,一般使用類型斷言表達式或類型switch語句來判斷
  2. 對於已有相應變量且類型相同的一系列錯誤值,一般直接使用判等操作來判斷
  3. 對於沒有相應變量且類型未知的一系列錯誤值,只能使用其錯誤信息的字符串表示形式來做判斷

對於上面的3種情況,接下來分別展開。

第一種情況
類型在已知範圍內的錯誤值是最容易分辨的。拿os包中的幾個代表錯誤的類型os.PathError、os.LinkError、os.SyscallError和os/exec.Error舉例,它們的指針類型都是error接口的實現類型,同時它們也都包含了一個名叫Err,類型爲error接口類型的代表潛在錯誤的字段。
如果得到一個error類型值,並且知道該值的實際類型肯定是它們中的某一個,那就可以用類型switch語句去做判斷。示例如下:

package main

import (
    "fmt"
    "os"
    "os/exec"
)

// underlyingError 會返回已知的操作系統相關錯誤的潛在錯誤值。
func underlyingError(err error) error {
    switch err := err.(type) {
    case *os.PathError:
        return err.Err
    case *os.LinkError:
        return err.Err
    case *os.SyscallError:
        return err.Err
    case *exec.Error:
        return err.Err
    }
    return err
}

func main() {
    r, w, err := os.Pipe()
    if err != nil {
        fmt.Fprintf(os.Stderr, "unexpected error: %s\n", err)
        return
    }
    // 人爲製造 *os.PathError 類型的錯誤。
    r.Close()
    _, err = w.Write([]byte("hi"))
    if err != nil {
        uError := underlyingError(err)
        fmt.Fprintf(os.Stderr, "underlying error: %s (type: %T)\n", uError, uError)
    }
}

函數underlyingError的作用是,獲取和返回已知的操作系統相關錯誤的潛在錯誤值。裏面用switch做類型判斷,如果是已知的那些類型,這些類型都會有Err字段,直接返回Err字段的值。如果case子句都沒有被選中,那麼就是一個其他的類型,直接返回傳入的參數err,即放棄獲取潛在錯誤值。

第二種情況
在Go語言的標準庫中也有不少以相同方式創建的同類型的錯誤值。還拿os包來說,其中不少的錯誤值都是通過調用errors.New函數來初始化的,比如:os.ErrClosed、os.ErrInvalid以及os.ErrPermission。與之前的那些錯誤類型不同,這幾個都是已經定義好的、確切的錯誤值。os包中的代碼有時候會把它們當做潛在錯誤值,封裝進前面那些錯誤類型的值中。
如果我們在操作文件系統的時候得到了一個錯誤值,並且知道該值的潛在錯誤值肯定是上述值中的某一個,那麼就可以用普通的switch語句去做判斷。這裏比較難理解,示例如下:

package main

import (
    "fmt"
    "os"
    "os/exec"
)

// underlyingError 會返回已知的操作系統相關錯誤的潛在錯誤值。
func underlyingError(err error) error {
    switch err := err.(type) {
    case *os.PathError:
        return err.Err
    case *os.LinkError:
        return err.Err
    case *os.SyscallError:
        return err.Err
    case *exec.Error:
        return err.Err
    }
    return err
}

func main() {
    paths := []string{
        os.Args[0],           // 當前的源碼文件或可執行文件。
        "/it/must/not/exist", // 肯定不存在的目錄。
        os.DevNull,           // 肯定存在的目錄。
    }
    printError := func(i int, err error) {
        if err == nil {
            fmt.Println("nil error")
            return
        }
        err = underlyingError(err)  // 先去獲取潛在錯誤值
        // 然後對錯誤值進行判等來分辨
        switch err {
        case os.ErrClosed:
            fmt.Printf("case: %s\n", os.ErrClosed)
            fmt.Printf("error(closed)[%d]: %s\n", i, err)
        case os.ErrInvalid:
            fmt.Printf("case: %s\n", os.ErrInvalid)
            fmt.Printf("error(invalid)[%d]: %s\n", i, err)
        case os.ErrPermission:
            fmt.Printf("case: %s\n", os.ErrPermission)
            fmt.Printf("error(permission)[%d]: %s\n", i, err)
        default:
            fmt.Println("case not fount")
            fmt.Printf("error(unknow)[%d]: %s\n", i, err)
        }
    }
    var f *os.File
    var index int
    var err error
    {
        index = 0
        f, err = os.Open(paths[index])
        if err != nil {
            fmt.Printf("unexpected error: %s\n", err)
            return
        }
        // 人爲製造潛在錯誤爲 os.ErrClosed 的錯誤。
        f.Close()
        _, err = f.Read([]byte{})
        printError(index, err)
    }
    {
        index = 1
        // 人爲製造 os.ErrInvalid 錯誤。
        f, _ = os.Open(paths[index])
        _, err = f.Stat()
        printError(index, err)
    }
    {
        index = 2
        // 人爲製造潛在錯誤爲 os.ErrPermission 的錯誤。
        _, err = exec.LookPath(paths[index])
        printError(index, err)
    }
    if f != nil {
        f.Close()
    }
}

這裏會用到上一個例子裏的underlyingError函數。printError變量代表的函數會接受一個error類型的參數值,該值代表某個文件操作的相關錯誤。先用underlyingError函數得到它的潛在錯誤值(也可能類型都不符合得到的是原來的錯誤值),然後用switch語句對錯誤值進行判等操作。如此來分辨出具體的錯誤。

第三種情況
對於上面的兩種情況,都有明確的方式來解決。但是,如果對一個錯誤的函數並不清楚,那隻能通過它擁有的錯誤信息去判斷了。總是能夠通過錯誤值的Error方法拿到它的錯誤信息,就是錯誤信息的字符串表示形式。還是os包,裏面就有做這種判斷的函數,比如:os.IsExist、os.IsNotExist和os.IsPermission。
這裏的例子和上面那個差不多,這次用了if來做判斷(case和if都可以用),示例如下:

package main

import (
    "fmt"
    "os"
    "os/exec"
    "runtime"
)

func main() {
    paths := []string{
        runtime.GOROOT(),     // 當前環境下的Go語言根目錄。
        "/it/must/not/exist", // 肯定不存在的目錄。
        os.DevNull,           // 肯定存在的目錄。
    }
    printError2 := func(i int, err error) {
        if err == nil {
            fmt.Println("nil error")
            return
        }
        if os.IsExist(err) {
            fmt.Printf("error(exist)[%d]: %s\n", i, err)
        } else if os.IsNotExist(err) {
            fmt.Printf("error(not exist)[%d]: %s\n", i, err)
        } else if os.IsPermission(err) {
            fmt.Printf("error(permission)[%d]: %s\n", i, err)
        } else {
            fmt.Printf("error(other)[%d]: %s\n", i, err)
        }
    }
    var f *os.File
    var index int
    var err error
    {
        index = 0
        err = os.Mkdir(paths[index], 0700)
        printError2(index, err)
    }
    {
        index = 1
        f, err = os.Open(paths[index])
        printError2(index, err)
    }
    {
        index = 2
        _, err = exec.LookPath(paths[index])
        printError2(index, err)
    }
    if f != nil {
        f.Close()
    }
}

這裏的代碼裏看不出什麼,這種情況是獲取錯誤的字符串表示形式然後做判斷。這裏做判斷的就是os.IsExist、os.IsNotExist和os.IsPermission這3個函數。具體看os.IsNotExist做了什麼,這個去源碼裏看一下:

// 轉去調用一個內部的方法
func IsNotExist(err error) bool {
    return isNotExist(err)
}

// 再轉去調用字符串分析的方法
func isNotExist(err error) bool {
    return checkErrMessageContent(err, "does not exist", "not found",
        "has been removed", "no parent")
}

// 這個函數就是看看錯誤信息裏是否有特定的字符串
func checkErrMessageContent(err error, msgs ...string) bool {
    if err == nil {
        return false
    }
    // 第一個例子就開始用的這個函數,就是從源碼裏超的
    err = underlyingError(err)  
    for _, msg := range msgs {
        if contains(err.Error(), msg) {
            return true
        }
    }
    return false
}

這裏看到了,我們的代碼裏用用做判斷的函數,在源碼裏具體做的事情就是獲取錯誤信息的字符串表示信息,然後去判斷是否包含了特定的字符串。

總結

這篇主要就是講錯誤類型的判斷,並且用os包舉例了3種判斷錯誤類型的方法。
第一種類型斷言,就是直接用類型斷言判斷錯誤的類型。error類型是一個接口類型,這裏要用類型斷言判斷出該類型的動態類型,通過這個動態類型來分辨。
第二種錯誤值判等,通過錯誤值來判斷,這裏的錯誤值是已知的,所以使用判等來進行判斷。
第三種分析錯誤值,其實還是通過錯誤值來判斷,但是這裏的錯誤值不確定。例子裏用了os包中提供的方法來進行判斷,其底層就是檢查字符串是否包含特定的字符。
另外,用於判斷的語句,類型斷言應該還是用case比較合適。其他情況case和if都可以用來做判斷。

錯誤處理(下)

在上篇中,主要是從使用者的角度看“怎樣處理錯誤值”。這篇,要從建造者的角度關心“怎麼才能給予使用者恰當的錯誤值”。

構建錯誤值體系的基本方式有兩種:

  • 創建立體的錯誤類型體系
  • 創建扁平的錯誤值列表

錯誤類型體系

由於在Go語言中實現接口是非侵入式的,所以可以做的很靈活。比如,在標準庫的net代碼包中,有一個名爲Error的接口類型。它算是內建接口類型error的一個擴展接口,因爲error是net.Error的嵌入接口。net.Error接口除了擁有error接口的Error方法外,還有兩者自己什麼的方法:Timeout和Temporary。net包中有很多錯誤類型都實現了net.Error接口,比如下面這些:

  • *net.OpError
  • *net.AddrError
  • net.UnknownNetworkError

這些錯誤類型就是一個樹形結構,內建接口error就是根節點,而net.Error接口就是就是第一級子節點。
當我們細看net包中的這些具體錯誤類型的實現時,還會發現,與os包中的一些錯誤類型類似,它們也都有一個名爲Err、類型爲error接口類型的字段,代表的也是當前錯誤的潛在錯誤。
所以,這些錯誤類型的值纏綿還有另外一種關係,即:鏈式關係。比如,使用者調用net.DialTCP之類的函數是,net包的代碼可能會返回給他一個 *net.OpError 類型的錯誤值,這個表示用於操作不當造成了一個錯誤。同時,這些代碼還會把一個 *net.AddrError 或 net.UnknownNetworkError 類型的值賦值該錯誤值的Err字段,以表示導致這個錯誤的潛在原因。所以,如果此處的潛在錯誤值的Err字段也有非nil值,那麼就指明瞭更深層次的錯誤原因。如此一級有一級就像鏈條指向了問題的根源。
以上這些內容總結成一句話就是,用類型建立起樹形結構的錯誤體系,用統一字段建立起可追根溯源的鏈式錯誤關聯。這是Go語言標準庫給予我們的優秀範本,非常有借鑑意義。
不過要注意,如果不想讓包外代碼改動你返回的錯誤值的話,字段名稱一定要小寫。可以通過暴露某些方法讓包外代碼可以進一步獲取錯誤信息,比如寫一個Ere方法返回私有的err字段的值。下面的扁平化方式就不得不暴露字段給包外代碼,這會帶來一些問題。
小結
錯誤類型體系是立體的,從整體上看它往往呈現出樹形的結構。通過接口間的嵌套以及接口的實現,就可以構建出一棵錯誤類型樹。通過這棵樹,使用者就可以一步步地確定錯誤值的種類。
另外,爲了追根溯源,還可以在錯誤類型中,統一安放一個可以代表潛在錯誤的字段。這叫做鏈式的錯誤關聯,可以幫助使用者找到錯誤的根源。

扁平的錯誤值列表

這個就簡單得多了。當我們只是想預先創建一些代表已知錯誤的錯誤值的時候,用扁平化的方法就是可以了。
由於error是接口類型,所以通過error.New函數生成的錯誤值只能被賦值給變量,不能給常量。又由於這些變量需要給包外的代碼使用,所以訪問權限只能公開(首字母大寫)。
這就帶來了一個問題,如果有惡意代碼改變了這些公開變量的值,那麼程序的功能就會受到影響。因爲在這種情況下,我們一般就是通過判等操作來判斷拿到的湊之具體是哪一個錯誤,如果值被改變了,就會影響到判等操作的結果。這裏光看文字沒啥感覺,下面有兩個示例。
示例1:

package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    _, err := exec.LookPath(os.DevNull)
    fmt.Printf("error: %s\n", err)
    if execErr, ok := err.(*exec.Error); ok {
        // 這裏修改了err裏的值,因爲字段名Name和Err是大寫的
        execErr.Name = os.TempDir()
        execErr.Err = os.ErrNotExist
    }
    fmt.Printf("error: %s\n", err)  // err還是開頭的err,但是值被修改了
}

示例2:

package main

import (
    "fmt"
    "os"
    "errors"
)

func main() {
    err := os.ErrPermission
    // 現在的判斷是正確的
    if os.IsPermission(err) {
        fmt.Printf("error(permission): %s\n", err)
    } else {
        fmt.Printf("error(other): %s\n", err)
    }
    // 由於字段名是大寫的,就可以修改了。
    // os.ErrPermission = os.ErrExist  // 這句怕看不懂,其實就是改掉原本的值
    os.ErrPermission = errors.New("可以是任意內容啊")  // 把原值改掉,改成什麼不重要
    // 這次再判斷err類型就不一樣了。err還是開頭的err,但是判斷結果不一樣了
    if os.IsPermission(err) {
        fmt.Printf("error(permission): %s\n", err)
    } else {
        fmt.Printf("error(other): %s\n", err)
    }
}

這兩個示例其實就是一個情況,字段名大寫了,於是就暴露出來,可以修改了。示例1中if語句內是這裏所說的惡意代碼,示例2中 os.ErrPermission = os.ErrExist 是這裏所說的惡意代碼。原本以爲不改不就OK了?但是在這裏的問題是err的值被改了,但是沒有看到顯示的修改err的代碼。這個問題就很嚴重了,問題難以被發現。
解決方案有兩個:
方案一,先私有化變量,然後編寫公開的用於獲取錯誤值以及用於判等的錯誤值的函數。就是像上節錯誤類型體系的最後說的那麼做。
方案二,此方案存在於syscall包中。該包中有一個類型叫Errno,該類型代表了系統調用是可能發生的底層錯誤。這個錯誤類型是error接口的實現類型,同時也是對內建類型uintptr的再定義類型。由於uintptr可以常量的類型,所以syscall.Error就可以是常量。syscall包中聲明有大量的Errno類型的常量,包外的代碼可以獲取到這些大寫的常量的值,但是無法改標這些常量。
下面是方案二所說的,定義了int類型Errno,並且實現了error接口。自定義這類錯誤的示例:

package main

import (
    "fmt"
    "strconv"
)

// Errno 代表某種錯誤的類型。
type Errno int

// error接口類型,需要實現一個Error方法,這個方法不接受任何參數,但是會返回一個string類型的結果
func (e Errno) Error() string {
    return "errno " + strconv.Itoa(int(e))
}

func main() {
    const (
        ERR0 = Errno(0)
        ERR1 = Errno(1)
        ERR2 = Errno(2)
    )
    var myErr error = Errno(0)
    switch myErr {
    case ERR0:
        fmt.Println("ERR0")
    case ERR1:
        fmt.Println("ERR1")
    case ERR2:
        fmt.Println("ERR2")
    }
}

小結
方案一:使用私有變量,使錯誤值不可見也不可改,然後編寫公開的函數返回私有變量的值。
方案二:使用常量,這樣可見但是不可改,需要像syscall那樣聲明新的類型來實現error接口。
總之,扁平的錯誤值列表雖然相對簡單,但是你需要知道其中的隱患以及解決方案。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章