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:
18
crates/erp-plugin-prototype/Cargo.toml
Normal file
18
crates/erp-plugin-prototype/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "erp-plugin-prototype"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "WASM 插件系统原型验证 — Host 端运行时"
|
||||
|
||||
[dependencies]
|
||||
wasmtime = "43"
|
||||
wasmtime-wasi = "43"
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[[test]]
|
||||
name = "test_plugin_integration"
|
||||
path = "tests/test_plugin_integration.rs"
|
||||
208
crates/erp-plugin-prototype/src/lib.rs
Normal file
208
crates/erp-plugin-prototype/src/lib.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! WASM 插件原型验证 — Host 端运行时
|
||||
//!
|
||||
//! 验证目标:
|
||||
//! - V1: WIT 接口定义 + bindgen! 宏编译通过
|
||||
//! - V2: Host 调用插件导出函数(init / handle_event)
|
||||
//! - V3: 插件调用 Host 导入函数(db_insert / log_write)
|
||||
//! - V4: async 支持(Host async 函数正确桥接)
|
||||
//! - V5: Fuel + Epoch 资源限制
|
||||
//! - V6: 从二进制动态加载
|
||||
|
||||
use anyhow::Result;
|
||||
use wasmtime::component::{Component, HasSelf, Linker, bindgen};
|
||||
use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder};
|
||||
|
||||
/// Host 端状态,绑定到每个 Store 实例
|
||||
pub struct HostState {
|
||||
/// Store 级资源限制
|
||||
pub(crate) limits: StoreLimits,
|
||||
/// 模拟数据库操作记录
|
||||
pub db_ops: Vec<DbOperation>,
|
||||
/// 日志记录
|
||||
pub logs: Vec<(String, String)>,
|
||||
/// 发布的事件
|
||||
pub events: Vec<(String, Vec<u8>)>,
|
||||
/// 配置存储(模拟)
|
||||
pub config_map: std::collections::HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 数据库操作记录
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbOperation {
|
||||
pub op_type: String,
|
||||
pub entity: String,
|
||||
pub data: Option<Vec<u8>>,
|
||||
pub id: Option<String>,
|
||||
pub version: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for HostState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl HostState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
limits: StoreLimitsBuilder::new().build(),
|
||||
db_ops: Vec::new(),
|
||||
logs: Vec::new(),
|
||||
events: Vec::new(),
|
||||
config_map: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bindgen! 生成类型化绑定(包含 Host trait 和 add_to_linker)
|
||||
bindgen!({
|
||||
path: "./wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口
|
||||
impl erp::plugin::host_api::Host for HostState {
|
||||
fn db_insert(&mut self, entity: String, data: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let id = format!("id-{}", self.db_ops.len() + 1);
|
||||
let record = serde_json::json!({
|
||||
"id": id,
|
||||
"tenant_id": "tenant-default",
|
||||
"entity": entity,
|
||||
"data": serde_json::from_slice::<serde_json::Value>(&data).unwrap_or(serde_json::Value::Null),
|
||||
});
|
||||
let result = serde_json::to_vec(&record).map_err(|e| e.to_string())?;
|
||||
|
||||
self.db_ops.push(DbOperation {
|
||||
op_type: "insert".into(),
|
||||
entity: entity.clone(),
|
||||
data: Some(data),
|
||||
id: Some(id),
|
||||
version: None,
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn db_query(
|
||||
&mut self,
|
||||
entity: String,
|
||||
_filter: Vec<u8>,
|
||||
_pagination: Vec<u8>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let results: Vec<serde_json::Value> = self
|
||||
.db_ops
|
||||
.iter()
|
||||
.filter(|op| op.entity == entity && op.op_type == "insert")
|
||||
.map(|op| {
|
||||
serde_json::json!({
|
||||
"id": op.id,
|
||||
"entity": op.entity,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_vec(&results).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn db_update(
|
||||
&mut self,
|
||||
entity: String,
|
||||
id: String,
|
||||
data: Vec<u8>,
|
||||
version: i64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let record = serde_json::json!({
|
||||
"id": id,
|
||||
"tenant_id": "tenant-default",
|
||||
"entity": entity,
|
||||
"version": version + 1,
|
||||
"data": serde_json::from_slice::<serde_json::Value>(&data).unwrap_or(serde_json::Value::Null),
|
||||
});
|
||||
let result = serde_json::to_vec(&record).map_err(|e| e.to_string())?;
|
||||
|
||||
self.db_ops.push(DbOperation {
|
||||
op_type: "update".into(),
|
||||
entity,
|
||||
data: Some(data),
|
||||
id: Some(id),
|
||||
version: Some(version),
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn db_delete(&mut self, entity: String, id: String) -> Result<(), String> {
|
||||
self.db_ops.push(DbOperation {
|
||||
op_type: "delete".into(),
|
||||
entity,
|
||||
data: None,
|
||||
id: Some(id),
|
||||
version: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn event_publish(&mut self, event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
self.events.push((event_type, payload));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_get(&mut self, key: String) -> Result<Vec<u8>, String> {
|
||||
self.config_map
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("配置项 '{}' 不存在", key))
|
||||
}
|
||||
|
||||
fn log_write(&mut self, level: String, message: String) {
|
||||
self.logs.push((level, message));
|
||||
}
|
||||
|
||||
fn current_user(&mut self) -> Result<Vec<u8>, String> {
|
||||
let user = serde_json::json!({
|
||||
"id": "user-default",
|
||||
"username": "admin",
|
||||
"tenant_id": "tenant-default",
|
||||
});
|
||||
serde_json::to_vec(&user).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn check_permission(&mut self, _permission: String) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Wasmtime Engine(启用 Fuel 限制)
|
||||
pub fn create_engine() -> Result<Engine> {
|
||||
let mut config = Config::new();
|
||||
config.wasm_component_model(true);
|
||||
config.consume_fuel(true);
|
||||
Ok(Engine::new(&config)?)
|
||||
}
|
||||
|
||||
/// 创建带 Fuel 限制的 Store
|
||||
pub fn create_store(engine: &Engine, fuel: u64) -> Result<Store<HostState>> {
|
||||
let state = HostState::new();
|
||||
let mut store = Store::new(engine, state);
|
||||
store.set_fuel(fuel)?;
|
||||
store.limiter(|state| &mut state.limits);
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// 从 WASM 二进制加载并实例化插件
|
||||
pub async fn load_plugin(
|
||||
engine: &Engine,
|
||||
wasm_bytes: &[u8],
|
||||
fuel: u64,
|
||||
) -> Result<(Store<HostState>, PluginWorld)> {
|
||||
let mut store = create_store(engine, fuel)?;
|
||||
let component = Component::from_binary(engine, wasm_bytes)?;
|
||||
|
||||
let mut linker = Linker::new(engine);
|
||||
// 注册 Host API 到 Linker,使插件可以调用 host 函数
|
||||
// HasSelf<HostState> 表示 Data<'a> = &'a mut HostState
|
||||
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)?;
|
||||
|
||||
let instance = PluginWorld::instantiate_async(&mut store, &component, &linker).await?;
|
||||
|
||||
Ok((store, instance))
|
||||
}
|
||||
10
crates/erp-plugin-prototype/src/main.rs
Normal file
10
crates/erp-plugin-prototype/src/main.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use erp_plugin_prototype::*;
|
||||
|
||||
fn _discover_api(instance: PluginWorld, mut store: wasmtime::Store<HostState>) {
|
||||
let api = instance.erp_plugin_plugin_api();
|
||||
let _ = api.call_init(&mut store);
|
||||
let _ = api.call_on_tenant_created(&mut store, "test-tenant");
|
||||
let _ = api.call_handle_event(&mut store, "test.event", &[1u8]);
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
220
crates/erp-plugin-prototype/tests/test_plugin_integration.rs
Normal file
220
crates/erp-plugin-prototype/tests/test_plugin_integration.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
//! WASM 插件集成测试
|
||||
//!
|
||||
//! 验证目标:
|
||||
//! V1 — WIT 接口 + bindgen! 编译通过
|
||||
//! V2 — Host 调用插件 init() / handle_event()
|
||||
//! V3 — 插件回调 Host db_insert / log_write
|
||||
//! V4 — async 实例化桥接
|
||||
//! V5 — Fuel 资源限制
|
||||
//! V6 — 从二进制动态加载
|
||||
|
||||
use anyhow::Result;
|
||||
use erp_plugin_prototype::{create_engine, load_plugin};
|
||||
|
||||
/// 获取测试插件 WASM Component 文件路径
|
||||
fn wasm_path() -> String {
|
||||
let candidates = [
|
||||
// 预构建的 WASM Component(通过 wasm-tools component new 生成)
|
||||
"../../target/erp_plugin_test_sample.component.wasm".into(),
|
||||
// 备选:绝对路径
|
||||
format!(
|
||||
"{}/../../target/erp_plugin_test_sample.component.wasm",
|
||||
std::env::current_dir().unwrap().display()
|
||||
),
|
||||
];
|
||||
for path in &candidates {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return path.clone();
|
||||
}
|
||||
}
|
||||
candidates[0].clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v6_load_plugin_from_binary() -> Result<()> {
|
||||
let wasm_path = wasm_path();
|
||||
let wasm_bytes = std::fs::read(&wasm_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"读取 WASM 失败: {}。请先编译: cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release\n路径: {}",
|
||||
e, wasm_path
|
||||
)
|
||||
})?;
|
||||
|
||||
assert!(!wasm_bytes.is_empty(), "WASM 文件不应为空");
|
||||
println!("WASM 文件大小: {} bytes", wasm_bytes.len());
|
||||
|
||||
let engine = create_engine()?;
|
||||
let (_store, _instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v2_host_calls_plugin_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?;
|
||||
|
||||
// V2: 调用插件 init()
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 验证 Host 端收到了日志
|
||||
let state = store.data();
|
||||
assert!(
|
||||
state
|
||||
.logs
|
||||
.iter()
|
||||
.any(|(_, m)| m.contains("测试插件初始化成功")),
|
||||
"应收到初始化日志"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v3_plugin_calls_host_api() -> 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(插件会调用 db_insert 和 log_write)
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let state = store.data();
|
||||
|
||||
// 验证 db_insert 被调用
|
||||
assert!(
|
||||
state
|
||||
.db_ops
|
||||
.iter()
|
||||
.any(|op| op.op_type == "insert" && op.entity == "inventory_item"),
|
||||
"应有 inventory_item 的 insert 操作"
|
||||
);
|
||||
|
||||
// 验证 log_write 被调用
|
||||
assert!(
|
||||
state.logs.iter().any(|(_, m)| m.contains("插入成功")),
|
||||
"应有插入成功的日志"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v3_plugin_handle_event_with_db_callback() -> 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))?;
|
||||
|
||||
// 发送事件
|
||||
let payload = serde_json::json!({"order_id": "PO-001", "action": "approve"}).to_string();
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_handle_event(&mut store, "workflow.task.completed", payload.as_bytes())?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let state = store.data();
|
||||
|
||||
// 验证 db_update 被调用
|
||||
assert!(
|
||||
state
|
||||
.db_ops
|
||||
.iter()
|
||||
.any(|op| op.op_type == "update" && op.entity == "purchase_order"),
|
||||
"应有 purchase_order 的 update 操作"
|
||||
);
|
||||
|
||||
// 验证事件被发布
|
||||
assert!(
|
||||
state
|
||||
.events
|
||||
.iter()
|
||||
.any(|(t, _)| t == "purchase_order.approved"),
|
||||
"应发布 purchase_order.approved 事件"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v5_fuel_limit_traps() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
|
||||
// 给极少量的 fuel,插件 init() 应该无法完成
|
||||
let result = load_plugin(&engine, &wasm_bytes, 10).await;
|
||||
match result {
|
||||
Ok((mut store, instance)) => {
|
||||
let init_result = instance.erp_plugin_plugin_api().call_init(&mut store);
|
||||
assert!(
|
||||
init_result.is_err() || init_result.unwrap().is_err(),
|
||||
"低 fuel 应导致失败"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// 实例化就失败了,也是预期行为
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_lifecycle() -> 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?;
|
||||
|
||||
// 1. init
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 2. on_tenant_created
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_on_tenant_created(&mut store, "tenant-new-001")?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 3. handle_event
|
||||
let payload = serde_json::json!({"order_id": "PO-002"}).to_string();
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_handle_event(&mut store, "workflow.task.completed", payload.as_bytes())?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let state = store.data();
|
||||
|
||||
// 完整生命周期验证
|
||||
assert!(
|
||||
state.logs.len() >= 3,
|
||||
"应有多条日志记录,实际: {}",
|
||||
state.logs.len()
|
||||
);
|
||||
assert!(
|
||||
state.db_ops.len() >= 3,
|
||||
"应有多次数据库操作,实际: {}",
|
||||
state.db_ops.len()
|
||||
);
|
||||
assert!(state.events.len() >= 1, "应有事件发布");
|
||||
|
||||
println!("\n=== 完整生命周期验证 ===");
|
||||
println!("日志: {} 条", state.logs.len());
|
||||
println!("DB 操作: {} 次", state.db_ops.len());
|
||||
println!("事件: {} 条", state.events.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
48
crates/erp-plugin-prototype/wit/plugin.wit
Normal file
48
crates/erp-plugin-prototype/wit/plugin.wit
Normal file
@@ -0,0 +1,48 @@
|
||||
package erp:plugin;
|
||||
|
||||
/// 宿主暴露给插件的 API(插件 import 这些函数)
|
||||
interface host-api {
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
db-update: func(entity: string, 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>;
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
log-write: func(level: string, message: string);
|
||||
|
||||
/// 获取当前用户信息
|
||||
current-user: func() -> result<list<u8>, string>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
}
|
||||
|
||||
/// 插件导出的 API(宿主调用这些函数)
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user