谈谈 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

相关推荐
安的列斯凯奇5 小时前
SpringBoot篇 单元测试 理论篇
spring boot·后端·单元测试
架构文摘JGWZ5 小时前
FastJson很快,有什么用?
后端·学习
BinaryBardC5 小时前
Swift语言的网络编程
开发语言·后端·golang
邓熙榆6 小时前
Haskell语言的正则表达式
开发语言·后端·golang
专职8 小时前
spring boot中实现手动分页
java·spring boot·后端
Ciderw9 小时前
Go中的三种锁
开发语言·c++·后端·golang·互斥锁·
m0_748246359 小时前
SpringBoot返回文件让前端下载的几种方式
前端·spring boot·后端
m0_748230449 小时前
创建一个Spring Boot项目
java·spring boot·后端
卿着飞翔9 小时前
Java面试题2025-Mysql
java·spring boot·后端
C++小厨神9 小时前
C#语言的学习路线
开发语言·后端·golang