NewLife/X

fix: Json配置保存时数字与布尔按原生类型写出,键名风格不一致时告警

https://github.com/NewLifeX/X/issues/188
反馈内容:Json 配置读写无法保持文件原格式——值类型被字符串化、下划线键静默匹配失败,
并质疑首次加载自动回写与外部字段/注释被重写。

核查结论:只有"值类型被字符串化"是真缺陷(实际写出 "Debug": "true",与《配置系统Config》
示例的 "Console": true 相反);下划线键不匹配属既定命名约定(键名=属性名,仅忽略大小写),
自动回写与注释、多余键重写均为设计如此,不修改。

配置树 Value 统一为字符串,JsonConfigProvider 回写时把 3306/true 写成字符串,
外部消费者按类型读取失败。新增 ConfigSection.RawValue 记录字符串化前的原始值,
JSON 写出时还原数字与布尔(字符串、时间、枚举仍为字符串),不引入任何序列化库,全 TFM 可用。

下划线键无法匹配属性属既定命名约定(不做归一化),但原先静默取默认值,
现对仅命名风格不同的近名键输出告警,并提示可用 [DataMember(Name=...)]/[JsonPropertyName]
自定义键名。
石头 authored at 2026-10-29 17:43:18 石头 committed at 2026-10-29 18:16:28
ed1dd6f
Tree
1 Parent(s) 1176403
Summary: 7 changed files with 218 additions and 10 deletions.
Modified +2 -0
Modified +2 -0
Modified +2 -0
Modified +13 -0
Modified +14 -1
Modified +21 -8
Modified +164 -1
Modified +2 -0
diff --git "a/Doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/Doc/\346\236\266\346\236\204\350\256\276\350\256\241.md"
index c4b7540..752d476 100644
--- "a/Doc/\346\236\266\346\236\204\350\256\276\350\256\241.md"
+++ "b/Doc/\346\236\266\346\236\204\350\256\276\350\256\241.md"
@@ -306,6 +306,8 @@ ApiHelper              # API 调用辅助方法
 | 零仓库引用但生态在用成员 | `MapEmbedded`(Agent)、`WebSocketClient.SetRequestHeader`(AI/Remoting)、`NetHelper.GetMac/GetMacs/GetDns/GetGateways/GetMulticasts/GetRelativeEndPoint`、`ReceiveString`(NewLife.Net/Stardust/XCoder)、`IVectorStore`/`VectorRecord`(StarChat/NovaDb)、`DbTable.WriteData`(XCode/AntJob) | 库内文本扫描看不到生态调用;公开面"零引用"结论必须以 `C:\X` 全生态搜索为准,禁止仅凭库内扫描判死 |
 | 零引用扫描误报类别 | P/Invoke 布局字段(`MEMORYSTATUSEX`、`MIB_TCPROW` 端口字节)、框架回调覆写(`LogEventListener` 等)、调试器反射契约(`Stub.ObjectIdForDebugger`)、多态与 TFM 变体(`OnParse`/`OnWrite`/`OnRead`/`RefreshWindows`) | 文本级扫描的固有误报:无静态引用但由布局/运行时/调试器/多态调用;这些成员保留,勿再按零引用清理 |
 | 多语言 | 英文源文 + 内置译文表;默认跟随 `CurrentUICulture`,`Culture`/`LogCulture` 可强制;缺翻译回退英文 | 未接入组件行为不变;异常、日志与配置注释共用同一套文本 | 详见《多语言本地化》 |
+| Json 配置值类型 | 数字与布尔按原生 Json 类型写出;字符串、时间日期、枚举仍为字符串 | `IConfigSection.Value` 统一为字符串,直接写出会把 `3306`/`true` 变成 `"3306"`/`"true"`,与《配置系统Config》的示例格式相悖,也让外部消费者按类型读取失败;类型信息由 `ConfigSection.RawValue` 在字符串化前留存,不引入任何 Json 序列化库,全部 TFM 可用 | 文件外部被改写过的数字/布尔,回写时按模型属性类型重新决定,而不是按文件原文 |
+| 配置键名匹配 | 忽略大小写;不做下划线/连字符归一(`token_key` 不匹配 `TokenKey`);近名键在绑定时输出告警 | 归一化匹配会让同一文件中 `token_key` 与 `TokenKey` 并存时产生歧义,且回写时键名会被改回属性名,归一化只延续不一致;自定义键名请用 `[DataMember(Name=...)]`(全 TFM)或 `[JsonPropertyName(...)]`(net6+)。告警只为消除"静默取默认值",不改变匹配规则 | 有意保留的下划线键仍不会被采纳(设计如此) |
 
 ## 4. 组件依赖关系
 
Modified +2 -0
diff --git "a/Doc/\351\205\215\347\275\256\346\217\220\344\276\233\350\200\205IConfigProvider.md" "b/Doc/\351\205\215\347\275\256\346\217\220\344\276\233\350\200\205IConfigProvider.md"
index 3796566..ca0a924 100644
--- "a/Doc/\351\205\215\347\275\256\346\217\220\344\276\233\350\200\205IConfigProvider.md"
+++ "b/Doc/\351\205\215\347\275\256\346\217\220\344\276\233\350\200\205IConfigProvider.md"
@@ -91,6 +91,8 @@ var name = composite["Sys:Name"]; // 读取时按顺序查找
 - `IsNew` 用于指示配置源是否首次创建,`Config<T>.Current` 会据此决定是否持久化默认值。
 - 注释:模型属性上的 `DescriptionAttribute/DisplayNameAttribute` 会写入配置项注释;Xml/Ini 保存时按 `LogCulture ?? Culture` 翻译,两者为空时保持原文(默认英文源文),不跟随 `CurrentUICulture`。
 - 列表与数组:`ConfigHelper` 支持 `T[]` 与 `IList<T>`;基元类型元素会进行类型转换。
+- 值类型:`IConfigSection.Value` 统一为字符串,JSON 提供者保存时按 `ConfigSection.RawValue` 还原数字与布尔(`3306` 写数字、`"3306"` 写字符串),其余类型仍写字符串。
+- 键名匹配:忽略大小写,但不做下划线/连字符归一(`token_key` 不匹配属性 `TokenKey`)。仅命名风格不同的近名键在绑定时输出告警;自定义键名请在属性上标注 `[DataMember(Name = "...")]`(全 TFM)或 `[JsonPropertyName("...")]`(net6+)。
 
 ## 5. 本仓库优化点(已处理)
 
Modified +2 -0
diff --git "a/Doc/\351\205\215\347\275\256\347\263\273\347\273\237Config.md" "b/Doc/\351\205\215\347\275\256\347\263\273\347\273\237Config.md"
index 639e11f..4301e48 100644
--- "a/Doc/\351\205\215\347\275\256\347\263\273\347\273\237Config.md"
+++ "b/Doc/\351\205\215\347\275\256\347\263\273\347\273\237Config.md"
@@ -185,6 +185,8 @@ public class LoggingConfig
 }
 ```
 
+> 数字与布尔按原生 Json 类型写出(`3306`、`true`),字符串保持带引号,枚举写名称;键名忽略大小写,但不支持下划线/连字符归一,自定义键名请用 `[DataMember(Name = "...")]` 或 `[JsonPropertyName("...")]`。
+
 ### IniConfigProvider
 
 ```csharp
Modified +13 -0
diff --git a/NewLife.Core/Configuration/ConfigHelper.cs b/NewLife.Core/Configuration/ConfigHelper.cs
index 0b5c9cb..1432ade 100644
--- a/NewLife.Core/Configuration/ConfigHelper.cs
+++ b/NewLife.Core/Configuration/ConfigHelper.cs
@@ -2,6 +2,7 @@
 using System.ComponentModel;
 using System.Diagnostics.CodeAnalysis;
 using System.Reflection;
+using NewLife.Log;
 using NewLife.Reflection;
 using NewLife.Serialization;
 
@@ -93,6 +94,9 @@ public static class ConfigHelper
             section.Value = value.ToString();
         else
             section.Value = value?.ToString();
+
+        // 记录原始值,供JSON等带类型的数据源还原数字与布尔
+        if (section is ConfigSection cs) cs.RawValue = value;
     }
     #endregion
 
@@ -127,6 +131,10 @@ public static class ConfigHelper
             var cfg = childs.FirstOrDefault(e => e.Key.EqualIgnoreCase(name));
             if (cfg == null)
             {
+                // 仅命名风格不同(下划线/连字符/空格)的键无法匹配,给出告警,避免静默取默认值
+                var near = childs.FirstOrDefault(e => e.Key != null && TrimKeyStyle(e.Key).EqualIgnoreCase(TrimKeyStyle(name)));
+                if (near != null) XTrace.Log?.Warn("Configuration key [{0}] is not mapped to property [{1}]; default value used. Add [DataMember(Name = \"{0}\")] or [JsonPropertyName(\"{0}\")] on the property to bind a custom key name", near.Key, name);
+
                 prv?.MissKey(name);
                 continue;
             }
@@ -136,6 +144,11 @@ public static class ConfigHelper
         }
     }
 
+    /// <summary>去掉命名风格差异(下划线/连字符/空格),用于识别近似的配置键</summary>
+    /// <param name="key">配置键或属性名</param>
+    /// <returns>归一化后的名称</returns>
+    private static String TrimKeyStyle(String key) => key.Replace("_", "").Replace("-", "").Replace(" ", "");
+
     /// <summary>映射配置子节点到字典</summary>
     /// <param name="childs">子节点数组</param>
     /// <param name="dic">目标字典</param>
Modified +14 -1
diff --git a/NewLife.Core/Configuration/IConfigSection.cs b/NewLife.Core/Configuration/IConfigSection.cs
index f191cd6..d153f0c 100644
--- a/NewLife.Core/Configuration/IConfigSection.cs
+++ b/NewLife.Core/Configuration/IConfigSection.cs
@@ -30,8 +30,21 @@ public class ConfigSection : IConfigSection
     /// <summary>配置名</summary>
     public String Key { get; set; } = null!;
 
+    private String? _value;
     /// <summary>配置值</summary>
-    public String? Value { get; set; }
+    /// <remarks>赋值时清空<see cref="RawValue"/>,避免原始值与字符串值不一致</remarks>
+    public String? Value
+    {
+        get => _value;
+        set
+        {
+            _value = value;
+            RawValue = null;
+        }
+    }
+
+    /// <summary>原始值。记录字符串化之前的值,供JSON等带类型的数据源保存时还原数字与布尔</summary>
+    internal Object? RawValue { get; set; }
 
     /// <summary>注释</summary>
     public String? Comment { get; set; }
Modified +21 -8
diff --git a/NewLife.Core/Configuration/JsonConfigProvider.cs b/NewLife.Core/Configuration/JsonConfigProvider.cs
index ca7bd66..e32a621 100644
--- a/NewLife.Core/Configuration/JsonConfigProvider.cs
+++ b/NewLife.Core/Configuration/JsonConfigProvider.cs
@@ -1,4 +1,5 @@
-using NewLife.Serialization;
+using NewLife.Reflection;
+using NewLife.Serialization;
 
 namespace NewLife.Configuration;
 
@@ -125,11 +126,8 @@ public class JsonConfigProvider : FileConfigProvider
                 var key = elm?.GetType()?.Name;
                 if (!key.IsNullOrEmpty())
                 {
-                    var cfg2 = new ConfigSection
-                    {
-                        Key = key,
-                        Value = elm + "",
-                    };
+                    var cfg2 = new ConfigSection { Key = key };
+                    cfg2.SetValue(elm);
                     cfg.Childs.Add(cfg2);
                 }
             }
@@ -167,7 +165,7 @@ public class JsonConfigProvider : FileConfigProvider
             }
             else
             {
-                dst[key] = item.Value;
+                dst[key] = GetJsonValue(item);
             }
         }
     }
@@ -181,7 +179,7 @@ public class JsonConfigProvider : FileConfigProvider
         if (cs.Count > 0)
         {
             var childs = cs[0].Childs;
-            if (childs == null || childs.Count == 0) return cs.Select(e => e.Value).ToArray();
+            if (childs == null || childs.Count == 0) return cs.Select(GetJsonValue).ToArray();
         }
 
         var list = new List<Object>();
@@ -194,6 +192,21 @@ public class JsonConfigProvider : FileConfigProvider
         return list;
     }
 
+    /// <summary>获取配置节对应的Json值,数字与布尔还原为原生类型</summary>
+    /// <remarks>
+    /// 配置树的<see cref="IConfigSection.Value"/>统一为字符串,直接写出会把数字与布尔字符串化(3306 变成 "3306"),
+    /// 使Json文件的类型信息与文档约定不符。此处按<see cref="ConfigSection.RawValue"/>还原原生类型;
+    /// 字符串、时间日期、枚举等仍保持字符串形式。
+    /// </remarks>
+    /// <param name="section">配置节</param>
+    /// <returns>原生Json值;无法还原时返回配置值字符串</returns>
+    private static Object? GetJsonValue(IConfigSection section)
+    {
+        if (section is ConfigSection { RawValue: { } raw } && (raw is Boolean || raw.GetType() is { IsEnum: false } type && type.IsNumber())) return raw;
+
+        return section.Value;
+    }
+
     /// <summary>清理json字符串中的注释</summary>
     /// <remarks>避免json解析错误,支持多行注释和单行注释</remarks>
     /// <param name="text">原始json文本</param>
Modified +164 -1
diff --git a/XUnitTest.Core/Configuration/JsonConfigProviderTests.cs b/XUnitTest.Core/Configuration/JsonConfigProviderTests.cs
index 76a6464..0e878b8 100644
--- a/XUnitTest.Core/Configuration/JsonConfigProviderTests.cs
+++ b/XUnitTest.Core/Configuration/JsonConfigProviderTests.cs
@@ -1,9 +1,11 @@
-using NewLife;
+using System.Text;
+using NewLife;
 using NewLife.Common;
 using NewLife.Configuration;
 using NewLife.Log;
 using NewLife.Reflection;
 using NewLife.Security;
+using NewLife.Serialization;
 using NewLife.Web;
 using Xunit;
 
@@ -272,4 +274,165 @@ public class JsonConfigProviderTests
         prv.Period = 0;
         Assert.Equal(0, prv.Period);
     }
+
+    [Fact(DisplayName = "保存时数字与布尔按原生Json类型写出")]
+    public void WriteNativeTypes()
+    {
+        var prv = new JsonConfigProvider { FileName = "Config/typed.json" };
+
+        var set = new TypedModel
+        {
+            Port = 3306,
+            Ratio = 1.5,
+            Amount = 12.34m,
+            Enable = true,
+            Code = "0012",
+            Level = LogLevel.Warn,
+            Numbers = [1, 2, 3],
+        };
+        prv.Save(set);
+
+        var txt = File.ReadAllText(prv.FileName.GetBasePath());
+        Assert.Contains("\"Port\": 3306", txt);
+        Assert.Contains("\"Enable\": true", txt);
+        Assert.Contains("\"Code\": \"0012\"", txt);
+        Assert.Contains("\"Level\": \"Warn\"", txt);
+
+        // 反解析校验类型:数字仍是数字,字符串仍是字符串
+        var dic = txt.DecodeJson();
+        Assert.NotNull(dic);
+        Assert.Equal(3306, dic["Port"]);
+        Assert.Equal(1.5d, dic["Ratio"]);
+        Assert.Equal(12.34d, Convert.ToDouble(dic["Amount"]));
+        Assert.Equal(true, dic["Enable"]);
+        Assert.Equal("0012", dic["Code"]);
+        Assert.Equal("Warn", dic["Level"]);
+
+        var nums = dic["Numbers"] as IList<Object>;
+        Assert.NotNull(nums);
+        Assert.Equal(3, nums.Count);
+        Assert.Equal(1, nums[0]);
+
+        // 回读一致
+        var set2 = new JsonConfigProvider { FileName = prv.FileName }.Load<TypedModel>();
+        Assert.NotNull(set2);
+        Assert.Equal(set.Port, set2.Port);
+        Assert.Equal(set.Ratio, set2.Ratio);
+        Assert.Equal(set.Amount, set2.Amount);
+        Assert.Equal(set.Enable, set2.Enable);
+        Assert.Equal(set.Code, set2.Code);
+        Assert.Equal(set.Level, set2.Level);
+        Assert.Equal(set.Numbers, set2.Numbers);
+    }
+
+    [Fact(DisplayName = "读取已有文件后再输出,保持原有值类型")]
+    public void KeepFileValueTypes()
+    {
+        var prv = new JsonConfigProvider { FileName = "Config/keeptypes.json" };
+        var file = prv.FileName.GetFullPath().EnsureDirectory(true);
+        File.WriteAllText(file, """
+            {
+              "port": 3306,
+              "ratio": 1.25,
+              "enable": true,
+              "code": "0012",
+              "nums": [1, 2, 3],
+              "names": ["a", "b"]
+            }
+            """);
+
+        prv.LoadAll();
+
+        var txt = prv.GetString();
+        Assert.Contains("\"port\": 3306", txt);
+        Assert.Contains("\"enable\": true", txt);
+
+        var dic = txt.DecodeJson();
+        Assert.NotNull(dic);
+        Assert.Equal(3306, dic["port"]);
+        Assert.Equal(1.25d, dic["ratio"]);
+        Assert.Equal(true, dic["enable"]);
+        // 形如数字的字符串仍保持字符串
+        Assert.Equal("0012", dic["code"]);
+
+        var nums = dic["nums"] as IList<Object>;
+        Assert.NotNull(nums);
+        Assert.Equal(1, nums[0]);
+
+        var names = dic["names"] as IList<Object>;
+        Assert.NotNull(names);
+        Assert.Equal("a", names[0]);
+    }
+
+    [Fact(DisplayName = "下划线键无法匹配属性时输出告警")]
+    public void UnderscoreKeyWarn()
+    {
+        var json = """
+            {
+              "token_key": "abc",
+              "connect_timeout": 30
+            }
+            """;
+
+        var prv = new JsonConfigProvider { FileName = "Config/underscore.json" };
+        var file = prv.FileName.GetFullPath().EnsureDirectory(true);
+        File.WriteAllText(file, json);
+        prv.LoadAll();
+
+        var model = new SnakeModel();
+        var old = XTrace.Log;
+        var log = new CaptureLog();
+        try
+        {
+            XTrace.Log = log;
+
+            prv.Bind(model, false);
+        }
+        finally
+        {
+            XTrace.Log = old;
+        }
+
+        // 命名风格差异的键不会被采纳,模型保持默认值
+        Assert.Null(model.TokenKey);
+        Assert.Equal(0, model.ConnectTimeout);
+        // 但不再静默,日志给出告警与改名建议
+        var text = log.GetText();
+        Assert.Contains("token_key", text);
+        Assert.Contains("connect_timeout", text);
+    }
+
+    /// <summary>捕获日志文本的日志实现</summary>
+    private sealed class CaptureLog : Logger
+    {
+        private readonly StringBuilder _sb = new();
+
+        public String GetText()
+        {
+            lock (_sb) return _sb.ToString();
+        }
+
+        protected override void OnWrite(LogLevel level, String format, params Object?[] args)
+        {
+            var msg = args == null || args.Length == 0 ? format : String.Format(format, args);
+            lock (_sb) _sb.AppendLine(msg);
+        }
+    }
+
+    private class TypedModel
+    {
+        public Int32 Port { get; set; }
+        public Double Ratio { get; set; }
+        public Decimal Amount { get; set; }
+        public Boolean Enable { get; set; }
+        public String? Code { get; set; }
+        public LogLevel Level { get; set; }
+        public Int32[] Numbers { get; set; } = [];
+    }
+
+    private class SnakeModel
+    {
+        public String? TokenKey { get; set; }
+        public Int32 ConnectTimeout { get; set; }
+    }
 }
\ No newline at end of file