谈谈 Go 语言 error 处理最佳实践

前言

相信用过 Go 语言的同学,应该都吐槽过 Go 的 error,比如:

  • 异常处理简单粗暴,用过 Go 的应该都知道 if err != nil,因此有人吐槽 Go 语言源码一半都是错误处理逻辑;
  • Go 语言的 error 是一个值,默认不会携带堆栈信息,导致报错无法定位到具体哪一行代码;
  • 很多 Go 语言的初学者,经常会比较错误消息的字符串,以确定错误类型,非常恶心。

实际上后面两个问题,早已有解决方案了,比如想给 error 携带堆栈信息,可以用 # github.com/pkg/errors 这个包,而 error type 则可以通过自定义类型解决。感兴趣的同学推荐看下面两篇文章:

# Golang Error Handling --- Best Practice in 2020 # Go Best Practices --- Error handling

因此,这篇文章主要探讨下第一个问题怎么解。

1、Go 语言的 error 类型

Go 借鉴了 C 语言的风格,将 error 作为一个值传递,设计和约定鼓励开发者显式地检查出现错误的地方(作为对比,在 JS 中通常不关心具体哪行代码报错,只需要用一个 try...catch 包裹就完事了),缺点是异常处理逻辑复杂,容易导致满屏都是 if err != nil 的逻辑。

go 复制代码
f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f

此外不得不提的是,Go 语言有一个标志性的 ok/error 设计模式,通常配合函数多返回值使用(Go 与众不同的特性之一)。如果函数返回的多个值中,有一个是错误值,则将其作为最后一个返回值,例如:

go 复制代码
// 例如,`os.Open` 函数调用失败,会返回一个 non-nil `error`
func Open(name string) (file *File, err error)

再来看下 error 类型是个啥。在 Go 语言中,error 是一个接口类型,声明如下:

go 复制代码
type error interface {
    Error() string
}

需要注意,error 类型是一种 predeclared 的内置类型,无需导入任何包就可以使用

Go 语言标准库 errors 包提供了一个方法,让我们可以方便地构造一个 error

go 复制代码
// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

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

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

然后只需要调用 errors.New 就可以构造一个 error

go 复制代码
func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // implementation
}

New 函数也是一种 Go 语言标志性的设计模式,类似 OOP 语言中的构造方法

但实际上前面提到,error 是一种接口类型,任何类型只要实现了 error 接口,都可以视为 error,这样允许我们更加灵活地使用 error,比如:

go 复制代码
type NegativeSqrtError float64

// 实现 error 接口
func (f NegativeSqrtError) Error() string {
    return fmt.Sprintf("math: square root of negative number %g", float64(f))
}

上面这种定义类型别名的方式,还有一个好处,可以通过 类型断言 方式拿到具体错误类型,然后专门处理。当然如果简单的用 fmt.Printf 方法打印,则与其他 error 没有任何区别(最终都会调用 Error 方法格式化字符串)。

比如,在标准库 json 包中定义了一种 SyntaxError 错误类型(当 json.Decode 函数在解析 JSON blob 时遇到语法错误时返回):

go 复制代码
type SyntaxError struct {
    msg    string // description of error
    Offset int64  // error occurred after reading Offset bytes
}

func (e *SyntaxError) Error() string { return e.msg }

当错误类型为 SyntaxError 的时候,我们可以借助 Offset 字段向错误信息中添加一些额外信息(普通 error 中不存在 Offset 字段,无需处理):

go 复制代码
if err := dec.Decode(&val); err != nil {
	// 通过类型断言
	// 如果 err 为 `*json.SyntaxError` 类型时执行下面逻辑
    if serr, ok := err.(*json.SyntaxError); ok {
        line, col := findLine(f, serr.Offset)
        return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
    }
    // 否则就作为普通 error 处理
    return err
}

错误接口只需要一个 Error 方法;特定的错误实现可能会有额外的方法。例如,net 包返回类型为 error 的错误,遵循通常的惯例,但是一些错误实现具有由 net.Error 接口定义的额外方法:

go 复制代码
package net

type Error interface {
    error
    Timeout() bool   // Is the error a timeout?
    Temporary() bool // Is the error temporary?
}

客户端代码可以使用类型断言来测试 net.Error。然后将暂时性网络错误与永久性网络错误区分开来。例如,当 Web 爬虫遇到一个临时错误时,它可能会休眠并重试,否则就会放弃。

go 复制代码
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
    time.Sleep(1e9)
    continue
}
if err != nil {
    log.Fatal(err)
}

2、实际项目中如何处理 error

在 Go 语言中,错误处理非常重要。上面提到,Go 语言设计和约定鼓励开发者显式地检查出现错误的地方,因此很多人都会直接原地 if err != nil,这会导致出现大量重复性模板代码。但幸运的是,有一些技术可以用来最小化重复性错误处理。

下面是一个 App Engine 的应用程序,使用 HTTP handler 从数据存储中检索记录并使用模板进行格式化。

go 复制代码
func init() {
    http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

上面代码中,可以看到 viewRecord 函数 datastore.Get 函数和 viewTemplateExecute 方法返回的错误。这段代码看似简洁,但是随着业务逻辑不断增加,错误处理的模板代码也会大量重复。

为了减少重复,我们可以定义自己的 HTTP appHandler 类型,其中包含一个错误返回值:

go 复制代码
type appHandler func(http.ResponseWriter, *http.Request) error

然后将 viewRecord 函数修改如下,只需要返回 error,不包含错误处理逻辑:

go 复制代码
func viewRecord(w http.ResponseWriter, r *http.Request) error {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return err
    }
    return viewTemplate.Execute(w, record)
}

这样看起来比原版代码更简单。下一步我们给 appHandler 实现 http.Handler 接口的 ServeHTTP 方法,进行统一异常处理:

go 复制代码
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 在 `ServeHTTP` 方法中调用 `appHandler`
	// 注意此时的 receiver `fn` 是函数类型
    if err := fn(w, r); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

最后,我们给 http 包注册 viewRecord 需要改用 Handle 函数(而不是 HandleFunc),因为 appHandler 是一个 http.Handler 类型(而不是 http.HandlerFunc)。

go 复制代码
func init() {
    http.Handle("/view", appHandler(viewRecord))
}

怎么理解这段代码,viewRecord 函数的签名实际上和 appHandler 是一致的,因此 appHandler(viewRecord) 这句实际上是将 viewRecord 转为 appHandler 类型,这样就拥有了 appHandlerServeHTTP 方法,即实现了 http.Handler 接口。

个人理解下面这段代码也是可以的:

go 复制代码
var viewRecordHandle appHandler = viewRecord

有了这个基本的错误处理结构,还可以进一步优化,根据不同错误类型,响应对应的状态码(而不是只展示错误字符串),对用户更友好,同时还可以在控制台打印完整的错误信息。

我们可以定义一个 appError 结构体,包含 error 等字段:

go 复制代码
type appError struct {
    Error   error
    Message string
    Code    int
}

下一步我们将 appHandler 的返回类型改为 *appError

go 复制代码
type appHandler func(http.ResponseWriter, *http.Request) *appError

需要注意的是,在 Go 语言中推荐返回 error 类型,而不是具体的错误类型(可以参考 Go FAQ 的讨论)。但是在这个 demo 中可以这样做,因为 ServeHTTP 是唯一消费 error 的地方

然后让 appHandlerServeHTTP 方法用正确的 HTTP 状态码向用户显示 appError 的消息,并将完整的 Error 记录到开发者控制台:

go 复制代码
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if e := fn(w, r); e != nil { // e is *appError, not os.Error.
        c := appengine.NewContext(r)
        c.Errorf("%v", e.Error)
        http.Error(w, e.Message, e.Code)
    }
}

最后,更新 viewRecord 函数签名,并让它在遇到错误时返回更多上下文:

go 复制代码
func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return &appError{err, "Record not found", 404}
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        return &appError{err, "Can't display record", 500}
    }
    return nil
}

这个版本的 viewRecord 与原来的版本长度相同,但是现在每一行都有特定的含义,我们提供了更友好的用户体验。

3、浅析 net/http 包设计

上面 error handle 逻辑,如果按网上所说xx设计模式来说的话,其实就是用到了装饰器模式

有同学会问,前面提到的 http.HandleServeHTTP 是啥,这里顺便介绍一下 Go 语言标准库 net/http 包。

net/http 包中,处理请求核心数据结构是 ServeMux(服务复用器):

go 复制代码
type ServeMux struct {  
	mu sync.RWMutex // 读写锁,保证并发安全,注册处理器时会加写锁做保护
	m map[string]muxEntry // 路由规则,一个string对应一个mux实体,这里的string就是注册的路由表达式
	es []muxEntry // slice of entries sorted from longest to shortest.  
	hosts bool // whether any patterns contain hostnames  
}

上面的 muxEntry 定义如下(其实就是包含了 patternhandler):

go 复制代码
type muxEntry struct {  
	h Handler  
	pattern string  
}

当我们调用 NewServeMux 函数,就会创建一个新的 ServeMux 实例:

go 复制代码
// NewServeMux allocates and returns a new ServeMux.  
func NewServeMux() *ServeMux { return new(ServeMux) }

ServeMux 定义了一些 public 方法,其中包括 HandleHandleFunc 用来注册路由。我们先看一下 Handle 的实现,这是路由注册核心方法:

go 复制代码
// Handle registers the handler for the given pattern.
// If a handler already exists for pattern, Handle panics.
func (mux *ServeMux) Handle(pattern string, handler Handler) {
	// 加锁,保证并发安全
	mux.mu.Lock()  
	defer mux.mu.Unlock()  

	// 参数校验
	if pattern == "" {  
		panic("http: invalid pattern")  
	}  
	if handler == nil {  
		panic("http: nil handler")  
	}  
	// 不允许路由重复注册
	if _, exist := mux.m[pattern]; exist {  
		panic("http: multiple registrations for " + pattern)  
	}  

	// 首次调用时 map 为 nil
	// 先给 map 进行初始化
	if mux.m == nil {  
		mux.m = make(map[string]muxEntry)  
	}  
	e := muxEntry{h: handler, pattern: pattern}  
	// map存储路由和处理函数的映射
	mux.m[pattern] = e  
	
	// 如果路由最后加了`/`放入到切片后在路由匹配时做前缀匹配
	if pattern[len(pattern)-1] == '/' {  
		mux.es = appendSorted(mux.es, e)  
	}  
	
	// 如果路由第一位不是/,则认为注册的路由加上了host,所以在路由匹配时使用host+path进行匹配
	if pattern[0] != '/' {  
		mux.hosts = true  
	}  
}

上面涉及到一个 Handler 接口,我们看下定义:

go 复制代码
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)  
}

为啥说 Handle 是注册路由核心方法,我们常用的 HandleFunc 其实只是一个 shorthand,最终还是通过 Handle 注册路由:

go 复制代码
// HandleFunc registers the handler function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {  
	if handler == nil {  
		panic("http: nil handler")  
	}  
	mux.Handle(pattern, HandlerFunc(handler))  
}

上面代码,HandleFunc 第二个参数 handler 是函数类型,在调用 mux.Handle 方法的时候,用 HandlerFunc 进行类型转换。可以看到,最终还是转换为 Handler 接口类型:

go 复制代码
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function  
// with the appropriate signature, HandlerFunc(f) is a  
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)  
  
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {  
	f(w, r)  
}

我们实际在用 net/http 库的时候,不需要手动调用 http.NewServeMux() 去创建实例,而是可以直接用 http.HandleFunc 注册路由和处理函数。因为 net/http 内部已经创建了默认的 DefaultServeMux 实例。当我们调用 http.HandleFunchttp.Handle 会直接在 DefaultServeMux 上面注册路由:

go 复制代码
// DefaultServeMux is the default ServeMux used by Serve.  
var DefaultServeMux = &defaultServeMux  
  
var defaultServeMux ServeMux

// Handle registers the handler for the given pattern
// in the DefaultServeMux.  
// The documentation for ServeMux explains how patterns are matched.
func Handle(pattern string, handler Handler) { DefaultServeMux.Handle(pattern, handler) }  
  
// HandleFunc registers the handler function for the given pattern
// in the DefaultServeMux.  
// The documentation for ServeMux explains how patterns are matched.
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {  
	DefaultServeMux.HandleFunc(pattern, handler)  
}

以上就是路由注册的核心逻辑,下面来看下端口监听逻辑。

net/http 库提供了 ListenAndServe() 用来监听TCP连接并处理请求:

go 复制代码
// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.  
//  
// The handler is typically nil, in which case the DefaultServeMux is used.
//  
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {  
	server := &Server{Addr: addr, Handler: handler}  
	return server.ListenAndServe()  
}

上面代码,实际上是创建了一个 Server 实例,然后调用实例上的 ListenAndServe 方法监听端口。需要注意,Server 结构体是 public 的,因此某些场景下,我们需要更灵活的创建 server,可以自己创建 Server 实例,然后调用 ListenAndServe,而不是直接调用 http.ListenAndServe

当我们用默认 DefaultServeMux 实例的时候,ListenAndServe 第二个参数通常都会是 nil;但是当我们自己创建 ServeMux 的时候,会作为第二个参数传入,因此实际上 ServeMux 也实现了 Handler 接口

Server 实例的 ListenAndServe 方法内部,进行tcp连接,这里包含了创建socket、bind绑定socket与地址:

go 复制代码
// ListenAndServe listens on the TCP network address srv.Addr and then
// calls Serve to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.  
//  
// If srv.Addr is blank, ":http" is used.  
//  
// ListenAndServe always returns a non-nil error. After Shutdown or Close,  
// the returned error is ErrServerClosed.  
func (srv *Server) ListenAndServe() error {  
	if srv.shuttingDown() {  
		return ErrServerClosed  
	}  
	addr := srv.Addr  
	if addr == "" {  
		addr = ":http"  
	}  
	ln, err := net.Listen("tcp", addr)  
	if err != nil {  
		return err  
	}  
	return srv.Serve(ln)  
}

最后调用Serve方法循环等待客户端的请求:

go 复制代码
func (srv *Server) Serve(l net.Listener) error {  
  
 origListener := l  
 l = &onceCloseListener{Listener: l}  
 defer l.Close()  
  
 if err := srv.setupHTTP2_Serve(); err != nil {  
  return err  
 }  
  
 if !srv.trackListener(&l, true) {  
  return ErrServerClosed  
 }  
 defer srv.trackListener(&l, false)  
  
 baseCtx := context.Background()  
 if srv.BaseContext != nil {  
  baseCtx = srv.BaseContext(origListener)  
  if baseCtx == nil {  
   panic("BaseContext returned a nil context")  
  }  
 }  
  
 var tempDelay time.Duration // how long to sleep on accept failure  
  
 ctx := context.WithValue(baseCtx, ServerContextKey, srv)  
 for {  
  // 接收客户端请求  
  rw, err := l.Accept()  
  if err != nil {  
   select {  
   case <-srv.getDoneChan():  
    return ErrServerClosed  
   default:  
   }  
   // 网络错误进行延时等待  
   if ne, ok := err.(net.Error); ok && ne.Temporary() {  
    if tempDelay == 0 {  
     tempDelay = 5 * time.Millisecond  
    } else {  
     tempDelay *= 2  
    }  
    if max := 1 * time.Second; tempDelay > max {  
     tempDelay = max  
    }  
    srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)  
    time.Sleep(tempDelay)  
    continue  
   }  
   return err  
  }  
  connCtx := ctx  
  if cc := srv.ConnContext; cc != nil {  
   connCtx = cc(connCtx, rw)  
   if connCtx == nil {  
    panic("ConnContext returned nil")  
   }  
  }  
  tempDelay = 0  
  // 创建一个新的连接  
  c := srv.newConn(rw)  
  c.setState(c.rwc, StateNew, runHooks) // before Serve can return  
  // 读取起一个goroutine处理客户端请求  
  go c.serve(connCtx)  
 }  
}

从上述代码我们可以到每个HTTP请求服务端都会单独创建一个goroutine来处理请求,我们可以看下处理过程:

go 复制代码
// Serve a new connection.  
func (c *conn) serve(ctx context.Context) {  
 c.remoteAddr = c.rwc.RemoteAddr().String()  
 ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())  
 var inFlightResponse *response  
 defer func() {  
  // 添加recover函数防止panic引发主程序挂掉;  
  if err := recover(); err != nil && err != ErrAbortHandler {  
   const size = 64 << 10  
   buf := make([]byte, size)  
   buf = buf[:runtime.Stack(buf, false)]  
   c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)  
  }  
 }()  
  
  
 // HTTP/1.x from here on.  
 ctx, cancelCtx := context.WithCancel(ctx)  
 c.cancelCtx = cancelCtx  
 defer cancelCtx()  
  
 c.r = &connReader{conn: c}  
 c.bufr = newBufioReader(c.r)  
 c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)  
  
 for {  
  // 读取请求,从连接中获取HTTP请求并构建一个实现了`net/http.Conn.ResponseWriter`接口的变量`net/http.response`  
  w, err := c.readRequest(ctx)  
  if c.r.remain != c.server.initialReadLimitSize() {  
   c.setState(c.rwc, StateActive, runHooks)  
  }  
  if err != nil {  
  }  
  // 处理请求  
  serverHandler{c.server}.ServeHTTP(w, w.req)  
 }  
}

我们继续跟踪 ServeHTTP 方法,ServeMux是一个HTTP请求的多路复用器,在这里可以根据请求的URL匹配合适的处理器,我们看代码:

go 复制代码
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {  
 if r.RequestURI == "*" {  
  if r.ProtoAtLeast(1, 1) {  
   w.Header().Set("Connection", "close")  
  }  
  w.WriteHeader(StatusBadRequest)  
  return  
 }  
 // 进行路由匹配,获取注册的处理函数  
 h, _ := mux.Handler(r)  
 // 这块就是执行我们注册的handler,也就是例子中的getProfile()  
 h.ServeHTTP(w, r)  
}

mux.Handler() 中我们就看到了路由匹配的代码:

go 复制代码
func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {  
 mux.mu.RLock()  
 defer mux.mu.RUnlock()  
  
 // Host-specific pattern takes precedence over generic ones  
 if mux.hosts {  
  h, pattern = mux.match(host + path)  
 }  
 if h == nil {  
  h, pattern = mux.match(path)  
 }  
 if h == nil {  
  h, pattern = NotFoundHandler(), ""  
 }  
 return  
}  
func (mux *ServeMux) match(path string) (h Handler, pattern string) {  
 // 先从map中查找  
 v, ok := mux.m[path]  
 if ok {  
  // 找到了返回注册的函数  
  return v.h, v.pattern  
 }  
  
 // 从切片中进行前缀匹配  
 for _, e := range mux.es {  
  if strings.HasPrefix(path, e.pattern) {  
   return e.h, e.pattern  
  }  
 }  
 return nil, ""  
}

总结一下,这里代码看主逻辑主要是看两部分,一个是注册处理器,标准库使用map进行存储,本质是一个静态索引,同时维护了一个切片,用来做前缀匹配,只要以 / 结尾的,都会在切片中存储;服务端监听端口本质也是使用net网络库进行TCP连接,然后监听对应的TCP连接,每一个HTTP请求都会开一个goroutine去处理请求,所以如果有海量请求,会在一瞬间创建大量的goroutine,这个可能是一个性能瓶颈点,所以小伙伴要注意下这块的性能问题。

参考

# Error handling and Go

# Golang Error Handling --- Best Practice in 2020

# Go Best Practices --- Error handling

相关推荐
好奇的菜鸟6 分钟前
Go语言中的引用类型:指针与传递机制
开发语言·后端·golang
Alive~o.015 分钟前
Go语言进阶&依赖管理
开发语言·后端·golang
许苑向上20 分钟前
Dubbo集成SpringBoot实现远程服务调用
spring boot·后端·dubbo
郑祎亦1 小时前
Spring Boot 项目 myblog 整理
spring boot·后端·java-ee·maven·mybatis
本当迷ya1 小时前
💖2025年不会Stream流被同事排挤了┭┮﹏┭┮(强烈建议实操)
后端·程序员
计算机毕设指导62 小时前
基于 SpringBoot 的作业管理系统【附源码】
java·vue.js·spring boot·后端·mysql·spring·intellij-idea
paopaokaka_luck3 小时前
[371]基于springboot的高校实习管理系统
java·spring boot·后端
捂月4 小时前
Spring Boot 深度解析:快速构建高效、现代化的 Web 应用程序
前端·spring boot·后端
煎鱼eddycjy4 小时前
新提案:由迭代器启发的 Go 错误函数处理
go
煎鱼eddycjy4 小时前
Go 语言十五周年!权力交接、回顾与展望
go