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

105 lines
4.3 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
// 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))
}