Go語言核心36講(Go語言進階技術十四)--學習筆記

MingsonZheng發表於2021-11-07

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/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章