Skip to content

插件管理机制

本文基于 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. 插件生命周期状态机
  2. 插件加载流程
  3. PackageManager:安装/更新/删除
  4. 远程注册表
  5. 热更新
  6. 健康检查与自愈
  7. Scheduler 调度器
  8. Hash 验证体系

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)         // 存入内存 map

2.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 实现多级组合:

json
{
  "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 → handleUnhealthy

6.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 消息类型

go
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 校验时机与篡改检测

时机校验内容篡改处理
InstallFromUploadmanifest 声明的 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 的状态转移逻辑绘制。流程图基于各方法的实际执行步骤提取。