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,150 @@
|
||||
// Package jscriptx 用嵌入式 JS 引擎承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。
|
||||
//
|
||||
// 脚本用 ESM + TypeScript 编写、按目录组织,Go 侧按路径把它们当普通对象实例化并调用方法。
|
||||
// 底层是 goja(纯 Go 的 JS 引擎)加 esbuild(纯 Go 的打包器),依赖就这两个,
|
||||
// 公开 API 不暴露任何 goja 类型。
|
||||
//
|
||||
// goja 的反射会自动包装 Go 对象,框架里的链式 API 在脚本里照原样写,
|
||||
// 不需要为每个方法写胶水代码:
|
||||
//
|
||||
// resource.GetDBTable(user, req.WithPermission(req.ResAll))
|
||||
// .Where(db.C("name").Eq("测试产品"))
|
||||
// .Select("name", "cost_price")
|
||||
//
|
||||
// # 基本用法
|
||||
//
|
||||
// loader, err := esm.NewLoader("app/src")
|
||||
// e, err := jscriptx.New(
|
||||
// jscriptx.WithLoader(loader),
|
||||
// jscriptx.WithAutoReload(true),
|
||||
// jscriptx.WithGlobals(myWhitelist),
|
||||
// jscriptx.WithTimeout(3*time.Second),
|
||||
// )
|
||||
//
|
||||
// obj, err := e.New(ctx, "Resource/ResCreateController", "产品")
|
||||
// defer obj.Close()
|
||||
// got, err := obj.Call(ctx, "Init")
|
||||
//
|
||||
// # 为什么需要打包
|
||||
//
|
||||
// goja 的 ES6+ 支持相当完整(class 含私有字段和静态块、async/await、generator、解构、
|
||||
// 可选链、Proxy、BigInt 都能直接跑),但它没有 ES module——import/export 在 goja 的
|
||||
// token 表里是保留字,解析阶段就报错;TypeScript 也不在它的职责范围。
|
||||
//
|
||||
// esbuild 只补这两件事:把 import 内联掉、把 TypeScript 转译掉。交给 goja 的最终产物
|
||||
// 是普通 JS 语法,不含任何模块系统的东西。打包只在加载和热更新时发生,不在调用路径上。
|
||||
//
|
||||
// # 入口形态
|
||||
//
|
||||
// 产物是自包含的立即执行函数,所以脚本必须有 export——没有导出的顶层代码会被当死代码摇掉。
|
||||
// 入口就是模块的导出:
|
||||
//
|
||||
// export default class DeviceHandler { // 由本库实例化,构造参数从 Go 侧传
|
||||
// constructor(deviceId) { this.count = 0 }
|
||||
// onMessage(payload) { return ++this.count }
|
||||
// }
|
||||
//
|
||||
// export default new DeviceHandler() // 直接用这个实例
|
||||
// export default { onMessage(p) { ... } } // 对象当实例,按方法名调用
|
||||
// export default function handle(x) { ... } // 单函数入口,用 DefaultFunc 调用
|
||||
// export function Options() { ... } // 只有命名导出时,整个模块当实例
|
||||
//
|
||||
// 拿到实例的几种形态都是按方法名调用,this 绑定到实例,继承来的方法也找得到。
|
||||
//
|
||||
// # 两种执行方式
|
||||
//
|
||||
// 区别只有一个——脚本实例活多久:
|
||||
//
|
||||
// 方式 取 VM 实例生命周期 脚本里的 this.xxx
|
||||
// Script.Call 从 VM 池借 = VM 生命周期 随时可能归零,只能当缓存
|
||||
// Script.New → Instance 独占一个 VM 由你 Close 决定 跨调用保持
|
||||
//
|
||||
// 需要状态就 New 一个实例、用完 Close;不需要就直接 Call 走池。
|
||||
//
|
||||
// 本库不代管实例的生命周期——没有按 key 复用、没有空闲回收。要长期持有(比如按设备 ID
|
||||
// 存着),业务侧自己拿 map 存,跟 Go 版 controller 的写法一致。
|
||||
//
|
||||
// 并发粒度:不同实例完全并行;同一个实例的多次调用串行——那是保住 this.xxx 必需的,
|
||||
// 跟 Go 侧用 sync.Mutex 保护 struct 字段是一回事。
|
||||
//
|
||||
// # 作用域:让多个脚本共享数据
|
||||
//
|
||||
// 作用域通过 ctx 传递,只携带一段业务流程里要共享的东西,不管任何生命周期:
|
||||
//
|
||||
// st := store.New()
|
||||
// ctx = jscriptx.WithScope(ctx,
|
||||
// jscriptx.ScopeExtensions(st),
|
||||
// jscriptx.ScopeGlobals(map[string]any{"user": u}),
|
||||
// )
|
||||
//
|
||||
// obj1, err := cartCtrl.New(ctx) // 两个 controller
|
||||
// obj2, err := orderCtrl.New(ctx) // 同一个 ctx → 同一份 store
|
||||
//
|
||||
// 共享靠的是 Go 侧对象,不是共用 Runtime——共用 Runtime 会让作用域内所有脚本被迫串行,
|
||||
// 那才是真的并发瓶颈。现在各脚本各跑各的,只是手里的 store 指向同一个 Go 对象。
|
||||
//
|
||||
// 扩展就是「给脚本添一个全局对象」,实例由你创建(见 Extension)。Go 侧和脚本读写的
|
||||
// 天然是同一份,不用再取回来。本库不带内置扩展,扩展由调用方自己定义。
|
||||
//
|
||||
// # 多返回值约定(脚本作者唯一需要额外理解的规则)
|
||||
//
|
||||
// Go 函数的多返回值到了 JS 侧会按下面的规则转换,这是 goja 的行为,本库沿用:
|
||||
//
|
||||
// - func() T → 脚本拿到裸值
|
||||
// - func() (T, error) → error 为 nil 时拿到裸值 T;非 nil 时变成 JS 异常,
|
||||
// 脚本可以 try/catch,不 catch 就冒泡成本包的 KindRuntime 错误
|
||||
// - func() (A, B) → 脚本拿到数组 [A, B],用下标取
|
||||
// - func() (A, B, error) → error 为 nil 时拿到数组 [A, B];非 nil 时抛异常
|
||||
//
|
||||
// 也就是说 error 永远不出现在返回值里,它只会变成异常。db 的 ToSQL() 是典型例子:
|
||||
//
|
||||
// var r = sd.ToSQL() // r[0] 是 SQL 字符串,r[1] 是参数数组
|
||||
//
|
||||
// 另外 Go 的 nil 到脚本里是 null,不是 undefined。
|
||||
//
|
||||
// # async 可以用,但没有事件循环
|
||||
//
|
||||
// async 方法返回的 Promise 由本库自动解包,用起来跟同步方法一样。但 goja 没有事件循环,
|
||||
// 脚本里等不了真正的异步(定时器、网络、IO)——那种 Promise 永远 pending,会得到
|
||||
// ErrPromisePending。异步的活交给 Go 侧做,脚本只写同步逻辑。
|
||||
//
|
||||
// # 值不能跨出脚本边界
|
||||
//
|
||||
// Call 的返回值和 CallInto 的目标都不允许是 JS 函数/闭包:那种值绑在 VM 上,跨出边界
|
||||
// 就失效了,本库会直接拒绝(ErrValueEscape)。需要回调语义用 WithCall,它在 VM 借出
|
||||
// 期间完成整个交互,见 Caller 和 ExampleCaller。
|
||||
//
|
||||
// # 安全边界
|
||||
//
|
||||
// 脚本能看见的东西,只有 WithGlobals 显式放行的那些,加上 JS 语言自带的内置对象
|
||||
// (goja 不提供文件、网络、require,也没有 setTimeout)。白名单对象注入时会逐层拷贝成
|
||||
// 只读 JS 对象,脚本改不动,也不会跨 VM 共享同一个可变的 Go map。
|
||||
//
|
||||
// 本库不预设放行哪些 API——那取决于你的框架。原则是只放行「不带数据库连接、不能自己
|
||||
// 发起查询」的纯构造器和常量:放行构造列名/表名/字面量的那些,不放行能凭空造出查询
|
||||
// 数据集的(绕过资源层)、能往 SQL 里塞裸片段的(可以挂子查询探测别的表)、
|
||||
// 以及任何数据库连接对象。脚本要碰数据,由 Go 侧把已经过权限包装的资源对象当参数传进去。
|
||||
//
|
||||
// # 失控脚本
|
||||
//
|
||||
// 每次调用都带超时(WithTimeout,默认 5 秒)。超时或调用方 context 取消时,会从另一个
|
||||
// goroutine 中断脚本执行,死循环也能断掉。被中断过或 panic 过的 VM 直接丢弃不回池,
|
||||
// 避免状态污染。
|
||||
//
|
||||
// 脚本执行期间的 panic(脚本里的类型错误、注入进去的 Go 方法内部 panic)都会被 recover
|
||||
// 成 *Error 返回,不会掀翻调用方的 goroutine。
|
||||
//
|
||||
// # 错误
|
||||
//
|
||||
// 所有错误都是 *Error,带 Kind 分类、脚本名、函数名、脚本侧调用栈(行列号)和调用参数
|
||||
// 摘要,并实现了 slog.LogValuer:
|
||||
//
|
||||
// if err != nil {
|
||||
// logger.Error("脚本执行失败", slog.Any("err", err))
|
||||
// }
|
||||
//
|
||||
// 行号指向 .ts 源文件而不是打包产物——esbuild 输出 inline sourcemap,goja 自带 sourcemap 支持。
|
||||
//
|
||||
// 也可以用 errors.Is 匹配 ErrTimeout、ErrFuncNotFound、ErrScriptNotFound、ErrValueEscape
|
||||
// 等哨兵错误。
|
||||
package jscriptx
|
||||
Reference in New Issue
Block a user