Files
what 0c947dde70 docs: 加一篇函数流走向
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。
2026-09-10 16:24:18 +08:00

185 lines
9.4 KiB
Go
Raw Permalink 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 对象,它们的方法是真能调的)。
//
// 想看函数之间怎么串起来的(调用链、作用域从哪一步进 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 sourcemapgoja 自带 sourcemap 支持。
//
// 也可以用 errors.Is 匹配 ErrTimeout、ErrFuncNotFound、ErrScriptNotFound、ErrValueEscape
// 等哨兵错误。
package jscriptx