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

141 lines
4.8 KiB
Go

package jscriptx
import (
"context"
"github.com/dop251/goja"
)
// Caller 是在 VM 借出期间对脚本函数的操作入口。
//
// 它存在的理由是:有些调用模式没法用 Call/CallInto 表达——典型的是「回调 + next」,
// 需要先看脚本函数声明了几个形参,再决定怎么调它、把哪个 Go 闭包传进去。这些都得在
// 同一次 VM 借出期间完成,因为传给脚本的 Go 闭包只在那段时间里有效。
//
// 拿它写一个自定义的调用约定:
//
// err := target.WithCall(ctx, "handle", func(c jscriptx.Caller) error {
// if c.Arity() == 2 {
// res, err := c.Call(model, next) // next 是 Go 闭包,脚本能直接调
// …
// }
// return nil
// })
//
// 框架自己的回调约定(比如 orm 那套三种签名)就是这么实现的,见 jscriptx/dispatch。
//
// Caller 只在 do 回调执行期间有效,别存下来跨调用用。
type Caller interface {
// Arity 返回脚本函数声明的形参个数(JS 函数的 length 属性)。
// 靠它判断脚本写的是哪种形状,脚本就不用额外声明签名。
Arity() int
// Call 调用脚本函数。参数按 goja 的规则转换:Go 对象反射包装成脚本对象,
// Go 函数变成脚本能直接调的函数——「把 next 传给脚本」就是这么实现的。
Call(args ...any) (Result, error)
// Script 返回脚本名,拼错误信息时用得上。
Script() string
}
// Result 是脚本函数一次调用的返回值。
//
// 它只在下一次 Call 之前有效——同一个 Caller 的多次调用复用同一个对象,
// 要留着以后用就先 Value() 或 Into() 取出来。
type Result interface {
// IsEmpty 判断脚本有没有返回东西(undefined 或 null)。
// 「1 个形参、没有返回值」这种纯副作用的写法靠它识别。
IsEmpty() bool
// Value 返回导出成 Go 值的结果。空返回值时是 nil。
Value() any
// Into 把返回值转换进 out 指向的变量(out 必须是非 nil 指针),
// 目标是接口时要求返回值实现它。
Into(out any) error
}
// WithCall 借一个 VM,在借出期间把控制权交给 do。
//
// 超时中断、panic 恢复、错误分类、VM 归还这些都跟普通调用一样,do 里只管发起调用。
// do 返回的错误会被包成 *Error;想让调用方 errors.Is 得到,wrap 一个哨兵错误进去,
// 比如 ErrUnsupportedSignature。
func (s *Script) WithCall(ctx context.Context, fn string, do func(Caller) error) error {
return withCaller(ctx, s, fn, do)
}
// WithCall 同 Script.WithCall,只是在这个实例独占的 VM 上执行。
func (i *Instance) WithCall(ctx context.Context, fn string, do func(Caller) error) error {
return withCaller(ctx, i, fn, do)
}
func withCaller(ctx context.Context, r runner, fn string, do func(Caller) error) error {
return invoke(ctx, r, fn, nil, func(f *frame) error {
return do(&caller{f: f})
})
}
type caller struct {
f *frame
// 复用同一个 result:一次 WithCall 里可能调好几次脚本函数,
// 每次都分配一个返回值对象不划算。所以 Result 只在下一次 Call 之前有效。
res result
}
func (c *caller) Arity() int { return int(c.f.arity()) }
func (c *caller) Script() string { return c.f.script.name }
func (c *caller) Call(args ...any) (Result, error) {
v, err := c.f.callWith(args)
if err != nil {
return nil, err
}
c.res = result{f: c.f, v: v}
return &c.res, nil
}
type result struct {
f *frame
v goja.Value
}
func (r *result) IsEmpty() bool { return empty(r.v) }
func (r *result) Value() any {
if empty(r.v) {
return nil
}
return r.v.Export()
}
func (r *result) Into(out any) error {
if empty(r.v) {
return nil
}
if err := r.f.export(r.v, out); err != nil {
return newError(KindType, r.f.script.name, r.f.name, err,
"返回值无法转换成 %T(拿到的是 %T)", out, r.v.Export())
}
return nil
}
// Target 是能发起脚本调用的对象:*Script(从 VM 池借用,脚本无跨调用状态)
// 或 *Instance(独占一个 VM,脚本里的状态跨调用保持)。
//
// 这个接口不对外开放实现,只是让 dispatch 这类函数能同时接受两者。
type Target interface {
// Name 返回脚本名。
Name() string
// Has 判断脚本里有没有这个函数。
Has(fn string) bool
// Call 调用脚本函数,返回值导出成 Go 值。
Call(ctx context.Context, fn string, args ...any) (any, error)
// CallInto 调用脚本函数,并把返回值转换进 out 指向的变量。
CallInto(ctx context.Context, fn string, out any, args ...any) error
// WithCall 借一个 VM,在借出期间把控制权交给 do,用来实现自定义的调用约定。
WithCall(ctx context.Context, fn string, do func(Caller) error) error
// 未导出方法,接口不对外开放实现。
owner() *Script
}