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

239 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)