refactor: plugin functions can be defined in any format

This commit is contained in:
debugtalk
2022-01-14 16:37:36 +08:00
parent 5a943ff721
commit b8d6192a34
12 changed files with 350 additions and 259 deletions

View File

@@ -0,0 +1,65 @@
package main
import (
"fmt"
plugin "github.com/httprunner/hrp/plugin"
)
func SumTwoInt(a, b int) int {
return a + b
}
func SumInts(args ...int) int {
var sum int
for _, arg := range args {
sum += arg
}
return sum
}
func Sum(args ...interface{}) (interface{}, error) {
var sum float64
for _, arg := range args {
switch v := arg.(type) {
case int:
sum += float64(v)
case float64:
sum += v
default:
return nil, fmt.Errorf("unexpected type: %T", arg)
}
}
return sum, nil
}
func SumTwoString(a, b string) string {
return a + b
}
func SumStrings(s ...string) string {
var sum string
for _, arg := range s {
sum += arg
}
return sum
}
func Concatenate(args ...interface{}) (interface{}, error) {
var result string
for _, arg := range args {
result += fmt.Sprintf("%v", arg)
}
return result, nil
}
// register functions and build to plugin binary
func main() {
plugin.Register("sum_ints", SumInts)
plugin.Register("sum_two_int", SumTwoInt)
plugin.Register("sum", Sum)
plugin.Register("sum_two_string", SumTwoString)
plugin.Register("sum_strings", SumStrings)
plugin.Register("concatenate", Concatenate)
plugin.Serve()
}

View File

@@ -0,0 +1,87 @@
package main
import (
"fmt"
"os"
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
"github.com/httprunner/hrp/plugin/host"
)
func TestMain(m *testing.M) {
fmt.Println("[TestMain] build go plugin")
cmd := exec.Command("go", "build", "-o=debugtalk.bin", "./debugtalk.go")
if err := cmd.Run(); err != nil {
panic(err)
}
os.Exit(m.Run())
}
func TestInitHashicorpPlugin(t *testing.T) {
f, err := host.Init("./debugtalk.bin")
if err != nil {
t.Fatal(err)
}
defer host.Quit()
v1, err := f.GetNames()
if err != nil {
t.Fatal(err)
}
if !assert.Contains(t, v1, "sum_ints") {
t.Fatal(err)
}
if !assert.Contains(t, v1, "concatenate") {
t.Fatal(err)
}
var v2 interface{}
v2, err = f.Call("sum_ints", 1, 2, 3, 4)
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, 10, v2) {
t.Fail()
}
v2, err = f.Call("sum_two_int", 1, 2)
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, 3, v2) {
t.Fail()
}
v2, err = f.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 = f.Call("sum_two_string", "a", "b")
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, "ab", v3) {
t.Fail()
}
v3, err = f.Call("sum_strings", "a", "b", "c")
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, "abc", v3) {
t.Fail()
}
v3, err = f.Call("concatenate", "a", 2, "c", 3.4)
if err != nil {
t.Fatal(err)
}
if !assert.Equal(t, "a2c3.4", v3) {
t.Fail()
}
}

50
plugin/host/host.go Normal file
View File

@@ -0,0 +1,50 @@
package host
import (
"os"
"os/exec"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
"github.com/httprunner/hrp/plugin/shared"
)
var client *plugin.Client
func Init(path string) (shared.FuncCaller, error) {
// launch the plugin process
client = plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: shared.HandshakeConfig,
Plugins: map[string]plugin.Plugin{
shared.Name: &shared.HashicorpPlugin{},
},
Cmd: exec.Command(path),
Logger: hclog.New(&hclog.LoggerOptions{
Name: shared.Name,
Output: os.Stdout,
Level: hclog.Info,
}),
})
// Connect via RPC
rpcClient, err := client.Client()
if err != nil {
return nil, err
}
// Request the plugin
raw, err := rpcClient.Dispense(shared.Name)
if err != nil {
return nil, err
}
// We should have a Function now! This feels like a normal interface
// implementation but is in fact over an RPC connection.
function := raw.(shared.FuncCaller)
return function, nil
}
func Quit() {
client.Kill()
}

67
plugin/plugin.go Normal file
View File

@@ -0,0 +1,67 @@
package plugin
import (
"fmt"
"os"
"reflect"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
pluginShared "github.com/httprunner/hrp/plugin/shared"
)
// functionsMap stores plugin functions
type functionsMap map[string]reflect.Value
// functionPlugin implements the FuncCaller interface
type functionPlugin struct {
logger hclog.Logger
functions functionsMap
}
func (p *functionPlugin) GetNames() ([]string, error) {
var names []string
for name := range p.functions {
names = append(names, name)
}
return names, nil
}
func (p *functionPlugin) Call(funcName string, args ...interface{}) (interface{}, error) {
p.logger.Info("call function", "funcName", funcName, "args", args)
fn, ok := p.functions[funcName]
if !ok {
return nil, fmt.Errorf("function %s not found", funcName)
}
return pluginShared.CallFunc(fn, args...)
}
var functions = make(functionsMap)
func Register(funcName string, fn interface{}) {
if _, ok := functions[funcName]; ok {
return
}
functions[funcName] = reflect.ValueOf(fn)
}
func Serve() {
funcPlugin := &functionPlugin{
logger: hclog.New(&hclog.LoggerOptions{
Name: pluginShared.Name,
Output: os.Stdout,
Level: hclog.Info,
}),
functions: functions,
}
var pluginMap = map[string]plugin.Plugin{
pluginShared.Name: &pluginShared.HashicorpPlugin{Impl: funcPlugin},
}
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: pluginShared.HandshakeConfig,
Plugins: pluginMap,
})
}

15
plugin/shared/config.go Normal file
View File

@@ -0,0 +1,15 @@
package shared
import "github.com/hashicorp/go-plugin"
const Name = "debugtalk"
// handshakeConfigs are used to just do a basic handshake between
// a plugin and host. If the handshake fails, a user friendly error is shown.
// This prevents users from executing bad plugins or executing a plugin
// directory. It is a UX feature, not a security feature.
var HandshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "HttpRunnerPlus",
MagicCookieValue: Name,
}

103
plugin/shared/interface.go Normal file
View File

@@ -0,0 +1,103 @@
package shared
import (
"encoding/gob"
"net/rpc"
"github.com/hashicorp/go-plugin"
"github.com/rs/zerolog/log"
)
func init() {
gob.Register(new(funcData))
}
// funcData is used to transfer between plugin and host via RPC.
type funcData struct {
Name string // function name
Args []interface{} // function arguments
}
// FuncCaller is the interface that we're exposing as a plugin.
type FuncCaller interface {
GetNames() ([]string, error) // get all plugin function names list
Call(funcName string, args ...interface{}) (interface{}, error) // call plugin function
}
// functionRPC runs on the host side.
type functionRPC struct {
client *rpc.Client
}
func (g *functionRPC) GetNames() ([]string, error) {
var resp []string
err := g.client.Call("Plugin.GetNames", new(interface{}), &resp)
if err != nil {
log.Error().Err(err).Msg("rpc call GetNames() failed")
return nil, err
}
return resp, nil
}
// host -> plugin
func (g *functionRPC) Call(funcName string, funcArgs ...interface{}) (interface{}, error) {
log.Info().Str("funcName", funcName).Interface("funcArgs", funcArgs).Msg("call function via RPC")
f := funcData{
Name: funcName,
Args: funcArgs,
}
var args interface{} = f
var resp interface{}
err := g.client.Call("Plugin.Call", &args, &resp)
if err != nil {
log.Error().Err(err).
Str("funcName", funcName).Interface("funcArgs", funcArgs).
Msg("rpc call Call() failed")
return nil, err
}
return resp, nil
}
// functionRPCServer runs on the plugin side, executing the user custom function.
type functionRPCServer struct {
Impl FuncCaller
}
// plugin execution
func (s *functionRPCServer) GetNames(args interface{}, resp *[]string) error {
log.Info().Interface("args", args).Msg("GetNames called on plugin side")
var err error
*resp, err = s.Impl.GetNames()
if err != nil {
log.Error().Err(err).Msg("GetNames execution failed")
return err
}
return nil
}
// plugin execution
func (s *functionRPCServer) Call(args interface{}, resp *interface{}) error {
log.Info().Interface("args", args).Msg("function called on plugin side")
f := args.(*funcData)
var err error
*resp, err = s.Impl.Call(f.Name, f.Args...)
if err != nil {
log.Error().Err(err).Interface("args", args).Msg("function execution failed")
return err
}
return nil
}
// HashicorpPlugin implements hashicorp's plugin.Plugin.
type HashicorpPlugin struct {
Impl FuncCaller
}
func (p *HashicorpPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
return &functionRPCServer{Impl: p.Impl}, nil
}
func (HashicorpPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) {
return &functionRPC{client: c}, nil
}

50
plugin/shared/utils.go Normal file
View File

@@ -0,0 +1,50 @@
package shared
import (
"fmt"
"reflect"
)
// CallFunc calls function with arguments
// it is used when calling go plugin or builtin functions
func CallFunc(fn reflect.Value, args ...interface{}) (interface{}, error) {
fnArgsNum := fn.Type().NumIn()
if fnArgsNum > 0 && fn.Type().In(fnArgsNum-1).Kind() == reflect.Slice {
// last argument is slice, do not check arguments number
// e.g. ...interface{}
// e.g. a, b string, c ...interface{}
} else if fnArgsNum != len(args) {
// function arguments not match
return nil, fmt.Errorf("function arguments number not match, expect %d, got %d", fnArgsNum, len(args))
}
// arguments do not have slice, and arguments number matched
argumentsValue := make([]reflect.Value, len(args))
for index, argument := range args {
if argument == nil {
argumentsValue[index] = reflect.Zero(fn.Type().In(index))
} else {
argumentsValue[index] = reflect.ValueOf(args[index])
}
}
resultValues := fn.Call(argumentsValue)
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 arguments: interface{}
return resultValues[0].Interface(), nil
} else {
// return more than 2 arguments, unexpected
err := fmt.Errorf("function should return at most 2 arguments")
return nil, err
}
}