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,140 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user