前言
相信用过 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
函数和 viewTemplate
的 Execute
方法返回的错误。这段代码看似简洁,但是随着业务逻辑不断增加,错误处理的模板代码也会大量重复。
为了减少重复,我们可以定义自己的 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
类型,这样就拥有了 appHandler
的 ServeHTTP
方法,即实现了 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
的地方
然后让 appHandler
的 ServeHTTP
方法用正确的 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.Handle
和 ServeHTTP
是啥,这里顺便介绍一下 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
定义如下(其实就是包含了 pattern
和 handler
):
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 方法,其中包括 Handle
和 HandleFunc
用来注册路由。我们先看一下 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.HandleFunc
和 http.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,这个可能是一个性能瓶颈点,所以小伙伴要注意下这块的性能问题。