From 56f3e71acd8207e8cb9a284b063f032cc8f6191c Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Sun, 13 Jan 2019 20:08:38 -0500 Subject: [PATCH] merr: initial implementation, designed to replace merry and work nicely with mlog, while allowing support for multi-errors and other niceties in the future --- merr/kv.go | 73 ++++++++++++++++++++ merr/kv_test.go | 69 +++++++++++++++++++ merr/merr.go | 166 +++++++++++++++++++++++++++++++++++++++++++++ merr/merr_test.go | 41 +++++++++++ merr/stack.go | 85 +++++++++++++++++++++++ merr/stack_test.go | 47 +++++++++++++ 6 files changed, 481 insertions(+) create mode 100644 merr/kv.go create mode 100644 merr/kv_test.go create mode 100644 merr/merr.go create mode 100644 merr/merr_test.go create mode 100644 merr/stack.go create mode 100644 merr/stack_test.go diff --git a/merr/kv.go b/merr/kv.go new file mode 100644 index 0000000..91fd4c7 --- /dev/null +++ b/merr/kv.go @@ -0,0 +1,73 @@ +package merr + +import ( + "fmt" + "path/filepath" +) + +// not really used for attributes, but w/e +const attrKeyErr attrKey = "err" +const attrKeyErrSrc attrKey = "errSrc" + +// KVer implements the mlog.KVer interface. This is defined here to avoid this +// package needing to actually import mlog. +type KVer struct { + kv map[string]interface{} +} + +// KV implements the mlog.KVer interface. +func (kv KVer) KV() map[string]interface{} { + return kv.kv +} + +// KV returns a KVer which contains all visible values embedded in the error, as +// well as the original error string itself. Keys will be turned into strings +// using the fmt.Sprint function. +// +// If any keys conflict then their type information will be included as part of +// the key. +func KV(e error) KVer { + er := wrap(e, false, 1) + kvm := make(map[string]interface{}, len(er.attr)+1) + + keys := map[string]interface{}{} // in this case the value is the raw key + setKey := func(k, v interface{}) { + kStr := fmt.Sprint(k) + oldKey := keys[kStr] + if oldKey == nil { + keys[kStr] = k + kvm[kStr] = v + return + } + + // check if oldKey is in kvm, if so it needs to be moved to account for + // its type info + if oldV, ok := kvm[kStr]; ok { + delete(kvm, kStr) + kvm[fmt.Sprintf("%T(%s)", oldKey, kStr)] = oldV + } + + kvm[fmt.Sprintf("%T(%s)", k, kStr)] = v + } + + setKey(attrKeyErr, er.err.Error()) + for k, v := range er.attr { + if !v.visible { + continue + } + + stack, ok := v.val.(Stack) + if !ok { + setKey(k, v.val) + continue + } + + // compress the stack trace to just be the top-most frame + frame := stack.Frame() + file, dir := filepath.Base(frame.File), filepath.Dir(frame.File) + dir = filepath.Base(dir) // only want the first dirname, ie the pkg name + setKey(attrKeyErrSrc, fmt.Sprintf("%s/%s:%d", dir, file, frame.Line)) + } + + return KVer{kvm} +} diff --git a/merr/kv_test.go b/merr/kv_test.go new file mode 100644 index 0000000..483b943 --- /dev/null +++ b/merr/kv_test.go @@ -0,0 +1,69 @@ +package merr + +import ( + "strings" + . "testing" + + "github.com/mediocregopher/mediocre-go-lib/mtest/massert" +) + +func TestKV(t *T) { + er := New("foo") + kv := KV(er).KV() + massert.Fatal(t, massert.Comment( + massert.All( + massert.Len(kv, 2), + massert.Equal("foo", kv["err"]), + massert.Equal(true, + strings.HasPrefix(kv["errSrc"].(string), "merr/kv_test.go:")), + ), + "kv: %#v", kv, + )) + + type A string + type B string + type C string + + er = WithValue(er, "invisible", "you can't see me", false) + er = WithValue(er, A("k"), "1", true) + kv = KV(er).KV() + massert.Fatal(t, massert.Comment( + massert.All( + massert.Len(kv, 3), + massert.Equal("foo", kv["err"]), + massert.Equal(true, + strings.HasPrefix(kv["errSrc"].(string), "merr/kv_test.go:")), + massert.Equal("1", kv["k"]), + ), + "kv: %#v", kv, + )) + + er = WithValue(er, B("k"), "2", true) + kv = KV(er).KV() + massert.Fatal(t, massert.Comment( + massert.All( + massert.Len(kv, 4), + massert.Equal("foo", kv["err"]), + massert.Equal(true, + strings.HasPrefix(kv["errSrc"].(string), "merr/kv_test.go:")), + massert.Equal("1", kv["merr.A(k)"]), + massert.Equal("2", kv["merr.B(k)"]), + ), + "kv: %#v", kv, + )) + + er = WithValue(er, C("k"), "3", true) + kv = KV(er).KV() + massert.Fatal(t, massert.Comment( + massert.All( + massert.Len(kv, 5), + massert.Equal("foo", kv["err"]), + massert.Equal(true, + strings.HasPrefix(kv["errSrc"].(string), "merr/kv_test.go:")), + massert.Equal("1", kv["merr.A(k)"]), + massert.Equal("2", kv["merr.B(k)"]), + massert.Equal("3", kv["merr.C(k)"]), + ), + "kv: %#v", kv, + )) +} diff --git a/merr/merr.go b/merr/merr.go new file mode 100644 index 0000000..e7eff4c --- /dev/null +++ b/merr/merr.go @@ -0,0 +1,166 @@ +// Package merr extends the errors package with features like key-value +// attributes for errors, embedded stacktraces, and multi-errors. +package merr + +import ( + "errors" + "fmt" + "sort" + "strings" + "sync" +) + +var strBuilderPool = sync.Pool{ + New: func() interface{} { return new(strings.Builder) }, +} + +func putStrBuilder(sb *strings.Builder) { + sb.Reset() + strBuilderPool.Put(sb) +} + +//////////////////////////////////////////////////////////////////////////////// + +type val struct { + visible bool + val interface{} +} + +func (v val) String() string { + return fmt.Sprint(v.val) +} + +type err struct { + err error + attr map[interface{}]val +} + +// attr keys internal to this package +type attrKey string + +func wrap(e error, cp bool, skip int) *err { + er, ok := e.(*err) + if !ok { + er := &err{err: e, attr: map[interface{}]val{}} + if skip >= 0 { + setStack(er, skip+1) + } + return er + } else if !cp { + return er + } + + er2 := &err{ + err: er.err, + attr: make(map[interface{}]val, len(er.attr)), + } + for k, v := range er.attr { + er2.attr[k] = v + } + if _, ok := er2.attr[attrKeyStack]; !ok && skip >= 0 { + setStack(er, skip+1) + } + + return er2 +} + +// Wrap takes in an error and returns one wrapping it in merr's inner type, +// which embeds information like the stack trace. +func Wrap(e error) error { + return wrap(e, false, 1) +} + +// New returns a new error with the given string as its error string. New +// automatically wraps the error in merr's inner type, which embeds information +// like the stack trace. +func New(str string) error { + return wrap(errors.New(str), false, 1) +} + +// Errorf is like New, but allows for formatting of the string. +func Errorf(str string, args ...interface{}) error { + return wrap(fmt.Errorf(str, args...), false, 1) +} + +func (er *err) visibleAttrs() [][2]string { + out := make([][2]string, 0, len(er.attr)) + for k, v := range er.attr { + if !v.visible { + continue + } + out = append(out, [2]string{ + strings.Trim(fmt.Sprintf("%q", k), `"`), + fmt.Sprint(v.val), + }) + } + + sort.Slice(out, func(i, j int) bool { + return out[i][0] < out[j][0] + }) + + return out +} + +func (er *err) Error() string { + visAttrs := er.visibleAttrs() + if len(visAttrs) == 0 { + return er.err.Error() + } + + sb := strBuilderPool.Get().(*strings.Builder) + defer putStrBuilder(sb) + + sb.WriteString(strings.TrimSpace(er.err.Error())) + for _, attr := range visAttrs { + k, v := strings.TrimSpace(attr[0]), strings.TrimSpace(attr[1]) + sb.WriteString("\n\t* ") + sb.WriteString(k) + sb.WriteString(": ") + + // if there's no newlines then print v inline with k + if strings.Index(v, "\n") < 0 { + sb.WriteString(v) + continue + } + + for _, vLine := range strings.Split(v, "\n") { + sb.WriteString("\n\t\t") + sb.WriteString(strings.TrimSpace(vLine)) + } + } + + return sb.String() +} + +// WithValue returns a copy of the original error, automatically wrapping it if +// the error is not from merr (see Wrap). The returned error has a value set on +// with for the given key. +// +// visible determines whether or not the value is visible in the output of +// Error. +func WithValue(e error, k, v interface{}, visible bool) error { + er := wrap(e, true, 1) + er.attr[k] = val{val: v, visible: visible} + return er +} + +// GetValue returns the value embedded in the error for the given key, or nil if +// the error isn't from this package or doesn't have that key embedded. +func GetValue(e error, k interface{}) interface{} { + return wrap(e, false, -1).attr[k].val +} + +// Base takes in an error and checks if it is merr's internal error type. If it +// is then the underlying error which is being wrapped is returned. If it's not +// then the passed in error is returned as-is. +func Base(e error) error { + if er, ok := e.(*err); ok { + return er.err + } + return e +} + +// Equal is a shortcut for Base(e1) == Base(e2). +func Equal(e1, e2 error) bool { + return Base(e1) == Base(e2) +} diff --git a/merr/merr_test.go b/merr/merr_test.go new file mode 100644 index 0000000..bc765a1 --- /dev/null +++ b/merr/merr_test.go @@ -0,0 +1,41 @@ +package merr + +import ( + "errors" + . "testing" + + "github.com/mediocregopher/mediocre-go-lib/mtest/massert" +) + +func TestError(t *T) { + er := &err{ + err: errors.New("foo"), + attr: map[interface{}]val{ + "a": val{val: "aaa aaa\n", visible: true}, + "b": val{val: "invisible"}, + "c": val{val: "ccc\nccc\n", visible: true}, + "d\t": val{val: "weird key but ok", visible: true}, + }, + } + str := er.Error() + exp := `foo + * a: aaa aaa + * c: + ccc + ccc + * d\t: weird key but ok` + massert.Fatal(t, massert.Equal(exp, str)) +} + +func TestBase(t *T) { + errFoo, errBar := errors.New("foo"), errors.New("bar") + erFoo := Wrap(errFoo) + massert.Fatal(t, massert.All( + massert.Equal(errFoo, Base(erFoo)), + massert.Equal(errBar, Base(errBar)), + massert.Not(massert.Equal(errFoo, erFoo)), + massert.Not(massert.Equal(errBar, Base(erFoo))), + massert.Equal(true, Equal(errFoo, erFoo)), + massert.Equal(false, Equal(errBar, erFoo)), + )) +} diff --git a/merr/stack.go b/merr/stack.go new file mode 100644 index 0000000..e97ed9a --- /dev/null +++ b/merr/stack.go @@ -0,0 +1,85 @@ +package merr + +import ( + "fmt" + "runtime" + "strings" + "text/tabwriter" +) + +// MaxStackSize indicates the maximum number of stack frames which will be +// stored when embedding stack traces in errors. +var MaxStackSize = 50 + +const attrKeyStack attrKey = "stack" + +// Stack represents a stack trace at a particular point in execution. +type Stack []uintptr + +// Frame returns the first frame in the stack. +func (s Stack) Frame() runtime.Frame { + if len(s) == 0 { + panic("cannot call Frame on empty stack") + } + + frame, _ := runtime.CallersFrames([]uintptr(s)).Next() + return frame +} + +// Frames returns all runtime.Frame instances for this stack. +func (s Stack) Frames() []runtime.Frame { + if len(s) == 0 { + return nil + } + + out := make([]runtime.Frame, 0, len(s)) + frames := runtime.CallersFrames([]uintptr(s)) + for { + frame, more := frames.Next() + out = append(out, frame) + if !more { + break + } + } + return out +} + +// String returns the full stack trace. +func (s Stack) String() string { + sb := strBuilderPool.Get().(*strings.Builder) + defer putStrBuilder(sb) + tw := tabwriter.NewWriter(sb, 0, 4, 4, ' ', 0) + for _, frame := range s.Frames() { + file := fmt.Sprintf("%s:%d", frame.File, frame.Line) + fmt.Fprintf(tw, "%s\t%s\n", file, frame.Function) + } + if err := tw.Flush(); err != nil { + panic(err) + } + return sb.String() +} + +func setStack(er *err, skip int) { + stackSlice := make([]uintptr, MaxStackSize) + // incr skip once for setStack, and once for runtime.Callers + l := runtime.Callers(skip+2, stackSlice) + er.attr[attrKeyStack] = val{val: Stack(stackSlice[:l]), visible: true} +} + +// WithStack returns a copy of the original error, automatically wrapping it if +// the error is not from merr (see Wrap). The returned error has the embedded +// stacktrace set to the frame calling this function. +// +// skip can be used to exclude that many frames from the top of the stack. +func WithStack(e error, skip int) error { + er := wrap(e, true, -1) + setStack(er, skip+1) + return er +} + +// GetStack returns the Stack which was embedded in the error, if the error is +// from this package. If not then nil is returned. +func GetStack(e error) Stack { + stack, _ := wrap(e, false, -1).attr[attrKeyStack].val.(Stack) + return stack +} diff --git a/merr/stack_test.go b/merr/stack_test.go new file mode 100644 index 0000000..5491942 --- /dev/null +++ b/merr/stack_test.go @@ -0,0 +1,47 @@ +package merr + +import ( + "strings" + . "testing" + + "github.com/mediocregopher/mediocre-go-lib/mtest/massert" +) + +func TestStack(t *T) { + foo := New("foo") + fooStack := GetStack(foo) + + // test Frame + frame := fooStack.Frame() + massert.Fatal(t, massert.All( + massert.Equal(true, strings.Contains(frame.File, "stack_test.go")), + massert.Equal(true, strings.Contains(frame.Function, "TestStack")), + )) + + frames := fooStack.Frames() + massert.Fatal(t, massert.Comment( + massert.All( + massert.Equal(true, len(frames) >= 2), + massert.Equal(true, strings.Contains(frames[0].File, "stack_test.go")), + massert.Equal(true, strings.Contains(frames[0].Function, "TestStack")), + ), + "fooStack.String():\n%s", fooStack.String(), + )) + + // test that WithStack works and can be used to skip frames + inner := func() { + bar := WithStack(foo, 1) + barStack := GetStack(bar) + frames := barStack.Frames() + massert.Fatal(t, massert.Comment( + massert.All( + massert.Equal(true, len(frames) >= 2), + massert.Equal(true, strings.Contains(frames[0].File, "stack_test.go")), + massert.Equal(true, strings.Contains(frames[0].Function, "TestStack")), + ), + "barStack.String():\n%s", barStack.String(), + )) + } + inner() + +}