feat: 嵌入式 JS 脚本引擎核心
用 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 在脚本里表现为抛异常,不占返回值位置。
- 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
全局根本不会被转换。
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
package jscriptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTimeout 是单次脚本调用的默认时限,超过就中断脚本。
|
||||
DefaultTimeout = 5 * time.Second
|
||||
// DefaultMaxCallStackSize 限制脚本的调用栈深度,防止递归打爆 Go 栈。
|
||||
DefaultMaxCallStackSize = 2000
|
||||
)
|
||||
|
||||
// Engine 是脚本引擎,持有全局白名单、执行策略和脚本缓存。
|
||||
// 一个进程通常只需要一个 Engine,它本身并发安全。
|
||||
type Engine struct {
|
||||
globals map[string]any
|
||||
timeout time.Duration
|
||||
maxVMs int
|
||||
maxStack int
|
||||
logger *slog.Logger
|
||||
|
||||
loader Loader
|
||||
autoReload bool
|
||||
|
||||
// WithLoader 收下的层,New 里合成 loader;Option 没有出错的地方,
|
||||
// 校验只能推迟到那时候。
|
||||
loaderLayers []Loader
|
||||
|
||||
bundleOpts []BundleOption
|
||||
|
||||
mu sync.Mutex
|
||||
scripts map[string]*Script
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New 创建引擎。全局白名单里的名字不合法时返回错误。
|
||||
func New(opts ...Option) (*Engine, error) {
|
||||
e := &Engine{
|
||||
globals: map[string]any{},
|
||||
timeout: DefaultTimeout,
|
||||
maxVMs: runtime.GOMAXPROCS(0) * 2,
|
||||
maxStack: DefaultMaxCallStackSize,
|
||||
logger: slog.Default(),
|
||||
scripts: map[string]*Script{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
if e.maxVMs < 1 {
|
||||
e.maxVMs = 1
|
||||
}
|
||||
if err := e.resolveLoader(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name := range e.globals {
|
||||
if !validIdent(name) {
|
||||
return nil, fmt.Errorf("%w: 全局名 %q 不是合法的 JS 标识符", ErrBadGlobal, name)
|
||||
}
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// resolveLoader 把 WithLoader 收下的层合成一个 Loader。
|
||||
func (e *Engine) resolveLoader() error {
|
||||
switch len(e.loaderLayers) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
e.loader = e.loaderLayers[0]
|
||||
if e.loader == nil {
|
||||
return errors.New("jscriptx: WithLoader 收到 nil")
|
||||
}
|
||||
default:
|
||||
o, err := Overlay(e.loaderLayers...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.loader = o
|
||||
}
|
||||
e.loaderLayers = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile 用一段 ESM/TypeScript 源码注册脚本:先经 esbuild 打包,再交给 goja 编译,
|
||||
// 结果进缓存,之后 Script(name) 能取到。同名脚本会被替换,旧的 VM 池随即释放
|
||||
// (已经借出去的调用不受影响)。
|
||||
//
|
||||
// 源码必须有 export——产物是 IIFE,没有导出的顶层代码会被当死代码摇掉。
|
||||
// 源码里要写 import 的话,得用 jscriptx/esm 子包按目录加载,或者配 WithResolveDir
|
||||
// 给一个解析基准目录。
|
||||
func (e *Engine) Compile(name, source string) (*Script, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||||
}
|
||||
return e.compileLocked(name, source, hashVersion(source), false)
|
||||
}
|
||||
|
||||
// Script 按名字取脚本:命中缓存直接返回;没命中就走 Loader 加载并编译。
|
||||
// 打开了 WithAutoReload 时,每次都会跟 Loader 核对版本号,变了就重编译。
|
||||
func (e *Engine) Script(name string) (*Script, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||||
}
|
||||
|
||||
cached, ok := e.scripts[name]
|
||||
if ok && !e.autoReload {
|
||||
return cached, nil
|
||||
}
|
||||
if e.loader == nil {
|
||||
if ok {
|
||||
return cached, nil
|
||||
}
|
||||
return nil, newError(KindNotFound, name, "", ErrScriptNotFound,
|
||||
"没有配置 Loader,也没有通过 Compile 注册过这个脚本")
|
||||
}
|
||||
return e.loadLocked(name, cached)
|
||||
}
|
||||
|
||||
// Reload 强制重新从 Loader 加载并编译,不管版本号有没有变。
|
||||
func (e *Engine) Reload(name string) (*Script, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||||
}
|
||||
if e.loader == nil {
|
||||
return nil, newError(KindLoad, name, "", ErrScriptNotFound, "没有配置 Loader,无法重新加载")
|
||||
}
|
||||
return e.loadLocked(name, nil)
|
||||
}
|
||||
|
||||
// Invalidate 把脚本从缓存里剔除并释放它的 VM 池,下次 Script 会重新加载。
|
||||
func (e *Engine) Invalidate(name string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if s, ok := e.scripts[name]; ok {
|
||||
delete(e.scripts, name)
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Names 返回当前缓存里的脚本名。
|
||||
func (e *Engine) Names() []string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]string, 0, len(e.scripts))
|
||||
for name := range e.scripts {
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 关闭引擎,释放所有脚本的 VM 池。之后再取脚本会报 ErrClosed。
|
||||
func (e *Engine) Close() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.closed = true
|
||||
for name, s := range e.scripts {
|
||||
s.Close()
|
||||
delete(e.scripts, name)
|
||||
}
|
||||
}
|
||||
|
||||
// compileLocked 编译并替换缓存里的同名脚本。调用方必须持有 e.mu。
|
||||
//
|
||||
// prepared 为 true 表示源码已经过打包(Loader 自己做过了),跳过这一步——
|
||||
// 对已经是 IIFE 的产物再打包一次是纯浪费。
|
||||
func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Script, error) {
|
||||
if !prepared {
|
||||
// 脚本名同时当文件名传给打包器:扩展名(.ts/.json)决定按什么语法解析,
|
||||
// 也是 sourcemap 和报错里显示的位置。
|
||||
bundled, err := Bundle(name, source, e.bundleOpts...)
|
||||
if err != nil {
|
||||
return nil, newError(KindCompile, name, "", err, "脚本打包失败")
|
||||
}
|
||||
source = bundled
|
||||
}
|
||||
|
||||
// 传 false = 不强制严格模式。
|
||||
//
|
||||
// 注意产物里**没有** "use strict":esbuild 输出 ESM 格式时不加这个指令,
|
||||
// wrapESM 包成 IIFE 时也没加。所以脚本跑在非严格模式下,后果之一是给只读
|
||||
// 全局赋值会**静默失败**而不是抛错(见 lazyglobal_test.go 的断言)。
|
||||
//
|
||||
// 想改成严格模式就把这里传 true,但那是行为变更:脚本里任何依赖非严格语义的
|
||||
// 写法(给未声明变量赋值、with、八进制字面量……)都会开始报错。
|
||||
prog, err := goja.Compile(name, source, false)
|
||||
if err != nil {
|
||||
return nil, newError(KindCompile, name, "", err, "脚本编译失败")
|
||||
}
|
||||
|
||||
s := &Script{
|
||||
engine: e,
|
||||
name: name,
|
||||
version: version,
|
||||
prog: prog,
|
||||
pool: make(chan *vmHandle, e.maxVMs),
|
||||
}
|
||||
if old, ok := e.scripts[name]; ok {
|
||||
old.Close()
|
||||
}
|
||||
e.scripts[name] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bind 把白名单全局对象注入到一个新建的 VM 里。
|
||||
// extra 是这个 VM 专属的额外全局(作用域带来的扩展),可以为 nil;
|
||||
// 它跟白名单同样按只读注入,同名时以 extra 为准。
|
||||
//
|
||||
// 注入是**惰性**的:这里只装一个 getter,脚本第一次读到那个名字才把值转成
|
||||
// JS 对象。一个脚本通常只用得上少数几个扩展,而 freeze 要把每个方法都包装成
|
||||
// JS 函数——用不到的那些不该在每次建 VM 时都付一遍这个成本。见 lazyGlobal。
|
||||
func (e *Engine) bind(rt *goja.Runtime, script string, extra map[string]any) error {
|
||||
global := rt.GlobalObject()
|
||||
for name, val := range e.globals {
|
||||
if _, overridden := extra[name]; overridden {
|
||||
continue
|
||||
}
|
||||
if err := e.lazyGlobal(rt, global, name, val); err != nil {
|
||||
return newError(KindBind, script, "", err, "注入全局对象 %q 失败", name)
|
||||
}
|
||||
}
|
||||
for name, val := range extra {
|
||||
if err := e.lazyGlobal(rt, global, name, val); err != nil {
|
||||
return newError(KindBind, script, "", err, "注入会话全局对象 %q 失败", name)
|
||||
}
|
||||
}
|
||||
if e.logger != nil {
|
||||
_, taken := e.globals["console"]
|
||||
if _, t2 := extra["console"]; t2 {
|
||||
taken = true
|
||||
}
|
||||
if !taken {
|
||||
if err := e.lazyGlobal(rt, global, "console", newConsole(e.logger, script)); err != nil {
|
||||
return newError(KindBind, script, "", err, "注入 console 失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lazyGlobal 装一个惰性只读全局:脚本第一次读它才 freeze,之后复用。
|
||||
//
|
||||
// 为什么惰性:freeze 要把 map 里每个方法都包装成 JS 函数对象,而一个脚本通常只用
|
||||
// 得上少数几个扩展。急切注入的话,每建一个 VM 都要为**所有**扩展付这份成本——
|
||||
// 实测这是脚本层剩余开销里最大的一块。
|
||||
//
|
||||
// 缓存放在闭包里,不加锁:getter 只在脚本执行期间被调用,而那时这个 VM 是被独占的
|
||||
// (实例持着自己的锁,池化的 VM 同时只有一个借用者)。goja.Value 也跨不了 Runtime,
|
||||
// 所以这份缓存天然是每 VM 一份。
|
||||
//
|
||||
// 只给 getter 不给 setter,效果等同原来的 writable=false:脚本赋值时没有 setter 可调。
|
||||
// 产物跑在非严格模式下(见 compileLocked 那里的说明),所以赋值是**静默失败**——
|
||||
// 不抛错,值也不变。configurable 同样保持 false,删不掉也重定义不了。
|
||||
//
|
||||
// 代价是 freeze 的错误从"建 VM 时返回 Go 错误"变成"脚本读它时抛 JS 异常"。
|
||||
// freeze 只在属性名不合法时才会失败,而 New() 里的 validIdent 已经挡过一道,
|
||||
// 实际碰不到。
|
||||
func (e *Engine) lazyGlobal(rt *goja.Runtime, global *goja.Object, name string, val any) error {
|
||||
var (
|
||||
cached goja.Value
|
||||
failed error
|
||||
)
|
||||
getter := rt.ToValue(func(goja.FunctionCall) goja.Value {
|
||||
if cached == nil && failed == nil {
|
||||
cached, failed = e.freeze(rt, val)
|
||||
}
|
||||
if failed != nil {
|
||||
panic(rt.NewGoError(failed))
|
||||
}
|
||||
return cached
|
||||
})
|
||||
return global.DefineAccessorProperty(name, getter, nil, goja.FLAG_FALSE, goja.FLAG_TRUE)
|
||||
}
|
||||
|
||||
// freeze 把 map[string]any 递归转成只读的 JS 对象,其他值原样交给 goja 包装。
|
||||
//
|
||||
// 直接 rt.Set(name, someMap) 会把同一个 Go map 暴露给每个 VM:脚本一句
|
||||
// db.C = null 既能污染别的 VM,又是实打实的数据竞争。这里每个 VM 都拿到
|
||||
// 自己的一份不可写、不可重定义的对象。
|
||||
func (e *Engine) freeze(rt *goja.Runtime, val any) (goja.Value, error) {
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return rt.ToValue(val), nil
|
||||
}
|
||||
obj := rt.NewObject()
|
||||
for k, v := range m {
|
||||
child, err := e.freeze(rt, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := defineReadOnly(obj, k, child); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func defineReadOnly(obj *goja.Object, name string, v goja.Value) error {
|
||||
return obj.DefineDataProperty(name, v, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE)
|
||||
}
|
||||
|
||||
// validIdent 校验全局名是不是合法的 JS 标识符(只允许 ASCII 字母、数字、_ 和 $)。
|
||||
func validIdent(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch {
|
||||
case r == '_' || r == '$':
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hashVersion(source string) string {
|
||||
sum := sha256.Sum256([]byte(source))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// New 是 Script(name) + Script.New(ctx, args...) 的快捷方式:按名字取脚本,
|
||||
// 实例化它导出的 class,构造参数直接传给 constructor。
|
||||
//
|
||||
// ctrl, err := e.New(ctx, "PkgVersion/PkgImportController")
|
||||
// defer ctrl.Close()
|
||||
// got, err := ctrl.Call(ctx, "Init")
|
||||
func (e *Engine) New(ctx context.Context, name string, ctorArgs ...any) (*Instance, error) {
|
||||
s, err := e.Script(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.New(ctx, ctorArgs...)
|
||||
}
|
||||
Reference in New Issue
Block a user