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:
+238
@@ -0,0 +1,238 @@
|
||||
package jscriptx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VersionSep 分隔脚本名和版本标签。叠层时用 "脚本名@版本" 点名要哪一层的实现。
|
||||
const VersionSep = "@"
|
||||
|
||||
// OverlayLoader 把若干个 Loader 叠成一个,**后面的盖前面的**。
|
||||
// 由 Overlay 创建。
|
||||
type OverlayLoader struct {
|
||||
loaders []Loader
|
||||
prepared bool // 所有成员一致,构造时已校验过
|
||||
byVer map[string]int // 版本标签 -> 层号,贴了标签的层才在里面
|
||||
}
|
||||
|
||||
// Versioned 由带版本标签的 Loader 实现(比如 esm.Loader 配 WithVersion)。
|
||||
// 叠层时这个标签就是调用点用来点名的那个:Load("Foo/Bar@v1")。
|
||||
//
|
||||
// 没实现这个接口、或者标签是空串的层,只能通过"上层盖下层"取到,点不了名。
|
||||
type Versioned interface {
|
||||
// Version 返回这一层的版本标签。
|
||||
Version() string
|
||||
}
|
||||
|
||||
// Tag 给任意 Loader 贴一个版本标签,让它在叠层里能被点名。
|
||||
// 自带标签的 Loader(比如 esm.NewLoader 配了 WithVersion)不用它。
|
||||
//
|
||||
// e, _ := jscriptx.New(jscriptx.WithLoader(
|
||||
// disk,
|
||||
// jscriptx.Tag("hotfix", dbLoader), // 之后可以 Load("Foo/Bar@hotfix")
|
||||
// ))
|
||||
func Tag(version string, l Loader) Loader {
|
||||
return &taggedLoader{Loader: l, version: version}
|
||||
}
|
||||
|
||||
type taggedLoader struct {
|
||||
Loader
|
||||
version string
|
||||
}
|
||||
|
||||
func (t *taggedLoader) Version() string { return t.version }
|
||||
|
||||
// Prepared 透传底下那个 Loader 的取值,别让贴标签这件事改了它的性质。
|
||||
func (t *taggedLoader) Prepared() bool { return isPrepared(t.Loader) }
|
||||
|
||||
func versionOf(l Loader) string {
|
||||
v, ok := l.(Versioned)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return v.Version()
|
||||
}
|
||||
|
||||
// Overlay 把多个 Loader 叠成一个:取脚本时从最后一个往前找,谁先有就用谁的。
|
||||
// 排在后面的因此能覆盖前面的同名脚本——把"定制层"放最后,业务侧放一份同名脚本
|
||||
// 就能改写默认实现,不用动被覆盖的那一份:
|
||||
//
|
||||
// base, _ := esm.NewLoader("app/src")
|
||||
// custom, _ := esm.NewLoader("custom/src") // 配置可以跟 base 完全不同
|
||||
//
|
||||
// loader, err := jscriptx.Overlay(base, custom) // custom 盖 base
|
||||
// e, _ := jscriptx.New(jscriptx.WithLoader(loader))
|
||||
//
|
||||
// 每个成员是独立的 Loader,各有各的配置(入口规则、目标版本、node_modules 位置、
|
||||
// 扩展模块),来源也可以不同——一层来自磁盘目录,另一层来自数据库都行。
|
||||
//
|
||||
// 给某一层贴了版本标签(esm 的 WithVersion,或者 Tag),调用点就能点名要它:
|
||||
//
|
||||
// v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1"))
|
||||
// v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2"))
|
||||
//
|
||||
// e.New(ctx, "Resource/ResCreateController") // 不点名:上层盖下层,拿到 v2
|
||||
// e.New(ctx, "Resource/ResCreateController@v1") // 点名:只在 v1 那层找,不回落
|
||||
//
|
||||
// 点名是"只认这一层":那层没有这个脚本就直接报不存在,不会掉到别的层去——
|
||||
// 不然点名要 v1 却跑了 v2 的实现,比报错难查得多。
|
||||
//
|
||||
// 成员的 Prepared 必须一致:要么都是自己打好包的(比如 jscriptx/esm 的 Loader),
|
||||
// 要么都交出原始源码由引擎打包。混着来会返回错误,因为引擎只能对整个 Loader
|
||||
// 做一次判断,没法分脚本区别对待。真要混,把原始源码那层用 LoaderFunc 包一下,
|
||||
// 里面自己调 Bundle,它就跟其它层一样是"打好包的"了。
|
||||
func Overlay(loaders ...Loader) (*OverlayLoader, error) {
|
||||
if len(loaders) == 0 {
|
||||
return nil, errors.New("jscriptx: Overlay 至少要给一个 Loader")
|
||||
}
|
||||
for i, l := range loaders {
|
||||
if l == nil {
|
||||
return nil, fmt.Errorf("jscriptx: Overlay 的第 %d 个 Loader 是 nil", i)
|
||||
}
|
||||
}
|
||||
prepared := isPrepared(loaders[0])
|
||||
for i, l := range loaders[1:] {
|
||||
if isPrepared(l) != prepared {
|
||||
return nil, fmt.Errorf(
|
||||
"jscriptx: Overlay 的成员 Prepared 不一致(第 0 个是 %v,第 %d 个是 %v);"+
|
||||
"要么都自己打包,要么都交出原始源码", prepared, i+1, !prepared)
|
||||
}
|
||||
}
|
||||
byVer := map[string]int{}
|
||||
for i, l := range loaders {
|
||||
v := versionOf(l)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v, VersionSep) {
|
||||
return nil, fmt.Errorf("jscriptx: 版本标签 %q 里不能有 %q", v, VersionSep)
|
||||
}
|
||||
if j, dup := byVer[v]; dup {
|
||||
return nil, fmt.Errorf("jscriptx: 版本标签 %q 重了(第 %d 层和第 %d 层)", v, j, i)
|
||||
}
|
||||
byVer[v] = i
|
||||
}
|
||||
return &OverlayLoader{
|
||||
loaders: append([]Loader(nil), loaders...),
|
||||
prepared: prepared,
|
||||
byVer: byVer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Load 从最后一个成员往前找,返回第一个找到的脚本。
|
||||
//
|
||||
// 成员报"脚本不存在"就继续往前找;报别的错直接返回——加载出故障不该被
|
||||
// 后面那层的结果悄悄盖掉。
|
||||
func (o *OverlayLoader) Load(name string) (string, string, error) {
|
||||
if bare, version, ok := splitVersion(name); ok {
|
||||
return o.loadFrom(bare, version)
|
||||
}
|
||||
var notFound error
|
||||
for i := len(o.loaders) - 1; i >= 0; i-- {
|
||||
source, version, err := o.loaders[i].Load(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrScriptNotFound) {
|
||||
notFound = err
|
||||
continue
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
// 版本号带上是第几层给的:覆盖层的脚本删掉后会落回下面那层,
|
||||
// 两层的版本号万一撞上,不带层号就看不出脚本已经换了人。
|
||||
if version != "" {
|
||||
version = strconv.Itoa(i) + ":" + version
|
||||
}
|
||||
return source, version, nil
|
||||
}
|
||||
if notFound == nil {
|
||||
notFound = ErrScriptNotFound
|
||||
}
|
||||
return "", "", fmt.Errorf("%w: %s(%d 层都没有)", notFound, name, len(o.loaders))
|
||||
}
|
||||
|
||||
// loadFrom 只在点名的那一层找,找不到就报不存在,不回落到别的层。
|
||||
func (o *OverlayLoader) loadFrom(name, version string) (string, string, error) {
|
||||
i, ok := o.byVer[version]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("%w: %s(没有版本 %q 这一层,有的是 %v)",
|
||||
ErrScriptNotFound, name, version, o.Versions())
|
||||
}
|
||||
source, ver, err := o.loaders[i].Load(name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ver != "" {
|
||||
ver = strconv.Itoa(i) + ":" + ver
|
||||
}
|
||||
return source, ver, nil
|
||||
}
|
||||
|
||||
// splitVersion 把 "Foo/Bar@v1" 拆成 "Foo/Bar" 和 "v1"。
|
||||
// 用最后一个分隔符,脚本名里真带了 @ 也不会拆错。
|
||||
func splitVersion(name string) (bare, version string, ok bool) {
|
||||
i := strings.LastIndex(name, VersionSep)
|
||||
if i <= 0 || i == len(name)-1 {
|
||||
return name, "", false // 没有分隔符,或者两边空着
|
||||
}
|
||||
return name[:i], name[i+1:], true
|
||||
}
|
||||
|
||||
// Versions 返回各层的版本标签,按叠放顺序,没贴标签的层跳过。
|
||||
func (o *OverlayLoader) Versions() []string {
|
||||
var out []string
|
||||
for _, l := range o.loaders {
|
||||
if v := versionOf(l); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Prepared 返回成员们一致的取值,见 Overlay 的说明。
|
||||
func (o *OverlayLoader) Prepared() bool { return o.prepared }
|
||||
|
||||
// Loaders 返回成员,顺序即叠放顺序(后面的盖前面的)。
|
||||
func (o *OverlayLoader) Loaders() []Loader { return append([]Loader(nil), o.loaders...) }
|
||||
|
||||
// Names 汇总所有成员的脚本名,去重后按字典序排列。
|
||||
// 成员得有 Names() []string 方法才算得上,没有的(比如数据库来源)就跳过。
|
||||
func (o *OverlayLoader) Names() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, l := range o.loaders {
|
||||
lister, ok := l.(interface{ Names() []string })
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, n := range lister.Names() {
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Rebuild 挨个让成员重建。成员没有 Rebuild() error 方法就跳过。
|
||||
// 有成员失败时其余的照样会走一遍,返回的错误里带上所有失败。
|
||||
func (o *OverlayLoader) Rebuild() error {
|
||||
var errs []error
|
||||
for i, l := range o.loaders {
|
||||
r, ok := l.(interface{ Rebuild() error })
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := r.Rebuild(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("第 %d 层: %w", i, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
var _ Prepared = (*OverlayLoader)(nil)
|
||||
Reference in New Issue
Block a user