Files
jscriptx/script.go
T
what 0627d49425 feat: 嵌入式 JS 脚本引擎核心
用 goja 承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。脚本用
ESM + TypeScript 写,Go 侧按名字把它们当普通对象实例化并调用方法。

主要组成:

  - Engine    编译脚本、管配置,公开 API 不暴露任何 goja 类型
  - Script    一份编译好的脚本 + 它的 VM 池,热更新时整体顶替
  - Instance  独占一个 VM 的实例,状态留在 JS 侧
  - Caller    自定义调用约定,把脚本函数适配成 Go 侧要的签名
  - Scope     让同一个 ctx 下的多个脚本共享 Go 侧对象
  - Extension 扩展接口:给脚本添全局对象,配套 TS 类型
  - Overlay   多层 Loader 叠加,后面的盖前面的

几个关键取舍:

  - 源码一律先过 esbuild 打包成 ESM,再改写成立即执行函数。goja 不认
    import/export,而业务脚本要能拆文件、用 TypeScript。
  - VM 池化复用,但每个 VM 单线程。goja 的 Runtime 不是 goroutine 安全的。
  - Go 侧函数返回的 error 在脚本里表现为抛异常,不占返回值位置。
  - 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
    全局根本不会被转换。
2026-09-05 22:11:55 +08:00

328 lines
11 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
import (
"context"
"fmt"
"sync/atomic"
"github.com/dop251/goja"
)
// DefaultFunc 传给 Call/Dispatch 的 fn 参数时,表示脚本的默认导出本身,
// 也就是 `export default function ...` 这种"整个脚本就是一个函数"的写法。
// 默认导出是 class 或对象时用不上它——那种要按方法名调用。
const DefaultFunc = ""
// Script 是一份编译好的脚本,内部维护一个 VM 池。它并发安全,可以长期持有。
//
// 一个 Script 对应一份不可变的编译产物;热更新时 Engine 会造一个新的 Script
// 顶替它,已经拿着旧 Script 的调用不受影响,会继续跑完旧版本。
type Script struct {
engine *Engine
name string
version string
prog *goja.Program
pool chan *vmHandle
closed atomic.Bool
created atomic.Int64 // 累计新建过多少个 VM
dropped atomic.Int64 // 累计丢弃过多少个 VM(超时/panic/池满)
}
// vmHandle 是一个 VM:一个 goja.Runtime 加上它跑完脚本后的求值结果。
// Runtime 不是并发安全的,同一时刻只能有一个 goroutine 持有它。
//
// 一个 VM 只跑一个脚本。会话下多个脚本要共享数据时靠 store(Go 侧对象,注入到各个 VM
// 里的是同一份引用),而不是共用 Runtime——共用 Runtime 会让会话内的所有脚本被迫串行。
type vmHandle struct {
rt *goja.Runtime
defFn goja.Value // 脚本求值出的函数(单函数入口写法)
exports *goja.Object // 脚本求值出的对象(class 实例写法),方法调用时绑定为 this
ctor *goja.Object // 导出的 class 本身。静态方法挂在它上面,实例的原型链上没有
// scoped 表示这个 VM 注入过某个作用域的扩展,因此**不能**回池给别的作用域用。
// 用完直接丢,见 release。
scoped bool
}
// Name 返回脚本名。
func (s *Script) Name() string { return s.name }
// Version 返回脚本版本号(Loader 给的,或者源码哈希)。
func (s *Script) Version() string { return s.version }
// Stats 是 VM 池的运行统计,用于观测。
type Stats struct {
Pooled int // 池里闲置的 VM 数
MaxVMs int // 池容量
Created int64 // 累计新建
Dropped int64 // 累计丢弃
}
// Stats 返回 VM 池的当前统计。
func (s *Script) Stats() Stats {
return Stats{
Pooled: len(s.pool),
MaxVMs: cap(s.pool),
Created: s.created.Load(),
Dropped: s.dropped.Load(),
}
}
// Has 判断脚本导出的实例上有没有这个方法(继承来的也算)。
func (s *Script) Has(fn string) bool {
inst, err := s.borrow(context.Background())
if err != nil {
return false
}
defer s.release(inst, true)
_, _, _, ok := inst.lookup(fn)
return ok
}
// Close 释放池里所有 VM。之后的调用会返回 ErrClosed。
// 已经借出去、正在执行的调用不受影响,它们的 VM 归还时直接丢弃。
//
// New 出来的实例不在这条链上——它们的生命周期归调用方,Close 之后那些实例的调用
// 会因为脚本已关闭而报错,VM 等 GC 回收。
func (s *Script) Close() {
if s.closed.Swap(true) {
return
}
for {
select {
case <-s.pool:
s.dropped.Add(1)
default:
return
}
}
}
// borrow 从池里取一个 VM;池空就新建一个。
//
// 刻意不阻塞等待:并发量超过池容量时宁可临时多造几个 VM,也不让请求在这里排队。
// 池只是复用缓存,不承担限流职责。
//
// ctx 带了作用域时**不走池**:池是所有调用共享的,而作用域的扩展是这一次调用专属的
// (store、当前请求……)。混用要么让脚本看不见扩展(顶层 import 直接 ReferenceError),
// 要么把上一个作用域的对象漏给下一个。所以为它单造一个 VM,用完丢弃。
//
// 代价是这类调用每次约 5μs 建一个 VM。同一作用域下要反复调,用 New 拿实例更划算——
// 实例把 VM 攥在手里,不必每次重建。
func (s *Script) borrow(ctx context.Context) (*vmHandle, error) {
if sc, ok := scopeOf(ctx); ok {
// 作用域至少带一个 scope.key,所以只要有作用域就必然要单造
vm, err := s.newVM(ctx, sc.vmGlobals(), nil)
if err != nil {
return nil, err
}
vm.scoped = true
return vm, nil
}
select {
case inst := <-s.pool:
return inst, nil
default:
}
return s.newVM(ctx, nil, nil)
}
// release 归还 VM。healthy 为 false(被中断过或 panic 过)时直接丢弃:
// 脚本本来就不该有跨调用状态,重建一个 VM 远比拖着一个状态可疑的 VM 划算。
func (s *Script) release(inst *vmHandle, healthy bool) {
if inst == nil {
return
}
if !healthy || s.closed.Load() || inst.scoped {
// scoped 的 VM 带着某个作用域的扩展,回池就会漏给下一个调用
s.dropped.Add(1)
return
}
select {
case s.pool <- inst:
default:
s.dropped.Add(1) // 池满,丢弃
}
}
// newVM 造一个新 VM 并在里面加载这个脚本:注入白名单(extra 是会话专属的额外全局,
// 池化调用传 nil)→ 跑一遍脚本顶层代码 → 记下求值结果。
func (s *Script) newVM(ctx context.Context, extra map[string]any, ctorArgs []any) (*vmHandle, error) {
rt, err := s.engine.newRuntime(s.name, extra)
if err != nil {
return nil, err
}
return s.loadInto(ctx, rt, ctorArgs)
}
// newRuntime 造一个空 VM 并注入白名单。scope 是它在日志和 store 里的身份:
// 池化 VM 用脚本名,会话 VM 用会话 key。
func (e *Engine) newRuntime(scope string, extra map[string]any) (*goja.Runtime, error) {
rt := goja.New()
if e.maxStack > 0 {
rt.SetMaxCallStackSize(e.maxStack)
}
if err := e.bind(rt, scope, extra); err != nil {
return nil, err
}
return rt, nil
}
// loadInto 在一个已经建好的 VM 里跑这个脚本,取出它的导出。
// 顶层代码同样受超时保护,脚本在顶层写死循环不会把调用方卡住。
//
// 同一个 rt 上可以先后加载多个脚本:打包产物是 IIFE,除了 ModuleGlobal 这一个全局名
// 之外不往外泄漏东西,而那个名字的值在这里当场就取走了,后一个脚本覆盖它也不影响。
func (s *Script) loadInto(ctx context.Context, rt *goja.Runtime, ctorArgs []any) (*vmHandle, error) {
return s.load(ctx, rt, ctorArgs, false)
}
// load 把脚本装进一个 VM。noInstance 为 true 时不构造实例——
// 只想调静态方法时用,避免白跑一遍 constructor(那是每次调用的准备工作)。
func (s *Script) load(ctx context.Context, rt *goja.Runtime, ctorArgs []any, noInstance bool) (h *vmHandle, err error) {
stop := guard(ctx, rt, s.engine.timeout)
defer stop()
defer func() {
if r := recover(); r != nil {
h, err = nil, s.panicError(DefaultFunc, nil, r)
}
}()
v, err := rt.RunProgram(s.prog)
if err != nil {
return nil, classify(err, s.name, DefaultFunc, nil)
}
h = &vmHandle{rt: rt}
// 打包产物末尾的入口表达式求值出来的东西决定了脚本的形态:
// class → 由这里 new 出实例,构造参数从 Go 侧传(export default class X {}
// 普通函数 → 单函数入口,用 DefaultFunc 调用(export default function(){}
// 非函数对象 → 直接当导出实例(export default {...},或只有命名导出时的模块对象)
if !empty(v) {
switch {
case isClass(rt, v):
h.ctor = v.ToObject(rt)
if noInstance {
break // 只要静态方法,不跑 constructor
}
obj, err := construct(rt, v, ctorArgs)
if err != nil {
return nil, classify(err, s.name, "constructor", ctorArgs)
}
h.exports = obj
case isCallable(v):
h.defFn = v
default:
if obj, ok := v.(*goja.Object); ok {
h.exports = obj
}
}
}
s.created.Add(1)
return h, nil
}
// classDetector 判断一个值是不是 class。goja 的 AssertConstructor 对普通 function
// 也返回 true,区分不了;靠规范保证的差别来判:class 的 prototype 属性不可写,
// 普通函数的可写,箭头函数和方法简写根本没有 prototype。
var classDetector = goja.MustCompile("jscriptx:isclass", `(function (x) {
if (typeof x !== "function") { return false }
var d = Object.getOwnPropertyDescriptor(x, "prototype")
return !!d && d.writable === false
})`, true)
func isClass(rt *goja.Runtime, v goja.Value) bool {
if _, ok := goja.AssertConstructor(v); !ok {
return false
}
dv, err := rt.RunProgram(classDetector)
if err != nil {
return false
}
detect, ok := goja.AssertFunction(dv)
if !ok {
return false
}
res, err := detect(goja.Undefined(), v)
if err != nil {
return false
}
return res.ToBoolean()
}
func isCallable(v goja.Value) bool {
_, ok := goja.AssertFunction(v)
return ok
}
// construct 从 Go 侧 new 一个 JS class 实例,构造参数按 goja 的规则转换。
func construct(rt *goja.Runtime, class goja.Value, args []any) (*goja.Object, error) {
ctor, ok := goja.AssertConstructor(class)
if !ok {
return nil, fmt.Errorf("不是构造器")
}
jsArgs := make([]goja.Value, len(args))
for i, a := range args {
jsArgs[i] = rt.ToValue(a)
}
return ctor(nil, jsArgs...)
}
// lookup 找要调用的函数,同时给出调用时该绑定的 this。
//
// fn 为空取默认导出本身(export default function 那种);否则找导出实例的方法,
// this 绑定到实例,这样 class 里的 this.xxx 才有意义。
//
// 最后那层全局查找基本只是兜底:脚本经打包后是 IIFE,顶层声明进不了全局,
// 只有脚本显式往 globalThis 上挂东西时才走得到。
func (i *vmHandle) lookup(fn string) (callable goja.Callable, fnVal goja.Value, this goja.Value, ok bool) {
if fn == DefaultFunc {
if i.defFn == nil {
return nil, nil, nil, false
}
callable, ok = goja.AssertFunction(i.defFn)
return callable, i.defFn, goja.Undefined(), ok
}
if i.exports != nil {
// Get 会走原型链,class 方法定义在 prototype 上也找得到
if v := i.exports.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.exports, true
}
}
}
// 静态方法挂在 class 自身上,不在实例的原型链上,所以实例那边找不到才轮到这里。
// this 绑定到 class 本身,跟 JS 里 C.Startup() 的语义一致。
if i.ctor != nil {
if v := i.ctor.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.ctor, true
}
}
}
v := i.rt.Get(fn)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return nil, nil, nil, false
}
if callable, ok = goja.AssertFunction(v); !ok {
return nil, nil, nil, false
}
return callable, v, goja.Undefined(), true
}
// notFoundHint 在找不到函数时给一句有用的话。最常见的原因是脚本压根没写 export:
// 打包产物是 IIFE,没有导出的顶层代码会被当死代码摇掉,什么都不剩。
func (s *Script) notFoundHint(inst *vmHandle) string {
if inst.exports == nil && inst.defFn == nil {
return "脚本没有任何导出。打包产物是 IIFE,不写 export 的顶层代码会被当死代码摇掉;" +
"请用 export default 指明入口,或者用命名导出"
}
return "脚本里没有这个函数,或者它不是函数"
}