Go errors 堆栈

相对于 C 语言来说, Go 在处理传统的逻辑错误上确实略高一筹. 至少一个 func 返回 error, 我们知道需要处理并且偶尔会进行传递, 而不是干巴巴等着运行时崩溃.

Errors 也是值

我们每个人在学习 Go 的时候都被这样说服: Errors are values, 错误也是一种值, 就像其他任何类型的值一样.

我们先看一个简单的例子:

// https://play.golang.org/p/VFVX0fRKBnS
package main

import (
"fmt"
"strconv"
)

func main() {
if _, err := strconv.ParseInt("abcd", 10, 64); err != nil {
fmt.Println(err)
}
}
// 输出
// strconv.ParseInt: parsing "abcd": invalid syntax

看起来好像还不错, 起码我们知道 3 个内容:

  • 错误发生的 func: strconv.ParseInt
  • 发生错误 func 的参数: abcd
  • 错误的原因: invalid syntax

如果查看 strconv.ParseInt 的源码你会发现, 代码如标准库者依然没有使用最原始的 error 返回. 它之所以打印出上面的 3 个内容, 是因为使用了自定义的 NumError :

// A NumError records a failed conversion.
type NumError struct {
Func string // the failing function (ParseBool, ParseInt, ParseUint, ParseFloat)
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()
}

如果我们领会了 Errors are values 的精神, 基本上能写出这样的错误处理已经很符合 不要仅仅检查 errors, 优雅的处理它们 的宗旨了. 本文在这里也可能就结束了, 然后将标题改为: Go errors 指南 \ (•◡•) /.

Errors 堆栈跟踪

不, 这不够啊, 对于标准库可能每个错误都像那样模版式搞个花式自定义 error . 如果我们用过 Python 或者 Java 的 try, 不会不知道异常(可能有人在这里跟我掰 Go 里面 errors 不是异常)发生的时候, 打印行号等关键信息有多重要吧.

上面的 ParseInt 例子只是个简单的不能再简单的例子, 如果我们的工程和代码复杂度都上一个层次, 一个 func 里面可能需要处理多个第三方 func 返回的 errors. 一个简单的 errors 信息对于程序的调试并不友好. 当然你如果确保了像标准库那样给出了那样翔实的 errors 内容, 倒也不错. 即便是这样, 我觉得依然没有发生 errors 时给出文件和行号来得实用一些.

实现这个功能需要用到 Go 强大的 runtime 包, 我们尝试自己实现一个简单的自定义 errors 堆栈跟踪:

// https://play.golang.org/p/-tesfXuy9fc
package main

import (
"fmt"
"runtime"
"strings"
)

func callers() []uintptr {
var pcs [32]uintptr
n := runtime.Callers(3, pcs[:])
st := pcs[0:n]
return st
}

type trace struct {
m string
s []uintptr
}

func (e *trace) Error() string {
var b strings.Builder
b.WriteString(e.m)
b.WriteString("\n\n")
b.WriteString("Traceback:")
for _, pc := range e.s {
fn := runtime.FuncForPC(pc)
b.WriteString("\n")
f, n := fn.FileLine(pc)
b.WriteString(fmt.Sprintf("%s:%d", f, n))
}
return b.String()
}

// NewTrace creates a simple traceable error.
func NewTrace(message string) error {
return &trace{m: message, s: callers()}
}

func f() error {
return NewTrace("ooops")
}

func main() {
fmt.Println(f())
}

// goplay 输出
// ooops

// Traceback:
// /tmp/sandbox315155193/main.go:41
// /tmp/sandbox315155193/main.go:45
// /usr/local/go/src/runtime/proc.go:207
// /usr/local/go/src/runtime/asm_amd64p32.s:968

虽然简单粗糙了一些, 但确实实现了我们要的简单 errors 堆栈跟踪.

社区实现(pkg/errors)

我们当然不打算在平常的代码中使用这样一个简单的实现, 我们这里介绍一下社区已经存在的 pkg/errors 库.

对于标准库或者第三方库的 errors 返回我们需要一个简单的 wrap, 以便在同样使用 pkg/errors 的地方可以获取一致的 errors 体验:

_, err := ioutil.ReadAll(r)
if err != nil {
return errors.Wrap(err, "read failed")
}

我们看看怎样使用 pkg/errors 得到的 errors:

package main

import (
"fmt"
"os"

"github.com/pkg/errors"
)

func parseArgs(args []string) error {
if len(args) < 3 {
return errors.Errorf("not enough arguments, expected at least 3, got %d", len(args))
}
return nil
}

func main() {
err := parseArgs(os.Args[1:])
fmt.Printf("%v\n", err)
}

// 输出
// not enough arguments, expected at least 3, got 0

什么 ? 没有堆栈信息打印 ? pkg/errors 默认的 flag 是不打印堆栈信息的(虽然一直包含). 我们施展一下 Go Formatter 的魔法:

// ...
func main() {
err := parseArgs(os.Args[1:])
fmt.Printf("%+v\n", err) // 没错, 加个 `+` flag
}

// 输出
// not enough arguments, expected at least 3, got 0
// main.parseArgs
// /Users/xxx/.go/src/github.com/gorocks/snippets/go/cmd/gosnippets/main.go:12
// main.main
// /Users/xxx/.go/src/github.com/gorocks/snippets/go/cmd/gosnippets/main.go:18
// runtime.main
// /usr/local/Cellar/go/1.10.1/libexec/src/runtime/proc.go:198
// runtime.goexit
// /usr/local/Cellar/go/1.10.1/libexec/src/runtime/asm_amd64.s:2361

之所以会有这个效果是因为 pkg/errors 实现了 Formatter 接口:

func (f *fundamental) Format(s fmt.State, verb rune) {
switch verb {
case 'v':
if s.Flag('+') {
io.WriteString(s, f.msg)
f.stack.Format(s, verb)
return
}
fallthrough
case 's':
io.WriteString(s, f.msg)
case 'q':
fmt.Fprintf(s, "%q", f.msg)
}
}

实现 Formatter 的一个好处是, 我们可以如使用标准库般始终如一, 没有多余的任何 func 调用.

Benchmark

pkg/errors 堆栈跟踪不是没有运行时开销的, 官方给出的指标是每个操作大约 1000-3000 ns.

一般来说这不会构成性能忧虑. 如果超过了你的性能预期, 可以定制成调试模式启用.

参考资料