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)
This commit is contained in:
445
wiki/wasm-plugin.md
Normal file
445
wiki/wasm-plugin.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# 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<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 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 序列化:
|
||||
|
||||
```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 页面
|
||||
- **插件市场**: 插件元数据、版本管理、签名验证
|
||||
Reference in New Issue
Block a user