基于 WASM 的插件系统设计与落地
TL;DR —
#169对我来说不是“加一个插件入口”,而是把 PPanel 从一个固定功能集的后端,往“可扩展平台”推进了一步。我最后选择了WASM + wazero + WASI这条路线:宿主只暴露受控能力,插件通过plugin.proto和env.*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.protoenv.*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:
ConfigRedisStoreQueue
然后再创建 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。这不是随手写的,而是我很明确的选择。
原因很简单:
插件之间不应该共享运行时状态,尤其不应该在
envhost module 这一层互相污染。
如果多个插件共用一个 runtime,那么:
- host function 注入会变复杂
- 生命周期会互相影响
- 调试和清理都会变脏
所以我宁可多花一点运行时成本,也要换来每个插件的隔离性和可控性。
4.3 我接受的一个现实取舍:默认实例池大小是 1
当前实现里,默认 poolSize 是 1。
这意味着同一个插件默认只有一个 WASM 实例,请求进入这个插件时,本质上是串行进入 WASM 的。
这是我有意识接受的取舍:
- 先把生命周期做稳定
- 先把宿主边界做清楚
- 先把插件状态做一致
而不是一上来就追求复杂并发模型。
这也是为什么后面我又单独补了 async runtime:我不打算靠“在 WASM 里原地并发”来解问题,而是把慢 I/O 交回宿主去跑。
4.4 WASI 运行时不是摆设
在 loader.go 里,我给每个插件开了:
stdoutstderr- 时间函数
- 随机数
/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
因为插件系统传递的不是一两种消息,而是一整个能力面:
InitRequestHandleRequest / HandleResponseMiddlewareResponseDbQueryRequest / ResponseRedisGet/SetEmitEventHttpRequestAsyncSubmit / Resolve / WaitAny
如果这里我自己造字节协议,最后一定会把维护成本做爆。
用 protobuf 的好处非常直接:
- 结构化
- 双端一致
- 易扩展
- 易测试
- Go / Rust 都成熟
5.2 ABI 本身其实很朴素
internal/plugin/abi.go 和 ppanel-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 | router.Any("/v1/plugin/:plugin", handler) |
也就是说,宿主 router 永远只知道一件事:
只要请求落在
/v1/plugin/...下,就交给插件 dispatcher。
真正的路由匹配则由 Manager 自己维护的注册表完成。
我为什么这么做?
因为我更想要的是:
- 插件热启停简单
- 插件 reload 不影响主路由树
- 所有插件路由状态都能统一观测
- HTTP 框架层保持稳定
如果让我在“更原生一点的路由性能”和“运行时管理复杂度更低”之间选,我在 #169 里明显选了后者。
我现在回头看,仍然觉得这是对的。
插件流量通常不会是系统最核心的热点流量,但插件管理复杂度一旦失控,会直接把整套系统拖下水。
这个点我其实来回犹豫过
当时最自然、也最“显得厉害”的方案,其实是让插件直接往 Hertz 主 router 里注册原生路由。
但我很快发现,这个方案一旦落到真实运维动作上,就会变得非常难受:
- 插件禁用时怎么把已经注册进去的路由干净撤掉?
- 插件重载后,旧 handler 和新 handler 怎么保证不混在一起?
- 当前到底有哪些插件路由在生效,去哪儿看最权威?
这些问题叠在一起之后,我就不再纠结了:与其把主路由树搞成一个动态容器,不如明确承认插件路由是“第二层路由”,统一走 dispatcher。
这会多一层分发成本,但换来的是热管理简单、状态统一、问题更容易定位。对第一版来说,我觉得这是更值的交换。
中间件我也用同样的思路处理
插件中间件没有直接嵌入 Hertz 的原生链路,而是由 dispatcher 在真正调用 guest handler 前手动执行。
它分两类:
宿主内置中间件
authdevice
插件自定义中间件
- 插件通过
host_register_middleware注册 - 宿主通过
CallPluginMiddleware()执行
- 插件通过
WASM 中间件返回的是结构化的 MiddlewareResponse,用 next / abort / modify 来告诉宿主该怎么处理。
这个设计我自己很满意,因为它兼顾了两件事:
- 插件有表达能力
- 宿主保留最终控制权
七、权限和安全:这是我在 #169 里最不想妥协的部分
插件系统一旦能执行外部代码,它就天然变成了一条新的攻击面。
所以在这次实现里,我几乎把所有边界都尽量收紧了。
7.1 权限不是展示字段,而是真的决定能拿到什么能力
权限定义在 internal/plugin/types.go,包括:
http_routesmiddlewaredatabase_readdatabase_writeredisloggingconfig_readeventshttp_clientschedulerqueue
真正起作用的地方在 buildHostFunctions()。
也就是说,插件清单里声明了什么权限,宿主才会给它注入对应的 host import。
这个模型的核心不是“声明”,而是 capability。
7.2 Redis、配置、数据库都不是裸能力
我没有让插件直接拿宿主的 Redis / DB / 配置,而是都做了收敛。
Redis
Redis key 会自动加前缀:
1 | plugin:{plugin_name}:{key} |
这样插件之间天然隔离,也不会轻易误伤宿主自己的 key。
配置
host_config_get 只允许读取白名单 key,比如:
Site.SiteNameSite.HostCurrency.UnitCurrency.SymbolDebugHostPort
像数据库密码、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_request 和 doHTTP() 里我做了比较明确的限制:
- 只允许
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(...).awaithost::redis_async::get(...).awaithost::db_async::query(...).await
但真正干活的是宿主:
submitAsyncTask()分配task_idexecuteAsyncTask()在 goroutine 中跑真实 I/OresolveAsyncTask()取回结果waitAnyAsyncTask()等任意一个任务完成
同时,每个插件还有自己的 async in-flight 限制,当前默认是 64。
这避免了某个插件无限制创建后台任务,把宿主拖垮。
8.2 我做了一个“先把 ABI 布好”的前向设计
这套 async 机制有个我自己很喜欢的点:SDK 已经按真正 Future / event-loop 的方式设计好了,但当前宿主实现仍然相对保守。
现在的 async_resolve 实现实际上是阻塞到任务完成再返回,所以 Future 通常第一次 poll 就 Ready。
但我在 SDK 里还是把下面这些东西先布好了:
- pending-task 计数
async_wait_anyruntime::block_on()Poll::Pending分支- 最大 poll 次数保护
这意味着如果以后我要把宿主的 resolve 改成真正非阻塞,插件代码和 SDK 表层 API 基本不用重写。
换句话说,#169 这一版虽然还不是“完全成熟的异步插件调度器”,但 ABI 和 SDK 形状已经往那个方向对齐了。
8.3 这块最容易被误解的坑:SDK 看起来很 async,但第一版宿主其实是保守实现
如果只看插件侧代码,很容易产生一种错觉:好像插件已经获得了一个完整的异步运行时。
但更准确地说,第一版 async 的真正价值是两件事:
- 把慢 I/O 从 guest 直接调用栈里挪出去,交给宿主 goroutine 池跑
- 提前把 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 |
|
插件作者看到的是“写业务函数”,而不是“手搓 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 里已经迈出去了。