GolangWeb插件系统

作者: adm 分类: go 发布时间: 2022-07-19

初衷
最近软件项目中遇到很多不同工厂需要定制的功能。如:

A公司要求系统接入他们的设备并在软件上视图演示。
B公司要求导入他们的数据库,并在软件视图展示B公司和自家产品同时运算得到的统计图。
C公司要求隐藏某些数据以通过客户审厂。
这些要求如果全部靠写在Golang项目源码上,会造成代码维护、部署的困难,而且后面架构更新后这些代码非常难维护,最终落得删库跑路的结局。

方案选型
打算给项目增加一个插件系统,首先从搜索引擎查找后,得到几种方案。下面阐述各个方案和缺点:

原生的Golang plugin:
不支持Windows,这对于工业物联网应用来说可以说是致命的缺点了。
只能加载不能卸载。
插件和宿主使用go的版本必须一样

基于本地网络gRPC通讯的插件库
插件也是用go编写的,也逃不过编译。
在工厂普遍的弱网甚至断网环境下,远程调试时,不方便编译和分发。

再三斟酌后,我在网上选型了两款go的脚本虚拟机库 golua(lua)和otto(JavaScript)。

lua脚本在游戏行业中使用普遍广泛, 但后来还是选择了JavaScript。原因如下:

lua暂时玩不明白
javaScript关于web的库比较多,生态比较好,很有可能做到开箱即用。
不过后面如果发现JavaScript虚拟机在go运行性能不太好时,可能也会考虑部分迁移到lua。
毕竟架构完善时,甚至可以将全部业务层的代码挪到脚本上。

最后技术选型:

otto: https://github.com/robertkrimen/otto

编写代码
我们初步定义插件目录结构如图:

- plugins
  - plugin_name(插件名)
    - index.js
    - package.yaml

项目中导入 otto

go get github.com/robertkrimen/otto

package.yaml 为一个插件的描述文件,内容格式如下:

name: "xxx"
name: 插件名

项目创建一个 plugin 包,代码如下

package plugin

import (
	"errors"
	"fmt"
	"github.com/robertkrimen/otto"
	"gopkg.in/yaml.v3"
	"io/ioutil"
	"os"
	"time"
)

type PluginPackage struct {
	Name string `yaml:"name" json:"name"`
}

type OttoObject struct {
	Info *PluginPackage
	*otto.Otto
}

type PluginSystem struct {
	baseVM *otto.Otto
	vms    []*OttoObject
}

func NewPluginSystem() PluginSystem {
	return PluginSystem{
		baseVM: otto.New(),
	}
}

/* 
  注册函数
*/
func (this *PluginSystem) Register() (error, []error) {
	// 插件路径
	directoryPath := "./script"
	fs, err := ioutil.ReadDir(directoryPath)
	if err != nil {
		return err, []error{}
	}

	// 遍历时,异常全部记录,并进行continue跳过该异常插件加载的过程.
	var errorList []error

	// 遍历插件目录文件夹
	for _, dir := range fs {
		// 判断是否为目录
		if dir.IsDir() == true {
			scriptPath := fmt.Sprintf("%s/%s/", directoryPath, dir.Name())  // 脚本路径
			describePath := fmt.Sprintf("%s%s", scriptPath, "package.yaml") // 包描述文件的路径
			jsPath := fmt.Sprintf("%s%s", scriptPath, "index.js")           // 脚本文件

			// 导入描述文本
			if _, err := os.Stat(describePath); err != nil {
				// 文件不存在或者打开出错.
				errorList = append(errorList, errors.New(fmt.Sprintf("[%s] error : [%s]", dir.Name(), err.Error())))
				continue
			}

			// 读取描述文件
			packageInfoStr, err := ioutil.ReadFile(describePath)
			if err != nil {
				errorList = append(errorList, errors.New(fmt.Sprintf("[%s] error : [%s]", dir.Name(), err.Error())))
				continue
			}

			// 临时变量,将描述文件中的信息反序列给PluginPackage
			packageInfo := &PluginPackage{}
			if err := yaml.Unmarshal(packageInfoStr, packageInfo); err != nil {
				errorList = append(errorList, errors.New(fmt.Sprintf("[%s] error : [%s]", dir.Name(), err.Error())))
				continue
			}

			// 将代码载入到虚拟机中
			bytes, err := ioutil.ReadFile(jsPath)
			if err != nil {
				errorList = append(errorList, errors.New(fmt.Sprintf("[%s] error : [%s]", dir.Name(), err.Error())))
				continue
			}
			newVM := this.baseVM.Copy()
			_, err = newVM.Run(bytes)
			if err != nil {
				errorList = append(errorList, errors.New(fmt.Sprintf("[%s] error : [%s]", dir.Name(), err.Error())))
				newVM = nil //设为nil 释放该虚拟机.
				continue
			}

			// 将描述信息和虚拟机传入结构体中
			object := &OttoObject{
				packageInfo,
				newVM,
			}

			// 将该对象添加到虚拟机列表中
			this.vms = append(this.vms, object)
		}

	}

	return nil, errorList
}

func (this *PluginSystem) Start() {

	for {
		// 每次调用的时间可设,我们暂时设为1秒
		<-time.After(1 * time.Second)
		for _, vm := range this.vms {
			data := "123" // 模拟传参 传个123进去
			// 调用虚拟机的Update方法
			value, err := vm.Call("Update", nil, data)
			if err != nil {
				fmt.Println(err.Error())
				continue
			}
			fmt.Println(fmt.Sprintf("[%s] 脚本返回值: %s", vm.Info.Name, value.String()))
		}
	}

}

main.go 部分:

package main

// govm是项目名,可以根据自己的go module名称进行修改
import plugin "govm/plugins"

func main() {
	vm := plugin.NewPluginSystem()
	vm.Register()
	vm.Start()
}

演示
添加 script 文件夹,在 script 文件夹添加一个demo文件夹,然后新建两个文件 package.yaml 和 index.js

index.js

// 定义 time 变量
var time = 1

// 定义更新函数,以便被调用。
function Update(arg) {
    time++
    return "得到传参:"  + arg + " ,time的值:" + time
}

pacakge.yaml

name: "demo"

接下来我们可以运行看看

[demo] 脚本返回值: 得到传参:123 ,time的值:2
[demo] 脚本返回值: 得到传参:123 ,time的值:3
[demo] 脚本返回值: 得到传参:123 ,time的值:4
[demo] 脚本返回值: 得到传参:123 ,time的值:5
[demo] 脚本返回值: 得到传参:123 ,time的值:6
[demo] 脚本返回值: 得到传参:123 ,time的值:7
[demo] 脚本返回值: 得到传参:123 ,time的值:8

我们可以从输出看到,脚本每隔一秒被调用 Update() 方法,传入参数123,打印的time值也是会递增。

后记
从简入深去裁剪代码,并逐一讲解。
本来想把整个库的代码共享出来,后来发现自己代码有些部分写得太耦合了。写这篇文章的时候无意中又帮自己代码整理了一番。

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!