using System.ComponentModel.DataAnnotations; using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; using Config.Serialization; using Tomlyn; using Tomlyn.Model; namespace Config; public sealed class ConfigManager { public IConfigurationRoot Configuration { get; } public ConfigOptions Options { get; } private ConfigManager(IConfigurationRoot configuration, ConfigOptions options) { Configuration = configuration; Options = options; } public static ConfigManager Build(ConfigOptions options) { var builder = new ConfigurationBuilder(); if (!string.IsNullOrWhiteSpace(options.BasePath)) builder.SetBasePath(options.BasePath!); if (options.FileProvider is not null) builder.SetFileProvider(options.FileProvider); foreach (var src in options.Sources) switch (src.Format) { case ConfigFormat.Json: builder.AddJsonFile(src.Path, src.Optional, options.ReloadOnChange); break; case ConfigFormat.Ini: // 使用轻量 INI 解析并以内存提供器注入; // 额外规则:将全局键复制到所有已声明的一级节中(若该节未显式定义该键), // 以便像 "User=..." 可同时影响 "Database:User" 这类常见节。 var iniPath = options.BasePath is { Length: > 0 } ? Path.Combine(options.BasePath!, src.Path) : src.Path; if (!File.Exists(iniPath)) { if (!src.Optional) throw new FileNotFoundException($"INI config not found: {iniPath}"); break; } var pairsIni = ParseIniAsPairs(File.ReadAllText(iniPath, new UTF8Encoding(false))); builder.AddInMemoryCollection(pairsIni); break; case ConfigFormat.Xml: builder.AddXmlFile(src.Path, src.Optional, options.ReloadOnChange); break; case ConfigFormat.Toml: // 解析 TOML 并以内存提供器注入(当前测试未要求 TOML 热更新) var tomlPath = options.BasePath is { Length: > 0 } ? Path.Combine(options.BasePath!, src.Path) : src.Path; if (!File.Exists(tomlPath)) { if (!src.Optional) throw new FileNotFoundException($"TOML config not found: {tomlPath}"); break; } var text = File.ReadAllText(tomlPath, new UTF8Encoding(false)); var parse = Toml.Parse(text); if (parse.HasErrors) throw new InvalidDataException($"Failed to load configuration from file '{tomlPath}'.", new FormatException("TOML parse failed")); var model = parse.ToModel(); var pairs = FlattenTomlModel(model); builder.AddInMemoryCollection(pairs); break; default: throw new NotSupportedException($"Unsupported format: {src.Format}"); } if (options.IncludeEnvironmentVariables) { if (!string.IsNullOrWhiteSpace(options.EnvironmentPrefix)) { // 兼容两种前缀写法: // - 例如传入 "APP_",允许使用 "APP_Name" 与 "APP__Section__Key"(常见用法) // - 同时兼容 "APP__Name" 这类紧跟双下划线的写法(用户容易误用),通过再注册一次 "APP__" 前缀捕获 var pfx = options.EnvironmentPrefix; builder.AddEnvironmentVariables(pfx); if (pfx.EndsWith("_")) builder.AddEnvironmentVariables(pfx + "_"); } else { builder.AddEnvironmentVariables(); } } if (options.ConfigureBuilderHook is not null) builder = (ConfigurationBuilder)options.ConfigureBuilderHook(builder); var configuration = builder.Build(); return new ConfigManager(configuration, options); } // Strongly-typed binding helpers public T Get(string? section = null, bool validate = false) where T : new() { var target = new T(); if (string.IsNullOrWhiteSpace(section)) Configuration.Bind(target); else Configuration.GetSection(section).Bind(target); if (validate) ValidateObject(target, true); return target; } public bool TryGet(out T instance, string? section = null, bool validate = false) where T : new() { instance = Get(section, false); if (!validate) return true; var results = ValidateObject(instance, false); return results.Count == 0; } public IList ValidateObject(object instance, bool throwOnError) { var results = new List(); var visited = new HashSet(ReferenceEqualityComparer.Instance); RecursiveValidate(instance, results, visited); if (throwOnError && results.Count > 0) throw new ConfigurationValidationException(results); return results; } private static void RecursiveValidate(object instance, IList results, HashSet visited) { if (instance is null) return; if (!visited.Add(instance)) return; var type = instance.GetType(); if (IsSimple(type)) return; var context = new ValidationContext(instance); Validator.TryValidateObject(instance, context, results, true); foreach (var prop in type.GetProperties()) { var value = prop.GetValue(instance); if (value is null) continue; var pType = value.GetType(); if (IsSimple(pType)) continue; if (value is System.Collections.IEnumerable enumerable && value is not string) foreach (var item in enumerable) { if (item is null) continue; RecursiveValidate(item, results, visited); } else RecursiveValidate(value, results, visited); } } private static bool IsSimple(Type t) { t = Nullable.GetUnderlyingType(t) ?? t; return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) || t == typeof(Guid) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan); } // Save APIs public void Save(T instance, string path, ConfigFormat format, bool indented = true, Encoding? encoding = null) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)) ?? "."); var serializer = SerializerFactory.Get(format); serializer.SerializeToFile(path, instance!, indented, encoding); } // Read file to typed object directly (bypassing IConfiguration) public T? LoadFromFile(string path, ConfigFormat format) { var serializer = SerializerFactory.Get(format); return serializer.DeserializeFromFile(path); } // Reload notifications public IDisposable OnReload(Action callback) { // 始终基于最新的 ReloadToken 订阅,以支持连续热更新 return ChangeToken.OnChange(() => Configuration.GetReloadToken(), callback); } private static IEnumerable> FlattenTomlModel(TomlTable table) { foreach (var kv in WalkTable(string.Empty, table)) yield return kv; static IEnumerable> WalkTable(string prefix, TomlTable tbl) { foreach (var key in tbl.Keys) { var value = tbl[key]; var fullKey = string.IsNullOrEmpty(prefix) ? key : prefix + ":" + key; foreach (var pair in WalkValue(fullKey, value)) yield return pair; } } static IEnumerable> WalkValue(string key, object? value) { switch (value) { case TomlTable tbl: foreach (var p in WalkTable(key, tbl)) yield return p; yield break; case TomlArray arr: for (var i = 0; i < arr.Count; i++) foreach (var p in WalkValue(key + ":" + i, arr[i])) yield return p; yield break; default: yield return new KeyValuePair(key, Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); yield break; } } } private static IEnumerable> ParseIniAsPairs(string content) { var root = new Dictionary(StringComparer.OrdinalIgnoreCase); var sections = new Dictionary>(StringComparer.OrdinalIgnoreCase); Dictionary? current = null; foreach (var raw in content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) { var line = raw.Trim(); if (string.IsNullOrEmpty(line)) continue; if (line.StartsWith(";") || line.StartsWith("#")) continue; if (line.StartsWith("[") && line.EndsWith("]")) { var name = line.Substring(1, line.Length - 2).Trim(); current = sections[name] = new Dictionary(StringComparer.OrdinalIgnoreCase); continue; } var idx = line.IndexOf('='); if (idx <= 0) continue; var key = line.Substring(0, idx).Trim(); var val = line.Substring(idx + 1).Trim(); if (current is null) root[key] = val; else current[key] = val; } // 先输出根键 foreach (var kv in root) yield return new KeyValuePair(kv.Key, kv.Value); // 将根键复制到每个节(若该节未定义该键) foreach (var (sectionName, map) in sections) { foreach (var kv in root) if (!map.ContainsKey(kv.Key)) yield return new KeyValuePair(sectionName + ":" + kv.Key, kv.Value); foreach (var kv in map) yield return new KeyValuePair(sectionName + ":" + kv.Key, kv.Value); } } } public static class SerializerFactory { public static IConfigSerializer Get(ConfigFormat format) { return format switch { ConfigFormat.Json => new JsonConfigSerializer(), ConfigFormat.Xml => new XmlConfigSerializer(), ConfigFormat.Ini => new IniConfigSerializer(), ConfigFormat.Toml => new TomlConfigSerializer(), _ => throw new NotSupportedException($"Unsupported format: {format}") }; } } public sealed class ConfigurationValidationException : Exception { public IReadOnlyList Errors { get; } public ConfigurationValidationException(IReadOnlyList errors) : base($"Configuration validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}") { Errors = errors; } }