配置热重载机制:改配置不用重启程序
这是「从零搭建工业控制系统」系列第15篇。上一篇讲了配置系统基础——INI管全局参数,JSON管命令定义。这篇讲热加载:怎么在程序运行时改配置,不用重启就生效。
为什么要热加载
工业软件跟普通软件不一样——停机就是亏钱。
生产中经常需要调配置:改个命令参数、加个IO点位、调个报警阈值。如果每次改配置都要停程序、改文件、重新启动,那每次都要中断生产。
热加载的目标很简单:改了配置文件,程序自动重新加载,不用重启。
但实现起来有几个坑要处理。
FileSystemWatcher:监听文件变化
.NET 自带的 FileSystemWatcher 可以监听文件系统变化:
private void SetupFileWatcher()
{
var watcher = new FileSystemWatcher(_configDirectory, "*.jsonc")
{
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
watcher.Changed += OnConfigFileChanged;
watcher.Created += OnConfigFileChanged;
}
监听 Changed 和 Created 两个事件——改文件触发 Changed,新建文件触发 Created。删除配置文件不需要处理(你不能加载一个不存在的文件)。
看起来很简单,但实际用起来有几个坑。

坑1:事件触发多次
这是 FileSystemWatcher 最经典的坑。
用记事本保存文件时,记事本的写入方式是"写临时文件→替换原文件",这会触发多次 Changed 事件。用 VS Code 保存时行为又不一样。有时候同一次保存触发2-3次事件。
如果每次事件都重新加载配置,那一次保存会触发2-3次全量加载。配置文件多的时候,全量加载可能需要几百毫秒,卡住UI线程。
解决办法:防抖。
private Timer _debounceTimer;
private void OnConfigFileChanged(object sender, FileSystemEventArgs e)
{
// 500ms内多次事件只执行最后一次
_debounceTimer?.Change(500, Timeout.Infinite);
}
private void OnDebounceTick(object state)
{
ReloadAllConfigs();
_logger.Info("命令配置已热加载");
}
Timer.Change(500, Timeout.Infinite) 的意思是:500ms 后触发一次,如果在这500ms内又调了 Change,重新计时。最终效果是——事件停止触发后500ms,才真正执行加载。
500ms 是经验值。太短(比如50ms)可能防不住记事本的多次写入;太长(比如2000ms)操作员会觉得"改了配置半天没反应"。
坑2:文件被占用
FileSystemWatcher 的 Changed 事件触发时,文件可能还没被记事本释放。如果这时候去读,会报 IOException “文件正在被另一进程使用”。
解决办法是重试:
private string ReadFileWithRetry(string path, int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
return File.ReadAllText(path);
}
catch (IOException)
{
Thread.Sleep(100); // 等100ms再试
}
}
throw new IOException($"读取配置文件失败(重试{maxRetries}次): {path}");
}
3次重试,每次间隔100ms。正常情况下第一次重试就能成功。
坑3:部分写入的文件
这是最危险的坑。
如果配置文件很大,编辑器写入需要时间。FileSystemWatcher 可能在文件写了一半时就触发事件。这时候去读,读到的是不完整的JSON——解析直接报错。
更糟的情况:JSON刚好写到一半,语法没错但内容不完整。比如一个数组有5个元素,只写了2个就触发了事件。加载后注册表里只有2个命令,另外3个丢了。
解决办法有两个:
办法A:文件锁标记
写入前先创建一个 .lock 文件,写完再删除。监听器看到 .lock 文件存在就跳过加载。
编辑器保存流程:
创建 config.jsonc.lock
写入 config.jsonc
删除 config.jsonc.lock
监听器:
收到Changed事件
如果 .lock 文件存在 → 跳过
否则 → 加载
问题:记事本/VS Code 不会自动创建 .lock 文件。需要自定义编辑器或脚本。
办法B:JSON完整性校验
加载前先校验JSON是否完整:
private bool TryParseJson(string content, out JObject result)
{
try
{
result = JObject.Parse(content);
return true;
}
catch
{
result = null;
return false;
}
}
private void OnDebounceTick(object state)
{
var content = ReadFileWithRetry(path);
if (!TryParseJson(content, out var json))
{
_logger.Warn($"配置文件不完整,跳过加载: {path}");
return; // 下次保存时再加载
}
// 正常加载...
}
实际项目用的是办法B——简单,不依赖编辑器配合。代价是偶尔会跳过一次加载,但下次保存时会成功。

注册表热更新
加载了新配置后,要更新注册表。但不能直接替换注册表引用——可能有其他线程正在用旧注册表。
热更新流程:
1. 加载新配置文件 → 生成新注册表
2. 验证新注册表完整性
3. 原子替换:旧注册表引用 → 新注册表引用
4. 通知相关服务配置已变更
第3步用 Interlocked.Exchange 保证原子性:
private CommandRegistry _registry;
public CommandRegistry Registry
{
get => _registry;
}
public void UpdateRegistry(CommandRegistry newRegistry)
{
Interlocked.Exchange(ref _registry, newRegistry);
}
正在用旧注册表的线程不受影响——它们持有的是旧引用,会继续用完。新请求会拿到新注册表。这就是无锁更新的思路。

配置变更通知
注册表更新后,需要通知相关服务。用消息机制:
WeakReferenceMessenger.Default.Send(new ConfigChangedMessage());
订阅了 ConfigChangedMessage 的服务会收到通知,然后各自处理:
| 服务 | 收到通知后做什么 |
|---|---|
| 命令面板 | 刷新命令列表 |
| 安全服务 | 重新读取安全参数 |
| 灯塔服务 | 重新读取灯塔配置 |
| 追踪监控 | 重新加载追踪通道 |
为什么不用接口回调而用消息? 因为配置变更影响的服务很多,而且不是所有服务都在编译时确定。消息机制是松耦合的——服务自己决定要不要订阅,配置中心不需要知道谁订阅了。
哪些配置适合热加载,哪些不适合
不是所有配置都适合热加载。
| 配置类型 | 热加载 | 原因 |
|---|---|---|
| 命令定义 | ✅ | 改命令参数不影响已建立的连接 |
| 报警阈值 | ✅ | 下一次轮询就用新阈值 |
| IO点位配置 | ⚠️ 谨慎 | 新增IO可以,删除IO可能导致引用失效 |
| IP地址/端口 | ❌ | 改了需要重新建立Modbus连接 |
| 数据库配置 | ❌ | 改了需要重新连接数据库 |
IP地址不能热加载——改了IP后,旧的Modbus连接还连着旧IP。要断开重连才能用新IP。这种配置需要重启程序(或者提供专门的"重连"按钮)。

配置保存的完整流程
操作员在设置界面修改参数后点保存:
// 1. ViewModel收集UI值
config.AutoAllowControl = AutoAllowControl;
config.ManualAllowControl = ManualAllowControl;
config.TowerLightServiceEnable = TowerLightServiceEnable;
// 2. 调用ConfigReader写入文件
ConfigReader.Instance.WriteConfig(config);
// 3. 触发配置变更通知
WeakReferenceMessenger.Default.Send(new ConfigChangedMessage());
// 4. 安全服务收到消息后响应
写文件和发消息是两步。写完文件不发消息,其他服务不知道配置变了。发消息但不写文件,重启后配置丢失。两步缺一不可。
踩坑记录
坑1:防抖时间太短
一开始防抖设了100ms,结果记事本保存触发了2次事件,100ms内第二次事件来了,又重新计时。但记事本两次写入间隔可能正好100ms左右,偶尔会漏。改成500ms后稳定了。
坑2:热加载后UI没刷新
注册表更新了,但命令面板的列表还是旧的。原因是命令面板用的是 ObservableCollection,注册表更新后没有清空重新填充。后来加了消息通知,命令面板收到消息后重新从注册表拉取数据。
坑3:热加载时正在执行命令
操作员改配置时,正好有个命令在执行。热加载后注册表里那个命令可能被删了或改了。解决办法:执行中的命令用快照——开始执行时拷贝一份命令配置,执行过程中用快照,不受热加载影响。
本篇小结
| 概念 | 关键做法 |
|---|---|
| FileSystemWatcher | 监听文件变化,不用轮询 |
| 防抖 | 500ms Timer,避免多次触发 |
| 文件占用 | 重试3次,间隔100ms |
| 部分写入 | JSON完整性校验,不完整则跳过 |
| 注册表热更新 | 原子替换引用,无锁更新 |
| 变更通知 | 消息机制,松耦合 |
| 不适合热加载 | IP地址、数据库配置需重启 |
热加载的核心:让操作员改配置不用停机,但要处理好多次触发、文件占用、部分写入这三个坑。
下期预告
第16篇:界面布局 — Dock面板与多窗口
配置系统讲完了,下篇回到UI——Dock面板怎么布局,多窗口怎么管理,区域导航怎么搞。
:配置热重载机制——改配置不用重启程序&spm=1001.2101.3001.5002&articleId=163967167&d=1&t=3&u=8cdbc57a0e77418bbd24336105cf9d83)

被折叠的 条评论
为什么被折叠?



