人非聖賢孰能無過,Go lang1.18入門精鍊教程,由白丁入鴻儒,Go lang錯誤處理機制EP11

人非聖賢,孰能無過,有則改之,無則加勉。在程式語言層面,錯誤處理方式大體上有兩大流派,分別是以Python為代表的異常捕獲機制(try….catch);以及以Go lang為代表的錯誤返回機制(return error),前者是自動化流程,模式化的語法隔離正常邏輯和錯誤邏輯,而後者,需要將錯誤處理判斷編排在正常邏輯中。雖然模式化語法更容易讓人理解,但從系統資源開銷角度看,錯誤返回機制明顯更具優勢。

返回錯誤

Go lang的錯誤(error)也是一種數據類型,錯誤用內置的error 類型表示,就像其他的數據類型的,比如字元串、整形之類,錯誤的具體值可以存儲在變數中,從函數中返回:

package main  
  
import "fmt"  
  
func handle() (int, error) {  
	return 1, nil  
}  
  
func main() {  
	i, err := handle()  
	if err != nil {  
		fmt.Println("報錯了")  
		return  
	}  
  
	fmt.Println("邏輯正常")  
	fmt.Println(i)  
}

程式返回:



邏輯正常  
1


這裡的邏輯是,如果handle函數成功執行並且返回,那麼入口函數就會正常列印返回值i,假設handel函數執行過程中出現錯誤,將返回一個非nil錯誤。

如果一個函數返回一個錯誤,那麼理論上,它肯定是函數返回的最後一個值,因為在執行階段中可能會返回正常的值,而錯誤位置是未知的,所以,handle函數返回的值是最後一個值。

go lang中處理錯誤的常見方式是將返回的錯誤與nil進行比較。nil值表示沒有發生錯誤,而非nil值表示出現錯誤。在我們的例子中,我們檢查錯誤是否為nil。如果它不是nil,我們會通過fmt.Println方法提醒用戶並且從主函數返回,結束邏輯。

再來個例子:

package main  
  
import (  
	"fmt"  
	"net/http"  
)  
  
func main() {  
  
	resp, err := http.Get("123123")  
	if err != nil {  
		fmt.Println(err)  
		return  
	}  
  
	fmt.Println(resp.StatusCode)  
  
}

這回我們使用標準庫包http向一個叫做123123的網址發起請求,當然了,請求過程中有可能發生一些未知錯誤,所以我們使用err變數獲取Get方法的最後一個返回值,如果err不是nil,那麼就說明請求過程中報錯了,這裡列印具體錯誤,然後從主函數中返回。

程式返回:

Get "123123": unsupported protocol scheme ""

很明顯,肯定報錯了,因為Go lang並不知道所謂的123123到底是什麼網路協議。

具體錯誤類型

在Go lang中,錯誤本質上是一個介面:



type error interface {  
    Error() string  
}


包含一個帶有Error字元串的函數。任何實現這個介面的類型都可以作為一個錯誤使用。這個函數可以列印出具體錯誤的說明。

當列印錯誤時,fmt.Println函數在內部調用Error() 方法來獲取錯誤的說明:

Get "123123": unsupported protocol scheme ""

但有的時候,除了系統級別的錯誤說明,我們還需要針對錯誤進行分類,通過不同的錯誤類型的種類來決定下游的處理方式。

既然有了錯誤說明,為什麼還需要錯誤類型,直接通過說明判斷不就行了?這是因為系統的錯誤說明可能會隨著go lang版本的迭代而略有不同,而一個錯誤的錯誤類型則大概率不會發生變化。

通過對標準庫文檔的解讀://pkg.go.dev/net/http#ProtocolError,我們就可以對返回的錯誤類型進行判斷:

package main  
  
import (  
	"fmt"  
	"net"  
	"net/http"  
)  
  
func main() {  
  
	resp, err := http.Get("123123")  
	if err, ok := err.(net.Error); ok && err.Timeout() {  
		fmt.Println("超時錯誤")  
		fmt.Println(err)  
  
	} else if err != nil {  
		fmt.Println("其他錯誤")  
		fmt.Println(err)  
	}  
  
	fmt.Println(resp.StatusCode)  
  
}

程式返回:

其他錯誤  
Get "123123": unsupported protocol scheme ""

這裡我們把超時(Timeout)和其他錯誤區分開來,分別進入不同的錯誤處理邏輯。

訂製錯誤

訂製錯誤通過標準庫errors為程式的錯誤做個性化訂製,假設某個函數的作用是做除法運算,而如果除數為0,則返回一個錯誤:

package main  
  
import (  
	"errors"  
	"fmt"  
)  
  
func test(num1 int, num2 int) (int, error) {  
	if num2 == 0 {  
		return 0, errors.New("除數不能為0")  
	}  
	return num1 / num2, nil  
}  
  
func main() {  
  
	res, err := test(2, 1)  
	if err != nil {  
		fmt.Println(err)  
		return  
	}  
	fmt.Println("結果是", res)  
}

程式返回:

結果是 2

但如果參數不合法:

package main  
  
import (  
	"errors"  
	"fmt"  
)  
  
func test(num1 int, num2 int) (int, error) {  
	if num2 == 0 {  
		return 0, errors.New("除數不能為0")  
	}  
	return num1 / num2, nil  
}  
  
func main() {  
  
	res, err := test(2, 0)  
	if err != nil {  
		fmt.Println(err)  
		return  
	}  
	fmt.Println("結果是", res)  
}

程式返回:

除數不能為0

假設,出於某種原因,我們對除數有訂製化需求,比如不能為0或者為1,但條件變成了多條件,此時需要將除數顯性的展示在錯誤說明中,以便更具象化的提醒用戶:

package main  
  
import (  
	"fmt"  
)  
  
func test(num1 int, num2 int) (int, error) {  
	if (num2 == 0) || (num2 == 1) {  
		return 0, fmt.Errorf("除數為%d,除數不能為0或者1", num2)  
	}  
	return num1 / num2, nil  
}  
  
func main() {  
  
	res, err := test(2, 1)  
	if err != nil {  
		fmt.Println(err)  
		return  
	}  
	fmt.Println("結果是", res)  
}

程式返回:

除數為1,除數不能為0或者1

這裡使用fmt包的Errorf函數根據一個格式說明器格式化錯誤,並返回一個字元串作為值來滿足錯誤。

此外,還可以使用使用結構體和結構體中的屬性提供關於錯誤的更多資訊:

type testError struct {  
	err string  
	num int  
}

這裡定義結構體testError,裡面兩個屬性,分別是錯誤說明和除數值。

隨後,我們使用一個指針接收器區域錯誤來實現錯誤介面的Error() string方法。這個方法列印出錯誤的除數值和錯誤說明:

func (e *testError) Error() string {  
	return fmt.Sprintf("除數 %d:%s", e.num, e.err)  
}

接著通過結構體定址調用:

func test(num1 int, num2 int) (int, error) {  
	if (num2 == 0) || (num2 == 1) {  
		return 0, &testError{"除數非法", num2}  
	}  
	return num1 / num2, nil  
}

完整程式碼:

package main  
  
import (  
	"fmt"  
)  
  
type testError struct {  
	err string  
	num int  
}  
  
func (e *testError) Error() string {  
	return fmt.Sprintf("除數 %d:%s", e.num, e.err)  
}  
  
func test(num1 int, num2 int) (int, error) {  
	if (num2 == 0) || (num2 == 1) {  
		return 0, &testError{"除數非法", num2}  
	}  
	return num1 / num2, nil  
}  
  
func main() {  
  
	res, err := test(2, 1)  
	if err != nil {  
		fmt.Println(err)  
		return  
	}  
	fmt.Println("結果是", res)  
}

程式返回:

除數 1:除數非法

通過結構體的定義,錯誤說明更加規整,並且更易於維護。

異常(panic/recover)

異常的概念是,本來不應該出現問題的地方出現了問題,某些情況下,當程式發生異常時,無法繼續運行,此時,我們會使用 panic 來終止程式。當函數發生 panic 時,它會終止運行,在執行完所有的延遲函數後,程式返回到該函數的調用方,這樣的過程會一直持續下去,直到當前協程的所有函數都返回退出,然後程式會列印出 panic 資訊,接著列印出堆棧跟蹤,最後程式終止:

package main  
  
import "fmt"  
  
func main() {  
  
	panic("panic error")  
  
	fmt.Println("下游邏輯")  
  
}

程式返回:

panic: panic error

可以看到,panic方法執行後,程式下游邏輯並未執行,所以panic使用場景是,當下游依賴上游的操作,而上游的問題導致下游無計可施的時候,使用panic拋出異常。

但延遲執行是個例外:

package main  
  
import "fmt"  
  
func myTest() {  
	defer fmt.Println("defer myTest")  
	panic("panic myTest")  
}  
func main() {  
	defer fmt.Println("defer main")  
	myTest()  
}

程式返回:

defer myTest  
defer main  
panic: panic myTest

這裡當函數發生 panic 時,它會終止運行,在執行完所有的延遲函數後,程式返回到該函數的調用方,這樣的過程會一直持續下去,直到當前協程的所有函數都返回退出,然後程式會列印出 panic 資訊,接著列印出堆棧跟蹤,最後程式終止。

此外,recover方法可以捕獲異常的異常,從而列印異常資訊後,繼續執行下游邏輯:

package main  
  
import "fmt"  
  
func outOfArray(x int) {  
	defer func() {  
		// recover() 可以將捕獲到的 panic 資訊列印  
		if err := recover(); err != nil {  
			fmt.Println(err)  
		}  
	}()  
	var array [5]int  
	array[x] = 1  
}  
func main() {  
  
	outOfArray(20)  
  
	fmt.Println("下游邏輯")  
}

程式返回:

runtime error: index out of range [20] with length 5  
下游邏輯

結語

綜上,Go lang的錯誤處理,屬實不太優雅,大多數情況下會有很多重複程式碼:if err != nil,這在一定程度上影響了程式碼的可讀性和可維護性,同時容易丟失底層錯誤類型,且定位錯誤時,很難得到錯誤鏈,也就是在一定程度上阻礙了錯誤的追根溯源,但反過來想,錯誤本來就是業務的一部分,從業務角度上看,Golang這種返回錯誤的方式更貼合業務邏輯,你可以用多返回值包含 error處理業務異常,用 recover 處理系統異常。業務異常,可以定義為不會引起系統崩潰下游癱瘓的異常;系統異常可以定義為會引起系統崩潰下游癱瘓的異常。所以,歸根結底,一套功夫的威力,真的不在於其招式的設計,而在於運用功夫的那個人能否發揮這套武功的全部潛力。