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

15 KiB
Raw Permalink Blame History

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 — 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 HostStatebindgen 生成的 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 执行会 trapwasm trap: interruptHost 可以捕获并处理。

调用方法 — 同步,非 async

bindgen 生成的调用方法(call_initcall_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 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 序列化:

// 构造数据
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 页面
  • 插件市场: 插件元数据、版本管理、签名验证