diff --git a/README.md b/README.md new file mode 100644 index 0000000..14be815 --- /dev/null +++ b/README.md @@ -0,0 +1,765 @@ +# jscriptx + +用嵌入式 JS 引擎承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。 + +脚本用 **ESM + TypeScript** 编写、按目录组织,Go 侧按路径把它们当普通对象实例化并调用方法。 +底层是 [goja](https://github.com/dop251/goja)(纯 Go 的 JS 引擎)+ +[esbuild](https://github.com/evanw/esbuild)(纯 Go 的打包器),**依赖就这两个**,公开 API +不暴露任何 goja 类型。 + +goja 的反射会自动包装 Go 对象,框架里的链式 API 在脚本里照原样写,不需要为每个方法写胶水代码: + +```ts +resource.GetDBTable(user, req.WithPermission(req.ResAll)) + .Where(db.C("name").Eq("测试产品")) + .Select("name", "cost_price") +``` + +## 文档 + +| | | +| --- | --- | +| [docs/call-flow.md](docs/call-flow.md) | 执行流程:源码怎么变成可执行的、一次调用经过哪些环节、错误怎么分类 | +| [docs/lifecycle.md](docs/lifecycle.md) | 生命周期:Engine / Script / VM / Instance / 作用域各活多久,状态该放哪 | + +## 快速开始 + +``` +app/ +├── package.json npm 配置 +├── tsconfig.json esbuild 自动往上找到并生效 +├── node_modules/ 依赖 +└── src/ ← NewLoader 指这里 + ├── PkgVersion/ + │ └── PkgImportController.ts + └── Resource/ + ├── ResCreateController.ts + └── ResExecuter.ts ← 可以被同目录的 controller import +``` + +```ts +// app/src/Resource/ResCreateController.ts +import ResExecuter from "./ResExecuter" + +export default class ResCreateController { + private ex = new ResExecuter() + private count = 0 + + constructor(private resName: string) {} + + Init(): string { return "ready: " + this.resName } + Store(cfg: string): string { this.count++; return this.ex.Store(cfg) } +} +``` + +```go +loader, err := esm.NewLoader("app/src") +e, err := jscriptx.New( + jscriptx.WithLoader(loader), + jscriptx.WithAutoReload(true), // 改了 .ts 不用重启 + 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 只补这两件事: + +| | goja | 靠 esbuild | +| --- | --- | --- | +| class / async / 解构 / 可选链… | ✅ 原生 | 不需要 | +| `import` / `export` | ❌ 保留字 | ✅ 打包时内联,产物里一个不剩 | +| TypeScript | ❌ | ✅ 转译掉 | +| node_modules 公共库 | ❌ | ✅ 解析 + 内联 | + +交给 goja 的最终产物是**普通 JS 语法**,不含任何模块系统的东西。 + +## 目录与入口 + +默认入口规则是**第一层子目录下的所有 `.js`/`.ts`**(`*/*.js`、`*/*.ts`),按 `目录/文件名` 寻址。 +被 `import` 的模块不必是入口;想把辅助模块挡在入口之外,用 `esm.WithGlobs("*/*Controller.ts")` +收窄——挡在入口外不影响它被 import。 + +**加载器指向 `src` 而不是 `app`**,寻址就还是干净的 `Resource/ResCreateController`, +不带 `src/` 前缀;`node_modules` 和 `tsconfig.json` 在上层,esbuild 逐级往上都能找到。 + +`tsconfig.json` 是完整生效的,实测过这两项: + +```jsonc +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@lib/*": ["./src/lib/*"] }, // 路径别名 + "experimentalDecorators": true // 装饰器,@Route("/api") 之类 + } +} +``` + +## 多层覆盖 + +`WithLoader` 可以给多个 Loader,它们叠成一层层的,**后面的盖前面的**—— +取脚本时从最后一层往前找,谁先有就用谁的: + +```go +base, _ := esm.NewLoader("app/src") +custom, _ := esm.NewLoader("custom/src") + +e, _ := jscriptx.New(jscriptx.WithLoader(base, custom)) // custom 盖 base + +// custom/src/Resource/ResCreateController.ts 会盖掉 app/src 里的同名脚本 +obj, _ := e.New(ctx, "Resource/ResCreateController") +``` + +把「定制层」放最后,业务侧放一份同名脚本就能改写默认实现,不用动被覆盖的那一份。 +撤掉定制层的文件、重新打包后会自动落回下面那层。 + +每层是**独立的 Loader**,各有各的配置——入口规则、目标版本、`node_modules` 位置、 +扩展模块都可以不一样;来源也不必相同,一层来自磁盘目录、另一层来自数据库都行: + +```go +disk, _ := esm.NewLoader("app/src") +db := jscriptx.PreparedFunc(func(name string) (string, string, error) { + row, err := queryScript(name) // 找不到时返回包着 ErrScriptNotFound 的错误 + if err != nil { + return "", "", err + } + bundled, err := jscriptx.Bundle(name, row.Source) // 自己打包 + if err != nil { + return "", "", err + } + return bundled, row.UpdatedAt, nil +}) + +e, _ := jscriptx.New(jscriptx.WithLoader(disk, db)) // 数据库里的脚本盖住磁盘上的 +``` + +各层的 `Prepared` 必须一致(要么都自己打好包,要么都交出原始源码),否则 `New` 报错—— +引擎只能对整个 Loader 做一次判断,没法分脚本区别对待。上面那段就是靠 `PreparedFunc` ++ `Bundle` 把数据库那层也变成「打好包的」,好跟 `esm.Loader` 那层对齐。 + +### 点名要哪一层 + +给层贴上版本标签,调用时就能指定要哪个版本的实现: + +```go +v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1")) +v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2")) +e, _ := jscriptx.New(jscriptx.WithLoader(v1, v2)) + +e.New(ctx, "Resource/ResCreateController") // 不点名:上层盖下层,拿到 v2 +e.New(ctx, "Resource/ResCreateController@v1") // 点名:要 v1 那一层的 +``` + +点名是**只认这一层**:那层没有这个脚本就直接报 `ErrScriptNotFound`,不会掉到别的层去—— +不然点名要 v1 却跑了 v2 的实现,比报错难查得多。 + +各版本在引擎里是各自独立的缓存条目,热更新互不干扰。标签之间不能重名, +标签里也不能带 `@`。 + +不是 `esm.Loader` 的层用 `jscriptx.Tag` 贴标签: + +```go +jscriptx.WithLoader(disk, jscriptx.Tag("hotfix", dbLoader)) // Load("Foo/Bar@hotfix") +``` + +不经过 Engine 也能直接用这套叠放:`jscriptx.Overlay(base, custom)` 返回一个 +`*OverlayLoader`,除了 `Load` 还有 `Names()`(汇总各层入口,去重排序)、 +`Versions()`(各层的版本标签)和 `Rebuild()`(挨个让各层重建)。 + +## 公共库:node_modules + +esbuild 原生实现了 Node 的模块解析算法: + +```ts +import { upper } from "tinylib" // 裸模块名 +import { money } from "@fsdpf/util" // scoped 包,入口可以是 .ts +import ResExecuter from "./ResExecuter" // 相对 import 混用没问题 +``` + +从入口文件所在目录**逐级往上**找 `node_modules`,所以放在 `app/node_modules` 或它的任意 +上层目录都能找到。公共库在完全不相干的路径下时用 `esm.WithNodePaths("/opt/fsdpf/js-libs")` +指过去(传进去的目录本身相当于一个 `node_modules`,包直接放在它下面,不要再套一层); +要把某个模块名钉死到一份实现上用 `esm.WithAlias`。 + +两个细节:`node_modules` 里的文件不会被当成入口;它们也**不参与热更新的版本计算**—— +依赖包是装出来的,改动总伴随显式的安装动作,而真实的 npm 包动辄上千个文件,每次取脚本 +stat 一遍太贵。装完或换版本后调 `loader.Rebuild()`。 + +> esbuild 只**解析** `node_modules`,不下载。包怎么进去(npm/pnpm 安装、直接提交进仓库)由你决定。 + +### 挑第三方库要看两件事 + +**一、有没有用 goja 没有的全局。** esbuild 的 `target` 只降级**语法**,不补**全局对象**。 +所以库的语法多新都无所谓,但只要它调了下面这些,就是运行期才炸: + +``` +goja 有: Promise Proxy BigInt Object.fromEntries Object.hasOwn + Array#at / #flat / #findLast String#replaceAll RegExp#matchAll + +goja 没有:setTimeout setInterval queueMicrotask ← 没有事件循环 + structuredClone Object.groupBy + TextEncoder Intl WeakRef Symbol.asyncIterator +``` + +碰到这些时本库会在 `ReferenceError` 后面补一句说明,不至于让人以为是打包漏了依赖。 + +**二、能不能 tree-shake。** 每建一个 VM 都要把 bundle 跑一遍,体积直接变成并发成本。 +同样一组功能(groupBy / sum / chunk / get / isString / cloneDeep)实测下来: + +| 写法 | 打包产物 | 建 VM | +|---|---|---| +| 手写原生 | 1.8 KB | 0.02 ms | +| `radash` | 3.9 KB | 0.04 ms | +| `es-toolkit` | 7.0 KB | 0.03 ms | +| `es-toolkit/compat`(lodash 兼容 API) | 48 KB | 0.11 ms | +| `ramda` | 70 KB | 0.48 ms | +| `lodash-es` | 132 KB | 0.66 ms | +| **`lodash`(CommonJS)** | **419 KB** | **2.69 ms** | + +**CommonJS 包摇不动**——`import { isString } from "lodash"` 只挑一个函数,产物照样 411 KB。 +要用 lodash 就装 `lodash-es`,或者只从子路径 import(`lodash/isString`,8 KB)。 + +`remeda` 的 `clone` 依赖 `structuredClone`,在 goja 里直接报错。`ramda` 是唯一整包 +零定时器引用的,但体积是 es-toolkit 的十倍、API 是柯里化风格,除非确实需要那套写法, +否则不值得。 + +## 入口形态 + +产物是自包含的立即执行函数,所以**脚本必须有 `export`**——没有导出的顶层代码会被当死代码 +摇掉(真忘了写会得到一句明确的报错,不是莫名其妙的 `undefined`)。 + +```ts +export default class DeviceHandler { // 由本库实例化,构造参数从 Go 侧传 + constructor(deviceId: string) {} + onMessage(payload: string) {} +} + +export default new DeviceHandler() // 直接用这个实例 +export default { onMessage(p) {} } // 对象当实例,按方法名调用 +export default function handle(x) {} // 单函数入口,用 jscriptx.DefaultFunc 调用 +export function Options() {} // 只有命名导出时,整个模块当实例 +``` + +拿到实例的几种形态都是按方法名调用,`this` 绑定到实例,**继承来的方法也找得到**。 + +## 脚本级的钩子:静态方法 + +有些事情不属于任何一次调用——比如脚本第一次被启用时配置一下 Go 侧的扩展。写成 class 的**静态方法**,用 `CallStatic` 调: + +```ts +export default class PkgImportController { + static Startup() { store.Set("pkg.registry", "https://…") } + + constructor() { } // 每次调用的准备 + Execute(g) { } +} +``` + +```go +_, err := script.CallStatic(ctx, "Startup") +if err != nil && !errors.Is(err, jscriptx.ErrFuncNotFound) { + return err // ErrFuncNotFound 表示脚本没写这个钩子,是常态 +} +``` + +跟 `Call` 有两点不同,都是"静态"这个语义要求的: + +- **不构造实例**——`constructor` 不会跑。它做的是"这一次调用的准备",跟脚本级的初始化无关 +- **VM 用完就丢**,不回池 + +所以静态方法**建不出能存活的 JS 对象**——VM 一丢就没了。它的用处是把配置写进 Go 侧的扩展,那些副作用留在 Go 那边,后续每次调用都读得到。`ScopeExtensions` 在静态方法里照常可用,这正是它的用武之地。 + +`HasStatic(fn)` 能查有没有,但它也要建一个 VM;只想"有就调"的话直接 `CallStatic` 判 `ErrFuncNotFound` 更省。 + +## 脚本跑在非严格模式下 + +打包产物里**没有** `"use strict"`——esbuild 输出 ESM 格式时不加这个指令,包成 IIFE 时也没加。 + +一个后果是给只读全局赋值会**静默失败**而不是抛错: + +```ts +store = {} // 什么都不会发生,也不报错 +``` + +白名单和扩展仍然是真正只读的(改不动、删不掉),只是脚本改它时得不到任何提示。 + +## 多返回值:error 不出现在返回值里,它变成异常 + +这是脚本作者唯一需要额外理解的"非直觉"行为,是 goja 的转换规则,本库沿用: + +| Go 函数签名 | 脚本里拿到什么 | +| --- | --- | +| `func() T` | 裸值 `T` | +| `func() (T, error)` | `error` 为 nil:裸值 `T`;非 nil:抛 JS 异常 | +| `func() (A, B)` | 数组 `[A, B]` | +| `func() (A, B, error)` | `error` 为 nil:数组 `[A, B]`;非 nil:抛 JS 异常 | + +```ts +const r = sd.ToSQL() // r[0] 是 SQL,r[1] 是参数数组 +try { risky() } catch (e) { /* Go 侧返回的 error 在这里 */ } +``` + +脚本不 catch 的话,异常会冒泡成 Go 侧的 `*jscriptx.Error`,原始的 Go error 挂在 `Cause` 上, +调用方的 `errors.Is` 照样匹配得到。 + +另外 Go 的 `nil` 到脚本里是 **`null` 不是 `undefined`**。 + +## async 可以用,但没有事件循环 + +`async` 方法返回的 Promise 由本库自动解包,用起来跟同步方法一样: + +```ts +async Load(id: string): Promise { + const raw = await Promise.resolve(id) // 纯计算的 await 没问题 + return raw +} +``` + +但 **goja 没有事件循环**,脚本里等不了真正的异步(定时器、网络、IO)——那种 Promise 永远 +pending,会得到 `ErrPromisePending` 和一句说明。异步的活交给 Go 侧做,脚本只写同步逻辑。 +Promise 被 reject 则转成普通的 Go error(`ErrPromiseRejected`)。 + +微任务(`.then` 回调)会在**调用返回给 Go 之前**跑完,不会漏到下一次调用里——所以池化的 +VM 不存在"上个请求的回调在这个请求里执行"这种串扰。 + +`setTimeout` 等定时器**没有提供,也不打算提供**:它需要一个拥有 VM 的事件循环,而 VM 是 +池化复用的——定时器没触发时 VM 还不了池,回调真跑起来时请求上下文早没了。这几个全局 +故意保持 `undefined`(而不是定义成"一调就报错"的桩),库里 `typeof setTimeout !== "undefined"` +的特性探测才能正常降级;真调用了会在错误里说明该怎么办。 + +## 共享只到代码层面,不到状态层面 + +多个脚本 `import` 同一个模块时,各自拿到的是**独立副本**:模块被内联进各自的产物是一层原因, +更根本的是每个实例独占一个 VM,VM 之间不共享任何 JS 状态。 + +| 公共的东西 | 能不能 import 共享 | +| --- | --- | +| 纯函数、工具类、常量、类型 | ✅ 随便用 | +| 模块级状态(缓存、计数器、连接) | ❌ 各自一份,要共享得走作用域扩展 | + +这是那种低负载看不出问题、一上量才暴露的坑。 + +--- + +# Go 侧怎么调 + +## 两种执行方式 + +区别只有一个——**脚本实例活多久**: + +| 方式 | 取 VM | 实例生命周期 | 脚本里的 `this.xxx` | +| --- | --- | --- | --- | +| `Script.Call` | 从 VM 池借 | = VM 生命周期 | 随时可能归零,**只能当缓存** | +| `Script.New` → `Instance` | 独占一个 VM | 由你 `Close` 决定 | 跨调用保持 | + +```go +// 需要状态:New 一个实例,用完 Close +obj, err := e.New(ctx, "Resource/ResCreateController", "产品") +defer obj.Close() +obj.Call(ctx, "Store", cfg) + +// 不需要状态:直接 Call,走池 +s, _ := e.Script("Resource/ResQueryController") +s.Call(ctx, "Query", id) +``` + +`New` 会立刻建好 VM,所以 `constructor` 抛异常在 `New` 当场就报(`Func` 字段是 `"constructor"`)。 + +**本库不代管实例的生命周期**——没有按 key 复用、没有空闲回收。要长期持有(比如按设备 ID 存着), +业务侧自己拿 map 存,跟 Go 版 controller 的写法一致: + +```go +var devices sync.Map // deviceID -> *jscriptx.Instance + +func onConnect(id string) { + obj, _ := mqttCtrl.New(ctx, id) + devices.Store(id, obj) +} +func onDisconnect(id string) { + obj, _ := devices.LoadAndDelete(id) + obj.(*jscriptx.Instance).Close() +} +``` + +## 并发粒度 + +| 场景 | 能并发吗 | +| --- | --- | +| 不同实例(不同请求、不同设备) | ✅ 各自独占 VM | +| 同一作用域下的不同 controller | ✅ 各有各的 VM,只共享扩展 | +| **同一个实例**的多次调用 | ❌ 串行 | +| 不带作用域的池化调用 | ✅ 池里多个 VM | + +只有第三种是串行的,而那是**必需**的——`this.count++` 要跨调用保持,就必须保证同一时刻只有 +一个 goroutine 在动这个实例,跟 Go 侧用 `sync.Mutex` 保护 struct 字段是一回事。 + +## 作用域:让多个脚本共享数据 + +作用域通过 ctx 传递,只携带一段业务流程里要共享的东西,**不管任何生命周期**: + +```go +st := store.New() +ctx = jscriptx.WithScope(ctx, + jscriptx.ScopeKey(requestID), // 可选,脚本里 scope.key + jscriptx.ScopeExtensions(st), // 扩展 + jscriptx.ScopeGlobals(map[string]any{"user": u}), // 作用域专属全局 +) + +obj1, _ := cartCtrl.New(ctx) // 两个 controller +obj2, _ := orderCtrl.New(ctx) // 同一个 ctx → 同一份 store +``` + +```ts +// CartController.ts +export default class CartController { + Add(sku: string, qty: number) { store.Incr("total", qty) } +} +// OrderController.ts —— 另一个文件、另一个 VM,但读得到同一份 store +export default class OrderController { + Checkout() { return store.Get("total") } +} +``` + +**共享靠的是 Go 侧对象,不是共用 Runtime**——共用 Runtime 会让作用域内所有脚本被迫串行, +那才是真的并发瓶颈。现在各脚本各跑各的,只是手里的 `store` 指向同一个 Go 对象。 + +## 扩展 + +扩展就是"给脚本添一个全局对象",实例由你创建,跟着 ctx 走: + +```go +type Extension interface { + Name() string // 脚本里的全局名 + Bindings() map[string]any // 暴露的方法,大写开头 + Module() (path, source string) // 配套 TS 模块,脚本可以 import +} +``` + +**本库不带任何内置扩展**——扩展该由用引擎的人按自己的场景定义,引擎只给接口。 +(`ext/store` 曾经在这里,2026-09-05 搬到了 framework 侧,那里才知道"进程级共享状态" +对业务意味着什么。)写一个是这样: + +```go +type tx struct{ conn *sql.Tx } + +func (t *tx) Name() string { return "tx" } +func (t *tx) Bindings() map[string]any { + return map[string]any{"commit": t.conn.Commit, "rollback": t.conn.Rollback} +} +func (t *tx) Module() (string, string) { return "@fsdpf/tx", txTypings } +``` + +Go 侧和脚本读写的天然是同一份,不用再取回来: + +```go +st := store.New() +ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st)) +// …脚本里 store.Set("k", v)… +st.Get("k") +``` + +### 脚本可以 import 扩展拿类型 + +```go +loader, _ := esm.NewLoader("app/src", esm.WithExtensions(store.New())) +``` + +```ts +import store, { Store } from "@jscriptx/store" // 有类型提示 + +export default class C { + Put(k: string, v: string) { store.Set(k, v) } +} +``` + +模块是**虚拟的**——磁盘上没有这个文件,路径和源码由扩展的 `Module()` 提供,打包时内联。 +`WithExtensions` 只用于打包阶段(提供模块和类型),运行时注入哪些扩展仍由 ctx 决定。 +import 了却没注册,**打包阶段就会报错**,不会拖到运行时。 + +### 把类型落盘给编辑器 + +虚拟模块只有 esbuild 看得见,编辑器解析不了 `import store from "@jscriptx/store"`,写脚本一路飘红。落盘一份就好了: + +```go +n, err := loader.WriteTypings("app/node_modules") +// app/node_modules/@jscriptx/store/{package.json,index.ts} +``` + +用 node_modules 的布局而不是 tsconfig 的 `paths`——编辑器和 tsc 本来就按 Node 规则往上找,不用改任何配置。写出来的只是类型,运行期用不到,随时可以删掉重写,也不该提交进版本库。 + +只要内容不落盘的话,`loader.Typings()` 返回 `import 路径 -> 源码`;不想先建 Loader 就用包级的 `esm.Typings(exts...)`。 + +### 注入是惰性的 + +扩展的方法集**不在建 VM 时转换**,而是装一个只读的访问器属性,脚本第一次读到那个全局名才把它转成 JS 对象,转完在这个 VM 里缓存。 + +一个脚本通常只用得上少数几个扩展,而转换要把每个方法都包装成 JS 函数——用不到的那些不该在每次建 VM 时都付一遍。实测这曾是脚本层剩余开销里最大的一块。 + +对脚本完全透明:读到什么就转什么,只读性、可枚举性都跟以前一样。唯一能观测到的差别是属性描述符从 `{value, writable}` 变成 `{get, set}`,业务代码不会碰到。 + +## 自定义调用约定 + +`Call`/`CallInto` 表达不了的调用模式——典型是「回调 + next」中间件——用 `WithCall`: + +```go +err := target.WithCall(ctx, "handle", func(c jscriptx.Caller) error { + switch c.Arity() { // 脚本函数声明了几个形参 + case 2: + res, err := c.Call(model, next) // next 是 Go 闭包,脚本能直接调 + ... + } + return nil +}) +``` + +完整实现(三种回调签名自动分派)见 `ExampleCaller`,`go doc` 里能看到。 +本库**不预设脚本回调该长什么样**——那是框架的约定,各家不同。 + +## 值不能跨出脚本边界 + +`Call` 的返回值和 `CallInto` 的目标都不允许是 JS 函数/闭包:那种值绑在 VM 上,跨出边界 +就失效了,本库会直接拒绝(`ErrValueEscape`)。需要回调语义用 `WithCall`。 + +--- + +# 安全边界 + +脚本能访问的全局对象,只有 `WithGlobals` 显式放行的那些,加上 JS 语言自带的内置对象。 +goja 不提供文件、网络、`require`,也没有 `setTimeout`。 + +白名单对象注入时会逐层拷贝成**只读** JS 对象:脚本改不动它,多个 VM 之间也不会共享同一个 +可变的 Go map(否则脚本一句 `db.C = null` 既污染别的 VM,又是实打实的数据竞争)。 + +## 白名单该怎么定 + +本库不预设放行哪些 API——那取决于你的框架。下面是一份可以照抄的参考,思路是 +**只放行"不带数据库连接、不能自己发起查询"的纯构造器和常量**: + +```go +func Safe() map[string]any { + return map[string]any{ + "db": map[string]any{ + "C": db.C, "T": db.T, "V": db.V, // 构造列名/表名/字面量,纯 SQL 片段 + }, + "req": map[string]any{ + "WithPermission": req.WithPermission, + // 标志位按 int 暴露而不是自定义数值类型:goja 会把后者包装成 JS 对象, + // 那样脚本里 req.ResRow | req.ResMask 这种按位运算就不成立了。 + // 传回 Go 侧时 goja 会自动转回原类型。 + "ResRow": int(req.ResRow), + "ResMask": int(req.ResMask), + "ResAll": int(req.ResAll), + }, + } +} +``` + +**刻意不放行**: + +| 不放行 | 原因 | +| --- | --- | +| `db.From` | 能凭空造出查询数据集,绕过资源层 | +| `db.L` | 能往 SQL 里塞裸片段,可以挂子查询探测别的表,等于绕过权限设计 | +| `engine` / 任何数据库连接对象 | 同上 | + +脚本要碰数据,由 Go 侧把**已经过权限包装的** `req.Resource` 或 dataset 当参数传进去。 +脚本自己拿不到裸连接,也就绕不开资源层的行级权限过滤和字段脱敏。 + +--- + +# 可靠性 + +## 失控脚本 + +每次调用都带超时(`WithTimeout`,默认 5 秒)。超时或调用方 context 取消时,会从另一个 +goroutine 中断脚本,**死循环也能断掉**。被中断过或 panic 过的 VM 直接丢弃不回池。 + +脚本执行期间的 panic(脚本里的类型错误、注入进去的 Go 方法内部 panic)都会被 recover +成 error 返回,不会掀翻调用方的 goroutine。 + +## 错误 + +所有错误都是 `*jscriptx.Error`,带分类、脚本名、函数名、脚本侧调用栈(行列号)和调用参数摘要, +并实现了 `slog.LogValuer`: + +```go +if err != nil { + logger.Error("脚本执行失败", slog.Any("err", err)) + // kind=runtime script=Resource/ResCreateController func=Boom line=15 column=25 + // at="Boom (Resource/ResCreateController:15:25)" msg="Error: 创建失败" +} +``` + +**行号指向 `.ts` 源文件而不是打包产物**:esbuild 输出 inline sourcemap,goja 自带 sourcemap 支持。 + +哨兵错误:`ErrTimeout`、`ErrFuncNotFound`、`ErrScriptNotFound`、`ErrValueEscape`、 +`ErrClosed`、`ErrUnsupportedSignature`、`ErrPromiseRejected`、`ErrPromisePending`、`ErrBadGlobal`。 + +## 热更新 + +版本号由参与打包的**所有**源文件的 mtime 算出,改了被 `import` 的公共模块也会触发重编译; +新增文件会被自动发现。换掉旧脚本时,已经拿着旧 `*Script` 的调用会继续跑完旧版本。 + +```go +e, _ := jscriptx.New(jscriptx.WithLoader(loader), jscriptx.WithAutoReload(true)) +``` + +--- + +# 性能 + +Apple M4 Pro,`go test -run XXX -bench . -benchmem`。每行都标了对应的基准名, +数字过时了可以自己重跑。 + +| 场景 | 基准 | 耗时 | 分配 | +| --- | --- | --- | --- | +| 纯 Go 基准线 | `BenchmarkNative` | 96 ns | 1 | +| `Instance.Call`(状态在 JS 实例里) | `BenchmarkInstanceCall` | 454 ns | 10 | +| `Script.Call`(同脚本同参数,架构对照) | `BenchmarkCall_同脚本对照` | 417 ns | 10 | +| `Instance.Call` + 作用域扩展 | `BenchmarkStoreExtension` | 1.05 μs | 29 | +| `Script.Call` + 传 Go 对象当参数 | `BenchmarkCall` | 1.76 μs | 57 | +| 同上,但传可取消的 context | `BenchmarkCall_带可取消context` | 4.33 μs | 63 | +| `Script.New`(建一个实例) | `BenchmarkInstanceNew` | 3.49 μs | 110 | +| 重新编译 + 建全新 VM | `BenchmarkVM新建` | 224 μs | 1687 | + +几点说明: + +- **池化和独占 VM 的架构开销几乎一样**(417 vs 454 ns)。差别大的是状态放哪:放 JS 实例 + 只要 0.45 μs,放作用域扩展 1.05 μs,靠参数把 Go 对象反射包装过去要 1.76 μs—— + 那个反射往返才是大头。 +- 打包和编译只在**加载和热更新**时发生,不在调用路径上。 +- 传可取消的 `context` 明显变贵(1.76 → 4.33 μs):哨兵要起 goroutine 盯 `ctx.Done()`; + 只有超时限制时走定时器快路径。MQTT 那种高频路径建议传 `context.Background()`, + 靠 `WithTimeout` 兜底。 + +## 内存 + +每个实例独占一个 VM,3000 个实例的常驻内存: + +| 配置 | 单实例 | 3000 个 | +| --- | --- | --- | +| 裸 VM | 7.0 KB | 20.5 MB | +| + `console` | 12.5 KB | 36.6 MB | +| + `console` + 一个扩展 | 21.8 KB | 64.0 MB | + +> 这组是一次性手工测的(建 N 个实例后读 `runtime.MemStats`),仓库里没有对应的 +> 自动化测试,换机器或改了注入逻辑之后不保证还准。上面那张表的数字才是可重跑的。 + +白名单和 console 只在**建 VM 时**注入,对每次调用零影响。而且注入是惰性的(见「注入是惰性的」), +脚本没读到的全局根本不会被转换——上表是「全都读到」的上界。实例多又不需要脚本日志时, +`WithLogger(nil)` 关掉 console 能省约 44%。 + +## 写脚本时的性能建议 + +### 循环里访问 Go 对象的方法,先取出来 + +goja 每次从 Go 对象上取方法都要**新建一个函数包装**,它不缓存。这里的「Go 对象」包括 +传进脚本的参数、扩展、以及扩展返回的对象。 + +`Benchmark绑定_*` 在 JS 里循环 1000 次,下表已减掉空循环的基线、除以次数, +是**单次访问**的净开销: + +| 写法 | 基准 | 耗时 | 分配次数 | 相对纯 JS | +| --- | --- | --- | --- | --- | +| `obj.name`(纯 JS 对象) | `绑定_纯JS属性` | 90 ns | 0.9 | 1× | +| `obj.Name`(Go 结构体字段) | `绑定_Go结构体字段` | 128 ns | 3.9 | **4.2×** | +| `fn("x")`(绑定的 Go 函数) | `绑定_Go函数` | 270 ns | 7.9 | **8.5×** | +| `obj.Get("x")`(Go 对象的方法) | `绑定_Go方法调用` | 675 ns | 19.9 | **21×** | +| 循环外先取出来再调 | `绑定_Go方法提前取出` | 319 ns | 10.0 | **10.6×** | + +```ts +for (const row of rows) out.push(tpl.Render(row)) // ⚠️ 每轮都重新包装 + +const render = tpl.Render.bind(tpl) // ✅ 包装一次 +for (const row of rows) out.push(render(row)) +``` + +要点: + +- **贵在属性访问,不在调用本身**。`obj.Get` 这一下就要造函数包装,把它提出来能省掉一半。 +- **读 Go 结构体的字段便宜得多**(3.9 次分配),因为不用造包装。 +- **纯 JS 对象几乎免费**(0.9 次)——它不跨语言。 + +所以要同时满足**循环**和**Go 对象**两个条件才值得改写。一次调用里访问三五次 +(三次约 2 μs)别为它牺牲可读性。 + +### 别在模块顶层做重初始化 + +顶层每建一个 VM 就跑一遍。顶层建一张 2000 条的查找表,建一个实例要 **2.17 ms / 1.74 MB**, +而轻量脚本只要 10 μs / 9 KB。要预计算就放 Go 侧做成扩展。 + +### 少往脚本里传 Go 对象 + +每次传参都要反射包装一遍,方法多的接口尤其贵——上表里 `Script.Call` 传 Go 对象比 +`Instance.Call` 贵近四倍,差的就是这个。 + +--- + +# 附录 + +## 不按目录加载时 + +`esm` 子包管的是"按目录加载 + 路径寻址 + 热更新"。只有一段源码时直接 `Compile`, +它内部同样走 esbuild(TypeScript 照写),区别只是没有文件系统上下文,默认不能 `import`: + +```go +s, _ := e.Compile("user_approval.ts", src) +out, _ := s.Call(ctx, "handle", arg) +``` + +要在这段源码里 `import`,给一个解析基准目录: + +```go +jscriptx.New(jscriptx.WithBundleOptions(jscriptx.WithResolveDir("app/src/Resource"))) +``` + +## 自定义脚本来源 + +脚本存数据库表或配置中心时,实现这个接口交给 `WithLoader`: + +```go +type Loader interface { + Load(name string) (source string, version string, err error) +} +``` + +`version` 用来判断脚本变没变(`updated_at`、源码哈希都行,返回空字符串则退化成按源码哈希算)。 + +给出的源码默认由 Engine 交给 esbuild 打包,直接返回原始源码即可。**自己已经打过包的**要再 +实现 `Prepared` 接口——这不是优化开关而是正确性要求:打包产物里已经没有 `export` 了, +再打一遍会被当死代码整段摇空。记得钉一行 `var _ jscriptx.Prepared = (*MyLoader)(nil)`, +因为 Engine 靠类型断言识别,方法签名写错不会有编译错误。 + +## 测试 + +```bash +go test -race ./... +``` + +不需要任何 build tag,依赖只有 goja 和 esbuild。 + +## 为什么用 esbuild 而不是 rollup + +在 goja 里跑 rollup 这条路验证过,走不通: + +- **rollup 4** 依赖 `WebAssembly.instantiate` 和 wasm-bindgen 那套 glue,goja 没有 WASM 支持。 + Go 侧确实能跑 WASM(wazero,纯 Go),但要在 goja 里实现 `WebAssembly` JS API、桥接线性内存、 + 实现几十个 wasm-bindgen 回调、再 polyfill `TextDecoder`/`fetch`——数周工程且性能很差。 +- **rollup 3** 是纯 JS 能跑,但要 polyfill `TextDecoder`/`TextEncoder`/`fetch`/`Buffer`/`process` + (实测缺 6 处),且不处理 `.ts`,还得再叠一个转译器。 + +esbuild 是 Go 原生的同类工具,一行依赖,原生支持 TypeScript,实测快两个数量级。 diff --git a/docs/call-flow.md b/docs/call-flow.md new file mode 100644 index 0000000..ce50cdb --- /dev/null +++ b/docs/call-flow.md @@ -0,0 +1,253 @@ +# 执行流程 + +一段脚本从源码走到 Go 侧拿到结果,中间经过三个阶段:**打包编译**、**取 VM**、**调用**。 + +```mermaid +flowchart TD + A["源码
ESM / TypeScript"] --> B{"从哪来"} + B -->|"Engine.Compile(name, src)"| C + B -->|"Loader.Load(name)"| P{"实现了 Prepared?"} + P -->|"否(默认)"| C["Bundle(filename, source)"] + P -->|"是(jscriptx/esm)"| E + + C --> C1["esbuild:TS 转译 · import 内联
· tree-shaking · ESM 格式输出"] + C1 --> D["FinalizeBundle
改写 export → 立即执行函数"] + D --> E["goja.Compile → *goja.Program"] + E --> F["*Script(进 Engine 缓存)"] + + F --> G{"怎么调"} + G -->|"Script.Call"| H1["从 VM 池借"] + G -->|"Instance.Call"| H2["锁住独占的 VM"] + H1 --> I + H2 --> I["invoke()"] + I --> J["结果 / *Error"] +``` + +`*goja.Program` 是**无状态的**,同一个脚本的所有 VM 共享同一份编译产物;每个 VM 只是各自 +执行一遍它,产生自己的函数对象和实例。 + +--- + +## 阶段一:打包编译 + +只在**脚本加载和热更新**时发生,不在调用路径上(单段源码约 204 μs,一个目录 9 个入口约 1.5 ms)。 + +### 为什么必须打包 + +goja 的 ES6+ 支持很完整(class、async、解构、可选链、Proxy、BigInt 实测都能直接跑), +但它**没有 ES module**——`import`/`export` 在 goja 的 token 表里是保留字,解析阶段就挂; +TypeScript 也不在它的职责范围。打包把这两件事在交给引擎之前抹平。 + +### 为什么用 ESM 格式而不是 IIFE + +esbuild 按 IIFE 或 CJS 格式输出时,会附带一整套 CommonJS interop helper: + +```js +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __export = (target, all) => { … }; +var __copyProps = (to, from, except, desc) => { … }; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +``` + +那套东西是为了模拟 `__esModule` 语义,本库根本用不上——我们只要拿到导出对象。而它的代价是 +实打实的:**每建一个 VM 都要重新创建那 7 个函数、再遍历一遍属性装 getter**。 + +只有 ESM 格式是零 helper 的(导出信息就在 `export {...}` 那句声明里,不做任何转换)。 +代价是那句 goja 不认,得由 `FinalizeBundle` 改写掉: + +```js +// 源码 // 产物 +// p.ts (() => {// p.ts +var H = class {…}; ───► var H = class {…}; +export { return H;})() + H as default +}; +``` + +改写有两条硬约束: + +- 开头的 `(() => {` **必须紧贴原第一行**,不能另起一行,否则所有行号下移一位, + sourcemap 就对不上,报错定位不回 `.ts` 源码 +- `export` 语句在产物末尾,把它整段换掉不影响前面任何行 + +只有命名导出时,改写成对象字面量 `return {a:a, b:b}`——仍然零 helper,而且是普通属性 +(不是 getter),后续按方法名取的时候更快。 + +实测差距: + +| | 产物(代码部分) | 建一个实例 | 每实例常驻 | +| --- | --- | --- | --- | +| IIFE 格式(带 helper) | 1077 字节 | 22.1 μs / 472 allocs | 25.7 KB | +| ESM + 改写 | **74 字节** | **4.8 μs / 110 allocs** | **7.0 KB** | + +### 认不出 export 时的兜底 + +改写只认末尾那段 `export {...};`。格式对不上就当作"脚本没有导出",产物求值为 `undefined`, +调用时得到明确报错——不会静默产生错误结果。守护测试盯着三件事:产物不含任何 interop helper、 +包装不让行号偏移(端到端验证第 3 行报错仍是第 3 行)、9 种导出形态都能正确改写。 + +--- + +## 阶段二:建 VM + +池里没有可用 VM 时才走这条路: + +```mermaid +flowchart TD + A["goja.New()"] --> B["engine.bind:注入白名单"] + B --> B1["逐层拷贝成只读对象
+ console + 作用域的扩展/全局"] + B1 --> C["装超时哨兵"] + C --> D["rt.RunProgram(prog)
执行打包产物"] + D --> E["取完成值 v"] + E --> F{"v 是什么"} + F -->|"class"| G["construct(v, ctorArgs)
→ exports"] + F -->|"函数"| H["defFn
(DefaultFunc 调用)"] + F -->|"对象"| I["exports
(按方法名调用)"] + G --> J["*vmHandle"] + H --> J + I --> J +``` + +白名单注入时**逐层拷贝成只读对象**,不是直接 `rt.Set`:否则同一个 Go map 会被所有 VM 共享, +脚本一句 `db.C = null` 既污染别的 VM,又是实打实的数据竞争。 + +顶层代码同样受超时保护——脚本在顶层写死循环不会把调用方卡住。 + +--- + +## 阶段三:调用 + +所有调用都汇到 `invoke()`,`*Script` 和 `*Instance` 的区别只在"怎么取 VM、怎么还": + +```mermaid +flowchart TD + A["invoke(ctx, runner, fn, args, do)"] --> B{"脚本已关闭?"} + B -->|"是"| Z1["ErrClosed"] + B -->|"否"| C["runner.acquire(ctx)
池借 / 加锁"] + C --> D["lookup(fn)
找方法 + 绑定 this"] + D -->|"找不到"| Z2["ErrFuncNotFound
+ 没写 export 的提示"] + D --> E["guard:装中断哨兵"] + E --> F["defer recover:兜 panic"] + F --> G["do(frame) → 真正调用"] + G --> H["unwrapPromise
async 结果解包"] + H --> I["Export / ExportTo
转成 Go 值"] + I --> J{"出错了?"} + J -->|"否"| K["healthy = true"] + J -->|"是"| L["classify(err) 分类"] + L --> M{"致命?"} + M -->|"超时/取消/panic"| N["healthy = false"] + M -->|"脚本异常"| K + K --> O["runner.finish(inst, healthy)"] + N --> O + O --> P["结果 / *Error"] +``` + +### defer 的顺序很关键 + +三个 defer 按注册顺序倒着执行,缺一不可: + +``` +注册:finish → stop → recover +执行:recover(置 healthy=false)→ stop(清中断标志)→ finish(归还/丢弃) +``` + +recover 必须最先跑,否则 panic 时 `healthy` 还是 true,一个状态可疑的 VM 会被放回池子。 + +### 超时哨兵 + +```mermaid +flowchart LR + A{"ctx.Done() 存在?"} -->|"否(快路径)"| B["time.AfterFunc
约 80ns"] + A -->|"是"| C["起 goroutine
select ctx.Done()"] + B --> D["触发 → rt.Interrupt()"] + C --> D + D --> E["stop():先关哨兵
再 ClearInterrupt"] +``` + +`stop()` 里的顺序有讲究:**必须先确保哨兵不会再发信号,再清中断标志**。反过来的话,一个迟到的 +`Interrupt` 会落在已经清理过的 VM 上,毒死下一次用到它的调用。快路径用互斥量保证 `stop` 之后的 +`fire` 一律丢弃;goroutine 路径等哨兵真正退出再清。 + +高频调用(MQTT 消息级)建议传 `context.Background()` 走快路径——传可取消的 context 会让每次 +调用多起一个 goroutine,实测 0.45 μs 涨到 4.2 μs。 + +### 错误分类 + +`classify()` 把 goja 的各种错误翻译成统一的 `*Error`: + +| goja 侧 | Kind | VM 还能用吗 | +| --- | --- | --- | +| `InterruptedError` + `DeadlineExceeded` | `KindTimeout` | ❌ 丢弃 | +| `InterruptedError` + `Canceled` | `KindCanceled` | ❌ 丢弃 | +| Go 侧 panic(recover 到) | `KindPanic` | ❌ 丢弃 | +| `Exception`(脚本 throw) | `KindRuntime` | ✅ 回池 | +| `StackOverflowError` | `KindRuntime` | ❌ 丢弃 | + +脚本里没 catch 的 Go error 会顺着 `Exception.Unwrap()` 取回来挂在 `Cause` 上, +调用方的 `errors.Is` 照样能匹配到自己的哨兵错误。 + +--- + +## 自定义调用约定 + +`Call`/`CallInto` 表达不了的模式——典型是「回调 + next」中间件——走 `WithCall`: + +```mermaid +flowchart LR + A["WithCall(ctx, fn, do)"] --> B["借 VM、装哨兵、兜 panic"] + B --> C["do(Caller)"] + C --> D["c.Arity()
看声明了几个形参"] + D --> E["c.Call(value, next)
next 是 Go 闭包"] + E --> F["res.IsEmpty() / Into(&out)"] + F --> G["归还 VM、分类错误"] +``` + +`next` 能传给脚本,靠的是 goja 会把 Go 函数包装成 JS 函数——所以不需要单独暴露"值转换"的概念。 +但这也意味着它**只在这次 VM 借出期间有效**,`Caller` 和 `Result` 都不能存下来跨调用用 +(`Result` 还会被同一个 `Caller` 的下次 `Call` 复用)。 + +完整实现见 `ExampleCaller`。本库不预设脚本回调该长什么样——那是框架的约定,各家不同。 + +--- + +## 值的跨界规则 + +```mermaid +flowchart LR + subgraph Go + A["Go 对象"] + D["Go 值"] + end + subgraph JS["VM 内部"] + B["反射包装的对象"] + C["返回值"] + end + A -->|"rt.ToValue
反射包装"| B + C -->|"Export / ExportTo"| D + C -.->|"函数/闭包
❌ ErrValueEscape"| D +``` + +**函数和闭包不能跨出脚本边界**:那种值绑在 VM 上,VM 归还池子后再调用会出问题,所以 +`Call` 的返回值和 `CallInto` 的目标都拒绝函数类型。需要回调语义走 `WithCall`——它在 VM +借出期间就把整个交互完成了。 + +Go 对象每次传进脚本都要反射包装一次,这是池化调用的主要开销:同一个业务(记录设备消息计数), +状态放 Go 侧靠参数传进去要 2.0 μs,状态放 JS 实例里只要 0.45 μs。 + +另外 Go 的 `nil` 到脚本里是 **`null` 不是 `undefined`**。 + +--- + +## 多返回值约定 + +Go 函数的多返回值到 JS 的转换规则(goja 的行为,本库沿用): + +| Go 签名 | 脚本里拿到 | +| --- | --- | +| `func() T` | 裸值 | +| `func() (T, error)` | error 为 nil:裸值;非 nil:**抛 JS 异常** | +| `func() (A, B)` | 数组 `[A, B]` | +| `func() (A, B, error)` | error 为 nil:`[A, B]`;非 nil:抛异常 | + +**error 永远不出现在返回值里**,它只会变成异常。这是脚本作者唯一需要额外理解的"非直觉"行为。 diff --git a/docs/lifecycle.md b/docs/lifecycle.md new file mode 100644 index 0000000..f16655b --- /dev/null +++ b/docs/lifecycle.md @@ -0,0 +1,250 @@ +# 生命周期 + +四层对象,从长到短:**Engine → Script → VM → 调用**。搞清楚谁活多久,就知道状态该放哪。 + +```mermaid +flowchart TD + E["Engine
进程级"] --> S1["Script
一份编译产物 + VM 池"] + E --> S2["Script"] + S1 --> P["VM 池
无状态调用复用"] + S1 --> I1["Instance
独占 VM,你 Close"] + S1 --> I2["Instance"] + P --> V1["vmHandle"] + P --> V2["vmHandle"] + I1 --> V3["vmHandle"] + I2 --> V4["vmHandle"] +``` + +| 层 | 活多久 | 谁结束它 | +| --- | --- | --- | +| `Engine` | 整个进程 | `Close()` | +| `Script` | 到下次热更新 | 被同名脚本顶替,或 `Engine.Close()` | +| VM(池里) | 不确定 | 出错被丢弃、池满被丢弃、`Script` 关闭 | +| VM(`Instance`) | 到你 `Close()` | **只有你**——本库不代管 | +| 作用域(ctx 里) | 跟着 ctx | 没有生命周期,只是数据载体 | + +--- + +## Engine:只管配置,不追踪实例 + +Engine 持有白名单、超时策略、Loader、打包选项和编译产物缓存。它**不知道任何 Instance 的存在**—— +没有会话注册表、没有按 key 复用、没有空闲回收器。 + +这是有意的:`New` 出来的实例由你拿着,生命周期归你;Engine 追踪它反而是多余的耦合,还要处理 +"调用方忘了 Close 但 Engine 还引用着"导致的泄漏。 + +`Instance` 也不直接持有 Engine,它通过 `script.engine` 拿配置。 + +--- + +## Script:热更新时被顶替 + +```mermaid +stateDiagram-v2 + [*] --> 已编译: Compile / Loader 加载 + 已编译 --> 已编译: 版本号没变,复用 + 已编译 --> 新Script: 版本号变了,重新编译 + 新Script --> [*]: 旧 Script.Close() + 已编译 --> [*]: Engine.Close() + + note right of 新Script + 旧 Script 上正在跑的调用 + 会用旧版本跑完,不受影响 + end note +``` + +热更新时 Engine 造一个**新的** `Script` 顶替旧的,然后关掉旧的。已经拿着旧 `*Script` 指针的 +调用会继续跑完旧版本;旧 `Script` 池里的 VM 被丢弃。 + +从旧 `Script` `New` 出来的实例不在这条链上——它们会因为 `script.closed` 而在下次调用时报 +`ErrClosed`,VM 等 GC 回收。 + +版本号由 `Loader` 给。`jscriptx/esm` 用的是**参与打包的所有源文件**的 mtime+size 哈希—— +所以改了被 `import` 的公共模块也会触发重编译。`node_modules` 里的文件不算在内,改依赖后要 +显式 `Rebuild()`。 + +多层叠放(`WithLoader` 给多个)时,版本号会带上是第几层给的。这一步不能省:覆盖层的 +脚本删掉后会落回下面那层,两层的版本号万一撞上,不带层号引擎就看不出脚本已经换了人, +会继续用旧的编译结果。 + +--- + +## VM 池:无状态调用 + +`Script.Call` 走这条。VM 从池里借,用完还回去: + +```mermaid +stateDiagram-v2 + [*] --> 池中: newVM() + 池中 --> 借出: borrow() + 借出 --> 池中: release(healthy=true) + 借出 --> 丢弃: release(healthy=false) + 池中 --> 丢弃: Script.Close() + 借出 --> 丢弃: 池满 + + note right of 丢弃 + 超时、取消、panic 过的 VM + 一律不回池 + end note +``` + +两个刻意的设计: + +**借不到不阻塞**——池空时直接新建一个。池只是复用缓存,不承担限流职责;并发超过池容量时 +宁可临时多造几个 VM,也不让请求在这里排队。用完如果池满了,多出来的直接丢弃。 + +**坏 VM 不回池**——被中断或 panic 过的 VM 状态不确定,重建远比拖着它划算。 + +这条路径下**脚本里的 `this.xxx` 随时可能归零**:同一个业务流程的连续调用大概率落在不同 VM 上。 +而且丢得没规律——低负载时可能一直命中同一个 VM,看着正常,一上量就出问题。 + +--- + +## Instance:当普通对象用 + +```mermaid +stateDiagram-v2 + [*] --> 就绪: New(ctx, args...) + 就绪 --> 执行中: acquire() 加锁 + 执行中 --> 就绪: finish(healthy=true) 解锁 + 执行中 --> 待重建: finish(healthy=false) + 待重建 --> 就绪: 下次调用惰性重建 + 就绪 --> [*]: Close() + + note right of 待重建 + 构造参数会重新传一遍 + 但 this 上攒的状态归零 + Resets() 能查到次数 + end note +``` + +`New` 会**立刻建好 VM**,所以 `constructor` 抛异常在 `New` 当场就报(`Func` 字段是 +`"constructor"`),不用等到第一次调用。 + +`acquire` 拿到的锁一直持到 `finish` 才放——这就是"同一实例的调用串行执行"的保证。 +goja 的 Runtime 不是并发安全的,多个 goroutine 同时调同一个实例会排队,不同实例之间并行。 + +**出错会丢状态**:一次超时或 panic 让 VM 被丢弃,下次调用用全新实例重建。这把"状态随时可能丢" +降低成"只在脚本出错时丢",**不是绝不丢**——真正不能丢的东西放作用域的扩展里(那是 Go 侧对象, +不随 VM 重建)。 + +**长期持有由你管**:本库没有按 key 复用和空闲回收。要按设备 ID 存着,业务侧自己一个 `sync.Map`, +跟 Go 版 controller 的写法一致。 + +--- + +## 作用域:没有生命周期的数据载体 + +作用域装在 ctx 里,只携带一段业务流程要共享的东西——**它不持有 VM,也不管实例的生死**。 + +```mermaid +flowchart TD + CTX["ctx ─ scope"] --> K["key(标识,脚本里 scope.key)"] + CTX --> G["ScopeGlobals(专属全局)"] + CTX --> X["Extensions(你创建的对象)"] + X --> V1["ctrlA 的 VM"] + X --> V2["ctrlB 的 VM"] + X --> V3["ctrlC 的 VM"] + + note1["同一份 Go 对象引用
注入到各个 VM"] + X -.- note1 +``` + +扩展实例**由你创建**,所以 Go 侧和脚本读写的天然是同一份,不用再取回来: + +```go +st := store.New() +ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st)) +// …脚本里 store.Set("k", v)… +st.Get("k") +``` + +扩展的存活期就是你手里那个变量的存活期——`obj.Close()` 只释放实例的 VM,不碰扩展。 + +--- + +## 脚本级钩子:跑在临时 VM 里 + +`CallStatic` 调的是脚本导出的 class 上的**静态方法**,它跟实例的生命周期是分开的: + +```mermaid +flowchart LR + A["CallStatic(ctx, \"Startup\")"] --> B["建一个临时 VM"] + B --> C["跑模块顶层"] + C --> D["调静态方法
**不构造实例**"] + D --> E["丢弃 VM"] + E --> F["副作用留在 Go 侧扩展里"] +``` + +两点跟 `Call` 不同,都是"静态"这个语义要求的: + +- **不构造实例**——`constructor` 是"这一次调用的准备",跟脚本级的初始化无关,跑它是白费 +- **VM 用完就丢**,不回池——静态方法一般只在脚本生命周期里跑一两次 + +所以它**建不出能存活的 JS 对象**。用处是把配置写进 Go 侧的扩展,那些副作用留在 Go 那边, +后续每次调用都读得到。作用域扩展在静态方法里照常可用,这正是它的落点。 + +脚本没写这个静态方法时返回的错误能被 `errors.Is(err, ErrFuncNotFound)` 匹配——生命周期钩子 +多半是可选的,用它区分"没写"和"写了但炸了"。 + +## 状态该放哪 + +```mermaid +flowchart TD + A["要跨调用保持的东西"] --> B{"丢了会怎样"} + B -->|"只是慢一点"| C["缓存"] + B -->|"会算错"| D["状态"] + C --> C1["可以放 this.xxx"] + D --> D1{"用哪种调用"} + D1 -->|"Script.Call 池化"| D2["必须放 Go 侧
作用域扩展,或当参数传"] + D1 -->|"Instance"| D3["可以放 this.xxx
但脚本出错会归零"] + D3 --> D4["绝对不能丢的
放作用域扩展或落库"] +``` + +| | 实例生命周期 | 脚本里的 `this.xxx` | +| --- | --- | --- | +| `Script.Call` | = VM 生命周期 | 随时可能归零,**只能当缓存** | +| `Instance` | 由你 `Close` 决定 | 跨调用保持,出错时归零 | +| 作用域扩展 | 你手里的变量活多久 | 不受 VM 重建影响 | + +还有一层容易忽略的:**多个脚本 `import` 同一个模块时,模块级状态也不共享**。模块被内联进各自的 +产物是一层原因,更根本的是每个实例独占一个 VM,VM 之间不共享任何 JS 状态。公共的纯函数随便 +`import`,公共的状态放作用域扩展。 + +--- + +## 并发粒度 + +| 场景 | 能并发吗 | 为什么 | +| --- | --- | --- | +| 不同实例(不同请求、不同设备) | ✅ | 各自独占 VM | +| 同一作用域下的不同 controller | ✅ | 各有各的 VM,只共享 Go 侧的扩展对象 | +| **同一个实例**的多次调用 | ❌ 串行 | Runtime 非并发安全,且要保住 `this.xxx` | +| 不带作用域的池化调用 | ✅ | 池里多个 VM | + +只有第三种是串行的,那是必需的——跟 Go 侧用 `sync.Mutex` 保护 struct 字段是一回事。 + +共享数据靠扩展(Go 侧对象)而不是共用 Runtime:共用 Runtime 会让作用域内所有脚本被迫串行, +那才是真的并发瓶颈。 + +--- + +## 内存 + +每个实例独占一个 VM,3000 个实例的常驻内存实测: + +| 配置 | 单实例 | 3000 个 | +| --- | --- | --- | +| 裸 VM | 7.0 KB | 20.5 MB | +| + `console` | 12.5 KB | 36.6 MB | +| + `console` + store 扩展 | 21.8 KB | 64.0 MB | + +白名单、console、扩展都只在**建 VM 时**注入,对每次调用零影响。但每个实例一个 VM,这些会 +乘以实例数——实例多又不需要脚本日志时,`WithLogger(nil)` 关掉 console 能省约 44%。 + +而且注入是**惰性**的:建 VM 时只装一个只读的访问器属性,脚本第一次读到那个全局名才把方法集 +转成 JS 对象,转完在这个 VM 里缓存。一个脚本通常只用得上少数几个扩展,用不到的那些不该在 +每次建 VM 时都付一遍转换成本。所以上表是"全都读到"的上界,实际按脚本用到多少收费。 + +建一个实例约 4.8 μs。这个数字对"每请求一个实例"的场景直接相关:QPS 1000、每请求 3 个 +controller,约 14 ms/s 的 CPU。