Rust 热重载革新:即时编程的终极指南与实践:hot-lib-reloader

文摘   2024-06-25 09:56   江苏  

Rust 热重载革新:即时编程的终极指南与实践:hot-lib-reloader

博主找工作贴:主做大前端,9 年工作经验,正在找工作,base 南京,上海,苏州都可,希望有招聘方可以联系或内推。

hot-lib-reloader 是一个开发工具,允许你在 Rust 程序运行时重新加载函数。这使得你可以进行“即时编程”,在修改代码后立即在运行的程序中看到效果。

这个工具围绕 libloading crate[1] 构建,需要你将想要热重载的代码放入一个 Rust 库(dylib)。关于这个想法和实现的详细讨论,请参阅 这篇博客文章[2]

对于演示和解释,也请参见 这个 Rust and Tell 演讲[3]

unsetunset使用说明unsetunset

要快速生成一个支持热重载的新项目,你可以使用 cargo generate[4] 模板:cargo generate rksm/rust-hot-reload

先决条件

macOS

在 macOS 上,可重载的库需要进行代码签名。为此,hot-lib-reloader 将尝试使用 XCode 命令行工具中包含的 codesign 二进制文件。建议确保 它们已安装[5]

其他平台

应该可以直接使用。

示例项目设置

假设你使用一个工作空间项目,具有以下布局:

├── Cargo.toml
└── src
│ └── main.rs
└── lib
├── Cargo.toml
└── src
└── lib.rs

可执行文件

使用 ./Cargo.toml 中名为 bin 的根项目设置工作空间:

[workspace]
resolver = "2"
members = ["lib"]

[package]
name = "bin"
version = "0.1.0"
edition = "2021"

[dependencies]
hot-lib-reloader = "^0.6"
lib = { path = "lib" }

./src/main.rs 中定义一个子模块,使用 [hot_lib_reloader_macro::hot_module] 属性宏包装由库导出的函数:

// `dylib = "..."` 的值应该是包含热重载函数的库
// 它通常应该是你的子 crate 的 crate 名称。
#[hot_lib_reloader::hot_module(dylib = "lib")]
mod hot_lib {
    // 从 lib.rs 中读取公共 no_mangle 函数,并在此模块内生成具有相同签名的热重载
    // 包装函数。注意这个路径是相对于项目根目录的(或绝对路径)
    hot_functions_from_file!("lib/src/lib.rs");

    // 因为我们生成的函数具有完全相同的签名,
    // 我们需要导入使用的类型
    pub use lib::State;
}

fn main() {
    let mut state = hot_lib::State { counter: 0 };
    // 循环运行,这样你就可以修改代码并看到效果
    loop {
        hot_lib::step(&mut state);
        std::thread::sleep(std::time::Duration::from_secs(1));
    }
}

库应该公开函数。它应该在 ./lib/Cargo.toml 中将 crate 类型设置为 dylib

[package]
name = "lib"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["rlib", "dylib"]

你想要热重载的函数应该是公共的,并具有 #[no_mangle] 属性。注意,你可以定义其他不应该更改的函数而不使用 no_mangle,你将能够与其他函数一起使用这些函数。

pub struct State {
    pub counter: usize,
}

#[no_mangle]
pub fn step(state: &mut State) {
    state.counter += 1;
    println!("在迭代 {} 中执行操作", state.counter);
}

运行它

  1. 启动库的编译:cargo watch -w lib -x 'build -p lib'
  2. 在另一个终端运行可执行文件:cargo run

现在,例如改变 lib/lib.rs 中的打印语句,看看运行时的效果。

此外,推荐使用像 cargo runcc[6] 这样的工具。这允许你一次性运行库构建和应用程序。

库重载事件

LibReloadObserver

你可以使用 [LibReloadObserver] 提供的方法来获取两种事件的通知:

  • `wait_for_about_to_reload`[7] 观察到的库即将被重载(但旧版本仍然加载)
  • `wait_for_reload`[8] 观察到的库的新版本刚刚被重载

这对于在库更新前后运行代码非常有用。一个用例是序列化然后反序列化状态,另一个是驱动应用程序。

继续上面的例子,假设我们不想每秒运行一次库函数 step,我们只想在库更改时重新运行它。为了做到这一点,我们首先需要获得 LibReloadObserver。为此,我们可以公开一个带有 #[lib_change_subscription] 注释的 subscribe() 函数(该属性告诉 hot_module 宏提供它的实现):

#[hot_lib_reloader::hot_module(dylib = "lib")]
mod hot_lib {
    /* 上面的代码 */

    // 公开一个类型来订阅库加载事件
    #[lib_change_subscription]
    pub fn subscribe() -> hot_lib_reloader::LibReloadObserver {}
}

然后主函数只等待重载事件:

fn main() {
    let mut state = hot_lib::State { counter: 0 };
    let lib_observer = hot_lib::subscribe();
    loop {
        hot_lib::step(&mut state);
        // 阻塞直到库被重载
        lib_observer.wait_for_reload();
    }
}

如何在阻止重载以进行序列化/反序列化方面,请参阅 reload-events 示例[9]

was_updated 标志

要了解库是否已更改,可以公开一个简单的测试函数:

#[hot_lib_reloader::hot_module(dylib = "lib")]
mod hot_lib {
    /* ... */
    #[lib_updated]
    pub fn was_updated() -> bool {}
}

hot_lib::was_updated() 将在库重载后第一次调用时返回 true。然后直到发生另一次重载,它将返回 false。

unsetunset使用技巧unsetunset

了解限制

从动态库重新加载代码带来了一些需要注意的事项,这些在 这里[10] 有详细讨论。

不要更改签名

当热重载函数的签名更改时,可执行文件期望的参数和结果类型与库提供的不一致。在这种情况下,你很可能会看到崩溃。

类型更改需要小心

在可执行文件和库中都使用的 struct 和 enum 的类型不能随意更改。如果类型的布局不同,你会遇到未定义的行为,这很可能会导致崩溃。

使用序列化[11] 了解解决方法。

热重载函数不能是泛型的

由于 #[no_mangle] 不支持泛型,泛型函数不能在库中被命名 / 找到。

可重载代码中的全局状态

如果你的热重载库包含全局状态(或依赖于隐藏全局状态的库),你需要在重载后重新初始化它。这可能是一个问题,因为库可能会从用户那里隐藏全局状态。如果需要使用全局状态,请尽可能将其保留在可执行文件中,并在需要时将其传递给可重载函数。

还要注意,“全局状态”不仅仅是全局变量。正如 这个问题[12] 中指出的,依赖于类型 TypeId[13] 的 crates(像大多数 ECS 系统那样)会期望类型/id 映射是恒定的。重载后,类型将具有不同的 ids,这使得(反)序列化更具挑战性。

使用特性标志在热重载和静态代码之间切换

请参阅 reload-feature 示例[14] 以获取完整的项目。

Cargo 允许通过特性标志指定可选依赖项和条件编译。当你像这样定义一个特性:

[features]
default = []
reload = ["dep:hot-lib-reloader"]

[dependencies]
hot-lib-reloader = { version = "^0.6", optional = true }

然后在代码中条件性地使用普通或热模块调用可重载函数,你可以在应用程序的静态和热重载版本之间无缝切换:

#[cfg(feature = "reload")]
use hot_lib::*;
#[cfg(not(feature = "reload"))]
use lib::*;

#[cfg(feature = "reload")]
#[hot_lib_reloader::hot_module(dylib = "lib")]
mod hot_lib { /*...*/ }

要运行静态版本,只需使用 cargo run,热重载变体使用 cargo run --features reload

在发布模式下禁用 #[no-mangle]

为了避免在发布模式下使用 #[no_mangle] 暴露函数时的性能损失(见上一个提示),在那里一切都是静态编译的(不需要导出任何函数),你可以使用 no-mangle-if-debug 属性宏[15]。它将根据你是在构建发布还是调试模式来条件性地禁用名称混淆。

使用序列化或通用值来更改类型

如果你想在开发过程中迭代状态,你可以选择序列化它。如果你使用像 serde_json::Value[16] 这样的通用值表示,你就不需要字符串或二进制格式,通常甚至不需要克隆任何东西。

这里有一个示例,我们创建一个状态容器,它有一个内部的 serde_json::Value

#[hot_lib_reloader::hot_module(dylib = "lib")]
mod hot_lib {
    pub use lib::State;
    hot_functions_from_file!("lib/src/lib.rs");
}

fn main() {
    let mut state = hot_lib::State {
        inner: serde_json::json!(null),
    };

    loop {
        state = hot_lib::step(state);
        std::thread::sleep(std::time::Duration::from_secs(1));
    }
}

在库中,我们现在可以自由地更改 InnerState 的值和类型布局:

#[derive(Debug)]
pub struct State {
    pub inner: serde_json::Value,
}

#[derive(serde::Deserialize, serde::Serialize)]
struct InnerState {}

#[no_mangle]
pub fn step(state: State) -> State {
    let inner: InnerState = serde_json::from_value(state.inner).unwrap_or(InnerState {});

    // 你可以在这里自由地修改 InnerState 布局和 state.inner 值!

    State {
        inner: serde_json::to_value(inner).unwrap(),
    }
}

或者,你也可以在库即将被重载之前进行序列化,然后在重载后立即反序列化。这在 reload-events 示例[17] 中有展示。

使用热重载友好的应用程序结构

热重载是否易于使用取决于你如何构建你的应用程序。特别是,["functional core, imperative shell" pattern](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell ""functional core, imperative shell" pattern") 使得它很容易将状态和行为分开,并且与 hot-lib-reloader 很好地配合。

例如,对于一个简单的游戏,你可以在主函数中设置外部状态,然后将其传递给 fn update(state: &mut State)fn render(state: &State),这是获得两个热重载函数的直接方法。

但即使你使用的框架控制了流程,很可能也有办法让它调用热重载的代码。bevy 示例[18] 展示了如何使系统函数热重载,展示了这是如何工作的。查看 egui[19]tokio[20] 示例可能的设置。

调整文件监视去抖动持续时间

hot_module 宏允许设置 file_watch_debounce 属性,它定义了文件更改的去抖动持续时间(毫秒)。默认情况下这是 500ms。如果你看到一次重新编译触发了多次更新(可能会发生库非常大),增加这个值。你可以尝试减少它以获得更快的重载。对于小型库 / 快速硬件,50ms 或 20ms 应该可以正常工作。

#[hot_module(dylib = "lib", file_watch_debounce = 50)]
/* ... */

更改 dylib 文件的名称和位置

默认情况下,hot-lib-reloader 假设将在 $CARGO_MANIFEST_DIR/target/debug/$CARGO_MANIFEST_DIR/target/release 文件夹中有一个动态库可用,具体取决于是使用调试还是发布配置文件。库的名称由 #[hot_module(...)] 宏的 dylib = "..." 部分定义。因此,通过指定 #[hot_module(dylib = "lib")] 并使用调试设置构建,hot-lib-reloader 将尝试在 MacOS 上加载 target/debug/liblib.dylib,在 Linux 上加载 target/debug/liblib.so 或在 Windows 上加载 target/debug/lib.dll

如果库应该从不同的位置加载,你可以通过设置 lib_dir 属性来指定:

#[hot_lib_reloader::hot_module(
    dylib = "lib",
    lib_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/target/debug")
)]

mod hot_lib {
    /* ... */
}

调整 dylib 文件名

hot_module 宏允许使用 loaded_lib_name_template 参数设置影子文件名称。当多个进程尝试热重载同一个库时,这很有用,可以用来防止冲突。这个属性允许使用占位符,可以动态替换:

占位符描述特性标志
{lib_name}你在代码中定义的库的名称
{load_counter}每次热重载的递增计数器
{pid}运行应用程序的进程 ID
{uuid}UUID v4 字符串uuid

如果你没有指定 loaded_lib_name_template 参数,将使用默认的命名约定来命名影子文件。这个默认模式是:{lib_name}-hot-{load_counter}

#[hot_lib_reloader::hot_module(
    dylib = "lib",
    // 可能会导致这样的阴影文件 lib_hot_2644_0_5e659d6e-b78c-4682-9cdd-b8a0cd3e8fc6.dll
    // 需要 `{uuid}` 占位符的 'uuid' 特性标志
    loaded_lib_name_template = "{lib_name}

    _hot_{pid}_{load_counter}_{uuid}"

)]

mod hot_lib {
    /* ... */
}

调试

如果你的 hot_module 给出了奇怪的编译错误,请尝试使用 cargo expand 查看生成的代码。

默认情况下,hot-lib-reloader crate 不会写入 stdout 或 stderr,而是使用 log crate[21] 在 info、debug 和 trace 日志级别记录它的操作。根据你使用的日志框架(例如 env_logger[22]),你可以通过设置 RUST_LOG 过滤器(如 RUST_LOG=hot_lib_reloader=trace)来启用这些日志。

unsetunset示例unsetunset

示例可以在 rksm/hot-lib-reloader-rs/examples[23] 找到。

  • minimal[24]: 基础设置。
  • reload-feature[25]: 使用特性在动态和静态版本之间切换。
  • serialized-state[26]: 展示了一个允许自由修改类型和状态的选项。
  • reload-events[27]: 如何阻止重载以进行序列化 / 反序列化。
  • all-options[28]: hot_module 宏接受的所有选项。
  • bevy[29]: 如何使 bevy 系统热重载。
  • nannou[30]: 使用 nannou[31] 的交互式生成艺术。
  • egui[32]: 如何热重载一个原生 egui / eframe 应用程序。
  • iced[33]: 如何热重载一个 iced 应用程序。

unsetunset已知问题unsetunset

tracing crate

当与 tracing crate 结合使用时,可能会发生多个问题:

  • 当在被重载的库中使用 tracing 时,应用程序有时会因 Attempted to register a DefaultCallsite that already exists! 而崩溃。
  • 与 bevy 结合使用时,在重载后 commands.insert(component) 操作可能不再工作,很可能是因为内部状态被搞乱了。

如果可以的话,不要将 hot-lib-reloadertracing 结合使用。

unsetunset附录unsetunset

https://github.com/rksm/hot-lib-reloader-rs

参考资料
[1]

libloading crate: https://crates.io/crates/libloading

[2]

这篇博客文章: https://robert.kra.hn/posts/hot-reloading-rust

[3]

这个 Rust and Tell 演讲: https://www.youtube.com/watch?v=-UUImyqX8j0

[4]

cargo generate: https://cargo-generate.github.io/cargo-generate/

[5]

它们已安装: https://mac.install.guide/commandlinetools/

[6]

cargo runcc: https://crates.io/crates/runcc

[7]

wait_for_about_to_reload: LibReloadObserver::wait_for_about_to_reload

[8]

wait_for_reload: LibReloadObserver::wait_for_reload

[9]

reload-events 示例: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/reload-events

[10]

这里: https://robert.kra.hn/posts/hot-reloading-rust/#caveats-and-asterisks

[11]

使用序列化: #使用序列化或通用值来更改类型

[12]

这个问题: https://github.com/rksm/hot-lib-reloader-rs/issues/34

[13]

TypeId: https://doc.rust-lang.org/std/any/struct.TypeId.html

[14]

reload-feature 示例: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/reload-feature

[15]

no_mangle]` 暴露函数时的性能损失(见上一个提示),在那里一切都是静态编译的(不需要导出任何函数),你可以使用 [no-mangle-if-debug 属性宏: ./macro-no-mangle-if-debug

[16]

serde_json::Value: https://docs.rs/serde_json/latest/serde_json/value/enum.Value.html

[17]

reload-events 示例: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/reload-events

[18]

bevy 示例: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/bevy

[19]

egui: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/hot-egui

[20]

tokio: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/reload-events

[21]

log crate: https://crates.io/crates/log

[22]

env_logger: https://crates.io/crates/env_logger

[23]

rksm/hot-lib-reloader-rs/examples: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples

[24]

minimal: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/minimal

[25]

reload-feature: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/reload-feature

[26]

serialized-state: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/serialized-state

[27]

reload-events: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/reload-events

[28]

all-options: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/all-options

[29]

bevy: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/bevy

[30]

nannou: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/nannou-vector-field

[31]

nannou: https://nannou.cc

[32]

egui: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/hot-egui

[33]

iced: https://github.com/rksm/hot-lib-reloader-rs/tree/master/examples/hot-iced


编程悟道
自制软件研发、软件商店,全栈,ARTS 、架构,模型,原生系统,后端(Node、React)以及跨平台技术(Flutter、RN).vue.js react.js next.js express koa hapi uniapp Astro
 最新文章