call-flow.md 讲的是三个阶段各自在做什么,缺的是**函数之间怎么串起来的**——
想改代码、想知道加一层该加在哪,没有一处能查。
docs/flow.md 七张图:
全景 公开 API → 注册表 / 取 VM / 调用 三条链
runner 三态 Script / Instance / staticTarget 的 acquire+finish 差别,
「同一实例串行」就是 acquire 到 finish 之间一直持着 i.mu
newVM 内部 为什么 scope 是显式参数而不是从 ctx 嗅
作用域进 VM WithScope → vmGlobals → bind → lazyGlobal → freeze 五跳,
这是全库最容易看不清的一条链
invoke 内部 含 defer 的注册顺序(finish → stop → recover,后进先出)
lookup 三级 exports → ctor 静态 → 全局兜底,解释了 Script.Has 为什么
对静态方法也返回 true
编译链 Prepared 为什么不是优化开关而是正确性要求
写的时候实地核对了每条断言,抓出三处我自己写错的:
1. 图上把 Bundle → FinalizeBundle 画成两步,实际 Bundle 末尾自己就调了
2. 把「省 44% 常驻内存」归因给惰性注入——那是关掉 console 的收益
3. 「只读全局赋值静默失败」只对全局本身成立,freeze 拷出来的嵌套只读属性
(store.Set = null)实测是抛错的
顺带修一处上一轮改名的残留:engine.go 的注释还指着 lazyglobal_test.go。
185 lines
9.4 KiB
Go
185 lines
9.4 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")
|
||
//
|
||
// # 这个库能做什么
|
||
//
|
||
// 核心就五个概念,覆盖九成用法:
|
||
//
|
||
// Engine 引擎。管编译缓存和配置,一个进程一个
|
||
// Script 一份编译好的脚本 + 它的 VM 池。热更新时整体顶替
|
||
// Instance 独占一个 VM 的实例,脚本里 this 上的状态跨调用保持
|
||
// Scope 作用域。让同一个 ctx 下的多个脚本共享 Go 侧对象
|
||
// Extension 扩展。给脚本添一个全局对象,并附上 TS 类型
|
||
//
|
||
// 用不到就不用看的(按需要翻):
|
||
//
|
||
// 脚本从哪来 Loader / LoaderFunc / Prepared / Versioned / Tag / Overlay
|
||
// 默认从目录加载(esm 子包)。要从数据库、配置中心取,实现 Loader;
|
||
// 多来源叠加、后面盖前面,用 Overlay
|
||
// 自己打包 Bundle / FinalizeBundle / BundleOption
|
||
// 只有一段源码、不走目录时用
|
||
// 自定义调用约定 Caller / Result / WithCall
|
||
// 本库不预设脚本回调该长什么样。要按形参个数分派、要传 Go 闭包
|
||
// 当 next,用它
|
||
// 观测与排错 Stats / Error / Kind / Frame
|
||
// VM 池统计;错误带齐「哪个脚本、哪个函数、脚本里哪一行」
|
||
//
|
||
// 明确**不做**的:不代管实例生命周期(没有按 key 复用、没有空闲回收)、
|
||
// 没有事件循环(定时器、网络、真正的异步都没有)、不提供沙箱隔离
|
||
// (脚本能拿到你放行的 Go 对象,它们的方法是真能调的)。
|
||
//
|
||
// 想看函数之间怎么串起来的(调用链、作用域从哪一步进 VM、想加一层该加在哪),
|
||
// 见 docs/flow.md。
|
||
//
|
||
// # 基本用法
|
||
//
|
||
// 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
|