让我们再次回到我们不断演进的 C++/Rust/WASM 项目。在之前的探索中,我们成功地:
- 在 C++ EnTT ECS 框架内,建立了管理实体关系(1:1、1:N、N:N)的可靠方法。
- 使用 Wasmtime 构建了一座桥梁,实现了 C++ 宿主与 Rust WASM 模块之间的双向通信和内存共享。
结合了这些概念,创建了一个稳定的 C FFI 层,允许 Rust WASM 插件管理位于 C++ 宿主中的 EnTT 实体关系。
这种分层架构,利用 EnTT 的数据驱动特性和精心设计的 C FFI,在克服 WASM 边界固有的局限性方面被证明是有效的。然而,随着项目的增长,对更复杂交互模式的需求也随之出现。我们之前的解决方案依赖于 WASM 模块 调用宿主函数来执行操作。但是,如果我们还需要让宿主在 EnTT 世界中发生特定事件时通知 WASM 插件呢?如果 WASM 插件需要 拦截或修改宿主操作的行为呢?
我们对此的初步尝试是创建自定义的“触发器 (trigger)”和“补丁 (patching) ”机制。虽然这些解决方案能正常工作,但它们的临时性、通常依赖于基于字符串的函数查找以及需要手动管理回调等特性,暴露了显著的缺点,很快就导致系统变得复杂、脆弱且难以维护。我们具体遇到了一些挑战:首要的担忧是类型安全问题;依赖于以字符串形式表示的函数名,完全无法在编译时保证给定的 WASM 函数签名能够真正匹配宿主对特定触发点或补丁点的期望。另一个困难出现在连接管理上:手动追踪哪些 WASM 函数被注册用于处理哪些特定事件变得日益繁琐,而断开或更新这些注册则需要细致且容易出错的簿记工作。此外,我们的自定义系统缺乏内在能力来控制当多个 WASM 回调注册到同一个事件时的执行顺序或应用优先级。结果的处理也呈现出另一个重大问题:确定来自可能多个 WASM “补丁”函数的结果应如何组合,甚至是一个 WASM 插件是否应具备完全阻止宿主发起的某个操作的能力,这些在我们的自定义框架内都没有任何标准或明确定义的方法来解决。最后,还需要大量的样板代码;为每个触发点或补丁点实现必要的注册、查找和调用逻辑,涉及到了在 C++ 宿主和 Rust WASM 两端大量且重复的编码工作。
很明显,我们需要一个更健壮、标准化且功能更丰富的事件系统。这正是 Boost.Signals2
发挥作用的地方。
本文将详细记录这次重构之旅,用强大而灵活的 Boost.Signals2
库取代我们自定义的触发器和补丁机制。我们将探讨这种转变如何简化架构,增强类型安全(在跨
FFI 的可能范围内),提供诸如自动连接管理、优先级和结果组合(“combiners”)等复杂功能,并最终导向一个更易于维护和扩展的宿主-插件交互模型。
我们将剖析 C++ 宿主端的重大变化(引入 SignalManager
,调整 WasmHost
和 EnttManager
,并利用 C++ 宏进行信号发射)以及
Rust WASM 端的调整(实现信号槽和新的连接机制)。准备好深入了解如何利用一个成熟的信号库来协调跨 WASM 边界的复杂事件吧。
信号的意义:为何选择 Boost.Signals2
?
在拆除我们现有的触发器/补丁系统之前,让我们先理解为什么 Boost.Signals2
是一个引人注目的替代方案。Boost.Signals2
的核心是实现了信号与槽 (signals and slots) 编程模式,这是一种用于解耦通信的强大机制。
Boost.Signals2
的核心是实现了信号与槽编程模式,这是一种在应用程序内部促进解耦通信的有效机制。你可以将信号(signals)概念化为事件广播器。每当系统内发生特定事件时,例如一个实体即将被创建或一个名称组件刚刚被添加,相应的信号对象就会被正式“发射 (
emitted)”或“触发 (fired)”,宣告该事件的发生。
与信号相辅相成的是槽(slots),它们充当指定的事件接收器。这些槽通常是函数或可调用的函数对象(如 C++ lambda 表达式),它们被显式地注册或“连接 (connected)”到一个或多个特定的信号上。关键的行为是,当一个信号被发射时,框架会自动调用当前连接到该特定信号的所有槽。
在特定信号和槽之间建立的链接由一个连接(connection)对象表示。Boost.Signals2
提供的一个极其重要的特性,使其区别于许多手动系统,是它提供了自动连接管理。这意味着,如果信号本身或一个已连接的槽对象不再存在(例如,因为超出作用域)或者连接被显式断开,该库会自动断开链接。这种健壮的管理防止了常见且麻烦的悬挂回调问题,即系统可能尝试调用一个不再存在的函数,这与手动管理的毁掉列表相比是一个显著的优势。
Boost.Signals2
特别展示其威力的地方,尤其是在我们的集成场景中,是通过其组合器(combiners)的概念。组合器本质上是一条规则或策略,它规定了由连接到同一信号的多个槽所产生的返回值应如何被聚合或处理成单一的结果。例如,在处理“before”事件时(如
before_create_entity
),我们可能希望实现一种行为,即任何单个连接的槽都有能力否决或阻止原始操作的进行。这可以通过实现一个自定义组合器来有效达成,该组合器智能地停止调用序列,并在任何一个槽返回
true
时立即返回,从而表明应跳过该操作。相反,对于“after”事件,连接的槽可能意图修改结果(例如在 after_get_name
的场景中),我们可以使用像 boost::signals2::optional_last_value
这样的标准组合器。这个特定的组合器方便地返回序列中最后一个被执行的槽所产生的值,这种行为在与赋予槽不同优先级结合使用时尤其有用。同样值得注意的是,默认的组合器行为是,如果槽没有返回值则简单返回
void
,或者返回一个 boost::optional<ResultType>
,其中包含最后一个返回非 void
值的槽的结果。
此外,Boost.Signals2
允许在连接槽时关联组优先级(group priorities)。这个特性让开发者能够精确控制连接到同一信号的不同槽相对于彼此的执行顺序,从而能够实现更复杂的交互序列。
最后,该库提供了各种可配置级别的线程安全。虽然我们当前的宿主应用程序在单线程中运行,但对于潜在的多线程宿主环境而言,这项能力是一个至关重要的考量,确保信号的发射和槽的连接可以在并发条件下安全地处理。
通过采用 Boost.Signals2
,我们将我们自制的、易于出错的系统替换为一个经过良好测试、功能丰富的库,该库专为此类事件处理而设计,从而显著提高了系统的健壮性和可维护性。
宿主端的革新:SignalManager
与宏魔法
最显著的变化发生在 C++ 宿主端。我们需要一个中心化的位置来定义我们的信号并管理到 WASM 槽的连接,同时还需要一种非侵入性的方式,在调用我们现有的 C API 函数时发射这些信号。
引入 SignalManager
这个新类成为了我们宿主端事件系统的核心。
信号定义: 在 signal_manager.h
内部,我们使用 boost::signals2::signal
定义了具体的信号类型。模板参数定义了可以连接到它的槽的签名(返回类型和参数类型)。关键地,我们还指定了一个组合器 (combiner)
类型。
// signal_manager.h (示例片段)
#include <boost/signals2.hpp>
#include <cstdint>
#include <optional> // 用于 optional_last_value 组合器
namespace WasmHostSignals {
// 自定义组合器:如果任何槽返回 true,则停止调用。
// 对于 "before" 信号很有用,允许跳过原始操作。
struct StopOnTrueCombiner {
typedef bool result_type; // 组合器返回 bool
template<typename InputIterator>
result_type operator()(InputIterator first, InputIterator last) const {
while (first != last) {
// 解引用迭代器以获取槽的返回值
// 假设连接到使用此组合器的信号的槽返回 bool
if (*first) { // 如果槽返回 true...
return true; // ...停止并返回 true (表示跳过)
}
++first;
}
return false; // 没有槽返回 true,返回 false (不跳过)
}
};
// --- 信号类型定义 ---
// 示例:实体创建
// bool(): 返回 true 以跳过创建。
using SignalBeforeCreateEntity = boost::signals2::signal<bool(), StopOnTrueCombiner>;
// uint32_t(uint32_t original_id): 可以修改返回的 ID。
using SignalAfterCreateEntity = boost::signals2::signal<uint32_t(uint32_t), boost::signals2::optional_last_value<uint32_t>>;
// 示例:实体销毁
// bool(uint32_t entity_id): 返回 true 以跳过销毁。
using SignalBeforeDestroyEntity = boost::signals2::signal<bool(uint32_t), StopOnTrueCombiner>;
// void(uint32_t entity_id): 仅作为通知。
using SignalAfterDestroyEntity = boost::signals2::signal<void(uint32_t)>;
// 示例:获取名称(由于缓冲区而复杂)
// bool(uint32_t id, char* buffer, size_t buffer_len): 可以跳过原始获取操作。
// 注意:WASM 槽在这里不容易访问宿主缓冲区内容。
// 实践中签名可能会简化。
using SignalBeforeGetName = boost::signals2::signal<bool(uint32_t, char*, size_t), StopOnTrueCombiner>;
// size_t(uint32_t id, char* buffer, size_t buffer_len, size_t original_req_len): 可以修改 required_len。
using SignalAfterGetName = boost::signals2::signal<size_t(uint32_t, char*, size_t, size_t), boost::signals2::optional_last_value<size_t>>;
// ... 为所有相关的宿主操作定义信号类型 ...
class WasmHost; // 前向声明
class SignalManager {
public:
// 信号是公共成员,方便宏访问
// 也可以是私有成员,通过访问器方法访问。
SignalBeforeCreateEntity signal_before_create_entity;
SignalAfterCreateEntity signal_after_create_entity;
SignalBeforeDestroyEntity signal_before_destroy_entity;
SignalAfterDestroyEntity signal_after_destroy_entity;
// ... 其他信号成员 ...
SignalBeforeGetName signal_before_get_name;
SignalAfterGetName signal_after_get_name;
// ... 还有更多 ...
explicit SignalManager(WasmHost* host);
~SignalManager();
// 删除拷贝/移动构造函数/赋值运算符
SignalManager(const SignalManager&) = delete;
SignalManager& operator=(const SignalManager&) = delete;
// ...
// 将 WASM 函数(按名称)连接到特定信号(按名称)
bool connectWasmSlot(const std::string& signal_name, const std::string& wasm_func_name, int priority);
private:
WasmHost* wasm_host_ptr_; // 需要它来回调 WASM
// 工厂函数的类型定义
using WasmSlotConnectorFactory = std::function<boost::signals2::connection(
WasmHost* host, // 指向 WasmHost 的指针
boost::signals2::signal_base& signal, // 对特定信号对象的引用
const std::string& wasm_func_name, // WASM 函数的名称
int priority // 连接的优先级
)>;
// 从信号名称(字符串)到创建连接 lambda 的工厂的映射
std::map<std::string, WasmSlotConnectorFactory> slot_connector_factories_;
// 初始化 slot_connector_factories_ 映射
void initializeConnectorFactories();
// 用于潜在追踪连接信息的结构(可选)
struct WasmSlotInfo {
std::string wasm_function_name;
int priority = 0;
boost::signals2::connection connection; // 存储连接对象
};
// 按信号名称分组存储连接(可选,用于管理)
std::map<std::string, std::vector<std::shared_ptr<WasmSlotInfo>>> wasm_connections_;
};
} // namespace WasmHostSignals
几个关键的设计决策塑造了 SignalManager
的效能。组合器(combiners)的选择对于定义不同事件类型的交互逻辑至关重要。例如,我们为旨在操作
之前运行的信号(如 before_create_entity
)专门定义了自定义的 StopOnTrueCombiner
,它使得任何连接的槽仅通过返回 true
就能阻止原始操作。对于在操作之后发射的信号,特别是那些槽可能希望修改返回值的信号(例如 after_create_entity
可能改变返回的
ID),我们利用了标准的 boost::signals2::optional_last_value<T>
组合器。这种组合器的行为是返回序列中最后一个执行的槽所产生的值,这一特性与优先级系统自然地结合在一起。在信号纯粹作为通知的情况下(如
after_destroy_entity
),默认的组合器(简单返回 void
)就完全足够了。
信号签名(signal signatures)的定义,例如 bool()
、uint32_t(uint32_t)
、void(uint32_t)
等等,在为希望连接的任何槽建立契约方面起着至关重要的作用。这些签名规定了兼容的槽函数必须遵守的确切参数类型和返回类型,这对于维护整个系统的类型安全至关重要。值得注意的是,即使是复杂的场景,比如
before_get_name
信号,最初也在其签名中包含了缓冲区细节(char*
, size_t
)以匹配底层的 C API。然而,我们认识到 WASM
槽通过这些参数直接操作宿主内存缓冲区的实际困难,并预期实际的 WASM 槽实现可能会简化其方法,或许会忽略这些缓冲区参数,并在需要缓冲区内容时选择通过另一个
FFI 函数回调宿主。
连接 WASM 函数是通过 connectWasmSlot
公共方法来促进的。这个函数充当指定的入口点,WASM 模块最终将通过中介
host_connect_signal
FFI 函数调用它,以将其处理程序注册为槽。connectWasmSlot
需要目标信号在宿主上的字符串名称以及应该连接到它的
WASM 模块导出函数的字符串名称。
在内部,设置严重依赖于 initializeConnectorFactories
私有方法,该方法在 SignalManager
的构造函数中执行。此方法的职责是填充
slot_connector_factories_
映射。该映射使用信号的字符串名称(例如,字面字符串 "before_create_entity"
)作为其键。每个键对应的值是一个
C++ lambda 函数,我们称之为“lambda 工厂”。
存储在 slot_connector_factories_
映射中的每个 lambda 工厂都被精确设计来执行一个特定的任务:它知道如何将一个 WASM
函数(通过其名称字符串识别)连接到 SignalManager
实例内的一个特定的、硬编码的 Boost.Signals2
信号成员(例如,与键
"before_create_entity"
关联的工厂知道它必须操作 signal_before_create_entity
成员)。为实现这一点,工厂 lambda 通常捕获
SignalManager
的 this
指针,或者有时直接捕获它所针对的特定信号成员。它被设计为接受几个参数:一个指向 WasmHost
实例的指针(调用 WASM 函数所必需的),一个对特定目标信号对象的引用(作为 signal_base&
传入以支持工厂签名中的多态性,需要在内部进行
static_cast
回具体的信号类型),要连接的 WASM 函数的字符串名称,以及期望的连接优先级。工厂 lambda 内部的核心动作是调用
signal.connect(priority, [host, wasm_func] (...) { ... })
。这里的关键元素是传递给 signal.connect
的第二个 lambda ——
这个内部 lambda 是实际的槽包装器 (slot wrapper)。这个包装器 lambda 正是当它所连接的特定 Boost 信号被发射时,
Boost.Signals2
框架将执行的代码。嵌入在此槽包装器 lambda 中的逻辑负责桥接到 Wasmtime。它直接从 Boost 信号发射中接收参数,这些参数匹配
Boost 信号定义的签名(例如,对于 signal_after_create_entity
的 original_id
参数)。它的首要任务是将这些传入的 C++ 参数编组成
Wasmtime 期望的格式,通常是一个 std::vector<wasmtime::Val>
。接下来,它使用 WasmHost
指针及其 callFunction
方法(例如
host->callFunction<ReturnType>(wasm_func, args)
)按名称调用目标 WASM 函数,仔细指定预期的 ReturnType
,该类型基于 WASM
函数的 FFI 签名(比如对于返回布尔值的 WASM 函数是 int32_t
,或者对于返回实体 ID 的是 uint32_t
)。这个调用本身就包含了处理潜在
Wasmtime 陷阱 (trap) 的过程,通常通过检查 callFunction
返回的 Result
来完成。如果 WASM 调用成功,包装器接着将产生的
wasmtime::Val
结果解组成与 Boost 信号组合器所期望的 C++ 数据类型(例如,将 int32_t
结果转换回 bool
用于使用
StopOnTrueCombiner
的信号,或者转换回 uint32_t
用于使用 optional_last_value<uint32_t>
的信号)。最后,这个解组后的 C++
值由槽包装器 lambda 返回,将其反馈给 Boost 信号的处理机制(具体来说,是它的组合器)。
为了正确路由连接请求,connectWasmSlot
方法必须根据输入的 signal_name
字符串确定实际的 boost::signals2::signal
成员对象。当前的实现采用了一个直接但可能稍显冗长的 if/else if
链来进行这种映射。它将输入字符串与已知的信号名称进行比较,一旦找到匹配项,就将对相应信号成员(如
signal_before_create_entity
)的引用传递给从 slot_connector_factories_
映射中检索到的对应工厂 lambda。
最后,健壮的连接管理由 Boost.Signals2
隐式处理。虽然代码包含一个可选机制,用于将 connect
返回的
boost::signals2::connection
对象存储在 wasm_connections_
映射中(按信号名称键控),这可能有助于未来更精细化的管理(如定向断开连接),但主要的好处来自
SignalManager
的析构函数。在析构函数中,所有存储的连接都被显式断开。更重要的是,即使没有这种显式存储,Boost
保证如果信号或槽的上下文(这里不太直接适用,因为我们的槽是调用 WASM 的宿主端 lambda)被销毁,连接也会自动断开,从而显著降低了悬挂指针或回调的风险。
WasmHost
现在创建并拥有 SignalManager
和 EnttManager
两者,并将 SignalManager
的指针传递给 EnttManager
的构造函数。
EnttManager
本身得到了简化——它不再直接管理触发器,而是在适当的地方(主要是在 onEntityDestroyedSignalHook
中)使用其
SignalManager
指针来发射信号。
通过宏发射信号 (host_macros.h
)
我们需要在相应的宿主 C API 函数被 从 WASM 调用时触发这些信号。我们可以在 host.cpp
中的每个宿主函数 lambda
中手动插入信号发射代码,但这既重复又容易出错。取而代之,我们使用定义在 host_macros.h
中的 C++ 宏。
// host_macros.h (示例片段)
#pragma once
#include "entt_api.h"
#include "signal_manager.h"
#include "wasm_host.h"
#include <wasmtime.hh>
#include <vector>
#include <string>
#include <optional>
#include <stdexcept> // 用于 runtime_error
// 命名空间内的辅助函数,避免污染全局作用域
namespace WasmHostUtils {
// (在此保留 read_wasm_string_helper, check_result, handle_wasm_trap 辅助函数)
} // namespace WasmHostUtils
// 定义一个宿主函数宏,该函数接受0个参数并返回一个值
#define DEFINE_HOST_FUNC_0_ARGS_RET(LINKER, HOST_PTR, MGR_HANDLE, NAME, C_API_FUNC, WASM_TYPE, RET_TYPE, WASM_RET_TYPE, DEFAULT_RET) \
(LINKER).func_new( \
"env", (NAME), (WASM_TYPE), \
[(HOST_PTR), (MGR_HANDLE)]( \
wasmtime::Caller caller, \
wasmtime::Span<const wasmtime::Val> args, \
wasmtime::Span<wasmtime::Val> results \
) -> wasmtime::Result<std::monostate, wasmtime::Trap> { \
using namespace WasmHostSignals; \
using namespace WasmHostUtils; \
SignalManager& sig_mgr = (HOST_PTR)->getSignalManager(); \
RET_TYPE final_result = (DEFAULT_RET); /* 用默认值初始化 */ \
try { \
/* --- Before Signal --- */ \
/* 假设信号名称匹配:before_NAME */ \
bool skip = sig_mgr.signal_##before_##C_API_FUNC(); \
if (skip) { \
std::cout << "[Host Signal] Skipping " << (NAME) << " due to before_ signal." << std::endl; \
} else { \
/* --- Original C API Call --- */ \
RET_TYPE original_result = C_API_FUNC((MGR_HANDLE)); \
\
/* --- After Signal --- */ \
/* 假设信号名称匹配:after_NAME */ \
/* 传递原始结果,组合器决定最终结果 */ \
final_result = sig_mgr.signal_##after_##C_API_FUNC(original_result); \
} \
/* --- Marshall result for WASM --- */ \
results[0] = wasmtime::Val(static_cast<WASM_RET_TYPE>(final_result)); \
return std::monostate(); \
} catch (const wasmtime::Trap& trap) { \
std::cerr << "[Host Function Error] " << (NAME) << " trapped: " << trap.message() << std::endl; \
return wasmtime::Trap(trap.message()); /* 创建新的 trap */ \
} catch (const std::exception& e) { \
std::cerr << "[Host Function Error] " << (NAME) << " exception: " << e.what() << std::endl; \
return wasmtime::Trap(std::string("Host function ") + (NAME) + " failed: " + e.what()); \
} catch (...) { \
std::cerr << "[Host Function Error] " << (NAME) << " unknown exception." << std::endl; \
return wasmtime::Trap(std::string("Host function ") + (NAME) + " failed with unknown exception."); \
} \
} \
).unwrap() /* 示例中使用 unwrap(),生产环境应检查 Result */
// 其他用于不同签名的宏 (例如,U32_VOID, U32_STR_VOID, U32_GET_STR...)
// 示例:用于 uint32_t 参数,void 返回值的宏
#define DEFINE_HOST_FUNC_U32_VOID(LINKER, HOST_PTR, MGR_HANDLE, NAME, C_API_FUNC, WASM_TYPE) \
(LINKER).func_new( \
"env", (NAME), (WASM_TYPE), \
/* Lambda 实现与上面类似 */ \
[(HOST_PTR), (MGR_HANDLE)](/* ... */) -> wasmtime::Result<std::monostate, wasmtime::Trap> { \
/* ... 提取 uint32_t 参数 ... */ \
uint32_t arg0_u32 = /* ... */; \
try { \
bool skip = sig_mgr.signal_##before_##C_API_FUNC(arg0_u32); \
if (!skip) { \
C_API_FUNC((MGR_HANDLE), arg0_u32); \
sig_mgr.signal_##after_##C_API_FUNC(arg0_u32); \
} else { /* 记录跳过 */ } \
return std::monostate(); /* Void 返回 */ \
} catch(/* ... trap/异常处理 ... */) { /* ... */ } \
} \
).unwrap()
// 示例:用于 uint32_t 参数,获取字符串的宏
#define DEFINE_HOST_FUNC_U32_GET_STR(LINKER, HOST_PTR, MGR_HANDLE, NAME, C_API_FUNC, WASM_TYPE) \
(LINKER).func_new( \
"env", (NAME), (WASM_TYPE), \
/* Lambda 实现 */ \
[(HOST_PTR), (MGR_HANDLE)](/* ... */) -> wasmtime::Result<std::monostate, wasmtime::Trap> { \
/* ... 提取 uint32_t id, char* buffer_ptr_offset, size_t buffer_len ... */ \
uint32_t entity_id = /* ... */; \
int32_t buffer_offset = /* ... */; \
size_t buffer_len = /* ... */; \
char* wasm_buffer = nullptr; \
try { \
/* 安全地获取内存并计算 wasm_buffer 指针 */ \
auto mem_span_opt = WasmHostUtils::get_wasm_memory_span_helper(caller); \
if (!mem_span_opt) return wasmtime::Trap("Failed to get WASM memory"); \
auto& mem_span = mem_span_opt.value(); \
if (buffer_offset >= 0 && buffer_len > 0 /* ... 更多边界检查 ... */){ \
wasm_buffer = reinterpret_cast<char*>(mem_span.data() + buffer_offset);\
} else if (buffer_offset != 0 || buffer_len > 0) { /* 无效缓冲区参数 */ } \
\
size_t final_req_len = 0; /* 默认值 */ \
bool skip = sig_mgr.signal_##before_##C_API_FUNC(entity_id, wasm_buffer, buffer_len); \
if (!skip) { \
size_t original_req_len = C_API_FUNC((MGR_HANDLE), entity_id, wasm_buffer, buffer_len); \
/* 将 original_req_len 传递给 after 信号 */ \
final_req_len = sig_mgr.signal_##after_##C_API_FUNC(entity_id, wasm_buffer, buffer_len, original_req_len); \
} else { /* 记录跳过, 返回 0 */ final_req_len = 0; } \
results[0] = wasmtime::Val(static_cast<int32_t>(final_req_len)); /* 将 size_t 作为 i32 返回 */ \
return std::monostate(); \
} catch(/* ... trap/异常处理 ... */) { /* ... */ } \
} \
).unwrap()
// ... 更多用于其他模式的宏 ...
定义的 C++ 宏封装了几个关键要素,这些要素对于在通过 Wasmtime 将宿主的 C API 函数暴露给 WASM 模块时集成 Boost.Signals2
事件系统至关重要。它们的首要功能是减少样板代码;它们方便地包装了 Wasmtime 所需的 linker.func_new
调用,并构建了复杂的
lambda 函数,该函数充当可由 WASM 调用的实际宿主函数实现。
这些宏高度参数化,以处理不同的函数签名。它们通常接受诸如 Wasmtime 链接器对象、指向 WasmHost
实例的指针、EnttManager
的不透明句柄、WASM 模块将用于导入函数的特定名称(称为 NAME
)、指向被包装的底层 C API 函数的指针 (C_API_FUNC
)、相应的
Wasmtime 函数类型定义、C API 函数预期的 C++ 返回类型、对应的 WASM ABI 返回类型(例如,C int
对应 int32_t
或 uint32_t
)以及一个在操作被信号跳过时使用的默认返回值等参数。
在宏生成的 lambda 内部,特定的捕获是必不可少的。Lambda 捕获 HOST_PTR
,这对于访问发射信号所需的 SignalManager
实例至关重要,并且它还捕获 MGR_HANDLE
,即调用原始 C API 函数所需的不透明指针。
Lambda 实现处理了跨 WASM 边界的复杂参数和结果编组细节。它负责从 Wasmtime 提供的 Span<const wasmtime::Val>
中提取传入参数,并将它们转换为 C API 函数期望的类型。对于处理缓冲区或字符串的函数,它执行必要的边界检查(通常使用辅助函数)以确保与
WASM 线性内存交互时的内存安全。在操作和可能的信号处理之后,它将最终计算出的结果编组回 Span<wasmtime::Val>
中,供 WASM
调用者使用。
宏生成的 lambda 的一个核心职责是发射信号。它首先通过捕获的 HOST_PTR
检索 SignalManager
实例。然后,在调用被包装的 C API
函数之前,它发射相应的 “before” 信号。这次发射使用 C++ 预处理器标记粘贴 (##
) 来动态构建正确的信号成员名称,该名称基于 C
API 函数的名称(例如,将 signal_##before_##
与 entt_manager_create_entity
结合产生
signal_before_entt_manager_create_entity
)。Lambda 仔细检查由 “before” 信号的组合器(例如,来自 StopOnTrueCombiner
的布尔结果)提供的返回值。如果该返回值指示应跳过操作(通常是 true
),Lambda 会记录一条跳过消息,并立即将预定义的默认值返回给
WASM,从而绕过对原始 C API 函数的调用以及 “after” 信号的发射。如果 “before” 信号未指示跳过,Lambda 则继续使用捕获的管理器句柄和提取的参数调用原始
C API 函数 (C_API_FUNC
)。在 C API 调用之后,它发射相应的 “after” 信号,传递任何相关的原始参数以及从 C API
调用中获得的结果。最后,它捕获由 “after” 信号的组合器(可能已被 WASM 槽修改,例如使用 optional_last_value
)生成(或选择)的返回值,并将此值用作最终结果 (final_result
),该结果最终被编组并返回给 WASM 调用者。
最后,健壮的错误处理被内置到生成的 lambda 中。它包含全面的 try-catch
块,旨在捕获在执行 C API 函数、信号发射或 WASM
内部的槽调用期间可能发生的标准 C++ 异常 (std::exception
) 以及 Wasmtime 特定的陷阱 (wasmtime::Trap
)
。然后,这些捕获到的异常或陷阱被安全地转换为新的 wasmtime::Trap
对象,确保宿主端的错误能够优雅地传播回 WASM
运行时,而不会使宿主进程崩溃。在重新抛出或构造新的陷阱时,特别注意正确处理 wasmtime::Trap
的仅移动 (move-only) 语义。
在 host.cpp
中,我们现在用对这些宏的调用替换了直接的 lambda 定义,用于我们想要暴露的、且需要信号支持的每个宿主函数。
// host.cpp (main, 示例用法)
// ... includes, setup ...
// 获取指针和引用
WasmHost host(wasm_path);
EnttManager* manager_raw_ptr = &host.getEnttManager();
EnttManagerHandle* manager_handle = reinterpret_cast<EnttManagerHandle*>(manager_raw_ptr);
Linker& linker = host.getLinker();
Store& store = host.getStore();
WasmHost* host_ptr = &host; // 用于宏捕获
// SignalManager& signal_manager = host.getSignalManager(); // 此处不需要直接使用
host.setupWasi();
// 定义函数类型...
// 使用宏定义宿主函数
DEFINE_HOST_FUNC_0_ARGS_RET(linker, host_ptr, manager_handle,
"host_create_entity", entt_manager_create_entity, void_to_i32_type,
uint32_t, int32_t, FFI_NULL_ENTITY);
DEFINE_HOST_FUNC_U32_VOID(linker, host_ptr, manager_handle,
"host_destroy_entity", entt_manager_destroy_entity, i32_to_void_type);
DEFINE_HOST_FUNC_U32_GET_STR(linker, host_ptr, manager_handle,
"host_get_name", entt_manager_get_name, i32ptrlen_to_size_type);
// ... 使用适当的宏定义所有其他宿主函数 ...
// 定义信号连接函数(不需要宏,因为它不包装 C API 调用)
linker.func_new( "env", "host_connect_signal", /* 类型 */ ...,
// 通过引用捕获 signal_manager
[&signal_manager = host.getSignalManager()](...) { // 注意捕获细节
// ... 使用 signal_manager.connectWasmSlot 的实现 ...
}
).unwrap();
host.initialize(); // 实例化
// ... 在 WASM 中调用 connect_all_signals ...
// ... 在 WASM 中调用 test_relationships_oo ...
// ... 手动测试部分调用 linker.get() 以确保信号触发 ...
连接 WASM 槽:host_connect_signal
WASM 模块如何告知宿主,“请将我的 wasm_before_create_entity
函数连接到你的 before_create_entity
信号”?我们为此专门提供了
另一个宿主函数:host_connect_signal
。
这个特定的宿主函数 host_connect_signal
是直接在 host.cpp
中使用 linker.func_new
和一个 lambda
定义的,而不是依赖于宿主函数宏,这主要是因为它并不包装一个现有的 C API 函数,而是提供了针对信号系统的新功能。当被 WASM
模块调用时,该 lambda 实现执行几个不同的步骤。
首先,它通过 Span<const Val> args
直接从 WASM 调用者那里接收其必需的输入参数。这些参数包括代表信号名称的指针和长度(
signal_name_ptr
, signal_name_len
)、代表 WASM 函数名称的指针和长度(wasm_func_name_ptr
, wasm_func_name_len
),以及一个表示所需连接优先级的整数(priority
)。
接下来,为了安全地从 WASM 提供的可能不安全的指针中检索实际的字符串值,lambda 利用了
WasmHostUtils::read_wasm_string_helper
实用函数。这个辅助函数在给定的偏移量处从 WASM 线性内存中读取指定数量的字节,执行必要的边界检查并返回字符串。
至关重要的是,该 lambda 的定义方式使其捕获了对宿主中央 SignalManager
实例的引用。这个捕获的引用提供了与信号系统交互所需的上下文。
在成功读取信号和函数名称并且可以访问 SignalManager
之后,lambda 的核心逻辑得以执行:它调用捕获的 signal_manager
上的
connectWasmSlot
方法,并将检索到的 signal_name
、wasm_func_name
和 priority
作为参数传递。这个调用将创建和注册信号-槽连接的实际任务委托给了
SignalManager
。
最后,在连接尝试之后,lambda 将结果返回给 WASM 模块。它获取 connectWasmSlot
返回的布尔成功状态,并将其编组为预期的 FFI
格式,通常是 int32_t
(1 代表成功,0 代表失败),然后将其放入 Span<Val> results
中,供 WASM 调用者解释。
这就提供了关键的链接,允许 WASM 模块在其初始化阶段动态注册其处理程序。
WASM 端的适应:成为信号客户端
Rust WASM 模块现在需要适应这个新的基于信号的系统。
移除旧的管道
在调整 Rust WASM 模块时,第一步是拆除先前自定义的事件基础设施。这个清理工作需要移除现已废弃的触发器和补丁系统的残余部分。具体来说,
src/patch_handler.rs
文件及其内部定义的 PatchHandler
trait 必须从项目中完全删除。相应地,在 src/ffi.rs
中定义的 FFI
层,那些先前用于导入与注册补丁和触发器相关的宿主函数(即 host_register_patch
和 host_register_trigger
)的 extern "C"
导入声明需要被移除。最后,那些曾作为这些旧系统初始化入口点的导出 WASM 函数(init_patches
和 init_triggers
)也必须从导出列表中删除,因为宿主将不再调用它们。
连接槽
随着旧管道的移除,需要建立一个新的机制,让 WASM 模块能够主动发起将其处理程序连接到宿主信号的过程。这个新过程涉及 Rust
代码内部几个协调步骤。首先,必须在位于 src/ffi.rs
的 extern "C"
块中添加负责处理连接的新宿主函数 host_connect_signal
的 FFI 导入声明,确保其签名与宿主端定义的函数签名相匹配。其次,为了封装不安全的 FFI 交互,需要创建一个安全的 Rust 包装器函数
ffi::connect_signal
。这个包装器函数应该接受标准的 Rust 字符串切片 (&str
) 作为信号名称和 WASM
函数名称,以及一个整数优先级。它的实现将负责将这些 Rust 字符串转换为适合 FFI 调用的空终止 CStrings,并包含调用导入的
host_connect_signal
函数所需的 unsafe
块,最后返回一个布尔值,指示连接尝试是否成功。第三,协调 WASM 端所有必要连接的职责被集中到一个新函数
core::connect_all_signals
中,该函数在 src/core.rs
中实现。这个函数的唯一目的是重复调用安全的 ffi::connect_signal
包装器,系统地将宿主暴露的已知信号名称(如字符串 "before_create_entity"
)与旨在处理这些信号的相应导出 WASM 函数的名称(如字符串
"wasm_before_create_entity"
)以及它们期望的优先级配对。第四,也是最后一步,为了将这个连接逻辑暴露给宿主,需要使用
#[no_mangle] pub unsafe extern "C"
从 src/ffi.rs
导出一个名为 connect_all_signals
的 C 兼容函数。这个导出函数的实现只是简单地通过调用
core::connect_all_signals()
来委托实际工作。然后,C++ 宿主应用程序将负责恰好调用一次这个导出的 connect_all_signals
函数,通常是在 WASM 模块成功实例化之后立即进行,从而触发所有定义的 WASM 信号处理程序向宿主的 SignalManager
进行注册。
// src/ffi.rs (片段)
// ... 其他导入 ...
#[link(wasm_import_module = "env")]
unsafe extern "C" {
// --- 信号连接导入 (新) ---
fn host_connect_signal(
signal_name_ptr: *const c_char,
signal_name_len: usize,
wasm_func_name_ptr: *const c_char,
wasm_func_name_len: usize,
priority: c_int,
) -> c_int; // 返回 bool (0 或 1) 表示成功
// ... 其他宿主函数导入保持不变 ...
}
// --- 信号连接包装器 (新) ---
pub fn connect_signal(
signal_name: &str,
wasm_func_name: &str,
priority: i32,
) -> bool {
// ... (实现如前所示,使用 CString::new 和 unsafe 调用) ...
let success_code = unsafe { host_connect_signal(...) };
success_code != 0
}
// --- 供宿主触发连接的导出函数 ---
#[no_mangle]
pub unsafe extern "C" fn connect_all_signals() {
println!("[WASM Export] connect_all_signals called. Connecting handlers via core...");
crate::core::connect_all_signals(); // 委托给核心逻辑
}
// --- 导出的信号处理程序实现 (槽) ---
// ... (如前定义的函数 wasm_before_create_entity 等) ...
// --- 测试运行器导出 ---
#[no_mangle]
pub unsafe extern "C" fn test_relationships_oo() {
// ... 运行 core::run_entt_relationship_tests_oo ...
}
// src/core.rs (片段)
use crate::ffi::{connect_signal, DEFAULT_SIGNAL_PRIORITY /* ... */};
/// 将所有 WASM 信号处理程序 (槽) 连接到相应的宿主信号。
/// 由宿主通过 ffi.rs 中导出的 `connect_all_signals` 函数调用。
pub fn connect_all_signals() {
println!("[WASM Core] Connecting WASM functions to host signals...");
let mut success = true;
// 连接 host_create_entity 的槽
success &= connect_signal(
"before_create_entity", // 宿主信号名称 (字符串)
"wasm_before_create_entity", // 导出的 WASM 函数名称 (字符串)
DEFAULT_SIGNAL_PRIORITY,
);
success &= connect_signal(
"after_create_entity", // 宿主信号名称
"wasm_after_create_entity", // 导出的 WASM 函数名称
DEFAULT_SIGNAL_PRIORITY,
);
// ... 类似地连接所有其他槽 ...
success &= connect_signal(
"after_get_profile_for_player",
"wasm_after_get_profile_for_player_high_prio", // 名称匹配导出的函数
DEFAULT_SIGNAL_PRIORITY + 100, // 更高的优先级数值
);
if success { /* 记录成功 */ } else { /* 记录失败 */ }
}
// ... run_entt_relationship_tests_oo() 基本保持不变 ...
实现 WASM 信号槽
先前用于补丁机制的 Rust 函数(如 prefix_create_entity
)现在要么被重新调整用途,要么被新创建的函数所取代,这些函数专门设计用作
Boost.Signals2
框架内的信号槽。为了让这些函数能够正确接收宿主发射的信号,它们必须遵守两个基本要求。
首先,它们必须从 WASM 模块中正确导出,以便宿主的 SignalManager
(通过 Wasmtime)能够在连接或触发信号时定位并调用它们。这要求使用
#[no_mangle]
标记每个槽函数以防止 Rust 的名称混淆,并将其声明为 pub unsafe extern "C"
以确保 C 兼容的链接和调用约定。至关重要的是,在
Rust 代码中赋给每个导出槽函数的精确名称必须与在 core::connect_all_signals
函数中连接它时使用的字符串字面量完全匹配;任何不一致都将导致连接失败。
其次,同样关键的是,每个 WASM 槽的函数签名——包括其参数和返回类型——必须与相应的宿主端槽包装器 lambda 中硬编码的预期精确对齐。这些包装器
lambda 定义在 C++ 宿主的 SignalManager::initializeConnectorFactories
方法中。参数数量、它们的类型或返回类型的任何不匹配,都将在宿主尝试调用
WASM 槽时导致未定义行为或运行时陷阱。例如,宿主包装器期望槽 wasm_before_create_entity()
不接受任何参数并返回一个 c_int
,其中 0
表示继续,1
表示应跳过操作。类似地,wasm_after_create_entity(original_id: u32)
必须接受一个代表原始实体 ID 的
u32
并返回一个 u32
,从而使其有机会修改通过信号链传回的值。像 wasm_after_destroy_entity(entity_id: u32)
这样的槽则被期望接受
ID 但返回 void
,因为它纯粹充当通知。更复杂的情况,如 wasm_before_get_name(entity_id: u32, buffer_len: u32)
,展示了 FFI
签名的一种简化;宿主包装器期望它接收实体 ID 和预期的缓冲区长度,但不是宿主端的缓冲区指针本身,并返回一个 c_int
(0 或
1)以可能否决 get_name
操作。这种设计选择避免了 WASM
槽直接访问宿主缓冲区的复杂性和潜在的不安全性;如果槽在这个“before”阶段需要实际的字符串内容,它将需要发起一个单独的回调到宿主(例如,使用
host_get_name
本身)。相应地,wasm_after_get_name(entity_id: u32, buffer_len: u32, original_req_len: u32)
槽接收
ID、缓冲区长度以及由 C API 计算出的原始所需长度,并被期望返回一个 u32
,代表可能调整过的所需长度。这种精确匹配由宿主槽包装器
lambda 隐式定义的参数列表和返回类型的模式,必须严格应用于所有旨在充当各种宿主事件信号槽的其他 WASM 函数。
// src/ffi.rs (槽实现片段)
// --- host_create_entity ---
#[no_mangle]
pub unsafe extern "C" fn wasm_before_create_entity() -> c_int {
println!("[WASM Slot] <<< before_create_entity called");
0 // 允许创建
}
#[no_mangle]
pub unsafe extern "C" fn wasm_after_create_entity(original_id: u32) -> u32 {
println!("[WASM Slot] <<< after_create_entity called (Original ID: {})", original_id);
original_id // 返回原始 ID
}
// --- host_get_profile_for_player ---
#[no_mangle]
pub unsafe extern "C" fn wasm_before_get_profile_for_player(player_id: u32) -> c_int {
println!("[WASM Slot] <<< before_get_profile_for_player called (P: {})", player_id);
0 // 允许获取
}
// 默认优先级 postfix 槽
#[no_mangle]
pub unsafe extern "C" fn wasm_after_get_profile_for_player(player_id: u32, original_profile_id: u32) -> u32 {
println!("[WASM Slot] <<< after_get_profile_for_player called (P: {}, OrigProf: {})", player_id, original_profile_id);
// 这个只是观察
original_profile_id
}
// 高优先级 postfix 槽 (在默认之后运行)
#[no_mangle]
pub unsafe extern "C" fn wasm_after_get_profile_for_player_high_prio(player_id: u32, current_profile_id: u32) -> u32 {
println!(
"[WASM Slot][HIGH PRIO] <<< after_get_profile_for_player_high_prio called (P: {}, CurrentProf: {})",
player_id, current_profile_id // current_profile_id 是来自前一个槽/原始调用的结果
);
// 示例:覆盖玩家 2 的个人资料 ID
if player_id == 2 {
println!(" > [HIGH PRIO] Changing profile for player 2 to 888!");
return 888; // 覆盖该值
}
current_profile_id // 否则,返回传入的值
}
// ... 实现所有其他导出的槽函数 ...
现在,当宿主发射 signal_before_create_entity
时,WASM 模块中的 wasm_before_create_entity
函数将被执行。当宿主发射
signal_after_get_profile_for_player
时,wasm_after_get_profile_for_player
和
wasm_after_get_profile_for_player_high_prio
都将运行(按优先级顺序),而 optional_last_value
组合器将确保宿主宏看到的最终结果是高优先级槽返回的值。
全景图:带信号的执行流程
为了理解 WASM 模块、宿主和信号系统之间的相互作用,让我们追踪一次由 WASM 发起的、调用 host_create_entity
的序列。我们假设
WASM 槽 wasm_before_create_entity
和 wasm_after_create_entity
已经通过 connect_all_signals
成功连接到了相应的宿主信号。
该过程始于 WASM 模块内部。首先发生对高层 entity::create_entity()
函数的调用,该函数进而调用了底层的 FFI 包装器
ffi::create_entity()
。在这个 FFI 包装器内部,unsafe
代码块执行了实际的跨边界调用:host_create_entity()
。
控制权此时转移到 C++ 宿主。先前由 DEFINE_HOST_FUNC_0_ARGS_RET
宏生成并为名称 host_create_entity
注册到 Wasmtime
链接器的特定 lambda 包装器函数接收到这个传入调用。这个宿主 lambda 内的第一个动作是获取对 SignalManager
的引用。紧接着,该
lambda 发射 signal_before_create_entity
信号,根据信号的定义不传递任何参数。
Boost.Signals2
框架拦截了这次信号发射,并着手调用连接到 signal_before_create_entity
的任何槽。在我们的场景中,这会触发为
WASM 函数 "wasm_before_create_entity"
特别创建的宿主端槽包装器 lambda 的执行。这个槽包装器 lambda 准备好它的参数(本例中为空)并执行一个
Wasmtime 回调到模块中:host->callFunction<int32_t>("wasm_before_create_entity", ...)
。
执行流跳回到 WASM 模块,具体是 wasm_before_create_entity()
函数。该函数运行其逻辑,通常是打印一条日志消息表明它已被调用,然后返回其结果
0
(在 C ABI 布尔约定中代表 false),示意操作应该继续。
回到宿主,槽包装器 lambda 从 Wasmtime 调用中接收到 int32_t
结果 (0
),并将其解组成 C++ 的 bool
(false
)。这个布尔结果随后被传回给
Boost.Signals2
框架。与 signal_before_create_entity
关联的 StopOnTrueCombiner
接收到这个 false
值。由于它不是
true
,组合器允许处理继续进行(如果连接了其他槽,它们现在会运行)。最终,组合器向发射信号的原始宿主函数 lambda 返回 false
。
宿主 lambda 检查由组合器返回的 skip
标志。由于它是 false
,lambda 确定不应跳过该操作,并继续执行核心逻辑。它现在调用底层的
C API 函数:entt_manager_create_entity(manager_handle)
。这个 C API 函数进而调用 C++ 管理器对象上的
EnttManager::createEntity()
方法。在管理器内部,registry_.create()
被调用,一个新的 EnTT 实体被创建,其 ID 被转换为
uint32_t
,打印一条创建日志消息,然后返回这个 uint32_t
ID。
这个 ID (original_result
) 沿着调用栈从 EnttManager
返回到 C API 函数,再返回到宿主 lambda。现在,宿主 lambda 发射第二个信号:
signal_after_create_entity(original_result)
,传递新创建实体的 ID。
Boost.Signals2
再次接管,调用连接到 signal_after_create_entity
的槽。这导致与 "wasm_after_create_entity"
关联的槽包装器
lambda 被执行,并被传入 original_id
。这个包装器 lambda 准备好它的参数(将 original_id
打包成 wasmtime::Val
)并回调到模块中:
host->callFunction<int32_t>("wasm_after_create_entity", ...)
。注意预期的返回类型是 int32_t
,因为 WASM 函数返回 u32
,它可以容纳在 i32
中。
执行流回到 WASM 的 wasm_after_create_entity(original_id)
函数。它执行其逻辑(例如,日志记录),并且在这个例子中,简单地返回它接收到的
original_id
。
宿主槽包装器从 Wasmtime 接收到这个 ID 作为 int32_t
结果,并将其解组成 uint32_t
。这个值被传递给 Boost.Signals2
框架。与
signal_after_create_entity
关联的 optional_last_value<uint32_t>
组合器接收到这个结果。由于它是执行的唯一(或最后一个)槽,组合器包装这个值并向宿主
lambda 返回 boost::optional<uint32_t>(result)
。
宿主 lambda 收到组合器的结果 (boost::optional<uint32_t>
)。它提取其中包含的值(如果 optional
为空,则会使用默认值,尽管在这里不预期为空)。这个提取出的值成为 final_result
。然后,lambda 将这个 final_result
(实体
ID)编组到 results
span 中,作为种类为 I32
的 wasmtime::Val
,供最初的 WASM 调用者使用。
最后,宿主 lambda 通过向 Wasmtime 运行时返回成功 (std::monostate()
) 来完成其执行。Wasmtime 随后将控制权交还给 WASM 的
ffi::create_entity
函数中最初调用 host_create_entity()
的地方。该函数接收到 ID 并将其返回给 entity::create_entity
,后者接着使用 Entity::new(id)
创建了新创建实体的最终 Rust 包装器对象。至此,整个跨边界调用序列完成,包括了信号的拦截处理。
这个详细的流程展示了 Boost.Signals2
提供的强大编排能力,处理了槽的调用、参数传递(从信号到槽包装器)、返回值的组合,并允许在核心
C API 逻辑之前和之后设置拦截点,所有这些都与跨 FFI 边界的 Wasmtime 调用集成在一起。
收益与考量再访
这项重大的重构工作为 C++/Rust/WASM 集成的整体架构和可维护性带来了实质性的好处。其中最首要的是建立了一个统一的机制;
Boost.Signals2
系统现在取代了先前自定义的触发器实现和独立的补丁框架,为宿主和插件之间的事件处理提供了一个单一、一致的模型。这对系统的健壮性有显著贡献。
Boost.Signals2
固有地自动管理信号-槽连接,有效地防止了手动系统中可能出现的悬挂回调这一常见问题。此外,其内置的组合器概念提供了标准且可预测的方法,用于在多个监听器(WASM
槽)响应同一宿主信号时聚合或处理结果。这次重构也促进了宿主应用程序内部更好的解耦。例如,C API 实现层 (entt_api.cpp
)
变得相当简单,因为它不再需要任何关于触发器或补丁逻辑的内在感知。EnttManager
类同样得到了简化,卸下了事件管理的职责。取而代之的是,新引入的
C++ 宏和专用的 SignalManager
现在清晰地封装了与信号发射和连接管理相关的逻辑。系统通过 Boost.Signals2
提供的功能获得了相当大的灵活性;可分配的优先级允许精确控制连接到同一信号的不同 WASM 槽的执行顺序,而各种组合器的可用性使得能够实现多样化的交互模式,例如允许
WASM 否决宿主操作、修改返回值或仅仅接收通知。最终,这导向了改进的可维护性。核心逻辑、C API、信号管理基础设施以及 WASM
FFI/槽实现之间更清晰的关注点分离,再加上对像 Boost.Signals2 这样成熟的标准库的依赖,使得整个代码库对于开发者来说更容易理解、调试和安全地进行修改。
然而,采用这种方法也引入了一些必须承认的考量因素。最明显的是引入了对 Boost 库的新的外部依赖,特别需要 Boost.Signals2
,而根据构建系统和配置的不同,这可能会隐式地引入其他 Boost 组件。同时,概念复杂性也有内在的增加;现在使用该系统的开发人员需要理解
Boost.Signals2
的核心概念,包括信号、槽、组合器、连接管理以及我们 SignalManager
内部使用的特定工厂模式,这与先前更简单(尽管不够健壮)的自定义解决方案相比,代表了一个初始的学习曲线。此外,在 host_macros.h
中使用的 C++ 宏魔法,虽然在减少信号发射的重复样板代码方面很有效,但也可能引入一层不透明性,如果不理解宏的展开方式,可能会使得调试宿主函数包装器内部的确切控制流程变得更加困难。一个潜在的脆弱性关键点仍然存在于
FFI 签名匹配上:C++ 宿主的槽包装器 lambda(在信号连接器工厂中定义)与其打算调用的导出 Rust WASM
槽函数的签名之间的契约,必须极其小心地进行手动同步。参数类型、参数数量或返回类型的任何不匹配都不会在编译时被捕获,而是会表现为难以诊断的运行时陷阱或未定义行为。最后,在关键的连接阶段,仍然依赖于基于字符串的名称。宿主端的
connectWasmSlot
方法和 WASM 端的 connect_signal
包装器函数都使用字符串字面量来识别信号和 WASM
函数。这些字符串名称中的简单拼写错误将导致连接无声地失败,如果没有在 FFI 边界两侧进行仔细的日志记录或调试程序,这可能难以追踪。
结论:一座更优雅的桥梁
通过用 Boost.Signals2
替换我们自定义的事件系统,我们显著提升了 C++ EnTT 宿主与 Rust WASM
插件之间交互的复杂度和健壮性。我们现在拥有了一个统一、灵活且更易于维护的机制,使得宿主和插件能够相互响应对方的操作,拦截操作,并以受控的方式修改结果。
SignalManager
集中了信号定义和连接逻辑,而 C++ 宏提供了一种简洁的方式来为我们现有的宿主 C API 函数配备信号发射能力。在
WASM 端,导出专用的槽函数,并使用单一的宿主调用 (host_connect_signal
) 来注册它们,简化了插件的职责。诸如组合器 (
StopOnTrueCombiner
, optional_last_value
) 和优先级等特性,解锁了强大的模式,如否决操作或覆盖结果,所有这些都由
Boost.Signals2
框架管理。
虽然它引入了 Boost 依赖并需要理解其概念,但在减少自定义代码复杂性、自动连接管理和标准化事件处理方面所获得的回报是巨大的。这种架构为构建更复杂、更动态的跨 WASM 边界交互提供了坚实的基础,证明了即使是复杂的事件驱动通信,也可以通过正确的工具和设计模式来实现。
我们的旅程仍在继续,但这次重构标志着朝着更成熟、更适合生产环境的 C++/Rust/WASM 集成迈出了重要一步。