初衷

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

  • A公司要求系统接入他们的设备并在软件上视图演示。
  • B公司要求导入他们的数据库,并在软件视图展示B公司和自家产品同时运算得到的统计图。
  • C公司要求隐藏某些数据以通过客户审厂。

这些要求如果全部靠写在Golang项目源码上,会造成代码维护、部署的困难,而且后面架构更新后这些代码非常难维护,最终落得删库跑路的结局。

方案选型

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

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


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


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

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

  • lua暂时玩不明白
  • javaScript关于web的库比较多,生态比较好,很有可能做到开箱即用。

不过后面如果发现JavaScript虚拟机在go运行性能不太好时,可能也会考虑部分迁移到lua。
毕竟架构完善时,甚至可以将全部业务层的代码挪到脚本上。

最后技术选型:

编写代码

我们初步定义插件目录结构如图:

- 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()
}

演示

scriptscriptpackage.yamlindex.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()

后记

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

本文使用 Zhihu On VSCode 创作并发布