约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.go。
engine.go 355 → 231 行。原来混了三件不相干的事
engine_globals.go ← bind / lazyGlobal / freeze / defineReadOnly(96 行)
它是「Go 值 → 只读 JS 全局」的转换层,跟脚本缓存毫无关系
engine_console.go ← console.go,跟上面是同一主题
script.go 325 → 100 行,只留公开方法
script_vm.go ← VM 的取、还、装载。上个 commit 合一的三条路径现在住一起
script_static.go ← static.go
errors.go 364 → 150 行
errors_goja.go ← goja 错误的翻译层
errors_hints.go ← missingGlobalHint,一份 JS 运行时知识库,跟错误分类是
两回事;拆出来之后 missing_global_test.go 才有对应源文件
bundle_finalize.go ← esmwrap.go
bundle.go 收下 validIdent / hashVersion(原来住在 engine.go)
Engine.New 原来排在所有私有函数之后,挪到导出方法那一段。
classify 83 → 54 行:四个 errors.As 分支各手搓一个 8 字段的 &Error{},脚本上下文
那三行重复了 4 遍,抽出 gojaError 构造器。
顺带修四处注释漂移:
Session/会话 代码里叫 Instance,注释里大面积残留。engine_globals.go 那条
「注入会话全局对象失败」还是用户可见文案,而公开 API 里根本
没有「会话」这个概念
Extension 文档示例写 Module() string,接口是 Module() (path, source string)。
这是唯一一段教人写扩展的文档,照抄编译不过
doc.go 的 freeze 说白名单「逐层拷贝成只读对象,不会跨 VM 共享可变的 Go map」,
但那只对 map[string]any 成立。结构体指针和 slice 是**共享同一个
对象**的——扩展走的正是这条路,不该被当成隔离保证
155 lines
7.8 KiB
Go
155 lines
7.8 KiB
Go
// 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)。
|
||
//
|
||
// 注入时的拷贝**只对 map[string]any 成立**:那种值会逐层拷成只读 JS 对象,脚本改不动,
|
||
// 各个 VM 拿到的也是各自的副本。放行的是结构体指针、slice 或别的 Go 对象时,
|
||
// **各个 VM 共享的是同一个对象**,脚本通过它的方法改到的东西是真改了。要跨 VM 共享
|
||
// 可变状态就该这么用(扩展走的正是这条路),但别把它当成隔离保证。
|
||
//
|
||
// 本库不预设放行哪些 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
|