Go 語(yǔ)言 錯(cuò)誤和異常

2023-03-22 14:57 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch1-basic/ch1-07-error-and-panic.html


1.7 錯(cuò)誤和異常

錯(cuò)誤處理是每個(gè)編程語(yǔ)言都要考慮的一個(gè)重要話題。在 Go 語(yǔ)言的錯(cuò)誤處理中,錯(cuò)誤是軟件包 API 和應(yīng)用程序用戶界面的一個(gè)重要組成部分。

在程序中總有一部分函數(shù)總是要求必須能夠成功的運(yùn)行。比如 strconv.Itoa 將整數(shù)轉(zhuǎn)換為字符串,從數(shù)組或切片中讀寫元素,從 map 讀取已經(jīng)存在的元素等。這類操作在運(yùn)行時(shí)幾乎不會(huì)失敗,除非程序中有 BUG,或遇到災(zāi)難性的、不可預(yù)料的情況,比如運(yùn)行時(shí)的內(nèi)存溢出。如果真的遇到真正異常情況,我們只要簡(jiǎn)單終止程序就可以了。

排除異常的情況,如果程序運(yùn)行失敗僅被認(rèn)為是幾個(gè)預(yù)期的結(jié)果之一。對(duì)于那些將運(yùn)行失敗看作是預(yù)期結(jié)果的函數(shù),它們會(huì)返回一個(gè)額外的返回值,通常是最后一個(gè)來(lái)傳遞錯(cuò)誤信息。如果導(dǎo)致失敗的原因只有一個(gè),額外的返回值可以是一個(gè)布爾值,通常被命名為 ok。比如,當(dāng)從一個(gè) map 查詢一個(gè)結(jié)果時(shí),可以通過(guò)額外的布爾值判斷是否成功:

if v, ok := m["key"]; ok {
    return v
}

但是導(dǎo)致失敗的原因通常不止一種,很多時(shí)候用戶希望了解更多的錯(cuò)誤信息。如果只是用簡(jiǎn)單的布爾類型的狀態(tài)值將不能滿足這個(gè)要求。在 C 語(yǔ)言中,默認(rèn)采用一個(gè)整數(shù)類型的 errno 來(lái)表達(dá)錯(cuò)誤,這樣就可以根據(jù)需要定義多種錯(cuò)誤類型。在 Go 語(yǔ)言中,syscall.Errno 就是對(duì)應(yīng) C 語(yǔ)言中 errno 類型的錯(cuò)誤。在 syscall 包中的接口,如果有返回錯(cuò)誤的話,底層也是 syscall.Errno 錯(cuò)誤類型。

比如我們通過(guò) syscall 包的接口來(lái)修改文件的模式時(shí),如果遇到錯(cuò)誤我們可以通過(guò)將 err 強(qiáng)制斷言為 syscall.Errno 錯(cuò)誤類型來(lái)處理:

err := syscall.Chmod(":invalid path:", 0666)
if err != nil {
    log.Fatal(err.(syscall.Errno))
}

我們還可以進(jìn)一步地通過(guò)類型查詢或類型斷言來(lái)獲取底層真實(shí)的錯(cuò)誤類型,這樣就可以獲取更詳細(xì)的錯(cuò)誤信息。不過(guò)一般情況下我們并不關(guān)心錯(cuò)誤在底層的表達(dá)方式,我們只需要知道它是一個(gè)錯(cuò)誤就可以了。當(dāng)返回的錯(cuò)誤值不是 nil 時(shí),我們可以通過(guò)調(diào)用 error 接口類型的 Error 方法來(lái)獲得字符串類型的錯(cuò)誤信息。

在 Go 語(yǔ)言中,錯(cuò)誤被認(rèn)為是一種可以預(yù)期的結(jié)果;而異常則是一種非預(yù)期的結(jié)果,發(fā)生異??赡鼙硎境绦蛑写嬖?BUG 或發(fā)生了其它不可控的問題。Go 語(yǔ)言推薦使用 recover 函數(shù)將內(nèi)部異常轉(zhuǎn)為錯(cuò)誤處理,這使得用戶可以真正的關(guān)心業(yè)務(wù)相關(guān)的錯(cuò)誤處理。

如果某個(gè)接口簡(jiǎn)單地將所有普通的錯(cuò)誤當(dāng)做異常拋出,將會(huì)使錯(cuò)誤信息雜亂且沒有價(jià)值。就像在 main 函數(shù)中直接捕獲全部一樣,是沒有意義的:

func main() {
    defer func() {
        if r := recover(); r != nil {
            log.Fatal(r)
        }
    }()

    ...
}

捕獲異常不是最終的目的。如果異常不可預(yù)測(cè),直接輸出異常信息是最好的處理方式。

1.7.1 錯(cuò)誤處理策略

讓我們演示一個(gè)文件復(fù)制的例子:函數(shù)需要打開兩個(gè)文件,然后將其中一個(gè)文件的內(nèi)容復(fù)制到另一個(gè)文件:

func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }

    written, err = io.Copy(dst, src)
    dst.Close()
    src.Close()
    return
}

上面的代碼雖然能夠工作,但是隱藏一個(gè) bug。如果第一個(gè) os.Open 調(diào)用成功,但是第二個(gè) os.Create 調(diào)用失敗,那么會(huì)在沒有釋放 src 文件資源的情況下返回。雖然我們可以通過(guò)在第二個(gè)返回語(yǔ)句前添加 src.Close() 調(diào)用來(lái)修復(fù)這個(gè) BUG;但是當(dāng)代碼變得復(fù)雜時(shí),類似的問題將很難被發(fā)現(xiàn)和修復(fù)。我們可以通過(guò) defer 語(yǔ)句來(lái)確保每個(gè)被正常打開的文件都能被正常關(guān)閉:

func CopyFile(dstName, srcName 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語(yǔ)句可以讓我們?cè)诖蜷_文件時(shí)馬上思考如何關(guān)閉文件。不管函數(shù)如何返回,文件關(guān)閉語(yǔ)句始終會(huì)被執(zhí)行。同時(shí) defer 語(yǔ)句可以保證,即使 io.Copy 發(fā)生了異常,文件依然可以安全地關(guān)閉。

前文我們說(shuō)到,Go 語(yǔ)言中的導(dǎo)出函數(shù)一般不拋出異常,一個(gè)未受控的異??梢钥醋魇浅绦虻?BUG。但是對(duì)于那些提供類似 Web 服務(wù)的框架而言;它們經(jīng)常需要接入第三方的中間件。因?yàn)榈谌降闹虚g件是否存在 BUG 是否會(huì)拋出異常,Web 框架本身是不能確定的。為了提高系統(tǒng)的穩(wěn)定性,Web 框架一般會(huì)通過(guò) recover 來(lái)防御性地捕獲所有處理流程中可能產(chǎn)生的異常,然后將異常轉(zhuǎn)為普通的錯(cuò)誤返回。

讓我們以 JSON 解析器為例,說(shuō)明 recover 的使用場(chǎng)景。考慮到 JSON 解析器的復(fù)雜性,即使某個(gè)語(yǔ)言解析器目前工作正常,也無(wú)法肯定它沒有漏洞。因此,當(dāng)某個(gè)異常出現(xiàn)時(shí),我們不會(huì)選擇讓解析器崩潰,而是會(huì)將 panic 異常當(dāng)作普通的解析錯(cuò)誤,并附加額外信息提醒用戶報(bào)告此錯(cuò)誤。

func ParseJSON(input string) (s *Syntax, err error) {
    defer func() {
        if p := recover(); p != nil {
            err = fmt.Errorf("JSON: internal error: %v", p)
        }
    }()
    // ...parser...
}

標(biāo)準(zhǔn)庫(kù)中的 json 包,在內(nèi)部遞歸解析 JSON 數(shù)據(jù)的時(shí)候如果遇到錯(cuò)誤,會(huì)通過(guò)拋出異常的方式來(lái)快速跳出深度嵌套的函數(shù)調(diào)用,然后由最外一級(jí)的接口通過(guò) recover 捕獲 panic,然后返回相應(yīng)的錯(cuò)誤信息。

Go 語(yǔ)言庫(kù)的實(shí)現(xiàn)習(xí)慣: 即使在包內(nèi)部使用了 panic,但是在導(dǎo)出函數(shù)時(shí)會(huì)被轉(zhuǎn)化為明確的錯(cuò)誤值。

1.7.2 獲取錯(cuò)誤的上下文

有時(shí)候?yàn)榱朔奖闵蠈佑脩衾斫?;底層?shí)現(xiàn)者會(huì)將底層的錯(cuò)誤重新包裝為新的錯(cuò)誤類型返回給用戶:

if _, err := html.Parse(resp.Body); err != nil {
    return nil, fmt.Errorf("parsing %s as HTML: %v", url,err)
}

上層用戶在遇到錯(cuò)誤時(shí),可以很容易從業(yè)務(wù)層面理解錯(cuò)誤發(fā)生的原因。但是魚和熊掌總是很難兼得,在上層用戶獲得新的錯(cuò)誤的同時(shí),我們也丟失了底層最原始的錯(cuò)誤類型(只剩下錯(cuò)誤描述信息了)。

為了記錄這種錯(cuò)誤類型在包裝的變遷過(guò)程中的信息,我們一般會(huì)定義一個(gè)輔助的 WrapError 函數(shù),用于包裝原始的錯(cuò)誤,同時(shí)保留完整的原始錯(cuò)誤類型。為了問題定位的方便,同時(shí)也為了能記錄錯(cuò)誤發(fā)生時(shí)的函數(shù)調(diào)用狀態(tài),我們很多時(shí)候希望在出現(xiàn)致命錯(cuò)誤的時(shí)候保存完整的函數(shù)調(diào)用信息。同時(shí),為了支持 RPC 等跨網(wǎng)絡(luò)的傳輸,我們可能要需要將錯(cuò)誤序列化為類似 JSON 格式的數(shù)據(jù),然后再?gòu)倪@些數(shù)據(jù)中將錯(cuò)誤解碼恢復(fù)出來(lái)。

為此,我們可以定義自己的 github.com/chai2010/errors 包,里面是以下的錯(cuò)誤類型:

type Error interface {
    Caller() []CallerInfo
    Wraped() []error
    Code() int
    error

    private()
}

type CallerInfo struct {
    FuncName string
    FileName string
    FileLine int
}

其中 Error 為接口類型,是 error 接口類型的擴(kuò)展,用于給錯(cuò)誤增加調(diào)用棧信息,同時(shí)支持錯(cuò)誤的多級(jí)嵌套包裝,支持錯(cuò)誤碼格式。為了使用方便,我們可以定義以下的輔助函數(shù):

func New(msg string) error
func NewWithCode(code int, msg string) error

func Wrap(err error, msg string) error
func WrapWithCode(code int, err error, msg string) error

func FromJson(json string) (Error, error)
func ToJson(err error) string

New用于構(gòu)建新的錯(cuò)誤類型,和標(biāo)準(zhǔn)庫(kù)中 errors.New 功能類似,但是增加了出錯(cuò)時(shí)的函數(shù)調(diào)用棧信息。FromJson 用于從 JSON 字符串編碼的錯(cuò)誤中恢復(fù)錯(cuò)誤對(duì)象。NewWithCode 則是構(gòu)造一個(gè)帶錯(cuò)誤碼的錯(cuò)誤,同時(shí)也包含出錯(cuò)時(shí)的函數(shù)調(diào)用棧信息。Wrap 和 WrapWithCode 則是錯(cuò)誤二次包裝函數(shù),用于將底層的錯(cuò)誤包裝為新的錯(cuò)誤,但是保留的原始的底層錯(cuò)誤信息。這里返回的錯(cuò)誤對(duì)象都可以直接調(diào)用 json.Marshal 將錯(cuò)誤編碼為 JSON 字符串。

我們可以這樣使用包裝函數(shù):

import (
    "github.com/chai2010/errors"
)

func loadConfig() error {
    _, err := ioutil.ReadFile("/path/to/file")
    if err != nil {
        return errors.Wrap(err, "read failed")
    }

    // ...
}

func setup() error {
    err := loadConfig()
    if err != nil {
        return errors.Wrap(err, "invalid config")
    }

    // ...
}

func main() {
    if err := setup(); err != nil {
        log.Fatal(err)
    }

    // ...
}

上面的例子中,錯(cuò)誤被進(jìn)行了 2 層包裝。我們可以這樣遍歷原始錯(cuò)誤經(jīng)歷了哪些包裝流程:

    for i, e := range err.(errors.Error).Wraped() {
        fmt.Printf("wrapped(%d): %v\n", i, e)
    }

同時(shí)也可以獲取每個(gè)包裝錯(cuò)誤的函數(shù)調(diào)用堆棧信息:

    for i, x := range err.(errors.Error).Caller() {
        fmt.Printf("caller:%d: %s\n", i, x.FuncName)
    }

如果需要將錯(cuò)誤通過(guò)網(wǎng)絡(luò)傳輸,可以用 errors.ToJson(err) 編碼為 JSON 字符串:

// 以 JSON 字符串方式發(fā)送錯(cuò)誤
func sendError(ch chan<- string, err error) {
    ch <- errors.ToJson(err)
}

// 接收 JSON 字符串格式的錯(cuò)誤
func recvError(ch <-chan string) error {
    p, err := errors.FromJson(<-ch)
    if err != nil {
        log.Fatal(err)
    }
    return p
}

對(duì)于基于 http 協(xié)議的網(wǎng)絡(luò)服務(wù),我們還可以給錯(cuò)誤綁定一個(gè)對(duì)應(yīng)的 http 狀態(tài)碼:

err := errors.NewWithCode(404, "http error code")

fmt.Println(err)
fmt.Println(err.(errors.Error).Code())

在 Go 語(yǔ)言中,錯(cuò)誤處理也有一套獨(dú)特的編碼風(fēng)格。檢查某個(gè)子函數(shù)是否失敗后,我們通常將處理失敗的邏輯代碼放在處理成功的代碼之前。如果某個(gè)錯(cuò)誤會(huì)導(dǎo)致函數(shù)返回,那么成功時(shí)的邏輯代碼不應(yīng)放在 else 語(yǔ)句塊中,而應(yīng)直接放在函數(shù)體中。

f, err := os.Open("filename.ext")
if err != nil {
    // 失敗的情形, 馬上返回錯(cuò)誤
}

// 正常的處理流程

Go 語(yǔ)言中大部分函數(shù)的代碼結(jié)構(gòu)幾乎相同,首先是一系列的初始檢查,用于防止錯(cuò)誤發(fā)生,之后是函數(shù)的實(shí)際邏輯。

1.7.3 錯(cuò)誤的錯(cuò)誤返回

Go 語(yǔ)言中的錯(cuò)誤是一種接口類型。接口信息中包含了原始類型和原始的值。只有當(dāng)接口的類型和原始的值都為空的時(shí)候,接口的值才對(duì)應(yīng) nil。其實(shí)當(dāng)接口中類型為空的時(shí)候,原始值必然也是空的;反之,當(dāng)接口對(duì)應(yīng)的原始值為空的時(shí)候,接口對(duì)應(yīng)的原始類型并不一定為空的。

在下面的例子中,試圖返回自定義的錯(cuò)誤類型,當(dāng)沒有錯(cuò)誤的時(shí)候返回 nil

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = ErrBad
    }
    return p // Will always return a non-nil error.
}

但是,最終返回的結(jié)果其實(shí)并非是 nil:是一個(gè)正常的錯(cuò)誤,錯(cuò)誤的值是一個(gè) MyError 類型的空指針。下面是改進(jìn)的 returnsError

func returnsError() error {
    if bad() {
        return (*MyError)(err)
    }
    return nil
}

因此,在處理錯(cuò)誤返回值的時(shí)候,沒有錯(cuò)誤的返回值最好直接寫為 nil。

Go 語(yǔ)言作為一個(gè)強(qiáng)類型語(yǔ)言,不同類型之間必須要顯式的轉(zhuǎn)換(而且必須有相同的基礎(chǔ)類型)。但是,Go 語(yǔ)言中 interface 是一個(gè)例外:非接口類型到接口類型,或者是接口類型之間的轉(zhuǎn)換都是隱式的。這是為了支持鴨子類型,當(dāng)然會(huì)犧牲一定的安全性。

1.7.4 剖析異常

panic支持拋出任意類型的異常(而不僅僅是 error 類型的錯(cuò)誤),recover 函數(shù)調(diào)用的返回值和 panic 函數(shù)的輸入?yún)?shù)類型一致,它們的函數(shù)簽名如下:

func panic(interface{})
func recover() interface{}

Go 語(yǔ)言函數(shù)調(diào)用的正常流程是函數(shù)執(zhí)行返回語(yǔ)句返回結(jié)果,在這個(gè)流程中是沒有異常的,因此在這個(gè)流程中執(zhí)行 recover 異常捕獲函數(shù)始終是返回 nil。另一種是異常流程: 當(dāng)函數(shù)調(diào)用 panic 拋出異常,函數(shù)將停止執(zhí)行后續(xù)的普通語(yǔ)句,但是之前注冊(cè)的 defer 函數(shù)調(diào)用仍然保證會(huì)被正常執(zhí)行,然后再返回到調(diào)用者。對(duì)于當(dāng)前函數(shù)的調(diào)用者,因?yàn)樘幚懋惓顟B(tài)還沒有被捕獲,和直接調(diào)用 panic 函數(shù)的行為類似。在異常發(fā)生時(shí),如果在 defer 中執(zhí)行 recover 調(diào)用,它可以捕獲觸發(fā) panic 時(shí)的參數(shù),并且恢復(fù)到正常的執(zhí)行流程。

在非 defer 語(yǔ)句中執(zhí)行 recover 調(diào)用是初學(xué)者常犯的錯(cuò)誤:

func main() {
    if r := recover(); r != nil {
        log.Fatal(r)
    }

    panic(123)

    if r := recover(); r != nil {
        log.Fatal(r)
    }
}

上面程序中兩個(gè) recover 調(diào)用都不能捕獲任何異常。在第一個(gè) recover 調(diào)用執(zhí)行時(shí),函數(shù)必然是在正常的非異常執(zhí)行流程中,這時(shí)候 recover 調(diào)用將返回 nil。發(fā)生異常時(shí),第二個(gè) recover 調(diào)用將沒有機(jī)會(huì)被執(zhí)行到,因?yàn)?nbsp;panic 調(diào)用會(huì)導(dǎo)致函數(shù)馬上執(zhí)行已經(jīng)注冊(cè) defer 的函數(shù)后返回。

其實(shí) recover 函數(shù)調(diào)用有著更嚴(yán)格的要求:我們必須在 defer 函數(shù)中直接調(diào)用 recover。如果 defer 中調(diào)用的是 recover 函數(shù)的包裝函數(shù)的話,異常的捕獲工作將失??!比如,有時(shí)候我們可能希望包裝自己的 MyRecover 函數(shù),在內(nèi)部增加必要的日志信息然后再調(diào)用 recover,這是錯(cuò)誤的做法:

func main() {
    defer func() {
        // 無(wú)法捕獲異常
        if r := MyRecover(); r != nil {
            fmt.Println(r)
        }
    }()
    panic(1)
}

func MyRecover() interface{} {
    log.Println("trace...")
    return recover()
}

同樣,如果是在嵌套的 defer 函數(shù)中調(diào)用 recover 也將導(dǎo)致無(wú)法捕獲異常:

func main() {
    defer func() {
        defer func() {
            // 無(wú)法捕獲異常
            if r := recover(); r != nil {
                fmt.Println(r)
            }
        }()
    }()
    panic(1)
}

2 層嵌套的 defer 函數(shù)中直接調(diào)用 recover 和 1 層 defer 函數(shù)中調(diào)用包裝的 MyRecover 函數(shù)一樣,都是經(jīng)過(guò)了 2 個(gè)函數(shù)幀才到達(dá)真正的 recover 函數(shù),這個(gè)時(shí)候 Goroutine 的對(duì)應(yīng)上一級(jí)棧幀中已經(jīng)沒有異常信息。

如果我們直接在 defer 語(yǔ)句中調(diào)用 MyRecover 函數(shù)又可以正常工作了:

func MyRecover() interface{} {
    return recover()
}

func main() {
    // 可以正常捕獲異常
    defer MyRecover()
    panic(1)
}

但是,如果 defer 語(yǔ)句直接調(diào)用 recover 函數(shù),依然不能正常捕獲異常:

func main() {
    // 無(wú)法捕獲異常
    defer recover()
    panic(1)
}

必須要和有異常的棧幀只隔一個(gè)棧幀,recover 函數(shù)才能正常捕獲異常。換言之,recover 函數(shù)捕獲的是祖父一級(jí)調(diào)用函數(shù)棧幀的異常(剛好可以跨越一層 defer 函數(shù))!

當(dāng)然,為了避免 recover 調(diào)用者不能識(shí)別捕獲到的異常, 應(yīng)該避免用 nil 為參數(shù)拋出異常:

func main() {
    defer func() {
        if r := recover(); r != nil { ... }
        // 雖然總是返回 nil, 但是可以恢復(fù)異常狀態(tài)
    }()

    // 警告: 用 nil 為參數(shù)拋出異常
    panic(nil)
}

當(dāng)希望將捕獲到的異常轉(zhuǎn)為錯(cuò)誤時(shí),如果希望忠實(shí)返回原始的信息,需要針對(duì)不同的類型分別處理:

func foo() (err error) {
    defer func() {
        if r := recover(); r != nil {
            switch x := r.(type) {
            case string:
                err = errors.New(x)
            case error:
                err = x
            default:
                err = fmt.Errorf("Unknown panic: %v", r)
            }
        }
    }()

    panic("TODO")
}

基于這個(gè)代碼模板,我們甚至可以模擬出不同類型的異常。通過(guò)為定義不同類型的保護(hù)接口,我們就可以區(qū)分異常的類型了:

func main {
    defer func() {
        if r := recover(); r != nil {
            switch x := r.(type) {
            case runtime.Error:
                // 這是運(yùn)行時(shí)錯(cuò)誤類型異常
            case error:
                // 普通錯(cuò)誤類型異常
            default:
                // 其他類型異常
            }
        }
    }()

    // ...
}

不過(guò)這樣做和 Go 語(yǔ)言簡(jiǎn)單直接的編程哲學(xué)背道而馳了。



以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)