關(guān)于Go程序錯誤處理的一些建議
Go 的錯誤處理這塊是日常被大家吐槽較多的地方,我在工作中也觀察到一些現(xiàn)象,比較嚴重的是在各層級的邏輯代碼中對錯誤的處理有些重復(fù)。
比如,有人寫代碼就會在每一層都判斷錯誤并記錄日志,從代碼層面看,貌似很嚴謹,但是如果看日志會發(fā)現(xiàn)一堆重復(fù)的信息,等到排查問題時反而會造成干擾。
今天給大家總結(jié)三點 Go
代碼錯誤處理相關(guān)的最佳實踐給大家。
這些最佳實踐也是網(wǎng)上一些前輩分享的,我自己實踐后在這里用自己的語言描述出來,希望能對大家有所幫助。
認識error
Go
程序通過 error
類型的值表示錯誤
error
類型是一個內(nèi)建接口類型,該接口只規(guī)定了一個返回字符串值的 Error
方法。
- type error interface {
- Error() string
- }
Go
語言的函數(shù)經(jīng)常會返回一個 error
值,調(diào)用者通過測試 error
值是否是 nil
來進行錯誤處理。
- i, err := strconv.Atoi("42")
- if err != nil {
- fmt.Printf("couldn't convert number: %v\n", err)
- return
- }
- fmt.Println("Converted integer:", i)
error
為 nil
時表示成功;非 nil
的 error
表示失敗。
自定義錯誤記得要實現(xiàn)error接口
我們經(jīng)常會定義符合自己需要的錯誤類型,但是記住要讓這些類型實現(xiàn) error
接口,這樣就不用在調(diào)用方的程序里引入額外的類型。
比如下面我們自己定義了 myError
這個類型,如果不實現(xiàn) error
接口的話,調(diào)用者的代碼中就會被 myError
這個類型侵入。比如下面的 run
函數(shù),在定義返回值類型時,直接定義成 error
即可。
- package myerror
- import (
- "fmt"
- "time"
- )
- type myError struct {
- Code int
- When time.Time
- What string
- }
- func (e *myError) Error() string {
- return fmt.Sprintf("at %v, %s, code %d",
- e.When, e.What, e.Code)
- }
- func run() error {
- return &MyError{
- 1002,
- time.Now(),
- "it didn't work",
- }
- }
- func TryIt() {
- if err := run(); err != nil {
- fmt.Println(err)
- }
- }
如果 myError
不實現(xiàn) error
接口的話,這里的返回 值類型就要 定義成 myError
類型。 可想而知,緊接著調(diào)用者的程序里就要通過 myError.Code == xxx
來判斷到底是哪種具體的錯誤(當然想要這么干得先把 myError
改成導出的 MyError
)。
那調(diào)用者判斷自定義 error
是具體哪種錯誤的時候應(yīng)該怎么辦呢, myError
并未向包外暴露,答案是通過向包外暴露檢查錯誤行為的方法來實現(xiàn)。
- myerror.IsXXXError(err)
- ...
抑或是通過比較 error
本身與包向外暴露的常量錯誤是否相等來判斷,比如操作文件時常用來判斷文件是否結(jié)束的 io.EOF
。
類似的還有 gorm.ErrRecordNotFound
等各種開源包對外暴露的錯誤常量。
- if err != io.EOF {
- return err
- }
錯誤處理常犯的錯誤
先看一段簡單的程序,看大家能不能發(fā)現(xiàn)一些細微的問題
- func WriteAll(w io.Writer, buf []byte) error {
- _, err := w.Write(buf)
- if err != nil {
- log.Println("unable to write:", err) // annotated error goes to log file
- return err // unannotated error returned to caller
- }
- return nil
- }
- func WriteConfig(w io.Writer, conf *Config) error {
- buf, err := json.Marshal(conf)
- if err != nil {
- log.Printf("could not marshal config: %v", err)
- return err
- }
- if err := WriteAll(w, buf); err != nil {
- log.Println("could not write config: %v", err)
- return err
- }
- return nil
- }
- func main() {
- err := WriteConfig(f, &conf)
- fmt.Println(err) // io.EOF
- }
錯誤處理常犯的兩個問題
上面程序的錯誤處理暴露了兩個問題:
1. 底層函數(shù) WriteAll
在發(fā)生錯誤后,除了向上層返回錯誤外還向日志里記錄了錯誤,上層調(diào)用者做了同樣的事情,記錄日志然后把錯誤再返回給程序頂層。
因此在日志文件中得到一堆重復(fù)的內(nèi)容
- unable to write: io.EOF
- could not write config: io.EOF
- ...
2. 在程序的頂部,雖然得到了原始錯誤,但沒有相關(guān)內(nèi)容,換句話說沒有把 WriteAll
、 WriteConfig
記錄到 log 里的那些信息包裝到錯誤里,返回給上層。
針對這兩個問題的解決方案可以是,在底層函數(shù) WriteAll
、 WriteConfig
中為發(fā)生的錯誤添加上下文信息,然后將錯誤返回上層,由上層程序最后處理這些錯誤。
一種簡單的包裝錯誤的方法是使用 fmt.Errorf
函數(shù),給錯誤添加信息。
- func WriteConfig(w io.Writer, conf *Config) error {
- buf, err := json.Marshal(conf)
- if err != nil {
- return fmt.Errorf("could not marshal config: %v", err)
- }
- if err := WriteAll(w, buf); err != nil {
- return fmt.Errorf("could not write config: %v", err)
- }
- return nil
- }
- func WriteAll(w io.Writer, buf []byte) error {
- _, err := w.Write(buf)
- if err != nil {
- return fmt.Errorf("write failed: %v", err)
- }
- return nil
- }
給錯誤附加上下文信息
fmt.Errorf
只是給錯誤添加了簡單的注解信息,如果你想在添加信息的同時還加上錯誤的調(diào)用棧,可以借助 github.com/pkg/errors
這個包提供的錯誤包裝能力。
- //只附加新的信息
- func WithMessage(err error, message string) error
- //只附加調(diào)用堆棧信息
- func WithStack(err error) error
- //同時附加堆棧和信息
- func Wrap(err error, message string) error
有包裝方法,就有對應(yīng)的解包方法, Cause
方法會返回包裝錯誤對應(yīng)的最原始錯誤--即會遞歸地進行解包。
- func Cause(err error) error
下面是使用 github.com/pkg/errors
改寫后的錯誤處理程序
- func ReadFile(path string) ([]byte, error) {
- f, err := os.Open(path)
- if err != nil {
- return nil, errors.Wrap(err, "open failed")
- }
- defer f.Close()
- buf, err := ioutil.ReadAll(f)
- if err != nil {
- return nil, errors.Wrap(err, "read failed")
- }
- return buf, nil
- }
- func ReadConfig() ([]byte, error) {
- home := os.Getenv("HOME")
- config, err := ReadFile(filepath.Join(home, ".settings.xml"))
- return config, errors.WithMessage(err, "could not read config")
- }
- func main() {
- _, err := ReadConfig()
- if err != nil {
- fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))
- fmt.Printf("stack trace:\n%+v\n", err)
- os.Exit(1)
- }
- }
上面格式化字符串時用的 %+v 是在 % v 基礎(chǔ)上,對值進行展開,即展開復(fù)合類型值,比如結(jié)構(gòu)體的字段值等明細。
這樣既能給錯誤添加調(diào)用棧信息,又能保留對原始錯誤的引用,通過 Cause
可以還原到最初始引發(fā)錯誤的原因。
總結(jié)
總結(jié)一下,錯誤處理的原則就是:
-
錯誤只在邏輯的最外層處理一次,底層只返回錯誤。
-
底層除了返回錯誤外,要對原始錯誤進行包裝,增加錯誤信息、調(diào)用棧等這些有利于排查的上下文信息。