Files
erp/wiki/wasm-plugin.md
iven 9568dd7875 chore: apply cargo fmt across workspace and update docs
- 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)
2026-04-15 00:49:20 +08:00

446 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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>;
}
// 插件导出的 APIHost 调用)
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<T> — Linker 注册模式
当 Store 数据类型(`HostState`)直接实现 `Host` trait 时,使用 `HasSelf<T>` 作为 `add_to_linker` 的类型参数:
```rust
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 格式。转换步骤:
```bash
# 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 资源限制
```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<u8>) -> result<string, string>;
http-proxy: func(url: string, method: string, body: option<list<u8>>) -> result<list<u8>, 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 APIbindgen 生成)
use crate::erp::plugin::host_api;
// 导入 Guest traitbindgen 生成)
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 序列化:
```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 <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` 配置
```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 页面
- **插件市场**: 插件元数据版本管理签名验证