refactor: plugin structure

This commit is contained in:
debugtalk
2022-01-17 15:18:30 +08:00
parent 24546e98e6
commit 675ded099d
8 changed files with 97 additions and 96 deletions

103
plugin/common/call.go Normal file
View File

@@ -0,0 +1,103 @@
package common
import (
"fmt"
"reflect"
"github.com/rs/zerolog/log"
)
// CallFunc calls function with arguments
func CallFunc(fn reflect.Value, args ...interface{}) (interface{}, error) {
argumentsValue, err := convertArgs(fn, args...)
if err != nil {
log.Error().Err(err).Msg("convert arguments failed")
return nil, err
}
return call(fn, argumentsValue)
}
func convertArgs(fn reflect.Value, args ...interface{}) ([]reflect.Value, error) {
fnArgsNum := fn.Type().NumIn()
// function arguments should match exactly if function's last argument is not slice
if len(args) != fnArgsNum && (fnArgsNum == 0 || fn.Type().In(fnArgsNum-1).Kind() != reflect.Slice) {
return nil, fmt.Errorf("function expect %d arguments, but got %d", fnArgsNum, len(args))
}
argumentsValue := make([]reflect.Value, len(args))
for index := 0; index < len(args); index++ {
argument := args[index]
if argument == nil {
argumentsValue[index] = reflect.Zero(fn.Type().In(index))
continue
}
argumentValue := reflect.ValueOf(argument)
actualArgumentType := reflect.TypeOf(argument)
var expectArgumentType reflect.Type
if (index == fnArgsNum-1 && fn.Type().In(fnArgsNum-1).Kind() == reflect.Slice) || index > fnArgsNum-1 {
// last fn argument is slice
expectArgumentType = fn.Type().In(fnArgsNum - 1).Elem() // slice element type
// last argument is also slice, e.g. []int
if actualArgumentType.Kind() == reflect.Slice {
if actualArgumentType.Elem() != expectArgumentType {
err := fmt.Errorf("function argument %d's slice element type is not match, expect %v, actual %v",
index, expectArgumentType, actualArgumentType)
return nil, err
}
argumentsValue[index] = argumentValue
continue
}
} else {
expectArgumentType = fn.Type().In(index)
}
// type match
if expectArgumentType == actualArgumentType {
argumentsValue[index] = argumentValue
continue
}
// type not match, check if convertible
if !actualArgumentType.ConvertibleTo(expectArgumentType) {
// function argument type not match and not convertible
err := fmt.Errorf("function argument %d's type is neither match nor convertible, expect %v, actual %v",
index, expectArgumentType, actualArgumentType)
return nil, err
}
// convert argument to expect type
argumentsValue[index] = argumentValue.Convert(expectArgumentType)
}
return argumentsValue, nil
}
func call(fn reflect.Value, args []reflect.Value) (interface{}, error) {
resultValues := fn.Call(args)
if resultValues == nil {
// no returns
return nil, nil
} else if len(resultValues) == 2 {
// return two arguments: interface{}, error
if resultValues[1].Interface() != nil {
return resultValues[0].Interface(), resultValues[1].Interface().(error)
} else {
return resultValues[0].Interface(), nil
}
} else if len(resultValues) == 1 {
// return one argument
if err, ok := resultValues[0].Interface().(error); ok {
// return error
return nil, err
} else {
// return interface{}
return resultValues[0].Interface(), nil
}
} else {
// return more than 2 arguments, unexpected
err := fmt.Errorf("function should return at most 2 values")
return nil, err
}
}

208
plugin/common/call_test.go Normal file
View File

@@ -0,0 +1,208 @@
package common
import (
"errors"
"fmt"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
type data struct {
f interface{}
args []interface{}
expVal interface{}
expErr error
}
func TestCallFuncBasic(t *testing.T) {
params := []data{
// zero argument, zero return
{f: func() {}, args: []interface{}{}, expVal: nil, expErr: nil},
// zero argument, return one value
{f: func() int { return 1 }, args: []interface{}{}, expVal: 1, expErr: nil},
{f: func() string { return "a" }, args: []interface{}{}, expVal: "a", expErr: nil},
{f: func() interface{} { return 1.23 }, args: []interface{}{}, expVal: 1.23, expErr: nil},
// zero argument, return error
{f: func() error { return errors.New("xxx") }, args: []interface{}{}, expVal: nil, expErr: errors.New("xxx")},
// zero argument, return one value and error
{f: func() (int, error) { return 1, errors.New("xxx") }, args: []interface{}{}, expVal: 1, expErr: errors.New("xxx")},
{f: func() (interface{}, error) { return 1.23, errors.New("xxx") }, args: []interface{}{}, expVal: 1.23, expErr: errors.New("xxx")},
// one argument, zero return
{f: func(n int) {}, args: []interface{}{1}, expVal: nil, expErr: nil},
// one argument, return one value
{f: func(n int) int { return n * n }, args: []interface{}{2}, expVal: 4},
{f: func(c string) string { return c + c }, args: []interface{}{"p"}, expVal: "pp"},
{f: func(arg interface{}) interface{} { return fmt.Sprintf("%v", arg) }, args: []interface{}{1.23}, expVal: "1.23"},
// one argument, return one value and error
{f: func(arg interface{}) (interface{}, error) { return 1.23, errors.New("xxx") }, args: []interface{}{"a"}, expVal: 1.23, expErr: errors.New("xxx")},
// two arguments in same type
{f: func(a, b int) int { return a * b }, args: []interface{}{2, 3}, expVal: 6},
// two arguments in different type
{
f: func(n int, c string) string {
var s string
for i := 0; i < n; i++ {
s += c
}
return s
},
args: []interface{}{3, "p"},
expVal: "ppp",
},
// variable arguments list: ...int, ...interface{}
{
f: func(n ...int) int {
var sum int
for _, arg := range n {
sum += arg
}
return sum
},
args: []interface{}{1, 2, 3},
expVal: 6,
},
{
f: func(args ...interface{}) (interface{}, error) {
var result string
for _, arg := range args {
result += fmt.Sprintf("%v", arg)
}
return result, nil
},
args: []interface{}{1, 2.3, "4.5", "p"},
expVal: "12.34.5p",
},
{
f: func(a, b int8, n ...int) int {
var sum int
for _, arg := range n {
sum += arg
}
sum += int(a) + int(b)
return sum
},
args: []interface{}{1, 2, 3, 4.5},
expVal: 10,
},
{
f: func(a, b int8, n ...int) int {
sum := int(a) + int(b)
for _, arg := range n {
sum += arg
}
return sum
},
args: []interface{}{1, 2},
expVal: 3,
},
{
f: func(a []int, n ...int) int {
var sum int
for _, arg := range a {
sum += arg
}
for _, arg := range n {
sum += arg
}
return sum
},
args: []interface{}{[]int{1, 2}, 3, 4},
expVal: 10,
},
}
for _, p := range params {
fn := reflect.ValueOf(p.f)
val, err := CallFunc(fn, p.args...)
if !assert.Equal(t, p.expErr, err) {
t.Fatal(err)
}
if !assert.Equal(t, p.expVal, val) {
t.Fatal()
}
}
}
func TestCallFuncComplex(t *testing.T) {
params := []data{
// arguments include slice
{
f: func(a int, n []int, b int) int {
sum := a
for _, arg := range n {
sum += arg
}
sum += b
return sum
},
args: []interface{}{1, []int{2, 3}, 4},
expVal: 10,
},
// last argument is slice
{
f: func(n []int) int {
var sum int
for _, arg := range n {
sum += arg
}
return sum
},
args: []interface{}{[]int{1, 2, 3}},
expVal: 6,
},
{
f: func(a, b int, n []int) int {
sum := a + b
for _, arg := range n {
sum += arg
}
return sum
},
args: []interface{}{1, 2, []int{3, 4}},
expVal: 10,
},
}
for _, p := range params {
fn := reflect.ValueOf(p.f)
val, err := CallFunc(fn, p.args...)
if !assert.Equal(t, p.expErr, err) {
t.Fatal(err)
}
if !assert.Equal(t, p.expVal, val) {
t.Fatal()
}
}
}
func TestCallFuncAbnormal(t *testing.T) {
params := []data{
// return more than 2 values
{
f: func() (int, int, error) { return 1, 2, nil },
args: []interface{}{},
expVal: nil,
expErr: fmt.Errorf("function should return at most 2 values"),
},
}
for _, p := range params {
fn := reflect.ValueOf(p.f)
val, err := CallFunc(fn, p.args...)
if !assert.Equal(t, p.expErr, err) {
t.Fatal(err)
}
if !assert.Equal(t, p.expVal, val) {
t.Fatal()
}
}
}

View File

@@ -0,0 +1,90 @@
// +build linux freebsd darwin
// go plugin doesn't support windows
package common
import (
"fmt"
"os"
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
)
func buildGoPlugin() {
fmt.Println("[setup] build go plugin")
// flag -race is necessary in order to be consistent with go test
cmd := exec.Command("go", "build", "-buildmode=plugin", "-race",
"-o=debugtalk.so", "../../examples/plugin/debugtalk.go")
if err := cmd.Run(); err != nil {
panic(err)
}
}
func removeGoPlugin() {
fmt.Println("[teardown] remove go plugin")
os.Remove("debugtalk.so")
}
func TestLocatePlugin(t *testing.T) {
buildGoPlugin()
defer removeGoPlugin()
_, err := locateFile("../", goPluginFile)
if !assert.Error(t, err) {
t.Fail()
}
_, err = locateFile("", goPluginFile)
if !assert.Error(t, err) {
t.Fail()
}
startPath := "debugtalk.so"
_, err = locateFile(startPath, goPluginFile)
if !assert.Nil(t, err) {
t.Fail()
}
startPath = "call.go"
_, err = locateFile(startPath, goPluginFile)
if !assert.Nil(t, err) {
t.Fail()
}
startPath = "."
_, err = locateFile(startPath, goPluginFile)
if !assert.Nil(t, err) {
t.Fail()
}
startPath = "/abc"
_, err = locateFile(startPath, goPluginFile)
if !assert.Error(t, err) {
t.Fail()
}
}
func TestCallPluginFunction(t *testing.T) {
buildGoPlugin()
defer removeGoPlugin()
plugin, err := Init("debugtalk.so")
if err != nil {
t.Fatal(err)
}
if !assert.True(t, plugin.Has("Concatenate")) {
t.Fail()
}
// call function without arguments
result, err := plugin.Call("Concatenate", "1", 2, "3.14")
if !assert.NoError(t, err) {
t.Fail()
}
if !assert.Equal(t, "123.14", result) {
t.Fail()
}
}

View File

@@ -0,0 +1,90 @@
package common
import (
"fmt"
"os"
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
)
func buildHashicorpPlugin() {
fmt.Println("[init] build hashicorp go plugin")
cmd := exec.Command("go", "build",
"-o=debugtalk.bin",
"../../examples/plugin/hashicorp.go", "../../examples/plugin/debugtalk.go")
if err := cmd.Run(); err != nil {
panic(err)
}
}
func removeHashicorpPlugin() {
fmt.Println("[teardown] remove hashicorp plugin")
os.Remove("debugtalk.bin")
}
func TestInitHashicorpPlugin(t *testing.T) {
buildHashicorpPlugin()
defer removeHashicorpPlugin()
plugin, err := Init("debugtalk.bin")
if err != nil {
t.Fatal(err)
}
defer plugin.Quit()
if !assert.True(t, plugin.Has("sum_ints")) {
t.Fatal(err)
}
if !assert.True(t, plugin.Has("concatenate")) {
t.Fatal(err)
}
var v2 interface{}
v2, err = plugin.Call("sum_ints", 1, 2, 3, 4)
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, 10, v2) {
t.Fail()
}
v2, err = plugin.Call("sum_two_int", 1, 2)
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, 3, v2) {
t.Fail()
}
v2, err = plugin.Call("sum", 1, 2, 3.4, 5)
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, 11.4, v2) {
t.Fail()
}
var v3 interface{}
v3, err = plugin.Call("sum_two_string", "a", "b")
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, "ab", v3) {
t.Fail()
}
v3, err = plugin.Call("sum_strings", "a", "b", "c")
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, "abc", v3) {
t.Fail()
}
v3, err = plugin.Call("concatenate", "a", 2, "c", 3.4)
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, "a2c3.4", v3) {
t.Fail()
}
}

221
plugin/common/init.go Normal file
View File

@@ -0,0 +1,221 @@
package common
import (
"fmt"
"os"
"path/filepath"
"plugin"
"reflect"
"runtime"
"github.com/rs/zerolog/log"
"github.com/httprunner/hrp/internal/ga"
pluginHost "github.com/httprunner/hrp/plugin/host"
pluginShared "github.com/httprunner/hrp/plugin/shared"
)
type pluginFile string
const (
goPluginFile pluginFile = pluginShared.Name + ".so" // built from go plugin
hashicorpGoPluginFile pluginFile = pluginShared.Name + ".bin" // built from hashicorp go plugin
hashicorpPyPluginFile pluginFile = pluginShared.Name + ".py"
)
type Plugin interface {
Init(path string) error // init plugin
Has(funcName string) bool // check if plugin has function
Call(funcName string, args ...interface{}) (interface{}, error) // call function
Quit() error // quit plugin
}
// goPlugin implements golang official plugin
type goPlugin struct {
*plugin.Plugin
cachedFunctions map[string]reflect.Value // cache loaded functions to improve performance
}
func (p *goPlugin) Init(path string) error {
if runtime.GOOS == "windows" {
log.Warn().Msg("go plugin does not support windows")
return fmt.Errorf("go plugin does not support windows")
}
var err error
// report event for loading go plugin
defer func() {
event := ga.EventTracking{
Category: "LoadGoPlugin",
Action: "plugin.Open",
}
if err != nil {
event.Value = 1 // failed
}
go ga.SendEvent(event)
}()
p.Plugin, err = plugin.Open(path)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("load go plugin failed")
return err
}
p.cachedFunctions = make(map[string]reflect.Value)
log.Info().Str("path", path).Msg("load go plugin success")
return nil
}
func (p *goPlugin) Has(funcName string) bool {
fn, ok := p.cachedFunctions[funcName]
if ok {
return fn.IsValid()
}
sym, err := p.Plugin.Lookup(funcName)
if err != nil {
p.cachedFunctions[funcName] = reflect.Value{} // mark as invalid
return false
}
fn = reflect.ValueOf(sym)
// check function type
if fn.Kind() != reflect.Func {
p.cachedFunctions[funcName] = reflect.Value{} // mark as invalid
return false
}
p.cachedFunctions[funcName] = fn
return true
}
func (p *goPlugin) Call(funcName string, args ...interface{}) (interface{}, error) {
if !p.Has(funcName) {
return nil, fmt.Errorf("function %s not found", funcName)
}
fn := p.cachedFunctions[funcName]
return CallFunc(fn, args...)
}
func (p *goPlugin) Quit() error {
// no need to quit for go plugin
return nil
}
// hashicorpPlugin implements hashicorp/go-plugin
type hashicorpPlugin struct {
pluginShared.FuncCaller
cachedFunctions map[string]bool // cache loaded functions to improve performance
}
func (p *hashicorpPlugin) Init(path string) error {
f, err := pluginHost.Init(path)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("load go hashicorp plugin failed")
return err
}
p.FuncCaller = f
p.cachedFunctions = make(map[string]bool)
log.Info().Str("path", path).Msg("load hashicorp go plugin success")
return nil
}
func (p *hashicorpPlugin) Has(funcName string) bool {
flag, ok := p.cachedFunctions[funcName]
if ok {
return flag
}
funcNames, err := p.GetNames()
if err != nil {
return false
}
for _, name := range funcNames {
if name == funcName {
p.cachedFunctions[funcName] = true // cache as exists
return true
}
}
p.cachedFunctions[funcName] = false // cache as not exists
return false
}
func (p *hashicorpPlugin) Call(funcName string, args ...interface{}) (interface{}, error) {
return p.FuncCaller.Call(funcName, args...)
}
func (p *hashicorpPlugin) Quit() error {
// kill hashicorp plugin process
pluginHost.Quit()
return nil
}
func Init(path string) (Plugin, error) {
if path == "" {
return nil, nil
}
var plugin Plugin
// priority: hashicorp plugin > go plugin > builtin functions
// locate hashicorp plugin file
pluginPath, err := locateFile(path, hashicorpGoPluginFile)
if err == nil {
// found hashicorp go plugin file
plugin = &hashicorpPlugin{}
err = plugin.Init(pluginPath)
return plugin, err
}
// locate go plugin file
pluginPath, err = locateFile(path, goPluginFile)
if err == nil {
// found go plugin file
plugin = &goPlugin{}
err = plugin.Init(pluginPath)
return plugin, err
}
// plugin not found
return nil, nil
}
// locateFile searches destFile upward recursively until current
// working directory or system root dir.
func locateFile(startPath string, destFile pluginFile) (string, error) {
stat, err := os.Stat(startPath)
if os.IsNotExist(err) {
return "", err
}
var startDir string
if stat.IsDir() {
startDir = startPath
} else {
startDir = filepath.Dir(startPath)
}
startDir, _ = filepath.Abs(startDir)
// convention over configuration
pluginPath := filepath.Join(startDir, string(destFile))
if _, err := os.Stat(pluginPath); err == nil {
return pluginPath, nil
}
// current working directory
cwd, _ := os.Getwd()
if startDir == cwd {
return "", fmt.Errorf("searched to CWD, plugin file not found")
}
// system root dir
parentDir, _ := filepath.Abs(filepath.Dir(startDir))
if parentDir == startDir {
return "", fmt.Errorf("searched to system root dir, plugin file not found")
}
return locateFile(parentDir, destFile)
}