基于 WASM 的插件系统设计与落地

TL;DR#169 对我来说不是“加一个插件入口”,而是把 PPanel 从一个固定功能集的后端,往“可扩展平台”推进了一步。我最后选择了 WASM + wazero + WASI 这条路线:宿主只暴露受控能力,插件通过 plugin.protoenv.* host functions 跟宿主通信;HTTP 侧不直接热改主路由树,而是用固定 dispatcher 做动态分发;同时,我还把开发侧一起补齐,做了 ppanel-sdk,用 Rust 宏、host wrapper 和一个轻量 async runtime 把底层 ABI 封装掉。现在回头看,#169 最难的其实不是“让插件跑起来”,而是在第一版里接受很多保守取舍:实例池先锁在 1,异步先把 ABI 形状立住,数据库能力先做白名单收敛。也正因为这些取舍,这套系统最后才能比较稳地落地。


一、#169 对我来说,不是一个“小功能 PR”

如果只看标题,feat: add plugin system 很容易让人以为这只是“增加一个插件目录,然后加载一下”。

但我自己很清楚,#169 真正要解决的问题并不是“让后端支持插件”这么泛,而是:

我想给 PPanel 建一条受控的扩展通道,让未来一些不适合继续堆进主仓库的能力,可以在宿主边界内被动态接入。

所以这次 PR 最后落地的并不只是路由注册,而是一整套东西:

  • 插件加载与生命周期管理
  • WASM 运行时
  • 宿主能力注入
  • HTTP dispatcher
  • 中间件模型
  • Redis / DB / 配置 / 事件 / HTTP / scheduler / queue 能力
  • 安装、校验、重载、启停等管理 API
  • 测试
  • 以及开发插件用的 ppanel-sdk

单看实现规模也能说明问题:核心提交 7930ce4 feat: add plugin system 一次性改了 45 个文件,新增了 8000+ 行代码。

对我来说,这不是 patch,而是一次很完整的基础设施建设。


二、我一开始定的几个原则

在真正写代码之前,我给这套插件系统定了几条原则。后面你会看到,几乎所有实现细节都能回到这几条原则上。

1. 插件必须是运行时扩展,不是编译期扩展

我不想把插件做成“重新编译主程序才能接进去”的模式。

我要的是:

  • 把插件包放进目录
  • 或者通过管理接口上传安装
  • 宿主启动时扫描
  • 运行中可以启用、禁用、重载

这决定了它必须是一套运行时模型

2. 宿主和插件之间必须有清晰边界

我不想把内部 Go 对象直接暴露给插件,也不想让插件随意拿到宿主上下文。

插件应该只能通过一组明确、可审计、可裁剪的宿主能力来工作。

这条原则最后落到了:

  • plugin.proto
  • env.* host functions
  • protobuf 消息
  • 线性内存读写

3. 热管理比“炫技式动态注册”更重要

我不希望插件系统把 HTTP 主路由树搞得非常复杂。相比“插件启动后往主 router 里打一堆原生路由”,我更在意:

  • 插件能不能稳定重载
  • 路由状态能不能统一观测
  • 宿主行为能不能保持可预测

这也是后来我选择固定 dispatcher 的原因。

4. 安全不是后补项,而是基础项

只要插件能上传、能执行、能访问宿主能力,它就不再只是“功能扩展”,而是一条新的攻击面。

所以路径穿越、SSRF、DB 范围、配置泄露、Redis 污染、压缩包逃逸这些问题,我从一开始就按“必做项”处理,而不是等以后补。

5. 插件机制和插件开发体验要同时成立

很多插件系统只做 runtime,不做 SDK,结果就是“理论上可扩展,实践上没人愿意写”。

我不想要那种结果。

所以 #169 里除了 internal/plugin/ 这套运行时之外,我还同时把 ppanel-sdk 做出来了。

6. 第一版必须保守,不能一上来追求“最强”

开发这套系统时,我很早就接受了一件事:第一版最重要的不是能力铺满,而是边界先立住。

所以你会看到我在几个关键点上都做了偏保守的选择:

  • 插件实例池默认先固定为 1
  • HTTP 不直接热改主路由树,而是走固定 dispatcher
  • async 先把 ABI 和 SDK 形状立住,宿主侧 resolve 先保守实现
  • 数据库能力先做白名单 CRUD,而不是直接把 ORM 能力敞开

这些地方单拿出来看,都会让人觉得“还能再往前做一步”。这没错,我自己也知道还能继续推。但如果在 #169 这一版里同时追求动态路由、完整异步调度、多实例并发和更开放的数据能力,复杂度会一下子失控。

现在回头看,恰恰是这些不那么激进的决定,保证了第一版是能落地、能管理、能继续演进的。


三、为什么我最后选了 WASM + wazero + WASI

从实现结果看,答案已经很明显了:我没有选 Go 的原生 plugin 机制,而是选了 WASM。

原因其实不复杂。

第一,边界清晰

WASM 天然就是一个比较好的隔离边界。

插件不是直接拿着宿主进程里的对象跑逻辑,而是在一个单独的模块里运行,宿主明确决定:

  • 给你什么 import
  • 给你多少内存
  • 给你多长时间
  • 给不给文件系统挂载

这跟“把一个动态库直接塞进主进程”是两种完全不同的心态。

第二,可移植性更好

我不想把插件系统绑死在某个平台、某个编译链或者某个特定语言实现上。

虽然我现在给出的第一套正式 SDK 是 Rust 的,但底层 ABI 是语言中立的。只要别的语言愿意按同样的协议去实现,也完全可以接进来。

第三,宿主控制权更强

我希望宿主永远掌握主动权。

比如:

  • 哪些能力可用
  • 哪些请求能过
  • 哪些 DB 表能查
  • 哪些 URL 能访问
  • 哪些插件允许加载

WASM 这条路线天然更适合做这种 capability-based 的能力发放。


四、运行时骨架:Manager 是整个插件系统的控制面

整套系统的核心基本都收敛在 internal/plugin/manager.go

从我的设计角度看,Manager 做的事情其实很明确:

  • 管理插件实例
  • 维护动态路由注册表
  • 维护中间件注册表
  • 维护事件总线
  • 维护 cron 任务
  • 维护异步任务表
  • 提供启停、重载、校验、查询状态等能力

也就是说,Manager 不是单纯的 loader,而是整个插件系统的控制面

4.1 插件启动时是怎么接进系统的

cmd/run.go 里,我会先构造 HostEnv

  • Config
  • Redis
  • Store
  • Queue

然后再创建 plugin.NewManager(...),挂到 ServiceContext 里。

同时,HTTP 服务启动前会等待 PluginReady.WaitReady()

这个细节后来变成了我在这次开发里踩到的第一个很现实的工程坑。

一开始我直觉上会以为:既然 plugin manager 也被加进了 service.Group,那它应该会在 HTTP 服务之前准备好。但回头看 pkg/service/service.go 才会发现,Group.Start() 是并发拉起所有 service 的,顺序根本不保证。

也就是说,如果我不显式做 WaitReady(),完全可能出现这样一种情况:HTTP 已经开始监听了,但插件还没扫完、init 还没跑完、路由注册表还是空的。这个问题在代码静态阅读时不明显,但在真实系统里会非常恶心,因为它不是“必现 bug”,而是“偶发启动竞态”。

所以最后我宁可把 ready 这个同步点明确做出来,也不想把插件系统的启动时序交给运气。

4.2 我为什么做成“每个插件一个 runtime”

PluginInstance 里直接保存了独立的 wazero.Runtime。这不是随手写的,而是我很明确的选择。

原因很简单:

插件之间不应该共享运行时状态,尤其不应该在 env host module 这一层互相污染。

如果多个插件共用一个 runtime,那么:

  • host function 注入会变复杂
  • 生命周期会互相影响
  • 调试和清理都会变脏

所以我宁可多花一点运行时成本,也要换来每个插件的隔离性和可控性。

4.3 我接受的一个现实取舍:默认实例池大小是 1

当前实现里,默认 poolSize 是 1。

这意味着同一个插件默认只有一个 WASM 实例,请求进入这个插件时,本质上是串行进入 WASM 的。

这是我有意识接受的取舍:

  • 先把生命周期做稳定
  • 先把宿主边界做清楚
  • 先把插件状态做一致

而不是一上来就追求复杂并发模型。

这也是为什么后面我又单独补了 async runtime:我不打算靠“在 WASM 里原地并发”来解问题,而是把慢 I/O 交回宿主去跑。

4.4 WASI 运行时不是摆设

loader.go 里,我给每个插件开了:

  • stdout
  • stderr
  • 时间函数
  • 随机数
  • /data 挂载目录

具体来说,会把 data/plugins/{pluginName} 挂到插件内部的 /data

这让我能比较自然地把插件看成一个受控的、可落盘的 WASI 模块,而不是单纯的函数回调容器。


五、ABI 设计:我把宿主和插件的边界收敛到了 plugin.proto

如果让我只挑一个最关键的设计点,那一定是 plugin.proto

服务端的协议定义在:

  • api/plugin/v1/plugin.proto

SDK 侧则有一份对应的:

  • ppanel-sdk/plugin.proto

然后 SDK 通过 ppanel-sdk/ppanel-sdk/build.rs 在编译期把这份协议编成 Rust 类型。

我这么做的核心考虑是:

宿主和插件之间应该只通过一份稳定、可演进的消息协议沟通,而不是共享内部实现。

5.1 为什么这里我坚持用 protobuf

因为插件系统传递的不是一两种消息,而是一整个能力面:

  • InitRequest
  • HandleRequest / HandleResponse
  • MiddlewareResponse
  • DbQueryRequest / Response
  • RedisGet/Set
  • EmitEvent
  • HttpRequest
  • AsyncSubmit / Resolve / WaitAny

如果这里我自己造字节协议,最后一定会把维护成本做爆。

用 protobuf 的好处非常直接:

  • 结构化
  • 双端一致
  • 易扩展
  • 易测试
  • Go / Rust 都成熟

5.2 ABI 本身其实很朴素

internal/plugin/abi.goppanel-sdk/ppanel-sdk/src/abi.rs 基本是镜像实现。

调用约定很简单:

  • 参数:(i32 ptr, i32 len)
  • 返回:i64,高 32 位是结果指针,低 32 位是结果长度
  • 请求和响应都走 protobuf
  • guest 导出 allocate/deallocate
  • host 负责从 WASM 线性内存读写消息

我很喜欢这种朴素的协议边界。

因为真正好的边界不一定复杂,反而应该是:

  • 足够底层
  • 足够稳定
  • 足够容易被 SDK 包掉

六、HTTP 这层我为什么没有直接热改主路由树

HTTP 这一层是我在 #169 里最明确的取舍之一。

我没有让插件启动后直接往主 router 里插入原生路由,而是选择了固定 dispatcher:

1
2
router.Any("/v1/plugin/:plugin", handler)
router.Any("/v1/plugin/:plugin/*path", handler)

也就是说,宿主 router 永远只知道一件事:

只要请求落在 /v1/plugin/... 下,就交给插件 dispatcher。

真正的路由匹配则由 Manager 自己维护的注册表完成。

我为什么这么做?

因为我更想要的是:

  • 插件热启停简单
  • 插件 reload 不影响主路由树
  • 所有插件路由状态都能统一观测
  • HTTP 框架层保持稳定

如果让我在“更原生一点的路由性能”和“运行时管理复杂度更低”之间选,我在 #169 里明显选了后者。

我现在回头看,仍然觉得这是对的。

插件流量通常不会是系统最核心的热点流量,但插件管理复杂度一旦失控,会直接把整套系统拖下水。

这个点我其实来回犹豫过

当时最自然、也最“显得厉害”的方案,其实是让插件直接往 Hertz 主 router 里注册原生路由。

但我很快发现,这个方案一旦落到真实运维动作上,就会变得非常难受:

  • 插件禁用时怎么把已经注册进去的路由干净撤掉?
  • 插件重载后,旧 handler 和新 handler 怎么保证不混在一起?
  • 当前到底有哪些插件路由在生效,去哪儿看最权威?

这些问题叠在一起之后,我就不再纠结了:与其把主路由树搞成一个动态容器,不如明确承认插件路由是“第二层路由”,统一走 dispatcher。

这会多一层分发成本,但换来的是热管理简单、状态统一、问题更容易定位。对第一版来说,我觉得这是更值的交换。

中间件我也用同样的思路处理

插件中间件没有直接嵌入 Hertz 的原生链路,而是由 dispatcher 在真正调用 guest handler 前手动执行。

它分两类:

  1. 宿主内置中间件

    • auth
    • device
  2. 插件自定义中间件

    • 插件通过 host_register_middleware 注册
    • 宿主通过 CallPluginMiddleware() 执行

WASM 中间件返回的是结构化的 MiddlewareResponse,用 next / abort / modify 来告诉宿主该怎么处理。

这个设计我自己很满意,因为它兼顾了两件事:

  • 插件有表达能力
  • 宿主保留最终控制权

七、权限和安全:这是我在 #169 里最不想妥协的部分

插件系统一旦能执行外部代码,它就天然变成了一条新的攻击面。

所以在这次实现里,我几乎把所有边界都尽量收紧了。

7.1 权限不是展示字段,而是真的决定能拿到什么能力

权限定义在 internal/plugin/types.go,包括:

  • http_routes
  • middleware
  • database_read
  • database_write
  • redis
  • logging
  • config_read
  • events
  • http_client
  • scheduler
  • queue

真正起作用的地方在 buildHostFunctions()

也就是说,插件清单里声明了什么权限,宿主才会给它注入对应的 host import。

这个模型的核心不是“声明”,而是 capability

7.2 Redis、配置、数据库都不是裸能力

我没有让插件直接拿宿主的 Redis / DB / 配置,而是都做了收敛。

Redis

Redis key 会自动加前缀:

1
plugin:{plugin_name}:{key}

这样插件之间天然隔离,也不会轻易误伤宿主自己的 key。

配置

host_config_get 只允许读取白名单 key,比如:

  • Site.SiteName
  • Site.Host
  • Currency.Unit
  • Currency.Symbol
  • Debug
  • Host
  • Port

像数据库密码、Redis 密码、JWT secret 这类敏感配置我默认都不暴露。

数据库

数据库能力也不是 ORM 直通车,而是:

  • 先做 model 白名单
  • 再做 field 白名单
  • 再区分读权限和写权限

这意味着插件拿到的是一个受限查询面,不是整个数据库的主钥匙。

这块我踩到的一个现实问题:数据库能力没法彻底“纯通用”

一开始我当然也希望插件数据库能力是一套非常干净、统一的抽象:给几个模型、给几个操作,然后所有东西都走同一条通用逻辑。

但真正接到现有业务上,很快就会发现现实没那么规整。最典型的例子就是 ticket_reply:它并不是简单往一张表插一行,而是要在事务里写 ticket_follow,然后再更新 ticket 状态。

所以最后在 internal/plugin/store_adapter.go 里,我承认了这个现实:大部分能力走通用白名单查询,少数确实带业务语义的写操作要保留专门分支。

这个决定不算“最优雅”,但它比给插件开放过大的数据库自由度要稳得多,也更符合第一版的目标。

7.3 我把路径穿越和安装包逃逸都在第一版处理了

只要支持插件上传安装,就必须先把压缩包安全问题处理掉。

所以在 internal/plugin/install.go 里,我做了这些限制:

  • 压缩包大小限制
  • 解压后总大小限制
  • 文件数量限制
  • 禁止 symlink
  • 只能有一个 plugin.yaml
  • 所有解压路径必须留在 staging / install 目录内

同时,插件名和插件文件路径也都做了校验:

  • 名称不能带路径穿越
  • main 不能是绝对路径
  • main 不能逃逸插件目录

7.4 外部 HTTP 请求必须带 SSRF 防护

插件只要能发 HTTP 请求,就有天然的 SSRF 风险。

所以 host_http_requestdoHTTP() 里我做了比较明确的限制:

  • 只允许 http/https
  • 禁掉 metadata.google.internal
  • 解析 DNS 后拒绝:
    • loopback
    • private IP
    • link-local
    • unspecified

这还不是完整的 egress policy,但至少它不是一个裸奔的 HTTP client。


八、我为什么还专门做了一套 async runtime

既然已经有同步 host function 了,为什么我还要再做 host_async_submit / host_async_resolve / host_async_wait_any 这一套?

答案很简单:

我不希望慢 I/O 一直堵在 WASM 调用栈里。

8.1 这层 async 的本质是什么

它不是让 WASM 变成一个多线程运行时,而是把耗时操作交给宿主 goroutine 池处理。

插件侧可以写:

  • host::http::get(...).await
  • host::redis_async::get(...).await
  • host::db_async::query(...).await

但真正干活的是宿主:

  • submitAsyncTask() 分配 task_id
  • executeAsyncTask() 在 goroutine 中跑真实 I/O
  • resolveAsyncTask() 取回结果
  • waitAnyAsyncTask() 等任意一个任务完成

同时,每个插件还有自己的 async in-flight 限制,当前默认是 64

这避免了某个插件无限制创建后台任务,把宿主拖垮。

8.2 我做了一个“先把 ABI 布好”的前向设计

这套 async 机制有个我自己很喜欢的点:SDK 已经按真正 Future / event-loop 的方式设计好了,但当前宿主实现仍然相对保守。

现在的 async_resolve 实现实际上是阻塞到任务完成再返回,所以 Future 通常第一次 poll 就 Ready。

但我在 SDK 里还是把下面这些东西先布好了:

  • pending-task 计数
  • async_wait_any
  • runtime::block_on()
  • Poll::Pending 分支
  • 最大 poll 次数保护

这意味着如果以后我要把宿主的 resolve 改成真正非阻塞,插件代码和 SDK 表层 API 基本不用重写。

换句话说,#169 这一版虽然还不是“完全成熟的异步插件调度器”,但 ABI 和 SDK 形状已经往那个方向对齐了。

8.3 这块最容易被误解的坑:SDK 看起来很 async,但第一版宿主其实是保守实现

如果只看插件侧代码,很容易产生一种错觉:好像插件已经获得了一个完整的异步运行时。

但更准确地说,第一版 async 的真正价值是两件事:

  1. 把慢 I/O 从 guest 直接调用栈里挪出去,交给宿主 goroutine 池跑
  2. 提前把 ABI、Future 形状和 SDK 使用方式立住

当前宿主里的 async_resolve 仍然是阻塞到任务完成再返回,所以它还不是“完全体”的非阻塞调度。我当时是故意这么做的,因为如果第一版就同时把真正非阻塞 resolve、多实例并发、调度唤醒和插件内并发语义一起做完,复杂度会一下子失控。

所以这块其实是一个很典型的工程取舍:先把接口形状做对,再逐步把宿主实现往前推。


九、为什么我还要自己把 ppanel-sdk 一起写出来

如果没有 SDK,这套插件系统在理论上可以工作,但在实践上会非常难写。

因为插件作者本来不应该关心这些底层细节:

  • 怎么 encode/decode protobuf
  • 怎么在 WASM 里分配内存
  • 怎么导出 init
  • 怎么让 handler 名和导出名对应上
  • 怎么把 async fn 驱动起来

所以我把这些脏活都压进了 ppanel-sdk

9.1 我在 SDK 里做了三层封装

第一层:协议代码生成

ppanel-sdk/ppanel-sdk/build.rs 会在编译时读取根目录的 plugin.proto,生成 Rust 类型。

这保证 SDK 和宿主看到的是同一套消息结构。

第二层:ABI 和 host wrapper

ppanel-sdk/ppanel-sdk/src/abi.rs 负责底层内存分配和 protobuf 编码。

ppanel-sdk/ppanel-sdk/src/host.rs 则把原始 host import 包成开发者真正会写的 API,比如:

  • host::route::register(...)
  • host::redis::get(...)
  • host::config::get(...)
  • host::events::subscribe(...)
  • host::http::get(...).await

第三层:过程宏和轻量 runtime

我在 ppanel-sdk-macros 里做了几个最核心的宏:

  • #[ppanel_sdk::init]
  • #[ppanel_sdk::handler]
  • #[ppanel_sdk::middleware]
  • #[ppanel_sdk::event_handler]
  • #[ppanel_sdk::start]
  • #[ppanel_sdk::stop]

这样插件作者写出来的代码可以长这样:

1
2
3
4
5
6
7
8
9
10
11
12
#[ppanel_sdk::init]
fn init(_req: InitRequest) -> Result<(), String> {
host::middleware::register("demo_guard", "mw_demo_guard")?;
host::route::register("GET", "/hello", "handle_hello")?;
host::events::subscribe("demo.ping", "on_demo_ping")?;
Ok(())
}

#[ppanel_sdk::handler(export = "handle_hello")]
fn hello(req: HandleRequest) -> HandleResponse {
// ...
}

插件作者看到的是“写业务函数”,而不是“手搓 ABI 粘合层”。

这正是我想要的结果。

9.2 ppanel-sdk 对我来说不是附属品,而是插件系统的一半

如果只有 runtime,没有 SDK,这套插件系统的真实门槛会非常高。

而 SDK 一旦存在,整个故事就变了:

  • 插件系统不再只是宿主内部机制
  • 它变成了一个可以被外部开发者实际使用的开发框架

这也是为什么我在 ppanel-sdk/examples/demo-plugin 里放了完整 demo。

我不希望这套系统停留在“理论可用”,我希望它是“拿来就能试”的。


十、回头看 #169,我最满意和最想继续推进的分别是什么

我最满意的几件事

1. 我没有把插件系统做成一堆散点能力

现在回头看,#169 不是“加了 N 个 host function”,而是形成了一套完整闭环:

  • 能加载
  • 能运行
  • 能注册 HTTP 能力
  • 能做安全收敛
  • 能运维管理
  • 能写插件
  • 能测试

这让我觉得它是一套系统,而不是半成品。

2. 固定 dispatcher 这个取舍我现在仍然觉得很值

它牺牲了一点“看起来更原生”的感觉,但换来了:

  • 热重载更简单
  • 路由状态更统一
  • 宿主框架更稳定

对于插件系统来说,我更愿意要这个结果。

3. SDK 和 runtime 同步推进是正确决定

如果当时我只做宿主 runtime,不做 SDK,这套系统今天的可用性会差很多。

我还想继续推进的几个点

1. 更灵活的实例池和并发模型

现在默认 poolSize = 1,这是第一版的保守策略。

后面如果要继续演进,我会考虑:

  • 是否把实例池大小做成配置项
  • 是否按能力或插件类型区分并发模型
  • 是否补更多运行时指标

2. 真正非阻塞的 async resolve

现在 ABI 和 SDK 已经为这件事铺好路了,后续可以继续把宿主侧实现补到位。

3. 权限模型继续收紧

比如当前 host_log 还是总是可用的,这一点后面还可以继续统一。

4. SDK 语言生态继续扩展

目前 ppanel-sdk 是 Rust-first 的,这完全合理,因为 Rust + WASI 的组合在这类场景下体验很好。

但从系统边界来看,它其实不是 Rust-only 的。后面如果要继续推动生态,完全可以再补别的语言 SDK。


十一、最后总结一下:#169 对我来说,埋下的是一个“扩展内核”

如果只看表面,#169 是一次“给 PPanel 加插件系统”的改造。

但从我自己的设计意图看,它真正完成的事其实是:

在 PPanel 内部建立了一套最小可用、边界清晰、可被继续演进的扩展内核。

我用 WASM + wazero + WASI 解决运行时隔离;
我用 plugin.proto 解决宿主与插件的协议边界;
我用固定 dispatcher + 动态注册表解决 HTTP 热管理;
我用权限、白名单、命名空间和 SSRF 防护解决安全边界;
我用 ppanel-sdk 解决插件作者真正写得动的问题。

现在回头看,#169 最重要的价值不是“插件已经很强大了”,而是:

PPanel 已经有了一个正确的扩展方向。

后面的能力可以继续加,SDK 可以继续打磨,并发模型可以继续升级,生态也可以继续长出来。

但最难的第一步——把边界、运行时、协议和开发体验一起立住——我觉得在 #169 里已经迈出去了。