- Run cargo fmt on all Rust crates for consistent formatting - Update CLAUDE.md with WASM plugin commands and dev.ps1 instructions - Update wiki: add WASM plugin architecture, rewrite dev environment docs - Minor frontend cleanup (unused imports)
15 KiB
wasm-plugin (WASM 插件系统)
设计思想
ERP 平台通过 WASM 沙箱实现安全、隔离、热插拔的业务扩展。插件在 Wasmtime 运行时中执行,只能通过 WIT 定义的 Host API 与系统交互,无法直接访问数据库、文件系统或网络。
核心决策:
- WASM 沙箱 — 插件代码在隔离环境中运行,Host 控制所有资源访问
- WIT 接口契约 — 通过
.wit文件定义 Host ↔ 插件的双向接口,bindgen 自动生成类型化绑定 - Fuel 资源限制 — 通过燃料机制限制插件 CPU 使用,防止无限循环
- 声明式 Host API — 插件通过
db_insert/event_publish等函数操作数据,Host 自动注入 tenant_id、校验权限
原型验证结果 (V1-V6)
| 验证项 | 状态 | 说明 |
|---|---|---|
| V1: WIT 接口 + bindgen! 编译 | 通过 | bindgen!({ path, world }) 生成 Host trait + Guest 绑定 |
| V2: Host 调用插件导出函数 | 通过 | call_init() / call_handle_event() / call_on_tenant_created() |
| V3: 插件回调 Host API | 通过 | 插件中 host_api::db_insert() 等正确回调到 HostState |
| V4: async 实例化桥接 | 通过 | instantiate_async 正常工作(调用方法本身是同步的) |
| V5: Fuel 资源限制 | 通过 | 低 fuel 时正确 trap,不会无限循环 |
| V6: 从二进制动态加载 | 通过 | .component.wasm 文件加载,测试插件 110KB |
项目结构
crates/
erp-plugin-prototype/ ← Host 端运行时
wit/
plugin.wit ← WIT 接口定义
src/
lib.rs ← Engine/Store/Linker 创建、HostState + Host trait 实现
main.rs ← 手动测试入口(空)
tests/
test_plugin_integration.rs ← 6 个集成测试
erp-plugin-test-sample/ ← 测试插件
src/
lib.rs ← 实现 Guest trait,调用 Host API
WIT 接口定义
文件:crates/erp-plugin-prototype/wit/plugin.wit
package erp:plugin;
// Host 暴露给插件的 API(插件 import)
interface host-api {
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
db-update: func(entity, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
db-delete: func(entity: string, id: string) -> result<_, string>;
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
config-get: func(key: string) -> result<list<u8>, string>;
log-write: func(level: string, message: string);
current-user: func() -> result<list<u8>, string>;
check-permission: func(permission: string) -> result<bool, string>;
}
// 插件导出的 API(Host 调用)
interface plugin-api {
init: func() -> result<_, string>;
on-tenant-created: func(tenant-id: string) -> result<_, string>;
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
}
world plugin-world {
import host-api;
export plugin-api;
}
关键技术要点
HasSelf — Linker 注册模式
当 Store 数据类型(HostState)直接实现 Host trait 时,使用 HasSelf<T> 作为 add_to_linker 的类型参数:
use wasmtime::component::{HasSelf, Linker};
let mut linker = Linker::new(engine);
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)?;
HasSelf<HostState> 表示 Data<'a> = &'a mut HostState,bindgen 生成的 Host for &mut T blanket impl 确保调用链正确。
WASM Component vs Core Module
wit_bindgen::generate! 生成的是 core WASM 模块(.wasm),但 Component::from_binary() 需要 WASM Component 格式。转换步骤:
# 1. 编译为 core wasm
cargo build -p <plugin-crate> --target wasm32-unknown-unknown --release
# 2. 转换为 component
wasm-tools component new target/wasm32-unknown-unknown/release/<name>.wasm \
-o target/<name>.component.wasm
Fuel 资源限制
let mut store = Store::new(engine, HostState::new());
store.set_fuel(1_000_000)?; // 分配 100 万 fuel
store.limiter(|state| &mut state.limits); // 内存限制
Fuel 不足时,WASM 执行会 trap(wasm trap: interrupt),Host 可以捕获并处理。
调用方法 — 同步,非 async
bindgen 生成的调用方法(call_init、call_handle_event)是同步的:
// 正确
instance.erp_plugin_plugin_api().call_init(&mut store)?;
// 错误(不存在 async 版本的调用方法)
instance.erp_plugin_plugin_api().call_init(&mut store).await?;
但实例化可以异步:PluginWorld::instantiate_async(&mut store, &component, &linker).await?
关键文件
| 文件 | 职责 |
|---|---|
crates/erp-plugin-prototype/wit/plugin.wit |
WIT 接口定义(Host API + Plugin API) |
crates/erp-plugin-prototype/src/lib.rs |
Host 运行时:Engine/Store 创建、HostState、Host trait 实现 |
crates/erp-plugin-prototype/tests/test_plugin_integration.rs |
V1-V6 集成测试 |
crates/erp-plugin-test-sample/src/lib.rs |
测试插件:Guest trait 实现 |
关联模块
- architecture — 插件架构是模块化单体的重要扩展机制
- erp-core — EventBus 事件将被桥接到插件的
handle_event - erp-server — 未来集成插件运行时的组装点
插件制作完整流程
以下是从零创建一个新业务模块插件的完整步骤。
第一步:准备 WIT 接口
WIT 文件定义 Host 和插件之间的契约。现有接口位于 crates/erp-plugin-prototype/wit/plugin.wit。
如果新插件需要扩展 Host API(如新增文件上传、HTTP 代理等),在 host-api interface 中添加函数:
// 在 host-api 中新增
file-upload: func(filename: string, data: list<u8>) -> result<string, string>;
http-proxy: func(url: string, method: string, body: option<list<u8>>) -> result<list<u8>, string>;
如果插件需要新的生命周期钩子,在 plugin-api interface 中添加:
// 在 plugin-api 中新增
on-order-approved: func(order-id: string) -> result<_, string>;
修改 WIT 后,需要重新编译 Host crate 和所有插件。
第二步:创建插件 crate
在 crates/ 下创建新的插件 crate:
mkdir -p crates/erp-plugin-<业务名>
Cargo.toml 模板:
[package]
name = "erp-plugin-<业务名>"
version = "0.1.0"
edition = "2024"
description = "<业务描述> WASM 插件"
[lib]
crate-type = ["cdylib"] # 必须是 cdylib 才能编译为 WASM
[dependencies]
wit-bindgen = "0.55" # 生成 Guest 端绑定
serde = { workspace = true }
serde_json = { workspace = true }
将新 crate 加入 workspace(编辑根 Cargo.toml):
members = [
# ... 已有成员 ...
"crates/erp-plugin-<业务名>",
]
第三步:实现插件逻辑
创建 src/lib.rs,实现 Guest trait:
//! <业务名> WASM 插件
use serde_json::json;
// 生成 Guest 端绑定(路径指向 Host crate 的 WIT 文件)
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
// 导入 Host API(bindgen 生成)
use crate::erp::plugin::host_api;
// 导入 Guest trait(bindgen 生成)
use crate::exports::erp::plugin::plugin_api::Guest;
// 插件结构体(名称任意,但必须是模块级可见的)
struct MyPlugin;
impl Guest for MyPlugin {
/// 初始化 — 注册默认数据、订阅事件等
fn init() -> Result<(), String> {
host_api::log_write("info", "<业务名>插件初始化");
// 示例:创建默认配置
let config = json!({"default_category": "通用"}).to_string();
host_api::db_insert("<业务>_config", config.as_bytes())
.map_err(|e| format!("初始化失败: {}", e))?;
Ok(())
}
/// 租户创建时 — 初始化租户的默认数据
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
host_api::log_write("info", &format!("新租户: {}", tenant_id));
let data = json!({"tenant_id": tenant_id, "name": "默认仓库"}).to_string();
host_api::db_insert("warehouse", data.as_bytes())
.map_err(|e| format!("创建默认仓库失败: {}", e))?;
Ok(())
}
/// 处理订阅的事件
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
host_api::log_write("debug", &format!("收到事件: {}", event_type));
let data: serde_json::Value = serde_json::from_slice(&payload)
.map_err(|e| format!("解析事件失败: {}", e))?;
match event_type.as_str() {
"order.created" => {
// 处理订单创建事件
let order_id = data["id"].as_str().unwrap_or("");
host_api::log_write("info", &format!("新订单: {}", order_id));
}
"workflow.task.completed" => {
// 处理审批完成事件
let order_id = data["order_id"].as_str().unwrap_or("unknown");
let update = json!({"status": "approved"}).to_string();
host_api::db_update("purchase_order", order_id, update.as_bytes(), 1)
.map_err(|e| format!("更新失败: {}", e))?;
// 发布下游事件
let evt = json!({"order_id": order_id}).to_string();
host_api::event_publish("<业务>.order.approved", evt.as_bytes())
.map_err(|e| format!("发布事件失败: {}", e))?;
}
_ => {
host_api::log_write("debug", &format!("忽略事件: {}", event_type));
}
}
Ok(())
}
}
// 导出插件实例(宏会注册 Guest trait 实现)
export!(MyPlugin);
Host API 速查
| 函数 | 签名 | 用途 |
|---|---|---|
db_insert |
(entity, data) → result<record, string> |
插入记录,Host 自动注入 id/tenant_id/timestamp |
db_query |
(entity, filter, pagination) → result<list, string> |
查询记录,自动过滤 tenant_id + 排除软删除 |
db_update |
(entity, id, data, version) → result<record, string> |
更新记录,检查乐观锁 version |
db_delete |
(entity, id) → result<_, string> |
软删除记录 |
event_publish |
(event_type, payload) → result<_, string> |
发布领域事件 |
config_get |
(key) → result<value, string> |
读取系统配置 |
log_write |
(level, message) → () |
写日志,自动关联 tenant_id + plugin_id |
current_user |
() → result<user_info, string> |
获取当前用户信息 |
check_permission |
(permission) → result<bool, string> |
检查当前用户权限 |
数据传递约定
所有 Host API 的数据参数使用 list<u8>(即 Vec<u8>),约定用 JSON 序列化:
// 构造数据
let data = json!({"sku": "ITEM-001", "quantity": 100}).to_string();
// 插入
let result_bytes = host_api::db_insert("inventory_item", data.as_bytes())
.map_err(|e| e.to_string())?;
// 解析返回
let record: serde_json::Value = serde_json::from_slice(&result_bytes)
.map_err(|e| e.to_string())?;
let new_id = record["id"].as_str().unwrap();
第四步:编译为 WASM
# 编译为 core WASM 模块
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
# 转换为 WASM Component(必须,Host 只接受 Component 格式)
wasm-tools component new \
target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
-o target/erp_plugin_<业务名>.component.wasm
检查产物大小(目标 < 2MB):
ls -la target/erp_plugin_<业务名>.component.wasm
第五步:编写集成测试
在 crates/erp-plugin-prototype/tests/ 下创建测试文件,或扩展现有测试:
use anyhow::Result;
use erp_plugin_prototype::{create_engine, load_plugin};
fn wasm_path() -> String {
"../../target/erp_plugin_<业务名>.component.wasm".into()
}
#[tokio::test]
async fn test_<业务名>_init() -> Result<()> {
let wasm_bytes = std::fs::read(wasm_path())?;
let engine = create_engine()?;
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
// 调用 init
instance.erp_plugin_plugin_api().call_init(&mut store)?
.map_err(|e| anyhow::anyhow!(e))?;
// 验证 Host 端效果
let state = store.data();
assert!(state.db_ops.iter().any(|op| op.entity == "<业务>_config"));
Ok(())
}
#[tokio::test]
async fn test_<业务名>_handle_event() -> Result<()> {
let wasm_bytes = std::fs::read(wasm_path())?;
let engine = create_engine()?;
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
// 先初始化
instance.erp_plugin_plugin_api().call_init(&mut store)?
.map_err(|e| anyhow::anyhow!(e))?;
// 模拟事件
let payload = json!({"id": "ORD-001"}).to_string();
instance.erp_plugin_plugin_api()
.call_handle_event(&mut store, "order.created", payload.as_bytes())?
.map_err(|e| anyhow::anyhow!(e))?;
Ok(())
}
第六步:运行测试
# 先确保编译了 component
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
-o target/erp_plugin_<业务名>.component.wasm
# 运行集成测试
cargo test -p erp-plugin-prototype
流程速查图
1. 修改 WIT(如需新接口) crates/erp-plugin-prototype/wit/plugin.wit
↓
2. 创建插件 crate crates/erp-plugin-<名>/
- Cargo.toml (cdylib + wit-bindgen)
- src/lib.rs (impl Guest)
↓
3. 编译 core wasm cargo build --target wasm32-unknown-unknown --release
↓
4. 转为 component wasm-tools component new <in.wasm> -o <out.component.wasm>
↓
5. 编写测试 crates/erp-plugin-prototype/tests/
↓
6. 运行测试 cargo test -p erp-plugin-prototype
常见问题
Q: "attempted to parse a wasm module with a component parser"
A: 使用了 core WASM 而非 Component。运行 wasm-tools component new 转换。
Q: "cannot infer type of the type parameter D"
A: add_to_linker 需要显式指定 HasSelf<T>:add_to_linker::<_, HasSelf<HostState>>(linker, |s| s)。
Q: "wasm trap: interrupt"(非 fuel 耗尽)
A: 检查是否启用了 epoch_interruption 但未定期 bump epoch。原型阶段建议只使用 fuel 限制。
Q: 插件中如何调试?
A: 使用 host_api::log_write("debug", "message") 输出日志,Host 端 store.data().logs 可查看所有日志。
Q: 如何限制插件内存?
A: 通过 StoreLimitsBuilder 配置:
let limits = StoreLimitsBuilder::new()
.memory_size(10 * 1024 * 1024) // 10MB
.build();
后续规划
- Phase 7: 将原型集成到 erp-server,替换模拟 Host API 为真实数据库操作
- 动态表: 支持
db_insert("dynamic_table", ...)自动创建/迁移表 - 前端集成: PluginCRUDPage 组件根据 WIT 定义自动生成 CRUD 页面
- 插件市场: 插件元数据、版本管理、签名验证