303 lines
12 KiB
C#
303 lines
12 KiB
C#
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<T>(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<T>(out T instance, string? section = null, bool validate = false) where T : new()
|
|
{
|
|
instance = Get<T>(section, false);
|
|
if (!validate) return true;
|
|
var results = ValidateObject(instance, false);
|
|
return results.Count == 0;
|
|
}
|
|
|
|
public IList<ValidationResult> ValidateObject(object instance, bool throwOnError)
|
|
{
|
|
var results = new List<ValidationResult>();
|
|
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
|
RecursiveValidate(instance, results, visited);
|
|
|
|
if (throwOnError && results.Count > 0) throw new ConfigurationValidationException(results);
|
|
|
|
return results;
|
|
}
|
|
|
|
private static void RecursiveValidate(object instance, IList<ValidationResult> results, HashSet<object> 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>(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<T>(string path, ConfigFormat format)
|
|
{
|
|
var serializer = SerializerFactory.Get(format);
|
|
return serializer.DeserializeFromFile<T>(path);
|
|
}
|
|
|
|
// Reload notifications
|
|
public IDisposable OnReload(Action callback)
|
|
{
|
|
// 始终基于最新的 ReloadToken 订阅,以支持连续热更新
|
|
return ChangeToken.OnChange(() => Configuration.GetReloadToken(), callback);
|
|
}
|
|
|
|
private static IEnumerable<KeyValuePair<string, string?>> FlattenTomlModel(TomlTable table)
|
|
{
|
|
foreach (var kv in WalkTable(string.Empty, table))
|
|
yield return kv;
|
|
|
|
static IEnumerable<KeyValuePair<string, string?>> 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<KeyValuePair<string, string?>> 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<string, string?>(key,
|
|
Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture));
|
|
yield break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<KeyValuePair<string, string?>> ParseIniAsPairs(string content)
|
|
{
|
|
var root = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
var sections = new Dictionary<string, Dictionary<string, string?>>(StringComparer.OrdinalIgnoreCase);
|
|
Dictionary<string, string?>? 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<string, string?>(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<string, string?>(kv.Key, kv.Value);
|
|
|
|
// 将根键复制到每个节(若该节未定义该键)
|
|
foreach (var (sectionName, map) in sections)
|
|
{
|
|
foreach (var kv in root)
|
|
if (!map.ContainsKey(kv.Key))
|
|
yield return new KeyValuePair<string, string?>(sectionName + ":" + kv.Key, kv.Value);
|
|
|
|
foreach (var kv in map)
|
|
yield return new KeyValuePair<string, string?>(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<ValidationResult> Errors { get; }
|
|
|
|
public ConfigurationValidationException(IReadOnlyList<ValidationResult> errors)
|
|
: base($"Configuration validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}")
|
|
{
|
|
Errors = errors;
|
|
}
|
|
} |