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 即生效」。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐