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,104 @@
|
||||
package jscriptx
|
||||
|
||||
// Loader 负责按名字提供脚本源码。脚本存在文件、数据库表还是配置中心,由实现方决定;
|
||||
// 本库只定义这个接口,不预设来源。
|
||||
//
|
||||
// 给出的源码默认会由 Engine 交给 esbuild 打包(ESM/TypeScript 都在那一步抹平),
|
||||
// 所以直接返回原始源码即可。已经自己打过包的实现(比如 jscriptx/esm 的 Loader)
|
||||
// 再实现 Prepared 接口,Engine 就会跳过这一步。
|
||||
//
|
||||
// version 用来判断脚本有没有变:Engine 拿它跟缓存里的版本比对,
|
||||
// 一致就复用已编译的 Program 和 VM 池,不一致才重新编译。取值随实现方便,
|
||||
// 比如文件的 mtime+size、数据库行的 updated_at、源码哈希都行;
|
||||
// 返回空字符串表示"我不提供版本号",Engine 会退化成拿源码算哈希。
|
||||
//
|
||||
// 打开 WithAutoReload 后每次取脚本都会调一次 Load,实现方要保证这个调用足够轻
|
||||
// (能只查版本就别每次全量读源码,或者自己加一层短 TTL 缓存)。
|
||||
//
|
||||
// 脚本不存在时,返回的错误要能被 errors.Is(err, ErrScriptNotFound) 匹配上,
|
||||
// 调用方才好区分"脚本没配"和"加载出故障"。
|
||||
//
|
||||
// 实现必须并发安全。
|
||||
type Loader interface {
|
||||
Load(name string) (source string, version string, err error)
|
||||
}
|
||||
|
||||
// LoaderFunc 让普通函数直接当 Loader 用:
|
||||
//
|
||||
// loader := jscriptx.LoaderFunc(func(name string) (string, string, error) {
|
||||
// row, err := db.QueryScript(name)
|
||||
// if err != nil {
|
||||
// return "", "", err
|
||||
// }
|
||||
// return row.Source, row.UpdatedAt, nil
|
||||
// })
|
||||
type LoaderFunc func(name string) (source string, version string, err error)
|
||||
|
||||
func (f LoaderFunc) Load(name string) (string, string, error) { return f(name) }
|
||||
|
||||
// PreparedFunc 跟 LoaderFunc 一样是把函数当 Loader 用,区别是它声明"源码已经打好包了"
|
||||
// (见 Prepared)。多层叠放时各层的 Prepared 必须一致,拿它就能让一个自定义来源
|
||||
// 跟 jscriptx/esm 那样的层对齐:
|
||||
//
|
||||
// db := jscriptx.PreparedFunc(func(name string) (string, string, error) {
|
||||
// row, err := db.QueryScript(name)
|
||||
// if err != nil {
|
||||
// return "", "", err
|
||||
// }
|
||||
// bundled, err := jscriptx.Bundle(name, row.Source) // 自己打包
|
||||
// if err != nil {
|
||||
// return "", "", err
|
||||
// }
|
||||
// return bundled, row.UpdatedAt, nil
|
||||
// })
|
||||
//
|
||||
// e, _ := jscriptx.New(jscriptx.WithLoader(esmLoader, db))
|
||||
//
|
||||
// 名副其实是你自己的责任:说了打好包,交出去的就必须是打包产物,
|
||||
// 否则脚本里的 import/export 会原样进 goja,直接编译失败。
|
||||
type PreparedFunc func(name string) (source string, version string, err error)
|
||||
|
||||
func (f PreparedFunc) Load(name string) (string, string, error) { return f(name) }
|
||||
|
||||
// Prepared 恒为 true,见 PreparedFunc 的说明。
|
||||
func (f PreparedFunc) Prepared() bool { return true }
|
||||
|
||||
var _ Prepared = PreparedFunc(nil)
|
||||
|
||||
// Prepared 由那些自己已经把源码打包好的 Loader 实现(比如 jscriptx/esm 的 Loader),
|
||||
// Engine 见到它就跳过打包这一步。
|
||||
//
|
||||
// 这不是性能优化,是正确性要求:打包产物是 IIFE,里面已经没有 export 了,
|
||||
// 再打包一遍会被 esbuild 当死代码整段摇空,脚本变成什么都不剩。
|
||||
// 所以自己打过包的 Loader 必须实现它。
|
||||
//
|
||||
// Engine 靠类型断言识别,方法签名写错了不会有编译错误。自己实现时最好钉一行
|
||||
//
|
||||
// var _ jscriptx.Prepared = (*MyLoader)(nil)
|
||||
type Prepared interface {
|
||||
// Prepared 返回 true 表示 Load 给出的源码已经可以直接交给引擎编译。
|
||||
Prepared() bool
|
||||
}
|
||||
|
||||
func isPrepared(l Loader) bool {
|
||||
p, ok := l.(Prepared)
|
||||
return ok && p.Prepared()
|
||||
}
|
||||
|
||||
// loadLocked 走 Loader 拿源码;cached 非空且版本一致时直接复用缓存。调用方必须持有 e.mu。
|
||||
func (e *Engine) loadLocked(name string, cached *Script) (*Script, error) {
|
||||
source, version, err := e.loader.Load(name)
|
||||
if err != nil {
|
||||
return nil, newError(KindLoad, name, "", err, "加载脚本源码失败")
|
||||
}
|
||||
if cached != nil && version != "" && cached.version == version {
|
||||
return cached, nil
|
||||
}
|
||||
if version == "" {
|
||||
version = hashVersion(source)
|
||||
if cached != nil && cached.version == version {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
return e.compileLocked(name, source, version, isPrepared(e.loader))
|
||||
}
|
||||
Reference in New Issue
Block a user