深入理解与使用go之错误处理--实现

深入理解与使用go之错误处理–实现

引子

错误管理是构建健壮和可观察的应用程序的一个基本方面,它应该与代码库的任何其他部分一样重要。在Go中,错误管理不像大多数编程语言那样依赖于传统的try/catch机制。相反,错误作为正常返回值返回。那么问题来了:

  • 程序什么时候发生崩溃(panic)
  • 通常处理错误的方式是怎样的
  • 我们应该忽略错误么
  • 在defer里出错应该怎么处理呢
  • 所有的panic错误都是可以捕获的么

我记得刚写程序的时候,感觉对go的错误很茫然,一股脑的忽略错误,像下面这样

// json 解码
_ = json.Unmarshal(data, &User)
// 打开文件
f, _ := os.Open("hello")

或者,我们避无可避,选择这样判断错误

if strings.Contains(err.Error(), "op error") {
		return ""
}
return data
  • 如果json解码的是配置文件,忽略错误会直接导致整个程序启动崩溃
  • 打开文件如果没有权限,下面的所有操作直接panic
  • 如果A开发错误内容是小写 “op error” 而B开发是大写 “OP ERROR”, 能判断么

带着上面的这些问题,我们来讨论讨论今天要说的错误处理

错误处理

错误的分类

我觉得开始处理之前,我们很有必要对错误进行一定的分类

  1. 崩溃型错误
    • 系统调用出现的崩溃 如堆栈溢出、数组越界、空指针引用等等
    • 程序限制型崩溃,如初始化过程中的配置读取、日志路径权限等等,出现错误整个程序就不该继续往下走
  2. 普通型错误
    • 错误我们需要处理,比如数据库连接异常 我们进行重试
  3. 正常型错误
    • 数据库因为查询为空返回的错误
    • 文件读取到末尾的EOF错误
    • 我们不感兴趣且对程序执行不会产生重大影响的错误

有了这三个分类,我们来一个一个看

崩溃型错误
  • 系统调用崩溃性错误

    这种错误一般是程序为了避免进一步的不确定行为和数据损坏,而提前退出

    如 数组越界

    a := []int{1, 2, 3}
    fmt.Println(a[3])
    

    运行会直接panic

    panic: runtime error: index out of range [3] with length 3
    
    goroutine 1 [running]:
    main.main()
            /data/www/hello/main.go:12 +0x1b
    exit status 2
    
  • 程序限制型

    net/http包的server.go 有段代码

    func checkWriteHeaderCode(code int) {
    	if code < 100 || code > 999 {
    		panic(fmt.Sprintf("invalid WriteHeader code %v", code))
    	}
    }
    

    原话是:

    // We used to send "HTTP/1.1 000 0" on the wire in responses but there's
    // no equivalent bogus thing we can realistically send in HTTP/2,
    // so we'll consistently panic instead and help people find their bugs
    // early. (We can't return an error from WriteHeader even if we wanted to.)
    我们过去常常在网络上发送“HTTP/1.1 000 0”作为响应,但现在。
    我们不能在HTTP/2中实际发送等同的虚假信息,
    因此,我们将持续恐慌,帮助人们及早发现他们的缺陷。(即使我们想从WriteHeader返回错误,也不能这样做。)
    

    database/sqlsql.go

    func Register(name string, driver driver.Driver) {
    	driversMu.Lock()
    	defer driversMu.Unlock()
    	if driver == nil {
    		panic("sql: Register driver is nil")
    	}
    	if _, dup := drivers[name]; dup {
    		panic("sql: Register called twice for driver " + name)
    	}
    	drivers[name] = driver
    }
    

    解释是:

    // Register makes a database driver available by the provided name.
    // If Register is called twice with the same name or if driver is nil,
    // it panics.
    注册使数据库驱动程序按提供的名称可用。
    如果使用相同的名称调用了Register两次,或者驱动程序为空,
    它就会出现恐慌。
    

    总结来说,我们的程序需要依赖项,如果程序员给了错误的依赖项,程序无法继续正常进行,就需要提前panic

    这几种情况都被视为程序员限制型错误,我们再看一个例子

    go 的 regexp 包提供了两个函数

    func Compile(expr string) (*Regexp, error) 
    func MustCompile(str string) *Regexp
    

    第一个会多返回一个错误,而第二个则在出错的时候直接panic, 而我们更倾向于使用第二个函数

    程序员在编写代码的时候,这种可预知的正则pattern模式错误是可以尽早的提前规避的

  • 处理

    如果我们在开发测试环境都未触发这些崩溃型错误,那到了生产环境部分case出现了,直接退出程序不再提供服务么

    当然不行,我们应当在适当的地方增加捕获机制,并记录日志告警来提醒程序员及时修复这些错误

    难道直接在入口函数里加 recovery

    func main() {
    	defer func() {
    		if err := recover(); err != nil {
    			log.Println(err)
    		}
    	}()
    	a := []int{1, 2, 3}
    	fmt.Println(a[3])	
    }
    

    正常被捕获了

    2024/03/20 09:29:35 runtime error: index out of range [3] with length 3
    

    但如果这样呢

    func printElm(index int) {
    	a := []int{1, 2, 3}
    	fmt.Println(a[index])
    }
    
    func main() {
    	defer func() {
    		if err := recover(); err != nil {
    			log.Println(err)
    		}
    	}()
    	go printElm(3)
    	time.Sleep(time.Second)
    }
    

    直接panic了

    panic: runtime error: index out of range [3] with length 3
    
    goroutine 6 [running]:
    main.printElm(0x0?)
            /data/www/hello/main.go:11 +0x9a
    created by main.main
            /data/www/hello/main.go:20 +0x45
    exit status 2
    

    这里就要提到一个重要的概念:

    recover只能在相同的 Go 协程中捕获 panic

    所以在子协程中,我们也应该做好错误捕获

    func printElm(index int) {
    	defer func() {
    		if err := recover(); err != nil {
    			log.Println(err)
    		}
    	}()
    	a := []int{1, 2, 3}
    	fmt.Println(a[index])
    }
    
    普通型错误
    • 调用三方包一般性错误 例如

      num, err := strconv.Atoi("123")
      if err != nil {
        log.Println(err)
        num = 0
      }
      fmt.Println(num)
      

      这种我们要做的就是接收错误

      • 可以兼容错误,兼容后继续处理
      • 不可兼容错误,向上返回错误,或打日志后直接返回
    • 自定义错误

      正常情况下,如果我们代码包可能出现多种错误的情况下,我们需要根据不同的错误进行不同的处理,我们就需要定义错误类型

      我们以 strconv 为例截取其中部分, 看看别人的包是怎么使用的错误

      1. 定义错误类型

        var ErrSyntax = errors.New("invalid syntax")
        
      2. 实现错误接口

        type NumError struct {
        	Func string // the failing function (ParseBool, ParseInt, ParseUint, ParseFloat, ParseComplex)
        	Num  string // the input
        	Err  error  // the reason the conversion failed (e.g. ErrRange, ErrSyntax, etc.)
        }
        
        func (e *NumError) Error() string {
        	return "strconv." + e.Func + ": " + "parsing " + Quote(e.Num) + ": " + e.Err.Error()
        }
        
      3. 包装错误方法

        func syntaxError(fn, str string) *NumError {
        	return &NumError{fn, cloneString(str), ErrSyntax}
        }
        
      4. 抛错

        func ParseInt(s string, base int, bitSize int) (i int64, err error) {
        	const fnParseInt = "ParseInt"
        
        	if s == "" {
        		return 0, syntaxError(fnParseInt, s)
        	}
        	// code here 
        }
        
      5. 使用判断

        假设我们要根据语法错误做特殊处理

        num, err := strconv.ParseInt("", 10, 64)
        if errors.Is(err, strconv.ErrSyntax) {
            fmt.Println(err)
            // 特殊处理
        }
        fmt.Println(num)
        

      从上面的例子不难看出,完整的错误处理分了大概5步

      我们日常使用中,如果对出错的参数和上下文不关心,错误处理可能就三步(第1,4,5)第4 步我们可以继续简化成

      if s == "" {
      		return 0, ErrSyntax
      }
      
    • 包装错误

      假设我们想使用别人的错误,但是又希望添加上我们的上下文

      • 在go1.13之后,可以使用 %w 参数进行包装

      • 然后使用 errors.As 进行包装错误的判断

        这里有个问题需要考虑下,As 方法的第二个参数在实现 Error 接口时,如果传递的是 *, 我们这里就要使用 **

        否则,使用 * 即可,参考 问题

      我们包装了一个 panic 错误

      type PanicError struct {
      	Err error
      }
      
      func (p PanicError) Error() string {
      	return fmt.Sprintf("panic error: %v", p.Err)
      }
      

      错误使用处,我们添加指明了函数上下文

      func printElm(index int) (err error) {
      	defer func() {
      		if e := recover(); e != nil {
      			err = fmt.Errorf("printElm index err %w ", customerror.PanicError{
      				Err: fmt.Errorf("org error is %v", e),
      			})
      		}
      	}()
      	a := []int{1, 2, 3}
      	fmt.Println(a[index])
      	return
      }
      

      然后使用时

      err := printElm(3)
      if errors.As(err, new(customerror.PanicError)) {
      		fmt.Println("panic error")
      }
      

      这里需要注意的是,如果上面实现 我们使用了

      func (p *PanicError) Error() string
      

      这里我们需要替换成

      panicError := new(customerror.PanicError)
      if errors.As(err, &panicError) 
      
    • defer 错误

      有一种错误产生可能是在 defer 语句里的,假设这样一个场景, 我们常常从数据库读取信息

      最后需要关闭连接

      func getUserInfo(db *sql.DB, userId int) (
          Info.User, error) {
          rows, err := db.Query(query, userId)
          if err != nil {
              return nil, err
          }
          defer rows.Close()
          // other code here
      }
      
      1. 如果 rows.Close()成功了,好了没问题

      2. 如果 rows.Close()失败了呢,

        有人说把错误处理 改成这样

        defer func() {
            err = rows.Close()
        }()
        
      3. 那么问题来了,当 db.Query 出现错误后, 这里有没有错误是不是都会覆盖

        怎么办呢

        • 如果程序本身 错误不为空
          • 关闭错误有值 记录日志,返回程序本身错误
          • 关闭错误无值 ,返回程序本身错误
        • 如果程序本身无错误,直接将关闭错误赋值给返回值
        defer func() {
            closeError := rows.Close() 
            if err != nil {
                if closeError != nil {
                    log.Printf("failed to close db rows: %v", err)
                }
                return
            }
            err = closeError
        }()
        
    正常型错误

    正常型错误有两种

    1. 正常的程序逻辑状态,甚至都不应该称之为错误

      比如下面的数据库返回条数为空

      if err != nil {
          if err == sql.ErrNoRows {
              // 正常的数据为空
          } else {
              // ...
          }
      }
      
    2. 错误可以出现,但我们不感兴趣

      假设我们有个通知处理,是额外的一部分,我们对于是否出现错误,不关心, 可以如下处理

      _ = notify()
      

      如果后面其他开发看到,这个就会很困惑,最好的做法是, 我们加个注释,记录个日志

      // notify the other people, don't care about online, we also notify offline
      err := notify()
      log.Println(err)
      

错误处理就是这些,如果有更好的做法,欢迎评论区讨论

  • 30
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
本书作者带你一步一步深入这些方法。你将理解 Go语言为何选定这些并发模型,这些模型又会带来什么问题,以及你如何组合利用这些模型中的原语去解决问题。学习那些让你在独立且自信的编写与实现任何规模并发系统时所需要用到的技巧和工具。 理解Go语言如何解决并发难以编写正确这一根本问题。 学习并发与并行的关键性区别。 深入到Go语言的内存同步原语。 利用这些模式中的原语编写可维护的并发代码。 将模式组合成为一系列的实践,使你能够编写大规模的分布式系统。 学习 goroutine 背后的复杂性,以及Go语言的运行时如何将所有东西连接在一起。 作者简介 · · · · · · Katherine Cox-Buday是一名计算机科学家,目前工作于 Simple online banking。她的业余爱好包括软件工程、创作、Go 语言(igo、baduk、weiquei) 以及音乐,这些都是她长期的追求,并且有着不同层面的贡献。 目录 · · · · · · 前言 1 第1章 并发概述 9 摩尔定律,Web Scale和我们所陷入的混乱 10 为什么并发很难? 12 竞争条件 13 原子性 15 内存访问同步 17 死锁、活锁和饥饿 20 确定并发安全 28 面对复杂性的简单性 31 第2章 对你的代码建模:通信顺序进程 33 并发与并行的区别 33 什么是CSP 37 如何帮助你 40 Go语言的并发哲学 43 第3章 Go语言并发组件 47 goroutine 47 sync包 58 WaitGroup 58 互斥锁和读写锁 60 cond 64 once 69 池 71 channel 76 select 语句 92 GOMAXPROCS控制 97 小结 98 第4章 Go语言的并发模式 99 约束 99 for-select循环103 防止goroutine泄漏 104 or-channel 109 错误处理112 pipeline 116 构建pipeline的最佳实践 120 一些便利的生成器 126 扇入,扇出 132 or-done-channel 137 tee-channel 139 桥接channel模式 140 队列排队143 context包 151 小结 168 第5章 大规模并发 169 异常传递169 超时和取消 178 心跳 184 复制请求197 速率限制199 治愈异常的goroutine 215 小结 222 第6章 goroutine和Go语言运行时 223 工作窃取223 窃取任务还是续体 231 向开发人员展示所有这些信息 240 尾声 240 附录A 241

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值