GolangWeb插件系统
初衷
最近软件项目中遇到很多不同工厂需要定制的功能。如:
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值也是会递增。
后记
从简入深去裁剪代码,并逐一讲解。
本来想把整个库的代码共享出来,后来发现自己代码有些部分写得太耦合了。写这篇文章的时候无意中又帮自己代码整理了一番。