発端

Go のアプリケーションでエラーを Sentry に送っているのだが、スタックトレースが表示されなくて調査が困ったときがあった。
どうすればスタックトレースを送れるのだろうと気になって調べたところ、sentry-go では次の 3 つのパッケージを使って errors を扱っている場合にのみ、スタックトレースを取得できるらしい。

これらのパッケージはスタックトレースを保持する仕組みを持っており(errors.WithStack() など)、sentry-go でそれを取得している。
これはどうやって実装しているのだろうと気になったので読んでみたところ、reflect パッケージでメソッド定義を調べ、動的に呼び出していた。
こんなメタプロっぽいことができるのかーと学びになったので、そのメモです。

sentry-go での実装

sentry-go では次のような実装になっている。各パッケージで定義されている「スタックトレースを取得する関数」を MethodByName() で取得・呼び出しをしている。

// https://github.com/getsentry/sentry-go/blob/77f95170e7f35f5c2c16bd353bed0e99a3696c82/stacktrace.go#L74-L101

func extractReflectedStacktraceMethod(err error) reflect.Value {
	errValue := reflect.ValueOf(err)

	// https://github.com/go-errors/errors
	methodStackFrames := errValue.MethodByName("StackFrames")
	if methodStackFrames.IsValid() {
		return methodStackFrames
	}

	// https://github.com/pkg/errors
	methodStackTrace := errValue.MethodByName("StackTrace")
	if methodStackTrace.IsValid() {
		return methodStackTrace
	}

	// https://github.com/pingcap/errors
	methodGetStackTracer := errValue.MethodByName("GetStackTracer")
	if methodGetStackTracer.IsValid() {
		stacktracer := methodGetStackTracer.Call(nil)[0]
		stacktracerStackTrace := reflect.ValueOf(stacktracer).MethodByName("StackTrace")

		if stacktracerStackTrace.IsValid() {
			return stacktracerStackTrace
		}
	}

	return reflect.Value{}
}

構造体のメソッドを取得して呼び出す

簡単に動作を確認してみる。
次のコードは、 構造体 S に メソッド M が定義されているかどうかを MethodByName() で調べるコード。

package main

import (
	"context"
	"fmt"
	"reflect"
)

type S struct{}

func (s S) M(ctx context.Context, msg string) error {
	fmt.Printf("Called M(): msg with %s\n", msg)
	return fmt.Errorf("this method is not implemented yet")
}

func main() {
	s := &S{}
	v := reflect.ValueOf(s)

	m := v.MethodByName("M")

	fmt.Printf("Method: %#v\n", m)

	if m.IsValid() && m.Kind() == reflect.Func {
		fmt.Println("Method found")
	}
}

実行結果は次の通り。確かに、定義されていることを確認できる。

// Output
Method: (func(context.Context, string) error)(0x105068720)
Method found

メソッド M を呼び出すには、Call() を使う。定義を見れば分かるように、引数も戻り値も []reflect.Value であることに注意。

package main

import (
	"context"
	"fmt"
	"reflect"
)

type S struct{}

func (s S) M(ctx context.Context, msg string) error {
	fmt.Printf("Called M(): msg with %s\n", msg)
	return fmt.Errorf("this method is not implemented yet")
}

func main() {
	s := &S{}
	v := reflect.ValueOf(s)

	m := v.MethodByName("M")

	fmt.Printf("Method: %#v\n", m)

	if m.IsValid() && m.Kind() == reflect.Func {
		result := m.Call([]reflect.Value{reflect.ValueOf(context.Background()), reflect.ValueOf("Hello")})

		if len(result) > 0 {
			err := result[0].Interface()
			if err != nil {
				fmt.Printf("Error: %s\n", err)
			}
		}
	}
}

実行結果は次の通り。メソッドが呼び出されていることが確認できる。

Method: (func(context.Context, string) error)(0x101008a80)
Called M(): msg with Hello
Error: this method is not implemented yet

Outro

Ruby でいう Object#send みたいなことをができて便利。
GitHub を見たら自分が知らなかっただけで、結構利用されていそう。フックとかもこの仕組みで実現しているソフトウェアもあった。
調べている途中に https://github.com/a8m/reflect-examples を見つけて知ったが、Implements() なども便利そう。