20 | 錯誤處理 (下)
在上一篇文章中,我們主要討論的是從使用者的角度看“怎樣處理好錯誤值”。那么,接下來我們需要關注的,就是站在建造者的角度,去關心“怎樣才能給予使用者恰當的錯誤值”的問題了。
知識擴展
問題:怎樣根據實際情況給予恰當的錯誤值?
我們已經知道,構建錯誤值體系的基本方式有兩種,即:創建立體的錯誤類型體系和創建扁平的錯誤值列表。
先說錯誤類型體系。由於在 Go 語言中實現接口是非侵入式的,所以我們可以做得很靈活。比如,在標准庫的net代碼包中,有一個名為Error的接口類型。它算是內建接口類型error的一個擴展接口,因為error是net.Error的嵌入接口。
net.Error接口除了擁有error接口的Error方法之外,還有兩個自己聲明的方法:Timeout和Temporary。
net包中有很多錯誤類型都實現了net.Error接口,比如:
1、*net.OpError;
2、*net.AddrError;
3、net.UnknownNetworkError等等。
你可以把這些錯誤類型想象成一棵樹,內建接口error就是樹的根,而net.Error接口就是一個在根上延伸的第一級非葉子節點。
同時,你也可以把這看做是一種多層分類的手段。當net包的使用者拿到一個錯誤值的時候,可以先判斷它是否是net.Error類型的,也就是說該值是否代表了一個網絡相關的錯誤。
如果是,那么我們還可以再進一步判斷它的類型是哪一個更具體的錯誤類型,這樣就能知道這個網絡相關的錯誤具體是由於操作不當引起的,還是因為網絡地址問題引起的,又或是由於網絡協議不正確引起的。
當我們細看net包中的這些具體錯誤類型的實現時,還會發現,與os包中的一些錯誤類型類似,它們也都有一個名為Err、類型為error接口類型的字段,代表的也是當前錯誤的潛在錯誤。
所以說,這些錯誤類型的值之間還可以有另外一種關系,即:鏈式關系。比如說,使用者調用net.DialTCP之類的函數時,net包中的代碼可能會返回給他一個*net.OpError類型的錯誤值,以表示由於他的操作不當造成了一個錯誤。
同時,這些代碼還可能會把一個*net.AddrError或net.UnknownNetworkError類型的值賦給該錯誤值的Err字段,以表明導致這個錯誤的潛在原因。如果,此處的潛在錯誤值的Err字段也有非nil的值,那么將會指明更深層次的錯誤原因。如此一級又一級就像鏈條一樣最終會指向問題的根源。
把以上這些內容總結成一句話就是,用類型建立起樹形結構的錯誤體系,用統一字段建立起可追根溯源的鏈式錯誤關聯。這是 Go 語言標准庫給予我們的優秀范本,非常有借鑒意義。
不過要注意,如果你不想讓包外代碼改動你返回的錯誤值的話,一定要小寫其中字段的名稱首字母。你可以通過暴露某些方法讓包外代碼有進一步獲取錯誤信息的權限,比如編寫一個可以返回包級私有的err字段值的公開方法Err。
相比於立體的錯誤類型體系,扁平的錯誤值列表就要簡單得多了。當我們只是想預先創建一些代表已知錯誤的錯誤值時候,用這種扁平化的方式就很恰當了。
不過,由於error是接口類型,所以通過errors.New函數生成的錯誤值只能被賦給變量,而不能賦給常量,又由於這些代表錯誤的變量需要給包外代碼使用,所以其訪問權限只能是公開的。
這就帶來了一個問題,如果有惡意代碼改變了這些公開變量的值,那么程序的功能就必然會受到影響。因為在這種情況下我們往往會通過判等操作來判斷拿到的錯誤值具體是哪一個錯誤,如果這些公開變量的值被改變了,那么相應的判等操作的結果也會隨之改變。
這里有兩個解決方案。第一個方案是,先私有化此類變量,也就是說,讓它們的名稱首字母變成小寫,然后編寫公開的用於獲取錯誤值以及用於判等錯誤值的函數。
比如,對於錯誤值os.ErrClosed,先改寫它的名稱,讓其變成os.errClosed,然后再編寫ErrClosed函數和IsErrClosed函數。
當然了,這不是說讓你去改動標准庫中已有的代碼,這樣做的危害會很大,甚至是致命的。我只能說,對於你可控的代碼,最好還是要盡量收緊訪問權限。
再來說第二個方案,此方案存在於syscall包中。該包中有一個類型叫做Errno,該類型代表了系統調用時可能發生的底層錯誤。這個錯誤類型是error接口的實現類型,同時也是對內建類型uintptr的再定義類型。
由於uintptr可以作為常量的類型,所以syscall.Errno自然也可以。syscall包中聲明有大量的Errno類型的常量,每個常量都對應一種系統調用錯誤。syscall包外的代碼可以拿到這些代表錯誤的常量,但卻無法改變它們。
我們可以仿照這種聲明方式來構建我們自己的錯誤值列表,這樣就可以保證錯誤值的只讀特性了。
好了,總之,扁平的錯誤值列表雖然相對簡單,但是你一定要知道其中的隱患以及有效的解決方案是什么。
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
)
// Errno 代表某種錯誤的類型。
type Errno int
func (e Errno) Error() string {
return "errno " + strconv.Itoa(int(e))
}
func main() {
var err error
// 示例1。
_, err = exec.LookPath(os.DevNull)
fmt.Printf("error: %s\n", err)
if execErr, ok := err.(*exec.Error); ok {
execErr.Name = os.TempDir()
execErr.Err = os.ErrNotExist
}
fmt.Printf("error: %s\n", err)
fmt.Println()
// 示例2。
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包中已定義的錯誤值。
// 這樣做會導致下面判斷的結果不正確。
// 並且,這會影響到當前Go程序中所有的此類判斷。
// 所以,一定要避免這樣做!
if os.IsPermission(err) {
fmt.Printf("error(permission): %s\n", err)
} else {
fmt.Printf("error(other): %s\n", err)
}
fmt.Println()
// 示例3。
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")
}
}
總結
今天,我從兩個視角為你總結了錯誤類型、錯誤值的處理技巧和設計方式。我們先一起看了一下 Go 語言中處理錯誤的最基本方式,這涉及了函數結果列表設計、errors.New函數、衛述語句以及使用打印函數輸出錯誤值。
接下來,我提出的第一個問題是關於錯誤判斷的。對於一個錯誤值來說,我們可以獲取到它的類型、值以及它攜帶的錯誤信息。
如果我們可以確定其類型范圍或者值的范圍,那么就可以使用一些明確的手段獲知具體的錯誤種類。否則,我們就只能通過匹配其攜帶的錯誤信息來大致區分它們的種類。
由於底層系統給予我們的錯誤信息還是很有規律可循的,所以用這種方式去判斷效果還比較顯著。但是第三方程序給出的錯誤信息很可能就沒那么規整了,這種情況下靠錯誤信息去辨識種類就會比較困難。
有了以上闡釋,當把視角從使用者換位到建造者,我們往往就會去自覺地仔細思考程序錯誤體系的設計了。我在這里提出了兩個在 Go 語言標准庫中使用很廣泛的方案,即:立體的錯誤類型體系和扁平的錯誤值列表。
之所以說錯誤類型體系是立體的,是因為從整體上看它往往呈現出樹形的結構。通過接口間的嵌套以及接口的實現,我們就可以構建出一棵錯誤類型樹。
通過這棵樹,使用者就可以一步步地確定錯誤值的種類了。另外,為了追根溯源的需要,我們還可以在錯誤類型中,統一安放一個可以代表潛在錯誤的字段。這叫做鏈式的錯誤關聯,可以幫助使用者找到錯誤的根源。
相比之下,錯誤值列表就比較簡單了。它其實就是若干個名稱不同但類型相同的錯誤值集合。
不過需要注意的是,如果它們是公開的,那就應該盡量讓它們成為常量而不是變量,或者編寫私有的錯誤值以及公開的獲取和判等函數,否則就很難避免惡意的篡改。
這其實是“最小化訪問權限”這個程序設計原則的一個具體體現。無論怎樣設計程序錯誤體系,我們都應該把這一點考慮在內。
思考題
請列舉出你經常用到或者看到的 3 個錯誤值,它們分別在哪個錯誤值列表里?這些錯誤值列表分別包含的是哪個種類的錯誤?
筆記源碼
https://github.com/MingsonZheng/go-core-demo
本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。