diff --git a/NewLife.Core/Configuration/FileConfigProvider.cs b/NewLife.Core/Configuration/FileConfigProvider.cs index 309a8ff036..df6a8bd270 100644 --- a/NewLife.Core/Configuration/FileConfigProvider.cs +++ b/NewLife.Core/Configuration/FileConfigProvider.cs @@ -107,9 +107,21 @@ public override Boolean SaveAll() fileName = fileName.GetBasePath(); fileName.EnsureDirectory(true); - // 写入文件 - OnWrite(fileName, Root); - _lastTime = fileName.AsFile().LastWriteTime; + // 共享锁:与 Save 互斥,避免并发写入交叉 + lock (this) + { + // 抑制自身 watcher 引发的 reload 重入 + _reading = true; + try + { + OnWrite(fileName, Root); + _lastTime = fileName.AsFile().LastWriteTime; + } + finally + { + _reading = false; + } + } // 通知绑定对象,配置数据有改变 NotifyChange(); @@ -143,32 +155,43 @@ public override Boolean Save(T model, String? path = null) protected virtual void OnWrite(String fileName, IConfigSection section) { var str = GetString(section); + + // 空内容防御:序列化异常退化为空时,不修改目标文件 + if (str.IsNullOrEmpty()) return; + var old = ""; - if (File.Exists(fileName)) old = File.ReadAllText(fileName)?.Trim() ?? ""; + if (File.Exists(fileName)) old = File.ReadAllText(fileName) ?? ""; - if (str != null && str != old) - { - if (old.IsNullOrEmpty()) - { - XTrace.WriteLine("新建配置:{0}", fileName); - } - else - { - // 如果文件内容有变化,输出差异 - var i = 0; - while (i < str.Length && i < old.Length && str[i] == old[i]) i++; + // 双边 trim 比较,避免末尾换行差异导致的无谓重写 + if (str.Trim() == old.Trim()) return; - var s = i > 16 ? i - 16 : 0; - var e = i + 32 < old.Length ? i + 32 : old.Length; - var ori = old[s..e].Replace("\r", "\\r").Replace("\n", "\\n"); - var e2 = i + 32 < str.Length ? i + 32 : str.Length; - var diff = str[s..e2].Replace("\r", "\\r").Replace("\n", "\\n"); + if (old.IsNullOrEmpty()) + { + XTrace.WriteLine("新建配置:{0}", fileName); + } + else + { + // 如果文件内容有变化,输出差异 + var i = 0; + while (i < str.Length && i < old.Length && str[i] == old[i]) i++; - XTrace.WriteLine("更新配置:{0},原:\"{1}\",新:\"{2}\"", fileName, ori, diff); - } + var s = i > 16 ? i - 16 : 0; + var e = i + 32 < old.Length ? i + 32 : old.Length; + var ori = old[s..e].Replace("\r", "\\r").Replace("\n", "\\n"); + var e2 = i + 32 < str.Length ? i + 32 : str.Length; + var diff = str[s..e2].Replace("\r", "\\r").Replace("\n", "\\n"); - File.WriteAllText(fileName, str); + XTrace.WriteLine("更新配置:{0},原:\"{1}\",新:\"{2}\"", fileName, ori, diff); } + + // 原子写入:先写临时文件,再原子替换。任何中途崩溃都不会让目标文件出现空白或半截内容 + var tmp = fileName + ".tmp"; + File.WriteAllText(tmp, str); + + if (File.Exists(fileName)) + File.Replace(tmp, fileName, null, ignoreMetadataErrors: true); + else + File.Move(tmp, fileName); } /// 获取字符串形式 diff --git a/NewLife.Core/Json/JsonConfig.cs b/NewLife.Core/Json/JsonConfig.cs index 056d3f75ea..8510bbb365 100644 --- a/NewLife.Core/Json/JsonConfig.cs +++ b/NewLife.Core/Json/JsonConfig.cs @@ -297,10 +297,23 @@ public virtual void Save(String filename) var json1 = File.Exists(filename) ? File.ReadAllText(filename).Trim() : null; var json2 = GetJson(); + // 空内容防御:序列化异常退化为空时,不修改目标文件 + if (json2.IsNullOrEmpty()) return; + + // 双边 trim 比较:json1 已 trim,json2 在此处 trim 后比较,但写入时使用未 trim 的原始内容 + if (json2.Trim() == json1) return; + //if (File.Exists(filename)) File.Delete(filename); filename.EnsureDirectory(true); - if (json1 != json2) File.WriteAllText(filename, json2); + // 原子写入:先写临时文件,再原子替换。任何中途崩溃都不会让目标文件出现空白或半截内容 + var tmp = filename + ".tmp"; + File.WriteAllText(tmp, json2); + + if (File.Exists(filename)) + File.Replace(tmp, filename, null, ignoreMetadataErrors: true); + else + File.Move(tmp, filename); } } diff --git a/NewLife.Core/Xml/XmlConfig.cs b/NewLife.Core/Xml/XmlConfig.cs index db7976ad65..f41c6a3cad 100644 --- a/NewLife.Core/Xml/XmlConfig.cs +++ b/NewLife.Core/Xml/XmlConfig.cs @@ -313,7 +313,20 @@ public virtual void Save(String? filename) /// 新配置文件的内容 protected virtual void OnSaving(String filename, String? oldXml, String newXml) { - if (oldXml != newXml) File.WriteAllText(filename, newXml); + // 空内容防御:序列化异常退化为空时,不修改目标文件 + if (newXml.IsNullOrEmpty()) return; + + // 双边 trim 比较:oldXml 在 Save 中已 trim,newXml 在此处 trim 后比较,但写入时使用未 trim 的原始内容 + if (newXml.Trim() == oldXml) return; + + // 原子写入:先写临时文件,再原子替换。任何中途崩溃都不会让目标文件出现空白或半截内容 + var tmp = filename + ".tmp"; + File.WriteAllText(tmp, newXml); + + if (File.Exists(filename)) + File.Replace(tmp, filename, null, ignoreMetadataErrors: true); + else + File.Move(tmp, filename); } /// 保存到配置文件中去 diff --git a/Test/Program.cs b/Test/Program.cs index 1a0b807c40..d3a939facb 100644 --- a/Test/Program.cs +++ b/Test/Program.cs @@ -15,6 +15,7 @@ using NewLife.Caching; using NewLife.Collections; using NewLife.Common; +using NewLife.Configuration; using NewLife.Data; using NewLife.Http; using NewLife.IO; @@ -77,7 +78,7 @@ private static async Task Main(String[] args) try { #endif - Test1(); + Test19(); #if !DEBUG } catch (Exception ex) @@ -906,4 +907,61 @@ private static void Test18() Dic = new Dictionary { } }; } + + private static void Test19() + { + // ===== 热更新自激环手工验证 ===== + // 期望:连续 SaveAll 期间,日志中【不应】出现 "配置文件改变,重新加载 ..." + // 因为是 SaveAll 自身写入触发的 watcher,已被 _reading 抑制; + // 最后一段"外部修改"模拟,日志中【应该】出现 "配置文件改变,重新加载 ...", + // 用于验证抑制只是临时的,watcher 仍然能感知真正的外部变更。 + + var fileName = "Config/HotReloadTest.json"; + var fullPath = fileName.GetBasePath(); + + // 清理上次残留 + if (File.Exists(fullPath)) File.Delete(fullPath); + if (File.Exists(fullPath + ".tmp")) File.Delete(fullPath + ".tmp"); + + var prv = new JsonConfigProvider { FileName = fileName, Period = 60 }; + + var model = new XYF { name = "init" }; + prv.Save(model); + prv.Bind(model, autoReload: true); + + XTrace.WriteLine("===== Phase 1: 连续 SaveAll × 100,期望日志中 NOT 出现 \"配置文件改变\" ====="); + for (var i = 1; i <= 100; i++) + { + var saveIndex = i; + Task.Run(() => + { + model.name = $"save-{saveIndex}"; + prv.Save(model); + + var fi = new FileInfo(fullPath); + XTrace.WriteLine(" 第 {0} 次 SaveAll 完成,LastWriteTime={1:HH:mm:ss.fff}", saveIndex, fi.LastWriteTime); + }); + } + + // 等够 watcher 防抖 (500ms) + 延迟读 (200ms) + 富余 + XTrace.WriteLine("等待 1.5 秒,确认抑制窗口期内 watcher 没有触发 reload ..."); + Thread.Sleep(1500); + + XTrace.WriteLine("===== Phase 2: 模拟外部修改,期望日志中【应该】出现 \"配置文件改变\" ====="); + // 直接用 File.WriteAllText 模拟"另一个进程改了配置文件" + // 这绕过 prv 自身的 SaveAll,触发的 watcher 不会被 _reading 挡住 + var externalContent = File.ReadAllText(fullPath).Replace("save", "external-edit"); + File.WriteAllText(fullPath, externalContent); + XTrace.WriteLine(" 外部修改已写入,等待 5 秒观察 watcher 是否触发 reload ..."); + + // watcher 有 200ms 延迟 + 防抖;定时器 60s(事件驱动可用时拉长),靠事件驱动 + Thread.Sleep(5000); + + XTrace.WriteLine("===== 验证结束 ====="); + XTrace.WriteLine(" 通过条件:"); + XTrace.WriteLine(" 1) Phase 1 期间,日志中 NOT 出现 \"配置文件改变,重新加载\""); + XTrace.WriteLine(" 2) Phase 2 期间,日志中【出现一次】\"配置文件改变,重新加载 {0}\"", fullPath); + + prv.TryDispose(); + } } \ No newline at end of file diff --git a/XUnitTest.Core/Configuration/FileConfigProviderAtomicWriteTests.cs b/XUnitTest.Core/Configuration/FileConfigProviderAtomicWriteTests.cs new file mode 100644 index 0000000000..77498184a5 --- /dev/null +++ b/XUnitTest.Core/Configuration/FileConfigProviderAtomicWriteTests.cs @@ -0,0 +1,143 @@ +using NewLife; +using NewLife.Configuration; +using Xunit; + +namespace XUnitTest.Configuration; + +/// FileConfigProvider 原子写入相关测试 +/// +/// 写入中途异常不留下半截文件、空内容不覆盖原文件、双边 trim 比较生效、 +/// 并发 SaveAll 不出现交叉写入。 +/// +public class FileConfigProviderAtomicWriteTests +{ + /// 故障注入:tmp 写完后立即抛异常,模拟进程在原子替换前崩溃 + private sealed class FaultyJsonConfigProvider : JsonConfigProvider + { + protected override void OnWrite(String fileName, IConfigSection section) + { + // 模拟基类原子写入的前半段:tmp 文件已经落盘(甚至内容是错的), + // 但还没来得及 File.Replace 时进程崩溃 + var tmp = fileName + ".tmp"; + File.WriteAllText(tmp, "broken half content"); + throw new IOException("simulated crash mid-write"); + } + } + + /// 覆盖:序列化退化为空字符串时不修改目标文件 + private sealed class EmptyContentJsonConfigProvider : JsonConfigProvider + { + public override String GetString(IConfigSection? section = null) => ""; + } + + [Fact] + public void AtomicWrite_ProcessCrashMidWrite_KeepsOldContent() + { + // 先写一份正常内容打底 + var fileName = "Config/atomic_crash.json"; + var prv = new JsonConfigProvider { FileName = fileName }; + var model = new AtomicTestModel { Name = "original", Count = 1 }; + prv.Save(model); + + var fullPath = fileName.GetBasePath(); + var oldContent = File.ReadAllText(fullPath); + Assert.Contains("original", oldContent); + + // 切换为故障 provider,模拟写入中途崩溃 + var faulty = new FaultyJsonConfigProvider { FileName = fileName }; + Assert.Throws(() => faulty.Save(new AtomicTestModel { Name = "new", Count = 2 })); + + // 关键断言:目标文件保持旧内容,不为空、不被截断 + var afterCrash = File.ReadAllText(fullPath); + Assert.Equal(oldContent, afterCrash); + Assert.Contains("original", afterCrash); + + // 清理可能残留的 tmp(不影响断言,仅避免污染下次运行) + var tmp = fullPath + ".tmp"; + if (File.Exists(tmp)) File.Delete(tmp); + } + + [Fact] + public void AtomicWrite_EmptyContent_DoesNotOverwrite() + { + // 先写一份正常内容打底 + var fileName = "Config/atomic_empty.json"; + var prv = new JsonConfigProvider { FileName = fileName }; + prv.Save(new AtomicTestModel { Name = "keep-me", Count = 42 }); + + var fullPath = fileName.GetBasePath(); + var oldContent = File.ReadAllText(fullPath); + var oldLastWrite = File.GetLastWriteTimeUtc(fullPath); + + // 切换为返回空字符串的 provider + var emptyPrv = new EmptyContentJsonConfigProvider { FileName = fileName }; + emptyPrv.Save(new AtomicTestModel { Name = "should-not-overwrite", Count = 0 }); + + // 关键断言:文件未被覆盖 + var afterEmpty = File.ReadAllText(fullPath); + Assert.Equal(oldContent, afterEmpty); + Assert.Contains("keep-me", afterEmpty); + Assert.Equal(oldLastWrite, File.GetLastWriteTimeUtc(fullPath)); + } + + [Fact] + public void AtomicWrite_TrailingWhitespaceOnly_NoActualWrite() + { + // 写一份初始内容 + var fileName = "Config/atomic_trim.json"; + var prv = new JsonConfigProvider { FileName = fileName }; + prv.Save(new AtomicTestModel { Name = "stable", Count = 7 }); + + var fullPath = fileName.GetBasePath(); + var oldLastWrite = File.GetLastWriteTimeUtc(fullPath); + + // 等待 mtime 分辨率,避免误判(Windows FAT/NTFS 至少 1ms,留 50ms 余量) + Thread.Sleep(50); + + // 用同一份模型再保存一次。GetString 输出与磁盘内容仅在末尾换行可能不同, + // 双边 trim 后应判定为"无变化",不触发实际写入 + var prv2 = new JsonConfigProvider { FileName = fileName }; + prv2.Save(new AtomicTestModel { Name = "stable", Count = 7 }); + + var newLastWrite = File.GetLastWriteTimeUtc(fullPath); + Assert.Equal(oldLastWrite, newLastWrite); + } + + [Fact] + public void AtomicWrite_ConcurrentSaveAll_NoCorruption() + { + var fileName = "Config/atomic_concurrent.json"; + var prv = new JsonConfigProvider { FileName = fileName }; + // 初始化一份,确保后续 SaveAll 走"目标存在"分支 + prv.Save(new AtomicTestModel { Name = "init", Count = 0 }); + + // 并发 SaveAll:让 N 个线程同时改 Root 并保存。 + // 共享同一把锁的前提下,磁盘上不会出现交叉写入或半截文件 + const Int32 threads = 8; + const Int32 iterationsPerThread = 20; + Parallel.For(0, threads, t => + { + for (var i = 0; i < iterationsPerThread; i++) + { + prv.Save(new AtomicTestModel { Name = $"t{t}", Count = i }); + } + }); + + // 写入全部完成后,文件必须可解析为完整的 JSON 配置 + var fullPath = fileName.GetBasePath(); + var content = File.ReadAllText(fullPath); + Assert.False(String.IsNullOrWhiteSpace(content)); + + var verify = new JsonConfigProvider { FileName = fileName }; + var loaded = verify.Load(); + Assert.NotNull(loaded); + Assert.False(String.IsNullOrEmpty(loaded.Name)); + } + + /// 仅供测试使用的最小配置模型 + public class AtomicTestModel + { + public String Name { get; set; } = ""; + public Int32 Count { get; set; } + } +}