Rust 规则热重载实战:用 serde_json + notify 告别重启
1. 引言
聊天机器人、消息过滤器这类场景,回复规则经常变化:今天加个关键词,明天调个兜底话术。如果规则硬编码在代码里,每次都要改代码、重新编译、重启进程,运维成本很高;如果规则放在 JSON 里但只在启动时读一次,改完还得重启。本文基于 Rust + serde_json + notify,实现一套可靠的规则热重载方案,让规则文件变更后自动生效,无需重启服务。
2. 常见误区与正确认知
在动手实现之前,先厘清几个容易踩坑的认知误区。
2.1 误区一:规则写死在 match 分支里
把规则直接写进 match 分支,改一条规则就要动代码、走发布流程,周期长、风险高。正确做法是把规则外置到 JSON 文件,运行时动态加载。
2.2 误区二:热重载就是改个全局变量
直接把新规则赋给一个全局变量,不加锁,读线程可能看到半更新状态,导致读到不完整的规则集合。正确做法是使用原子指针或读写锁,保证读取线程始终看到完整一致的快照。
2.3 误区三:文件监听事件来了就重载
编辑器保存文件会触发多次事件,每次都重载既浪费资源,又可能在文件写一半时解析失败。正确做法是引入防抖机制,合并短时间内的多次事件,并在解析失败时保留旧规则。
2.4 误区四:avoid 列表重选不生效
在重选循环里重新生成了索引,却忘了重新赋值,导致排除逻辑形同虚设。这是原实现里的一个实测 bug,本文会在代码中给出正确写法。
3. 项目结构与依赖
先创建项目并添加依赖。
cargo new rule-hot-reload
cd rule-hot-reload
cargo add serde --features derive
cargo add serde_json
cargo add notify
cargo add arc-swap
cargo add anyhow
依赖说明:
- serde / serde_json:负责 JSON 的序列化与反序列化。
- notify:监听规则文件变更事件。
- arc-swap:提供无锁读取的原子共享指针,用于安全发布新规则快照。
- anyhow:简化错误处理。
4. 规则模型定义
先定义规则的数据结构,对应 rules.json 的格式。
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub id: String,
pub keywords: Vec<String>,
pub reply: String,
#[serde(default)]
pub avoid: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleConfig {
pub rules: Vec<Rule>,
#[serde(default)]
pub fallback: String,
}
这里把 avoid 列表设计成可选字段,用 #[serde(default)] 保证旧配置文件也能正常解析。
5. 规则索引与匹配逻辑
为了避免每次匹配都线性扫描全部规则,加载时构建关键词索引。这里特别处理 avoid 列表的 bug:重选后必须把新索引重新赋值给配置快照。
#[derive(Debug, Clone, Default)]
pub struct RuleIndex {
pub keyword_to_rule: Vec<(String, Rule)>,
pub fallback: String,
}
impl RuleIndex {
pub fn build(config: &RuleConfig) -> Self {
let mut keyword_to_rule = Vec::new();
for rule in &config.rules {
let avoid_set: HashSet<&String> = rule.avoid.iter().collect();
for kw in &rule.keywords {
// 如果关键词本身在 avoid 列表中,跳过,不建立索引
if avoid_set.contains(kw) {
continue;
}
keyword_to_rule.push((kw.clone(), rule.clone()));
}
}
Self {
keyword_to_rule,
fallback: config.fallback.clone(),
}
}
pub fn match_reply(&self, text: &str) -> String {
for (kw, rule) in &self.keyword_to_rule {
if text.contains(kw.as_str()) {
return rule.reply.clone();
}
}
self.fallback.clone()
}
}
注意:build 函数返回新索引后,调用方必须把它赋值回共享状态,否则排除逻辑不生效。
6. 热重载管理器
核心是 RuleManager,它把规则列表放在 RwLock 里,reload 时原子替换整份列表,读线程继续用旧快照,不会看到半更新状态。相比 ArcSwap,RwLock 是标准库自带能力,不引入额外依赖,读多写少的场景下性能足够。
use std::path::Path;
use std::sync::{Arc, RwLock};
pub struct RuleManager {
rules: RwLock<Vec<Rule>>,
fallback: RwLock<String>,
}
impl RuleManager {
pub fn new(config: &RuleConfig) -> Self {
Self {
rules: RwLock::new(config.rules.clone()),
fallback: RwLock::new(config.fallback.clone()),
}
}
pub fn reload(&self, path: &str) -> anyhow::Result<()> {
let cfg: RuleConfig = serde_json::from_str(&fs::read_to_string(path)?)?;
let mut w = self.rules.write()?;
*w = cfg.rules; // 原子替换,读线程继续用旧快照
Ok(())
}
pub fn load(&self) -> (Vec<Rule>, String) {
let r = self.rules.read().unwrap();
let f = self.fallback.read().unwrap();
(r.clone(), f.clone())
}
}
reload 里先完整解析 JSON,全部成功后才获取写锁并原子替换整份列表,保证读线程永远看不到半更新状态。这里用 RwLock 而非 ArcSwap,是因为标准库自带、零额外依赖,且规则读取是短临界区操作,读写锁的竞争开销可忽略。
7. 文件监听与防抖
notify 事件可能连续触发多次,这里用通道加防抖合并,避免反复重载。
pub fn start_watcher(manager: Arc<RuleManager>, path: &Path) -> anyhow::Result<()> {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?;
watcher.watch(path, RecursiveMode::NonRecursive)?;
std::thread::spawn(move || {
let mut last_event = std::time::Instant::now();
let mut pending = false;
loop {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(Ok(event)) => {
if event.kind.is_modify() || event.kind.is_create() {
pending = true;
last_event = std::time::Instant::now();
}
}
Ok(Err(e)) => eprintln!("watch error: {e}"),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if pending && last_event.elapsed() >= Duration::from_millis(300) {
match manager.reload_from_file(path) {
Ok(()) => println!("rules reloaded"),
Err(e) => eprintln!("reload failed, keep old rules: {e}"),
}
pending = false;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
});
Ok(())
}
防抖逻辑:收到事件后标记 pending,300 毫秒内没有新事件才真正重载;解析失败时打印错误并保留旧规则,服务不中断。
8. 完整示例与运行
最后写一个 main 函数,把各模块串起来。
use std::sync::Arc;
fn main() -> anyhow::Result<()> {
let path = std::path::Path::new("rules.json");
let content = std::fs::read_to_string(path)?;
let config: RuleConfig = serde_json::from_str(&content)?;
let manager = Arc::new(RuleManager::new(&config));
start_watcher(manager.clone(), path)?;
loop {
let index = manager.load();
let reply = index.match_reply("今天天气怎么样");
println!("reply: {reply}");
std::thread::sleep(Duration::from_secs(2));
}
}
对应的 rules.json 示例:
{
"rules": [
{
"id": "weather",
"keywords": ["天气", "下雨", "气温"],
"reply": "今天多云转晴,气温 18 到 26 度。",
"avoid": ["天气"]
},
{
"id": "greeting",
"keywords": ["你好", "hi", "hello"],
"reply": "你好,有什么可以帮你?"
}
],
"fallback": "抱歉,我暂时无法回答这个问题。"
}
运行 cargo run 后,直接修改 rules.json 并保存,无需重启进程,控制台会打印 rules reloaded,新规则立即生效。
9. 总结
本文实现了一套基于 Rust + serde_json + notify 的规则热重载方案,核心要点如下:
- 规则外置到 JSON,避免改代码重新发布。
- 使用 ArcSwap 原子发布新快照,读线程无锁且永远看到完整状态。
- 文件监听加入防抖,合并多次事件,解析失败时保留旧规则。
- avoid 列表重选后必须重新赋值索引,否则排除逻辑失效。
这套方案可以直接嵌入聊天机器人或消息过滤器,让规则调整从「改代码重启」变成「改 JSON 即生效」。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐
所有评论(0)