Files
jscriptx/invoke.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

327 lines
10 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"
"reflect"
"runtime/debug"
"sync"
"time"
"github.com/dop251/goja"
)
// frame 是一次调用的上下文:拿到了 VM、目标函数,可以真正发起调用。
// 它只在 withCall 的回调里有效,不能存下来跨调用使用。
type frame struct {
script *Script
rt *goja.Runtime
fn goja.Callable
fnVal goja.Value
this goja.Value // 方法调用时绑定的 this(导出实例),默认导出函数为 undefined
name string
}
// arity 返回 JS 函数声明的形参个数(函数对象的 length 属性),
// Dispatch 靠它判断脚本写的是哪种回调形状。
func (f *frame) arity() int64 {
return f.fnVal.ToObject(f.rt).Get("length").ToInteger()
}
// call 调用目标函数,this 按 lookup 的结果绑定。
func (f *frame) call(args ...goja.Value) (goja.Value, error) {
return f.fn(f.this, args...)
}
// callWith 用 Go 值直接调用目标函数。
func (f *frame) callWith(args []any) (goja.Value, error) {
jsArgs := make([]goja.Value, len(args))
for i, a := range args {
jsArgs[i] = f.rt.ToValue(a)
}
return f.call(jsArgs...)
}
// export 把脚本返回值转成 Go 值,写进 target 指向的变量。
func (f *frame) export(v goja.Value, target any) error {
return f.rt.ExportTo(v, target)
}
// empty 判断脚本返回值是不是"什么都没返回"。
func empty(v goja.Value) bool {
return v == nil || goja.IsUndefined(v) || goja.IsNull(v)
}
// Call 调用脚本里的函数,返回值导出成 Go 值(JS 对象变 map[string]any,数组变 []any
// 传进去的 Go 对象原样回来)。fn 传 DefaultFunc 表示调用脚本自身求值出的那个函数。
//
// 脚本返回函数/闭包会被拒绝:那种值只在 VM 内部有效,VM 归还池子后再调用会出问题。
// 需要把脚本函数当回调用,走 Dispatch。
func (s *Script) Call(ctx context.Context, fn string, args ...any) (any, error) {
return callAny(ctx, s, fn, args)
}
// CallInto 调用脚本里的函数,并把返回值转换进 out 指向的变量(out 必须是非 nil 指针),
// 相当于带类型的 Call:目标是 int 就按 int 转,是某个接口就要求返回值实现它。
func (s *Script) CallInto(ctx context.Context, fn string, out any, args ...any) error {
return callInto(ctx, s, fn, out, args)
}
// callAny 是 Call 的共用实现,*Script 和 *Session 都走这里。
func callAny(ctx context.Context, r runner, fn string, args []any) (any, error) {
var out any
err := invoke(ctx, r, fn, args, func(f *frame) error {
res, err := f.callWith(args)
if err != nil {
return err
}
res, err = unwrapPromise(f, res, r.owner().name, fn)
if err != nil {
return err
}
if empty(res) {
return nil
}
if _, isFunc := goja.AssertFunction(res); isFunc {
return newError(KindType, r.owner().name, fn, ErrValueEscape,
"返回值是 JS 函数,只在脚本内部有效;需要回调语义请用 Dispatch")
}
out = res.Export()
return nil
})
return out, err
}
// callInto 是 CallInto 的共用实现。
func callInto(ctx context.Context, r runner, fn string, out any, args []any) error {
name := r.owner().name
rv := reflect.ValueOf(out)
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() {
return newError(KindType, name, fn, nil, "CallInto 的 out 必须是非 nil 指针,当前是 %T", out)
}
// 导出成 Go 函数意味着把一个绑定在 VM 上的闭包带出脚本边界,
// 而这个 VM 马上就要还回池子给别的请求用了。
if rv.Type().Elem().Kind() == reflect.Func {
return newError(KindType, name, fn, ErrValueEscape,
"不能把脚本函数导出成 Go 函数(VM 归还池子后它就失效了);需要回调语义请用 Dispatch")
}
return invoke(ctx, r, fn, args, func(f *frame) error {
res, err := f.callWith(args)
if err != nil {
return err
}
res, err = unwrapPromise(f, res, name, fn)
if err != nil {
return err
}
if empty(res) {
return nil
}
if err := f.export(res, out); err != nil {
return newError(KindType, name, fn, err, "返回值无法转换成 %T", out)
}
return nil
})
}
// runner 抽象"这次调用用哪个 VM、用完怎么处理":*Script 从池里借还,
// *Session 独占一个 VMacquire 会一直持锁到 finish,保证同一会话串行执行)。
type runner interface {
owner() *Script
acquire(ctx context.Context) (*vmHandle, error)
finish(inst *vmHandle, healthy bool)
}
func (s *Script) owner() *Script { return s }
func (s *Script) acquire(ctx context.Context) (*vmHandle, error) { return s.borrow(ctx) }
func (s *Script) finish(inst *vmHandle, healthy bool) { s.release(inst, healthy) }
// invoke 是所有脚本调用的骨架:取 VM → 找函数 → 装超时哨兵 → 兜 panic → 分类错误 → 交还 VM。
// do 里只管发起调用和处理返回值,异常处理交给这里。
func invoke(ctx context.Context, r runner, fn string, args []any, do func(*frame) error) (err error) {
s := r.owner()
if s.closed.Load() {
return newError(KindClosed, s.name, fn, ErrClosed, "脚本已关闭")
}
if ctx == nil {
ctx = context.Background()
}
inst, err := r.acquire(ctx)
if err != nil {
return err
}
healthy := true
// defer 是后进先出:recover 最先跑(把 healthy 置回 false),
// 然后 stop 清掉可能迟到的中断标志,最后才交还 VM。
defer func() { r.finish(inst, healthy) }()
callable, fnVal, this, ok := inst.lookup(fn)
if !ok {
return newError(KindNotFound, s.name, fn, ErrFuncNotFound, "%s", s.notFoundHint(inst))
}
stop := guard(ctx, inst.rt, s.engine.timeout)
defer stop()
defer func() {
if r := recover(); r != nil {
healthy = false
err = s.panicError(fn, args, r)
}
}()
f := &frame{script: s, rt: inst.rt, fn: callable, fnVal: fnVal, this: this, name: fn}
if e := do(f); e != nil {
err = classify(e, s.name, fn, args)
healthy = !fatal(err)
return err
}
return nil
}
// panicError 把 recover 到的 panic 包成带现场的错误。脚本里的类型错误、
// 注入进去的 Go 方法内部 panic,都会走到这里,不会掀翻调用方的 goroutine。
func (s *Script) panicError(fn string, args []any, r any) *Error {
return &Error{
Kind: KindPanic,
Script: s.name,
Func: fn,
Msg: fmt.Sprintf("脚本执行过程中发生 panic: %v", r),
Args: summarize(args),
GoStack: string(debug.Stack()),
Cause: toError(r),
}
}
// interruptGuard 保证"中断"和"收尾"两件事不会打架:收尾之后再迟到的中断信号必须被丢掉,
// 否则它会毒死下一次用到这个 VM 的调用。
type interruptGuard struct {
mu sync.Mutex
stopped bool
rt *goja.Runtime
}
func (g *interruptGuard) fire(reason error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.stopped {
return
}
g.rt.Interrupt(reason)
}
// stop 关掉哨兵并清掉可能已经发出的中断标志。返回后保证不会再有新的中断落到这个 VM 上。
func (g *interruptGuard) stop() {
g.mu.Lock()
g.stopped = true
g.mu.Unlock()
// 到这里要么 fire 已经跑完(中断标志由下面清掉),要么它以后永远不会再发。
g.rt.ClearInterrupt()
}
// guard 装一个中断哨兵:超时或者调用方 context 取消时,从另一个 goroutine 调
// Interrupt 打断正在执行的脚本(死循环也能断掉)。返回的函数负责收尾。
func guard(ctx context.Context, rt *goja.Runtime, timeout time.Duration) func() {
watchCtx := ctx.Done() != nil
if timeout <= 0 && !watchCtx {
return func() {}
}
g := &interruptGuard{rt: rt}
if !watchCtx {
// 快路径:没有 context 取消要盯,一个定时器就够了,不用为每次调用起 goroutine。
// MQTT 消息级这种高频调用走的就是这条路。
timer := time.AfterFunc(timeout, func() { g.fire(context.DeadlineExceeded) })
return func() {
g.stop()
timer.Stop()
}
}
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
}
done := make(chan struct{})
exited := make(chan struct{})
go func() {
defer close(exited)
select {
case <-ctx.Done():
g.fire(ctx.Err())
case <-done:
}
}()
return func() {
close(done)
<-exited
if cancel != nil {
cancel()
}
g.stop()
}
}
// unwrapPromise 把 async 方法返回的 Promise 解开。
//
// goja 在调用返回前会把 microtask 队列跑干净,所以只要脚本里没有真正需要等待外部
// 事件的操作(goja 没有事件循环,也没有 setTimeout),Promise 到这里已经是完成态了。
// 解开它有两个理由:一是 *goja.Promise 属于引擎类型,不该出现在本库的返回值里;
// 二是调用方拿到一个未解包的 Promise 什么也做不了。
func unwrapPromise(f *frame, v goja.Value, script, fn string) (goja.Value, error) {
p, ok := promiseOf(v)
if !ok {
return v, nil
}
switch p.State() {
case goja.PromiseStateFulfilled:
return p.Result(), nil
case goja.PromiseStateRejected:
reason := p.Result()
e := &Error{
Kind: KindRuntime,
Script: script,
Func: fn,
Msg: "async 方法的 Promise 被 reject",
Cause: ErrPromiseRejected,
}
if reason != nil {
e.Msg = reason.String()
e.Value = reason.Export()
}
return nil, e
default:
return nil, newError(KindRuntime, script, fn, ErrPromisePending,
"async 方法返回的 Promise 一直没完成。goja 没有事件循环,"+
"脚本里没法等待真正的异步操作(定时器、网络、IO);"+
"异步的活交给 Go 侧做,脚本只写同步逻辑")
}
}
// promiseOf 判断一个返回值是不是 Promise。
//
// *goja.Promise 只能从 Export() 拿到,但对普通对象调 Export 会把整个对象转成 map,
// 那个开销不能加在每次调用上。先看有没有 then 属性——一次廉价的属性查找就能排除掉
// 绝大多数返回值,只有 thenable 才走到 Export。
func promiseOf(v goja.Value) (*goja.Promise, bool) {
obj, ok := v.(*goja.Object)
if !ok {
return nil, false
}
if then := obj.Get("then"); then == nil || goja.IsUndefined(then) {
return nil, false
}
p, ok := obj.Export().(*goja.Promise)
return p, ok
}