人非聖賢孰能無過,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 處理系統異常。業務異常,可以定義為不會引起系統崩潰下游癱瘓的異常;系統異常可以定義為會引起系統崩潰下游癱瘓的異常。所以,歸根結底,一套功夫的威力,真的不在於其招式的設計,而在於運用功夫的那個人能否發揮這套武功的全部潛力。