# 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) -> result, string>; db-query: func(entity: string, filter: list, pagination: list) -> result, string>; db-update: func(entity, id: string, data: list, version: s64) -> result, string>; db-delete: func(entity: string, id: string) -> result<_, string>; event-publish: func(event-type: string, payload: list) -> result<_, string>; config-get: func(key: string) -> result, string>; log-write: func(level: string, message: string); current-user: func() -> result, string>; check-permission: func(permission: string) -> result; } // 插件导出的 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) -> result<_, string>; } world plugin-world { import host-api; export plugin-api; } ``` ## 关键技术要点 ### HasSelf — Linker 注册模式 当 Store 数据类型(`HostState`)直接实现 `Host` trait 时,使用 `HasSelf` 作为 `add_to_linker` 的类型参数: ```rust use wasmtime::component::{HasSelf, Linker}; let mut linker = Linker::new(engine); PluginWorld::add_to_linker::<_, HasSelf>(&mut linker, |state| state)?; ``` `HasSelf` 表示 `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 格式。转换步骤: ```bash # 1. 编译为 core wasm cargo build -p --target wasm32-unknown-unknown --release # 2. 转换为 component wasm-tools component new target/wasm32-unknown-unknown/release/.wasm \ -o target/.component.wasm ``` ### Fuel 资源限制 ```rust 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`)是同步的: ```rust // 正确 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 中添加函数: ```wit // 在 host-api 中新增 file-upload: func(filename: string, data: list) -> result; http-proxy: func(url: string, method: string, body: option>) -> result, string>; ``` 如果插件需要新的生命周期钩子,在 `plugin-api` interface 中添加: ```wit // 在 plugin-api 中新增 on-order-approved: func(order-id: string) -> result<_, string>; ``` 修改 WIT 后,需要重新编译 Host crate 和所有插件。 ## 第二步:创建插件 crate 在 `crates/` 下创建新的插件 crate: ```bash mkdir -p crates/erp-plugin-<业务名> ``` `Cargo.toml` 模板: ```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`): ```toml members = [ # ... 已有成员 ... "crates/erp-plugin-<业务名>", ] ``` ## 第三步:实现插件逻辑 创建 `src/lib.rs`,实现 `Guest` trait: ```rust //! <业务名> 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) -> 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` | 插入记录,Host 自动注入 id/tenant_id/timestamp | | `db_query` | `(entity, filter, pagination) → result` | 查询记录,自动过滤 tenant_id + 排除软删除 | | `db_update` | `(entity, id, data, version) → result` | 更新记录,检查乐观锁 version | | `db_delete` | `(entity, id) → result<_, string>` | 软删除记录 | | `event_publish` | `(event_type, payload) → result<_, string>` | 发布领域事件 | | `config_get` | `(key) → result` | 读取系统配置 | | `log_write` | `(level, message) → ()` | 写日志,自动关联 tenant_id + plugin_id | | `current_user` | `() → result` | 获取当前用户信息 | | `check_permission` | `(permission) → result` | 检查当前用户权限 | ### 数据传递约定 所有 Host API 的数据参数使用 `list`(即 `Vec`),约定用 JSON 序列化: ```rust // 构造数据 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 ```bash # 编译为 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): ```bash ls -la target/erp_plugin_<业务名>.component.wasm ``` ## 第五步:编写集成测试 在 `crates/erp-plugin-prototype/tests/` 下创建测试文件,或扩展现有测试: ```rust 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(()) } ``` ## 第六步:运行测试 ```bash # 先确保编译了 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 -o ↓ 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`:`add_to_linker::<_, HasSelf>(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` 配置: ```rust let limits = StoreLimitsBuilder::new() .memory_size(10 * 1024 * 1024) // 10MB .build(); ``` ## 后续规划 - **Phase 7**: 将原型集成到 erp-server,替换模拟 Host API 为真实数据库操作 - **动态表**: 支持 `db_insert("dynamic_table", ...)` 自动创建/迁移表 - **前端集成**: PluginCRUDPage 组件根据 WIT 定义自动生成 CRUD 页面 - **插件市场**: 插件元数据、版本管理、签名验证