您好,登錄后才能下訂單哦!
錯誤處理到現在為止應該已經接觸過幾次了。比如,聲明error類型的變量err,或是調用errors包中的New函數。
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種:
對于上面的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接口,比如下面這些:
這些錯誤類型就是一個樹形結構,內建接口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接口。
總之,扁平的錯誤值列表雖然相對簡單,但是你需要知道其中的隱患以及解決方案。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。