Files
jscriptx/doc.go
T
what 98eb734caa docs: 把能力面摆出来,更新性能数字
起因是「不知道有什么功能」——公开 API 有 14 个类型、17 个 With* 选项,全部平铺
在同一层文档里,看不出哪些是必须懂的。

doc.go 和 README 顶部都加了分层的能力清单:核心五个概念(Engine / Script /
Instance / Scope / Extension,覆盖九成用法),加上「用不到就不用看的」四类。
同时写清楚明确**不做**的三件事:不代管实例生命周期、没有事件循环、不提供沙箱隔离
(脚本能拿到你放行的 Go 对象,它们的方法是真能调的)。

性能数字重测了一遍。InstanceCall 454 → 300 ns——删掉 lastUsed 省下的两次
time.Now() 兑现了。

顺带加了一句测量方法的提醒:这些数字取的是多轮**最小值**。我这轮一度以为
Script.Call 退化了 27%,逐个 commit 二分下去发现跳变落在一个只删死代码和改注释
的 commit 上——那不可能影响调用路径。加大样本后两边最小值持平,是机器噪声。
中位数会被离群值带偏,微基准在有别的负载时能飘 30%。
2026-09-10 15:44:19 +08:00

182 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 对象,它们的方法是真能调的)。
//
// # 基本用法
//
// 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 sourcemapgoja 自带 sourcemap 支持。
//
// 也可以用 errors.Is 匹配 ErrTimeout、ErrFuncNotFound、ErrScriptNotFound、ErrValueEscape
// 等哨兵错误。
package jscriptx