初衷
最近软件项目中遇到很多不同工厂需要定制的功能。如:
- 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 创作并发布