插件管理机制
本文基于 internal/jsplugin/ 下的 manager.go、loader.go、package.go、registry.go、 hot_reload.go、health.go、scheduler.go、hash.go、service.go、plugin.go, 以及 internal/handlers/jsplugin.go、jsplugin_registry.go 撰写。
目录
1. 插件生命周期状态机
章节来源:manager.go(Manager 生命周期方法)、service.go(ServiceStatus)、health.go(HealthChecker 状态转移)、plugin.go(JSPluginStatus)
插件拥有两层状态:DB 持久化状态(JSPluginStatus)和运行时服务状态(ServiceStatus)。
1.1 DB 持久化状态(JSPluginStatus)
┌─────────────────────────┐
InstallFromUpload │ │
─────────────────────>│ inactive │
│ (新安装,未启用) │
└─────────┬───────────────┘
│ EnablePlugin
v
┌─────────────────────────┐
┌──────────>│ │<─────────┐
RecoverPlugin│ │ active │ │自愈成功
EnablePlugin │ │ (已启用,可加载) │ │(runRecoveryAttempts)
│ └──┬──────────────┬───────┘ │
│ │ │ │
│ DisablePlugin 连续 maxFailures │
│ │ 次健康检查失败 │
│ v v │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ inactive │ │ error │────────┘
│ │ (用户禁用) │ │ (自动标记异常) │
│ └─────────────┘ └─────────────────┘
│ │
└───────────────────────────┘三种 DB 状态:
- active:插件已启用,Manager 启动时会加载,请求到达时按需懒加载。
- inactive:用户主动禁用或新安装未启用,不会被加载。请求到达返回 403。
- error:HealthChecker 连续多次健康检查失败后自动标记,由自愈机制按指数退避尝试恢复。请求到达返回 503。
1.2 运行时服务状态(ServiceStatus)
stopped ──Load()──> ready ──HandleMessage()──> running ──完成──> ready
│ │
│<─────────────────────────────────────────┘
│
热更新开始
│
v
frozen ──卸载+重载──> ready
│
加载失败
v
stopped四种运行时状态:
- ready:已加载完毕,可接收并处理消息。
- running:正在处理一条 HTTP 请求或插件间消息。
- frozen:热更新进行中,暂停接收新消息。
- stopped:已停止(JS 环境已销毁),需要重新 Load 才能使用。
1.3 懒加载(EnsureLoaded)
当请求到达一个 DB 中 status=active 但运行时未加载(被空闲驱逐)的插件时,EnsureLoaded 按需触发 LoadPlugin。使用 singleflight.Group 按 entryPath 去重并发,避免多个请求同时触发重复的 hash 校验和 scheduler 注册。
2. 插件加载流程
章节来源:manager.go(Start、LoadPlugin、loadPlugins)、service.go(Load、Init)、loader.go(readEntryFromZip、extractStaticFromZip)
2.1 启动时全量加载
Manager.Start 的执行顺序:
Start(ctx)
├─ 创建 HealthChecker + HotReloader
├─ packager.SyncPluginsFromDirectory() // 从磁盘同步插件记录
│ ├─ 扫描 pluginsDir 中的 .jsplugin.zip
│ ├─ 新 ZIP → InstallFromUpload
│ ├─ 已有但 hash 不同 → Update
│ ├─ DB 有但 ZIP 缺失 → Uninstall(清理孤儿)
│ └─ 返回完整插件列表(避免再查 DB 引起 SQLITE_BUSY)
├─ loadPlugins(synced) // 只加载 status=active 的
├─ RefreshPublicPaths() // 刷新无需 JWT 的路径前缀缓存
├─ logPluginStaticURLs() // 打印插件静态页面 URL
├─ healthChecker.Start() // 启动健康检查 goroutine
└─ go hotReloader.WatchForChanges() // 启动热更新文件监控2.2 单插件加载(LoadPlugin)
LoadPlugin(ctx, plugin)
├─ os.MkdirAll(pluginsDataDir) // 确保数据目录存在
├─ NewJSService(plugin, scheduler, jsManager) // 创建服务实例
├─ NewBridgeHandler(service, ...) // 创建桥接处理器
├─ service.Load(pluginsDir, dataDir) // 核心加载
│ ├─ 读取 ZIP 文件到内存
│ ├─ Layer 1: ComputeCanonicalZipHash // 规范化 ZIP hash
│ ├─ 校验 zipHash(mtime 未变则判定篡改)
│ ├─ readEntryFromZip (优先 .jsc > .js) // 从 ZIP 读入口文件
│ ├─ Layer 2: sha256Hex(entryCode) // 入口文件 hash
│ ├─ 校验 entryHash
│ ├─ 尝试加载字节码缓存 (loadBytecodeCache)
│ ├─ 创建 JS 环境 (CreateEnv / CreateEnvWithBytecode)
│ ├─ 注册桥接回调 (SetBridgeCallback)
│ ├─ extractStaticFromZip → static/ // 解压静态资源
│ ├─ extractBinFromZip → bin/ // 解压可执行文件
│ └─ 异步编译并缓存字节码 (saveBytecodeCache)
├─ repo.UpdateHashes(ctx, ...) // 持久化 hash 到 DB
├─ scheduler.RegisterService(entryPath, svc) // 在调度器注册
├─ service.Init() // 调用 JS onInit()
│ ├─ ExecuteJS("onInit()", 10s 超时)
│ └─ 启动 runTimerProcessor goroutine(500ms 周期处理 JS 定时器)
└─ services.Store(entryPath, service) // 存入内存 map2.3 入口文件加载优先级
readEntryFromZip 根据 manifest 的 main 字段构造候选列表:优先 .jsc(预编译字节码),回退 .js(源码)。字节码模式下 bootstrap 源码先执行再加载字节码;源码模式下二者拼接一起执行,加载成功后异步编译并缓存字节码供下次使用。
3. PackageManager:安装/更新/删除
章节来源:package.go(PackageManager 全部方法)
PackageManager 负责 .jsplugin.zip 包的完整生命周期管理,与 Manager 解耦(Manager 负责运行时,PackageManager 负责包管理)。
3.1 安装流程(InstallFromUpload)
InstallFromUpload(zipData)
├─ readPluginManifestFromZip // 从 ZIP 解析 plugin.json
├─ ValidateManifest + ValidatePermissions
├─ 检查 entryPath 已存在 → 走 Update 路径(保留原 ID 与状态)
├─ readEntryFromZip + sha256Hex // 计算 entry_hash
├─ ComputeCanonicalZipHash // 计算规范化 zip_hash
├─ 静态校验:manifest 声明 hash == 实际内容 hash
├─ 保存 ZIP → extractStaticFromZip
├─ 构建 JSPlugin 对象(初始状态 = inactive)
└─ repo.Create(失败时回滚删除 ZIP)新安装的插件初始状态为 inactive,需用户手动启用。wasUpdate=true 表示走了覆盖更新路径。
3.2 更新流程(Update)
Update(pluginID, zipData)
├─ 获取已有插件 → 校验 entryPath 必须匹配
├─ 解析 + 校验新 manifest + hash
├─ 覆盖旧 ZIP 文件
├─ 清理旧 static/ → 重新解压
└─ repo.Update 更新数据库记录(保留原 ID 和 status)更新不会改变插件的启用状态。如果插件正在运行,handler 层会在更新后调用 manager.ReloadPlugin 触发热重载。
3.3 删除流程(Uninstall)
Uninstall(pluginID)
├─ repo.GetByID // 获取插件信息
├─ os.Remove(zipFile) // 删除 ZIP 文件
├─ os.RemoveAll(staticDir)// 删除 static 目录
└─ repo.Delete // 删除数据库记录handler 层在调用 Uninstall 前会先通过 manager.UnloadPlugin 卸载运行中的服务,之后刷新 publicPaths 缓存。
3.4 目录同步(SyncPluginsFromDirectory)
启动时调用,保持磁盘与数据库的一致性:
| 场景 | 行为 |
|---|---|
| 新发现 ZIP(DB 无记录) | 自动 InstallFromUpload |
| 已有记录但 zipHash 不一致 | 执行 Update(重新计算规范化 hash) |
| zipHash 一致但 manifest 元数据变化 | syncManifestMetadata 补偿更新(icon/name/description/homepage/updateURL) |
| DB 有记录但 ZIP 文件不在 | 删除孤儿记录(Uninstall) |
zipHash 算法排除了 plugin.json 自身,因此仅修改 manifest 元数据(如新增 icon 字段)不会改变 zipHash,需要 syncManifestMetadata 方法额外检测并同步。
3.5 远程更新检查与下载
CheckUpdate 通过插件的 updateURL 拉取远程 plugin.json,比较版本号判断是否有更新。DownloadUpdate 在确认有更新后下载新 ZIP 并调用 Update 安装。两者均支持 GitHub 加速代理前缀。
批量更新(handleBatchUpdate)遍历所有插件,逐个检查并更新,单个失败不影响其他插件。支持 force=true 跳过版本检查强制重新安装。
4. 远程注册表
章节来源:registry.go(RegistryService)、handlers/jsplugin_registry.go(注册表 API + 订阅源设置)
4.1 注册表结构
注册表采用 JSON 格式,支持嵌套 includes 实现多级组合:
{
"name": "Songloft 官方插件",
"includes": ["https://example.com/community-registry.json"],
"plugins": [
"https://example.com/plugin-a/plugin.json",
"https://example.com/plugin-b/plugin.json"
]
}plugins 数组中的每个 URL 指向独立的 plugin.json,包含插件元数据和 download_url。
4.2 递归拉取与合并(FetchAndMerge)
FetchAndMerge(registryURL)
├─ fetchRecursive() // 递归拉取(最大深度 20)
│ ├─ 去重(visited map,按 canonical URL)
│ ├─ 收集所有 plugin.json URL
│ └─ 递归处理 includes
├─ resolveAll() // 并发解析 plugin.json(并发度 8)
│ └─ resolvePluginJSON()
│ ├─ 解析 manifest → RegistryEntry
│ ├─ 拼接 icon URL(相对路径 → 绝对路径)
│ └─ 兼容旧版:download_url 为空时链式拉取 updateUrl
└─ 按 entryPath 去重(高版本优先)安全限制:
- 最大递归深度:20 层
- 最大插件数:500 个(超出截断并发出 warning)
- 响应体上限:2 MB
- 单次拉取超时:15 秒
- 并发解析 plugin.json 数:8 个
4.3 版本比较
compareVersion 支持 semver(1.2.3)和日期格式(2026.6.2),按 dot-separated 数值逐段比较。不足段补零。
4.4 订阅源管理
用户可配置多个注册表订阅源,通过 /api/v1/settings/plugin-registries 端点管理。每个源包含 URL、名称和是否启用。默认内置 Songloft 官方插件注册表。
从注册表安装插件通过 /api/v1/jsplugins/registry/install 端点,下载 ZIP 后走 InstallFromUpload 流程(若 entryPath 已存在自动走更新路径)。下载限制 50 MB,支持 GitHub 加速代理。
5. 热更新
章节来源:hot_reload.go(HotReloader)、manager.go(ReloadPlugin)
5.1 文件监控(WatchForChanges)
HotReloader 使用轮询方式(每 30 秒)监控插件 ZIP 文件的 mtime 变化:
WatchForChanges(ctx)
└─ 每 30s ticker:
checkForChanges()
├─ 遍历所有运行中的服务
├─ os.Stat(zipPath) 获取当前 mtime
└─ mtime 与 plugin.FileModTime 不一致
└─ 触发 ReloadPlugin(pluginID)5.2 热更新流程(ReloadPlugin)
ReloadPlugin(ctx, pluginID)
├─ 获取插件信息
├─ 获取旧服务(如果存在)
├─ 冻结旧服务(status = frozen,停止接收新消息)
├─ 卸载旧插件(UnloadPlugin)
│ ├─ scheduler.UnregisterService(等待队列消息处理完或 10s 超时)
│ ├─ service.Stop()
│ │ ├─ 停止定时器 goroutine
│ │ ├─ 调用 onDeinit() 回调
│ │ ├─ 清理桥接资源(终止后台进程)
│ │ └─ 销毁 JS 环境(含子 env)
│ └─ 从 services map 移除
├─ 清除字节码缓存(os.RemoveAll cacheDir)
├─ 重新加载插件(LoadPlugin)
│ └─ 完整的 Load → Init 流程
└─ RefreshPublicPaths()5.3 失败回滚
如果新版本加载失败,HotReloader 会尝试用原插件信息重新 LoadPlugin 作为回滚。若回滚也失败,则将插件标记为 error 状态,交由 HealthChecker 的自愈机制处理。
5.4 手动热更新
除文件监控自动触发外,API 上传(handleUpload/handleUpdate)、注册表安装(handleRegistryInstall)、批量更新(handleBatchUpdate)和远程下载更新(handleDownloadUpdate)在更新 ZIP 后若插件处于 active 状态,均会调用 manager.ReloadPlugin 触发热重载。
6. 健康检查与自愈
章节来源:health.go(HealthChecker 完整实现)
6.1 检查机制
HealthChecker 每 60 秒执行一轮健康检查,每轮的执行顺序:
runChecks(ctx)
├─ runRecoveryAttempts() // 先扫描 error 状态插件,尝试自愈
├─ runWakeupChecks() // 唤醒因长定时器而休眠的插件
└─ 遍历所有运行中的服务:
├─ checkIdle() // 检查空闲状态
│ ├─ lastActive 超过 idleTimeout(10min) → 卸载释放资源
│ ├─ 有活跃 WebSocket → 不休眠
│ ├─ 有运行中子进程 → 不休眠
│ └─ 有近期定时器(3x idleTimeout 内) → 不休眠
└─ checkHealth() // 健康探针(直连 VM,绕开 scheduler)
├─ Healthy → 重置失败计数
├─ Busy → 计数,连续 5 次升级为 Unhealthy
└─ Unhealthy → handleUnhealthy6.2 健康探针设计
健康检查不走 scheduler 队列(避免被长 fetch 阻塞导致假阳性),而是直接通过 jsruntime.HealthProbe 对 JS VM 互斥锁做 TryLock:抢到锁执行 eval("1+1") 验证 VM 存活(Healthy);抢不到说明 VM 正忙(Busy,不计失败);env 已销毁则判定 Unhealthy。
6.3 故障升级与自动禁用
连续 maxFailures(默认 3)次 Unhealthy → 标记 DB 为 error 状态并卸载插件:
handleUnhealthy()
├─ failures[entryPath]++
└─ failCount >= maxFailures:
├─ repo.UpdateStatus → error
├─ UnloadPlugin // 卸载但不删除文件
└─ 初始化 recoveryAttempt // 启动指数退避自愈连续 Busy 的兜底:连续 maxBusyRounds(默认 5,即 5 分钟)次 Busy 也会升级为 Unhealthy 处理,作为真死锁的安全网。
6.4 指数退避自愈
error 状态的插件由 runRecoveryAttempts 按退避序列自动尝试恢复:
| 档位 | 延迟 |
|---|---|
| 第 1 次 | 1 分钟 |
| 第 2 次 | 5 分钟 |
| 第 3 次 | 15 分钟 |
| 第 4 次 | 30 分钟 |
| 第 5 次及以后 | 60 分钟(持续) |
恢复流程:先将 DB 状态推回 active → 调用 LoadPlugin → 成功则清除 recovery 进度,失败则回滚 DB 为 error 并按下一档退避。超过退避表长度后日志降级为 Debug 级别避免刷屏。
用户主动 EnablePlugin 或手动 RecoverPlugin 时清空退避计数(ClearRecovery),让下次 error 退避从 1 分钟重新开始。
6.5 空闲驱逐与定时器唤醒
超过 idleTimeout(默认 10 分钟)无活动的插件会被卸载以释放资源,但 DB 状态保持 active(下次请求时 EnsureLoaded 会重新加载)。
定时器感知决策:
- 无定时器 → 直接休眠。
- 下一定时器在 3 x idleTimeout(30 分钟)内 → 保持活跃,不休眠。
- 下一定时器在 30 分钟之外 → 休眠,并记录唤醒时间。
runWakeupChecks会在定时器 deadline 前wakeupLead(默认 2 分钟)重新加载插件,补偿 VM 重建和 onInit 开销。
7. Scheduler 调度器
章节来源:scheduler.go(ServiceScheduler 完整实现)
7.1 设计理念
ServiceScheduler 借鉴 Skynet 的 Actor 消息分发模型,每个插件是一个独立的 Actor(serviceEntry),拥有独立的消息队列和 worker goroutine,保证同一插件内消息串行处理。
7.2 消息类型
MsgHTTPRequest // HTTP 路由请求
MsgTimerFire // 定时器触发
MsgInterPlugin // 插件间通信
MsgLifecycle // 生命周期事件(init/deinit)
MsgHostCall // 宿主函数调用结果
MsgHealthCheck // 健康检查7.3 消息投递模式
- 异步发送(Send):投递消息到目标队列后立即返回,不等待响应。
- 同步调用(Call):投递消息并通过
RespChan等待响应,超时默认 30 秒,支持 context 取消。内部创建带超时的 callCtx,dispatch 投递后 select 等待 respChan 或 callCtx.Done。
7.4 Worker 处理
每个 service 有一个 worker goroutine 串行消费消息队列。处理消息前会检查 msg.Ctx 是否已取消,已取消的请求(如用户快速切歌)直接跳过,避免被串行化的 ExecuteJS 卡住后续请求。
7.5 背压与资源保护
- 消息队列容量:默认 256
- 队列满时 dispatch 返回
ErrQueueFull(即时拒绝,不阻塞) - 注销服务时先标记
closed(拒绝新消息)→ 关闭 channel → 等待 worker 处理完剩余消息或超时后强制取消
关闭调度器时(Close):关闭所有服务入口 → 等待所有 worker 退出(全局超时 10 秒)→ 强制取消超时的 worker。
8. Hash 验证体系
章节来源:hash.go(hash 算法与校验函数)、loader.go(字节码缓存 hash)、service.go(Load 中的双层校验)
8.1 双层 hash 校验
插件加载时执行两层完整性验证,确保构建到运行全链路不可篡改:
Layer 1 - 规范化 ZIP Hash(zipHash):算法与 @songloft/plugin-builder 一致 -- 枚举 ZIP 内所有非 plugin.json 的普通文件,按文件名 Unicode 升序排序,对每个文件写入 <path>\n<sha256(content)>\n,最终拼接串再算 SHA256。排除 plugin.json 避免 hash 写入 manifest 后的循环依赖;规范化算法对 ZIP 内文件顺序、元数据不敏感,任意机器重打包结果一致。
Layer 2 - 入口文件 Hash(entryHash):对 ZIP 内入口文件(main.js/main.jsc)内容计算 SHA256,即使 ZIP 整体 hash 通过也单独校验,提供纵深防御。
8.2 校验时机与篡改检测
| 时机 | 校验内容 | 篡改处理 |
|---|---|---|
| InstallFromUpload | manifest 声明的 hash 与实际计算值对比 | 拒绝安装,返回 ErrManifestHashMismatch |
| Update | 同上 | 拒绝更新 |
| Load(运行时) | DB 中存储的 hash 与实际文件对比 | mtime 未变 → 判定篡改,拒绝加载;mtime 已变 → 合法更新,沿用新 hash |
| SyncPluginsFromDirectory | 规范化 zipHash 对比 | hash 不一致 → 执行 Update 流程 |
8.3 Manifest Hash 字段校验
ValidateHashField 严格校验 hash 字段:
- 不允许为空(ErrManifestHashMissing)
- 必须是 64 位小写 hex(ErrManifestHashInvalid)
- 必须与实际内容一致(ErrManifestHashMismatch)
8.4 字节码缓存 Hash
字节码缓存使用双行 hash 文件(.jsc.sha256):
- 第一行:源码 entryHash(源码变化 → 缓存失效)
- 第二行:.jsc 文件自身的 SHA256(检测缓存文件篡改)
加载时校验两行均通过才使用缓存;任一不匹配则删除缓存文件,强制从源码重新编译。
图表来源:状态机图基于 manager.go 中 EnablePlugin/DisablePlugin/LoadPlugin、health.go 中 handleUnhealthy/runRecoveryAttempts 的状态转移逻辑绘制。流程图基于各方法的实际执行步骤提取。
