用 goja 承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。脚本用
ESM + TypeScript 写,Go 侧按名字把它们当普通对象实例化并调用方法。
主要组成:
- Engine 编译脚本、管配置,公开 API 不暴露任何 goja 类型
- Script 一份编译好的脚本 + 它的 VM 池,热更新时整体顶替
- Instance 独占一个 VM 的实例,状态留在 JS 侧
- Caller 自定义调用约定,把脚本函数适配成 Go 侧要的签名
- Scope 让同一个 ctx 下的多个脚本共享 Go 侧对象
- Extension 扩展接口:给脚本添全局对象,配套 TS 类型
- Overlay 多层 Loader 叠加,后面的盖前面的
几个关键取舍:
- 源码一律先过 esbuild 打包成 ESM,再改写成立即执行函数。goja 不认
import/export,而业务脚本要能拆文件、用 TypeScript。
- VM 池化复用,但每个 VM 单线程。goja 的 Runtime 不是 goroutine 安全的。
- Go 侧函数返回的 error 在脚本里表现为抛异常,不占返回值位置。
- 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
全局根本不会被转换。
175 lines
6.0 KiB
Go
175 lines
6.0 KiB
Go
package jscriptx
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Instance 是脚本导出的 class 的一个实例,独占一个 VM,用起来跟普通 Go 对象差不多:
|
|
//
|
|
// obj, err := s.New(ctx, "dev-1", "温控器")
|
|
// defer obj.Close()
|
|
// got, err := obj.Call(ctx, "onMessage", payload)
|
|
//
|
|
// 跟从 VM 池借用的 Script.Call 不同,实例在整个生命周期里绑定同一个 VM,
|
|
// 所以脚本里的 this.xxx 能跨调用保持:
|
|
//
|
|
// export default class DeviceHandler {
|
|
// constructor(deviceId, model) { this.count = 0 }
|
|
// onMessage(payload) { return ++this.count } // 跨调用累加
|
|
// }
|
|
//
|
|
// # 必须知道的三件事
|
|
//
|
|
// 1. 生命周期归你管。Close 之前它一直占着一个 VM(约 16 KB),本库不会替你回收,
|
|
// 也没有按 key 复用那一套——要长期持有(比如按设备 ID 存着),业务侧自己拿 map 存。
|
|
// 2. 同一个实例的调用是串行的。goja 的 Runtime 不是并发安全的,多个 goroutine
|
|
// 同时调同一个实例会排队;不同实例之间并行。
|
|
// 3. 脚本出错会丢状态。一次超时或 panic 会让这个 VM 被丢弃,下次调用时用一份
|
|
// 全新的实例重建(构造参数会重新传一遍,但 this 上攒的状态归零)。
|
|
// Resets 能查到发生过几次。真正不能丢的东西放作用域的扩展里(比如 store)。
|
|
type Instance struct {
|
|
script *Script
|
|
ctorArgs []any
|
|
label string // 错误信息里怎么称呼它
|
|
scope *scope // ctx 带来的作用域:扩展和额外全局从这里拿,没有作用域时为 nil
|
|
|
|
mu sync.Mutex // 保证串行执行,acquire 持锁直到 finish
|
|
vm *vmHandle
|
|
done bool
|
|
|
|
lastUsed atomic.Int64 // UnixNano,空闲回收用
|
|
calls atomic.Int64
|
|
resets atomic.Int64
|
|
}
|
|
|
|
// New 实例化脚本导出的 class,构造参数直接传给 constructor。返回的实例独占一个 VM,
|
|
// 脚本里的 this.xxx 在它活着期间跨调用保持,用完 Close。
|
|
//
|
|
// ctx 里带了作用域(WithScope)时,作用域里的扩展和全局对象会注入给这个实例——
|
|
// 同一个 ctx 下 New 出来的多个实例因此共享同一份扩展(比如 store),但各有各的 VM,
|
|
// 互相并行、this.xxx 互不干扰。
|
|
//
|
|
// 脚本导出的不是 class 而是实例或函数时也能用,只是构造参数没有去处会被忽略。
|
|
// constructor 里抛异常会在这里就报出来,不用等到第一次调用。
|
|
func (s *Script) New(ctx context.Context, ctorArgs ...any) (*Instance, error) {
|
|
if s.closed.Load() {
|
|
return nil, newError(KindClosed, s.name, "", ErrClosed, "脚本已关闭")
|
|
}
|
|
i := &Instance{script: s, ctorArgs: ctorArgs, label: "实例"}
|
|
if sc, ok := scopeOf(ctx); ok {
|
|
i.scope = sc
|
|
i.label = "作用域 " + strconvQuote(sc.key) + " 的实例"
|
|
}
|
|
i.touch()
|
|
if err := i.warmup(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
// Name 返回脚本名。
|
|
func (i *Instance) Name() string { return i.script.name }
|
|
|
|
// Calls 返回这个实例累计发起过多少次调用。
|
|
func (i *Instance) Calls() int64 { return i.calls.Load() }
|
|
|
|
// Resets 返回这个实例的 VM 被重建过几次。每重建一次,脚本里 this 上的状态就归零一次。
|
|
func (i *Instance) Resets() int64 { return i.resets.Load() }
|
|
|
|
// IdleFor 返回这个实例空闲了多久。
|
|
func (i *Instance) IdleFor() time.Duration {
|
|
return time.Since(time.Unix(0, i.lastUsed.Load()))
|
|
}
|
|
|
|
// Call 在这个实例上调用方法,语义跟 Script.Call 一致,只是 this 绑定到本实例。
|
|
func (i *Instance) Call(ctx context.Context, fn string, args ...any) (any, error) {
|
|
return callAny(ctx, i, fn, args)
|
|
}
|
|
|
|
// CallInto 在这个实例上调用方法,并把返回值转换进 out 指向的变量。
|
|
func (i *Instance) CallInto(ctx context.Context, fn string, out any, args ...any) error {
|
|
return callInto(ctx, i, fn, out, args)
|
|
}
|
|
|
|
// Has 判断实例上有没有这个方法(继承来的也算)。
|
|
func (i *Instance) Has(fn string) bool {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
if err := i.ensure(context.Background()); err != nil {
|
|
return false
|
|
}
|
|
_, _, _, ok := i.vm.lookup(fn)
|
|
return ok
|
|
}
|
|
|
|
// Close 释放这个实例占用的 VM。之后的调用返回 ErrClosed。重复调用是安全的。
|
|
//
|
|
// 作用域里的扩展不归它管——那些是你自己创建的对象,Close 只释放这个实例的 VM。
|
|
func (i *Instance) Close() { i.shutdown() }
|
|
|
|
func (i *Instance) owner() *Script { return i.script }
|
|
func (i *Instance) touch() { i.lastUsed.Store(time.Now().UnixNano()) }
|
|
|
|
// warmup 提前建好 VM,让 constructor 的错误在 New 阶段就暴露出来。
|
|
func (i *Instance) warmup(ctx context.Context) error {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
return i.ensure(ctx)
|
|
}
|
|
|
|
// ensure 保证 VM 就绪,调用方必须持有 i.mu。
|
|
func (i *Instance) ensure(ctx context.Context) error {
|
|
if i.done || i.script.closed.Load() {
|
|
return newError(KindClosed, i.script.name, "", ErrClosed, "%s已关闭", i.label)
|
|
}
|
|
if i.vm != nil {
|
|
return nil
|
|
}
|
|
// 作用域带来的额外全局(扩展 + ScopeGlobals)现取现用,不在 Instance 上留副本。
|
|
var extra map[string]any
|
|
if i.scope != nil {
|
|
extra = i.scope.vmGlobals()
|
|
}
|
|
vm, err := i.script.newVM(ctx, extra, i.ctorArgs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
i.vm = vm
|
|
return nil
|
|
}
|
|
|
|
// acquire 锁住实例并交出它独占的 VM。
|
|
// 注意:锁一直持到 finish 才释放,这正是"同一实例串行执行"的保证。
|
|
func (i *Instance) acquire(ctx context.Context) (*vmHandle, error) {
|
|
i.mu.Lock()
|
|
if err := i.ensure(ctx); err != nil {
|
|
i.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
i.touch()
|
|
i.calls.Add(1)
|
|
return i.vm, nil
|
|
}
|
|
|
|
// finish 交还 VM 并解锁。VM 状态不确定时(超时/panic)直接丢弃,
|
|
// 下次调用会重建一个全新实例——脚本里 this 上的状态也就跟着归零了。
|
|
func (i *Instance) finish(_ *vmHandle, healthy bool) {
|
|
if !healthy {
|
|
i.vm = nil
|
|
i.resets.Add(1)
|
|
}
|
|
i.touch()
|
|
i.mu.Unlock()
|
|
}
|
|
|
|
// shutdown 真正释放 VM。
|
|
func (i *Instance) shutdown() {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
i.done = true
|
|
i.vm = nil
|
|
}
|