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 在脚本里表现为抛异常,不占返回值位置。
- 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
全局根本不会被转换。
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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 独占一个 VM(acquire 会一直持锁到 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
|
||||
}
|
||||
Reference in New Issue
Block a user