1. 程式人生 > 實用技巧 >【go語言學習】錯誤error和異常panic

【go語言學習】錯誤error和異常panic

一、錯誤和異常的區別

錯誤指的是可能出現問題的地方出現了問題。比如開啟一個檔案時失敗,這種情況在人們的意料之中 。

異常指的是不應該出現問題的地方出現了問題。比如引用了空指標,這種情況在人們的意料之外。

可見,錯誤是業務過程的一部分,而異常不是 。

二、錯誤演示

go語言中,錯誤是一種資料型別,使用內建的error型別,和其他資料型別一樣使用。

package main

import (
	"fmt"
	"os"
)

func main() {
	_, err := os.Open("a.txt")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("open file success")
}

執行結果

open a.txt: The system cannot find the file specified.

三、錯誤型別

go語言通過內建的error介面提供了非常簡單的錯誤處理機制。

type error interface {
        Error() string
}

這個介面中包含一個Error() string,任何實現了該方法的型別都可以作為一個錯誤使用。這個方法提供了錯誤的描述。

列印錯誤是,fmt.Println函式在內部呼叫Error()方法來獲取錯誤的描述。

從錯誤中提取更多資訊的方法

1、斷言底層結構體型別並從結構體欄位中獲取更多的資訊。

檢視go語言原始碼,可以發現上面的示例中開啟檔案的程式碼中返回值error

實現類error介面,可以看做error型別,但這個返回值error本身是PathError結構體型別。

go語言原始碼:

// PathError records an error and the operation and file path that caused it.
type PathError struct {
	Op   string
	Path string
	Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

func (e *PathError) Unwrap() error { return e.Err }

// Timeout reports whether this error represents a timeout.
func (e *PathError) Timeout() bool {
	t, ok := e.Err.(timeout)
	return ok && t.Timeout()
}

通過型別斷言,可以將error作為PathError型別的例項,訪問其屬性和方法。

示例:

package main

import (
	"fmt"
	"os"
)

func main() {
	_, err := os.Open("a.txt")
	if err != nil {
		if err, ok := err.(*os.PathError); ok {
			fmt.Println(err.Op)   // open
			fmt.Println(err.Path) // a.txt
			fmt.Println(err.Err)  // The system cannot find the file specified.
		}
		return
	}
	fmt.Println("open file success")
}
2、斷言底層結構體型別,呼叫其方法獲取更多資訊

go語言原始碼:

type DNSError struct {  
    ...
}

func (e *DNSError) Error() string {  
    ...
}
func (e *DNSError) Timeout() bool {  
    ... 
}
func (e *DNSError) Temporary() bool {  
    ... 
}

DNSError struct有兩個方法Timeout() bool和Temporary() bool,它們返回一個布林值,表示錯誤是由於超時還是臨時的。

示例:

package main

import (
	"fmt"
	"net"
)

func main() {
	add, err := net.LookupHost("www.baiddfudfsadf.com")
	if err != nil {
		if err, ok := err.(*net.DNSError); ok {
			if err.Timeout() {
				fmt.Println("operation time out")
			} else if err.Temporary() {
				fmt.Println("temporary error")
			} else {
				fmt.Println("genration error", err)
			}
		}
		return
	}
	fmt.Println(add)
}

執行結果

genration error lookup www.baiddfudfsadf.com: no such host

四、自定義錯誤

1、使用errors包下的New()函式

go語言原始碼:

// Package errors implements functions to manipulate errors.
  package errors

  // New returns an error that formats as the given text.
  func New(text string) error {
      return &errorString{text}
  }

  // errorString is a trivial implementation of error.
  type errorString struct {
      s string
  }

  func (e *errorString) Error() string {
      return e.s
  }

示例:

package main

import (
	"errors"
	"fmt"
	"math"
)

func main() {
	s := -4.2
	res, err := squareArea(s)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("squareArea =", res)
}

func squareArea(sideLength float64) (float64, error) {
	if sideLength < 0 {
		return 0, errors.New("sideLength < 0")
	}
	return math.Pow(sideLength, 2), nil
}

執行結果

sideLength < 0
2、使用fmt包下的Errorf()函式

go語言原始碼:

func Errorf(format string, a ...interface{}) error {}

示例:

package main

import (
	"fmt"
	"math"
)

func main() {
	s := -4.2
	res, err := squareArea(s)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("squareArea =", res)
}

func squareArea(sideLength float64) (float64, error) {
	if sideLength < 0 {
		return 0, fmt.Errorf("sideLength < 0, sideLength = %v\n", sideLength)
	}
	return math.Pow(sideLength, 2), nil
}

執行結果

sideLength < 0, sideLength = -4.2
3、使用struct型別屬性和方法提供更多關於錯誤的資訊。

示例:

package main

import (
	"fmt"
)

// areaError 結構體 錯誤型別
type areaError struct {
	err    string
	length float64
	width  float64
}

// Error() 方法,實現error介面
func (e *areaError) Error() string {
	return fmt.Sprintf("length: %v, width: %v, error: %v", e.length, e.width, e.err)
}

func (e *areaError) lengthNegative() bool {
	return e.length < 0
}

func (e *areaError) widthNegative() bool {
	return e.width < 0
}

func main() {
	length := -10.2
	width := 3.4
	res, err := rectangleArea(length, width)
	if err != nil {
		fmt.Println("err:", err)
		if err, ok := err.(*areaError); ok {
			if err.lengthNegative() {
				fmt.Println("length < 0", err.length)
			}
			if err.widthNegative() {
				fmt.Println("width < 0", err.width)
			}
		}
	}
	fmt.Println("rectangle area =", res)
}

func rectangleArea(length, width float64) (float64, error) {
	err := ""
	if length < 0 {
		err += "length < 0"
	}
	if width < 0 {
		if length < 0 {
			err += "&width < 0"
		} else {
			err = "width < 0"
		}
	}
	if err != "" {
		return 0, &areaError{err, length, width}
	}
	return length * width, nil
}

執行結果

err: length: -10.2, width: 3.4, error: length < 0
length < 0 -10.2
rectangle area = 0

五、panic和recover

Golang中引入兩個內建函式panic和recover來觸發和終止異常處理流程,同時引入關鍵字defer來延遲執行defer後面的函式。

panic:

  • 1、內建函式
  • 2、假如函式F中書寫了panic語句,會終止其後要執行的程式碼,在panic所在函式F內如果存在要執行的defer函式列表,按照defer的逆序執行
  • 3、返回函式F的呼叫者G,在G中,呼叫函式F語句之後的程式碼不會執行,假如函式G中存在要執行的defer函式列表,按照defer的逆序執行,這裡的defer 有點類似 try-catch-finally 中的 finally
  • 4、直到goroutine整個退出,並報告錯誤

recover:

  • 1、內建函式
  • 2、用來控制一個goroutine的panicking行為,捕獲panic,從而影響應用的行為
  • 3、一般的呼叫建議
    a). 在defer函式中,通過recever來終止一個gojroutine的panicking過程,從而恢復正常程式碼的執行
    b). 可以獲取通過panic傳遞的error

以下給出異常處理的作用域(場景):

  1. 空指標引用
  2. 下標越界
  3. 除數為0
  4. 不應該出現的分支,比如default
  5. 輸入不應該引起函式錯誤

六、錯誤處理的正確姿勢

姿勢一:失敗的原因只有一個時,不使用error

我們看一個案例:

func (self *AgentContext) CheckHostType(host_type string) error {
    switch host_type {
    case "virtual_machine":
        return nil
    case "bare_metal":
        return nil
    }
    return errors.New("CheckHostType ERROR:" + host_type)
}

我們可以看出,該函式失敗的原因只有一個,所以返回值的型別應該為bool,而不是error,重構一下程式碼:

func (self *AgentContext) IsValidHostType(hostType string) bool {
    return hostType == "virtual_machine" || hostType == "bare_metal"
}

說明:大多數情況,導致失敗的原因不止一種,尤其是對I/O操作而言,使用者需要了解更多的錯誤資訊,這時的返回值型別不再是簡單的bool,而是error。

姿勢二:沒有失敗時,不使用error

錯誤示例:

func (self *CniParam) setTenantId() error {
    self.TenantId = self.PodNs
    return nil
}

正確示例:

func (self *CniParam) setTenantId() {
    self.TenantId = self.PodNs
}
姿勢三:error應該放在返回值列表的最後

對於返回值型別error,用來傳遞錯誤資訊,在Golang中通常放在最後一個。

resp, err := http.Get(url)
if err != nil {
    return nill, err
}

bool作為返回值型別時也一樣。

value, ok := cache.Lookup(key) 
if !ok {
    // ...cache[key] does not exist… 
}
姿勢四:錯誤統一定義,而不是跟著感覺走

很多人寫程式碼時,到處return errors.New(value),而錯誤value在表達同一個含義時也可能形式不同,比如“記錄不存在”的錯誤value可能為:

  1. "record is not existed."
  2. "record is not exist!"
  3. "###record is not existed!!!"
  4. ...
姿勢五:錯誤逐層傳遞時,層層都加日誌

層層都加日誌非常方便故障定位。

說明:至於通過測試來發現故障,而不是日誌,目前很多團隊還很難做到。如果你或你的團隊能做到,那麼請忽略這個姿勢。

姿勢六:錯誤處理使用defer

我們一般通過判斷error的值來處理錯誤,如果當前操作失敗,需要將本函式中已經create的資源destroy掉,示例程式碼如下:

func deferDemo() error {
    err := createResource1()
    if err != nil {
        return ERR_CREATE_RESOURCE1_FAILED
    }
    err = createResource2()
    if err != nil {
        destroyResource1()
        return ERR_CREATE_RESOURCE2_FAILED
    }

    err = createResource3()
    if err != nil {
        destroyResource1()
        destroyResource2()
        return ERR_CREATE_RESOURCE3_FAILED
    }

    err = createResource4()
    if err != nil {
        destroyResource1()
        destroyResource2()
        destroyResource3()
        return ERR_CREATE_RESOURCE4_FAILED
    } 
    return nil
}

當Golang的程式碼執行時,如果遇到defer的閉包呼叫,則壓入堆疊。當函式返回時,會按照後進先出的順序呼叫閉包。
對於閉包的引數是值傳遞,而對於外部變數卻是引用傳遞,所以閉包中的外部變數err的值就變成外部函式返回時最新的err值。
根據這個結論,我們重構上面的示例程式碼:

func deferDemo() error {
    err := createResource1()
    if err != nil {
        return ERR_CREATE_RESOURCE1_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource1()
        }
    }()
    err = createResource2()
    if err != nil {
        return ERR_CREATE_RESOURCE2_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource2()
                   }
    }()

    err = createResource3()
    if err != nil {
        return ERR_CREATE_RESOURCE3_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource3()
        }
    }()

    err = createResource4()
    if err != nil {
        return ERR_CREATE_RESOURCE4_FAILED
    }
    return nil
}
姿勢七:當嘗試幾次可以避免失敗時,不要立即返回錯誤

如果錯誤的發生是偶然性的,或由不可預知的問題導致。一個明智的選擇是重新嘗試失敗的操作,有時第二次或第三次嘗試時會成功。在重試時,我們需要限制重試的時間間隔或重試的次數,防止無限制的重試。

兩個案例:

  1. 我們平時上網時,嘗試請求某個URL,有時第一次沒有響應,當我們再次重新整理時,就有了驚喜。
  2. 團隊的一個QA曾經建議當Neutron的attach操作失敗時,最好嘗試三次,這在當時的環境下驗證果然是有效的。
姿勢八:當上層函式不關心錯誤時,建議不返回error

對於一些資源清理相關的函式(destroy/delete/clear),如果子函數出錯,列印日誌即可,而無需將錯誤進一步反饋到上層函式,因為一般情況下,上層函式是不關心執行結果的,或者即使關心也無能為力,於是我們建議將相關函式設計為不返回error。

姿勢九:當發生錯誤時,不忽略有用的返回值

通常,當函式返回non-nil的error時,其他的返回值是未定義的(undefined),這些未定義的返回值應該被忽略。然而,有少部分函式在發生錯誤時,仍然會返回一些有用的返回值。比如,當讀取檔案發生錯誤時,Read函式會返回可以讀取的位元組數以及錯誤資訊。對於這種情況,應該將讀取到的字串和錯誤資訊一起打印出來。

說明:對函式的返回值要有清晰的說明,以便於其他人使用。

七、異常處理的正確姿勢

姿勢一:在程式開發階段,堅持速錯

速錯,簡單來講就是“讓它掛”,只有掛了你才會第一時間知道錯誤。在早期開發以及任何釋出階段之前,最簡單的同時也可能是最好的方法是呼叫panic函式來中斷程式的執行以強制發生錯誤,使得該錯誤不會被忽略,因而能夠被儘快修復。

姿勢二:在程式部署後,應恢復異常避免程式終止

在Golang中,某個Goroutine如果panic了,並且沒有recover,那麼整個Golang程序就會異常退出。所以,一旦Golang程式部署後,在任何情況下發生的異常都不應該導致程式異常退出,我們在上層函式中加一個延遲執行的recover呼叫來達到這個目的,並且是否進行recover需要根據環境變數或配置檔案來定,預設需要recover。
這個姿勢類似於C語言中的斷言,但還是有區別:一般在Release版本中,斷言被定義為空而失效,但需要有if校驗存在進行異常保護,儘管契約式設計中不建議這樣做。在Golang中,recover完全可以終止異常展開過程,省時省力。

我們在呼叫recover的延遲函式中以最合理的方式響應該異常:

  1. 列印堆疊的異常呼叫資訊和關鍵的業務資訊,以便這些問題保留可見;
  2. 將異常轉換為錯誤,以便呼叫者讓程式恢復到健康狀態並繼續安全執行。

我們看一個簡單的例子:

func funcA() error {
    defer func() {
        if p := recover(); p != nil {
            fmt.Printf("panic recover! p: %v", p)
            debug.PrintStack()
        }
    }()
    return funcB()
}

func funcB() error {
    // simulation
    panic("foo")
    return errors.New("success")
}

func test() {
    err := funcA()
    if err == nil {
        fmt.Printf("err is nil\\n")
    } else {
        fmt.Printf("err is %v\\n", err)
    }
}

我們期望test函式的輸出是:

err is foo

實際上test函式的輸出是:

err is nil

原因是panic異常處理機制不會自動將錯誤資訊傳遞給error,所以要在funcA函式中進行顯式的傳遞,程式碼如下所示:

func funcA() (err error) {
    defer func() {
        if p := recover(); p != nil {
            fmt.Println("panic recover! p:", p)
            str, ok := p.(string)
            if ok {
                err = errors.New(str)
            } else {
                err = errors.New("panic")
            }
            debug.PrintStack()
        }
    }()
    return funcB()
}
姿勢三:對於不應該出現的分支,使用異常處理

當某些不應該發生的場景發生時,我們就應該呼叫panic函式來觸發異常。比如,當程式到達了某條邏輯上不可能到達的路徑:

switch s := suit(drawCard()); s {
    case "Spades":
    // ...
    case "Hearts":
    // ...
    case "Diamonds":
    // ... 
    case "Clubs":
    // ...
    default:
        panic(fmt.Sprintf("invalid suit %v", s))
}
姿勢四:針對入參不應該有問題的函式,使用panic設計

入參不應該有問題一般指的是硬編碼,我們先看這兩個函式(Compile和MustCompile),其中MustCompile函式是對Compile函式的包裝:

func MustCompile(str string) *Regexp {
    regexp, error := Compile(str)
    if error != nil {
        panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
    }
    return regexp
}

所以,對於同時支援使用者輸入場景和硬編碼場景的情況,一般支援硬編碼場景的函式是對支援使用者輸入場景函式的包裝。
對於只支援硬編碼單一場景的情況,函式設計時直接使用panic,即返回值型別列表中不會有error,這使得函式的呼叫處理非常方便(沒有了乏味的"if err != nil {/ 列印 && 錯誤處理 /}"程式碼塊)。