Skip to content

插件系统设计

本文档基于以下源文件编写:

目录

  1. 插件系统概览
  2. QuickJS 运行时
  3. Polyfill 与 PendingJob 系统
  4. 宿主桥接架构
  5. 消息调度器
  6. Manager 概览
  7. Plugin 模型
  8. Service 实例

1. 插件系统概览

章节来源internal/jsruntime/runtime.gointernal/jsplugin/manager.go

Songloft 的 JS 插件系统允许第三方开发者在沙盒环境中扩展服务器功能(音源接入、Web UI 页面、跨插件通信等)。选择 QuickJS 的核心考量:

  • 沙盒安全:每个插件拥有完全隔离的 VM 实例(堆内存、全局对象),一切外部能力必须通过显式注册的桥接函数获取。
  • CGO-free:通过 modernc.org/quickjs(纯 Go 翻译自 C 源码)实现,无需 CGO,保证全平台交叉编译零额外依赖。
  • 确定性执行:单线程引擎,所有 JS 执行通过 sync.Mutex 串行化,不存在竞态条件。

整体架构分为三层:

                 HTTP 请求
                     |
            +--------v--------+
            |    Manager      |   协调器:生命周期、懒加载、热更新
            +--------+--------+
                     |
            +--------v--------+
            | ServiceScheduler|   Skynet 风格消息队列,per-service 单 worker
            +--------+--------+
                     |
            +--------v--------+
            |   JSService     |   per-plugin Actor:消息路由 + JS 执行
            +--------+--------+
                     |
            +--------v--------+
            | JSEnvManager    |   运行时管理:VM 创建/销毁/执行/事件循环
            +--------+--------+
                     |
            +--------v--------+
            |   QuickJS VM    |   沙盒:polyfill + 插件代码 + 桥接函数
            +---+---------+---+
                |         |
         __go_fetch    __go_bridge
           (HTTP)    (storage/DB/IPC)

2. QuickJS 运行时

章节来源internal/jsruntime/runtime.go

2.1 核心结构体

图表来源runtime.go:92-117JSEnv 结构体字段定义)

JSEnvManager 管理进程内的所有 JS 环境,JSEnv 代表一个独立的 VM 实例:

JSEnvManager
  envs:       map[string]*JSEnv      // envID -> 环境实例
  pluginEnvs: map[int64]map[string]  // pluginID -> 关联的 envID 集合
  shutdownCh: chan struct{}           // 全局关闭信号

JSEnv
  vm:            *quickjs.VM    // QuickJS 虚拟机(非线程安全)
  mu:            sync.Mutex     // 串行化所有 VM 访问
  asyncResults:  chan(256)      // 异步结果通道(fetch/bridge 完成后投递)
  asyncSignal:   chan(1)        // 单容量信号通道,唤醒事件循环
  asyncInflight: atomic.Int32   // 飞行中异步任务计数
  wsConns:       sync.Map       // WebSocket 连接池
  bridgeCallback: BridgeCallback // 插件层桥接回调

2.2 环境创建

CreateEnv 创建新环境:quickjs.NewVM()SetMaxStackSizeregisterBridgeFunctions → 注入 polyfill → 执行初始化代码 → 记录归属关系。CreateEnvWithBytecode 是变体路径,先执行 bootstrap 源码再加载预编译字节码(.jsc),用于重启后从缓存加载。

2.3 执行模型

ExecuteJS 实现完整的异步事件循环:

快速路径eval(code) 返回值非 thenable 且无飞行中异步任务,直接返回(健康探针、定时器等内部调用走此路径)。

慢速路径(异步 Promise):

  1. 将 Promise 挂到 globalThis.__execjs_pending,附加 .then 链回填结果
  2. 事件循环:pumpAsyncResultsExecutePendingJobsprocessExpiredTimers → 检查 done flag
  3. 未完成时释放 env.muselect 等待 asyncSignal / 50ms tick / ctx.Done / shutdownCh
  4. 被唤醒后重新加锁继续

关键保证:异步 await 期间 env.mu 被释放,健康探针、定时器、同插件其他请求均可抢锁,不会因一个 30s 的 fetch 冻结整个插件。

2.4 健康探针与并行执行

HealthProbe 通过 TryLock 直接探测 VM(不经过 scheduler 队列),返回 Healthy/Unhealthy/Busy/Missing 四种状态。ExecuteJSParallel 支持多环境竞速执行(多音源搜索场景),按窗口分批启动 goroutine,第一个成功结果立即返回。


3. Polyfill 与 PendingJob 系统

章节来源internal/jsruntime/polyfill.gointernal/jsruntime/pendingjob.go

3.1 Polyfill 清单

QuickJS 只提供 ES2023 语言规范。polyfillJS 在每个 VM 创建时注入,补齐标准接口:

分类Polyfill实现方式
控制台console.log/error/warn/info/debug/trace委托 __go_console 输出到 Go slog
定时器setTimeout/clearTimeout/setInterval/clearInterval纯 JS Map + __go_now_ms(),Go 侧周期调用 __processExpiredTimers
网络fetch(url, opts)原生 Promise + __go_fetch_async 后台 goroutine;支持内部头 X-Fetch-No-Redirect / X-Fetch-Timeout-Ms
编码TextEncoder/TextDecoderbtoa/atob__go_buffer_from/to_string + 纯 JS
二进制Buffer.from/alloc/concat/isBufferhex 内部表示 + Go 桥接
加密crypto.md5/sha1/sha256Bytes/rc4/aesEncrypt/aesDecrypt/rsaEncrypt/randomBytesGo 标准库 crypto/*
压缩zlib.inflate/deflateGo compress/zlib
URLURL/URLSearchParams纯 JS 正则解析
WebSocketWebSocket (connect/send/close/events)__go_ws_* + gorilla/websocket

Polyfill 还包含 Function.prototype.toString 重写,将桥接函数标记为 [native code],兼容 jsjiami.com v7 混淆脚本的反调试检查。

3.2 异步回调注册表

__asyncCallbacksMap<id, {resolve, reject}>)是 JS 侧异步基础设施核心:fetch/bridge 创建 Promise 时存入 resolve/reject,Go goroutine 完成后推到 env.asyncResults,事件循环调用 __pumpAsyncResults__resolveAsync 根据 type 包装 payload 并 resolve。WebSocket 消息通过 __wsRegistry 直接分发。

3.3 PendingJob 与定时器

pendingjob.go 通过 unsafe/reflect 访问 VM 内部字段,调用 JS_ExecutePendingJob 推进原生 Promise 微任务。SetMaxStackSize 将栈大小设为 0(使用默认 MaxStackSlots=1000),防止混淆代码递归反调试。

Go 侧定时器处理有三条路径:ExecuteJS 事件循环内的 processExpiredTimers(50ms tick);JSService.runTimerProcessor 独立 goroutine(500ms 周期,TryLock 非阻塞);processJobs 完整循环(异步结果 + 微任务 + 定时器,连续 500ms 无进展则退出)。


4. 宿主桥接架构

章节来源internal/jsruntime/runtime.go:1326-1720

Go 与 JS 通过 vm.RegisterFunc 注册的全局函数通信。耗时操作遵循真异步模式:JS 调用立即返回 ID,后台 goroutine 执行,通过 asyncResults 通道回送结果。

4.1 桥接函数清单

函数用途模式
__go_send / __go_console事件派发 / 日志同步
__go_fetch_asyncHTTP 请求;内部消费并剥离 X-Fetch-No-Redirect / X-Fetch-Timeout-Ms真异步("fetch:N"
__go_bridge通用桥接(storage/DB/IPC)真异步("bridge:N"
__go_pop_async_result弹出就绪异步结果同步(非阻塞)
__go_now_ms / __go_buffer_* / __go_crypto_* / __go_zlib_*工具函数同步
__go_ws_connect_async / __go_ws_send/close/stateWebSocket连接异步,操作同步

4.2 异步结果流转

JS: fetch(url) → new Promise → __asyncCallbacks.set(id, {resolve, reject})
    → __go_fetch_async(url, ...) → 返回 id, VM 锁释放
Go: goroutine doHTTPRequest → env.asyncResults <- result → env.asyncSignal
JS: 事件循环加锁 → __pumpAsyncResults → __resolveAsync(id, ok, data, "fetch")
    → cb.resolve(Response{...}) → ExecutePendingJobs → await 链继续

__go_bridge 的实际处理由 BridgeHandler.HandleBridgeCall 完成,将 action 分发到 Go 服务(storage/歌曲/歌单/跨插件通信/子进程等),是插件访问宿主能力的唯一入口。


5. 消息调度器

章节来源internal/jsplugin/scheduler.go

ServiceScheduler 借鉴 Skynet Actor 模型,为每个插件提供 worker goroutine + 消息队列(cap=256):

消息类型说明
MsgHTTPRequestHTTP 路由请求
MsgInterPlugin插件间通信
MsgLifecycle生命周期(init/deinit)
MsgHealthCheck健康检查
  • Send -- 异步投递,不等响应
  • Call -- 同步调用,RespChan + 超时(默认 30s)
  • dispatch -- 非阻塞投递,队列满返回 ErrQueueFull

Worker 串行处理消息,保证同一插件请求不并发。处理前检查 msg.Ctx.Err():客户端已放弃的请求直接跳过,避免 worker 被过时请求卡住。


6. Manager 概览

章节来源internal/jsplugin/manager.go

Manager 是插件系统顶层协调器,持有 Repository(SQLite 持久化)、PackageManager(ZIP 安装/同步)、ServiceScheduler、JSEnvManager、HealthChecker、HotReloader 和 singleflight.Group(懒加载去重)。

6.1 启动流程

Start(ctx) 依次执行:SyncPluginsFromDirectory(ZIP → DB 同步)→ loadPlugins(加载所有 active 插件)→ RefreshPublicPaths → 启动 HealthChecker → 启动 HotReloader。

6.2 核心操作

方法行为
LoadPlugin创建 JSService + BridgeHandler → service.Load(hash 校验/JS 环境创建)→ scheduler 注册 → onInit()
UnloadPluginscheduler 注销 → service.Stop()
ReloadPluginUnload + 清除字节码缓存 + Load
EnablePluginDB 状态 active + Load,清空自愈退避
DisablePluginUnload + DB 状态 inactive

6.3 懒加载(EnsureLoaded)

请求到达时目标插件未加载(可能因空闲驱逐),EnsureLoaded 按需加载。使用 singleflight.Group 按 entryPath 去重:50 个并发只执行一次 LoadPlugin。DB 中 inactive 返回 403,error 返回 503(HealthChecker 指数退避自愈),不存在返回 404。

6.4 关闭流程

Close() 保证无死锁:jsManager.SignalShutdown() → 停止 HealthChecker → 取消 context → 遍历 service 注销/Stop → 关闭 scheduler → 关闭 jsManager(tryLockWithTimeout 3s 兜底)。


7. Plugin 模型

章节来源internal/jsplugin/plugin.gointernal/models/models.go:519-543

7.1 JSPlugin 结构体

定义在 models 包,jsplugin 包通过类型别名引用。关键字段:

字段说明
EntryPath路由前缀(^[a-z][a-z0-9-]*$
Main入口文件(.js/.jsc
Permissions权限列表(net/storage/fs:music 等)
PublicPaths无需 JWT 的路径前缀
ExternalPaths可访问的外部绝对路径
ZipHash / EntryHash双层完整性校验 hash
Status状态:active/inactive/error

7.2 Manifest 与校验

plugin.json@songloft/plugin-builder 打包生成。ValidateManifest 校验:name 2-50 字符、version semver、entryPath 小写字母开头、main 以 .js/.jsc 结尾、entryHash/zipHash 为 64 位小写 hex。

7.3 状态机

  安装/启用         加载失败 / 健康检查异常
  --------> active -----------------------> error
              ^                                |
              |     HealthChecker 指数退避自愈  |
              +--------------------------------+
              |
  用户禁用    v
  --------> inactive

8. Service 实例

章节来源internal/jsplugin/service.go

8.1 JSService 结构

Per-plugin Actor,实现 MessageHandler 接口。持有插件元数据、envID、scheduler、jsManager、BridgeHandler,维护 status(ready/running/frozen/stopped)和 lastActive(空闲驱逐判定)。

8.2 加载流程(Load)

双层 hash 校验 + 代码加载:

  1. 读取 ZIP → Layer 1 计算规范化 ZIP hash(排除 plugin.json),mtime 未变但 hash 不一致判定篡改
  2. 读取入口文件 → Layer 2 计算 SHA256 校验一致性
  3. 字节码缓存:ZIP 自带 .jsc 直接用;有效缓存加载;否则用源码并异步编译缓存
  4. 创建 JS 环境(GetBootstrapCode() + 插件代码)→ 注册 BridgeCallback → 解压 static/bin 目录

8.3 生命周期

Load() -> Init() -> [HandleMessage 循环] -> Deinit() -> Stop()
回调超时说明
onInit()10s插件初始化(注册路由/启动定时任务)
onDeinit()10s插件清理(关闭连接/保存状态)

Init() 完成后启动 runTimerProcessor goroutine(500ms 周期 TryLock 处理 JS 定时器)。

8.4 请求处理

HandleMessage 按消息类型分发:

  • MsgHTTPRequest:将请求序列化为 JSON,包装成 (async function(){return JSON.stringify(await onHTTPRequest(req));})() 交给 ExecuteJS。支持 base64 body 编码和 msg.Ctx 取消。result == "" 返回 502(handler 漏 return),ctx.Canceled 返回 499。
  • MsgInterPlugin:调用 __handleInterPluginMessage(jsonStr) 处理插件间通信
  • MsgHealthCheck:执行 1+1,断言结果为 2
  • MsgLifecycle:分发 init/deinit 回调

8.5 停止流程

Stop() 的执行顺序:

  1. 关闭 timerStop channel,停止定时器 goroutine
  2. 调用 Deinit()(忽略错误,保证后续清理继续)
  3. bridgeHandler.Cleanup()(终止后台子进程)
  4. jsManager.DestroyPluginEnvs(pluginID) -- 批量销毁所有关联 JS 环境(包括主 env 和子 env)