初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
public class ArgConfig<TValue> : ParameterizedProperty<object, TValue>
|
||||
{
|
||||
public ArgConfig(Func<object?, TValue> getter, Action<object?, TValue> setter)
|
||||
{
|
||||
GetValue = getter;
|
||||
SetValue = setter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
#pragma warning disable CS9113 // Parameter is unread.
|
||||
|
||||
/// <summary>
|
||||
/// 标记一个 partial 属性,以添加对应配置项并自动生成访问器。
|
||||
/// </summary>
|
||||
/// <param name="key">配置键</param>
|
||||
/// <param name="defaultValue">默认值</param>
|
||||
/// <param name="source">配置来源</param>
|
||||
/// <typeparam name="TValue">值类型</typeparam>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public sealed class ConfigItemAttribute<TValue>(string key, TValue? defaultValue, ConfigSource source = default) : Attribute;
|
||||
|
||||
/// <summary>
|
||||
/// 标记一个 partial 属性,以添加对应配置项并自动生成访问器。
|
||||
/// <p>注意:默认值将调用指定类型的无参构造器来获取,以解决 C# attribute 在 2025 年仍然不支持隔壁 JVM 在 2015
|
||||
/// 年就支持的极其先进的自定义类型参数的问题。因此,值的类型必须有公开的无参构造器,否则运行时将会抛出异常。</p>
|
||||
/// </summary>
|
||||
/// <param name="key">配置键</param>
|
||||
/// <param name="source">配置来源</param>
|
||||
/// <typeparam name="TValue">值类型</typeparam>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public sealed class AnyConfigItemAttribute<TValue>(string key, ConfigSource source = default) : Attribute;
|
||||
|
||||
/// <summary>
|
||||
/// 标记一个 partial 类为配置组,以自动实现 <see cref="IConfigScope"/> 并生成对应的作用域检查方法。
|
||||
/// </summary>
|
||||
/// <param name="name">组名,需符合 C# 标识符规范</param>
|
||||
/// <param name="source">组级别的默认配置来源</param>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class ConfigGroupAttribute(string name, ConfigSource source = default) : Attribute;
|
||||
|
||||
/// <summary>
|
||||
/// 标记一个类型为 <see cref="ConfigEventRegistry"/> 的 public static 属性,以注册配置项事件。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public sealed class RegisterConfigEventAttribute : Attribute;
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 配置项事件。
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ConfigEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化,当且仅当程序初始化时调用一次。
|
||||
/// </summary>
|
||||
Init = 0b00001,
|
||||
|
||||
/// <summary>
|
||||
/// 获取。
|
||||
/// </summary>
|
||||
Get = 0b00010,
|
||||
|
||||
/// <summary>
|
||||
/// 设置值。
|
||||
/// </summary>
|
||||
Set = 0b00100,
|
||||
|
||||
/// <summary>
|
||||
/// 重置值。
|
||||
/// </summary>
|
||||
Reset = 0b01000,
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否为默认值。
|
||||
/// </summary>
|
||||
CheckDefault = 0b10000,
|
||||
|
||||
/// <summary>
|
||||
/// 保留备用。
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 所有读取操作。
|
||||
/// </summary>
|
||||
Read = Get | CheckDefault,
|
||||
|
||||
/// <summary>
|
||||
/// 所有更新操作。
|
||||
/// </summary>
|
||||
Update = Set | Reset,
|
||||
|
||||
/// <summary>
|
||||
/// 所有改变操作。
|
||||
/// </summary>
|
||||
Changed = Init | Update,
|
||||
|
||||
/// <summary>
|
||||
/// 所有操作。没事别监听这个,一点风吹草动都会触发它。
|
||||
/// </summary>
|
||||
All = Read | Changed
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 配置项事件参数。
|
||||
/// </summary>
|
||||
/// <param name="Item">配置项。</param>
|
||||
/// <param name="Event">触发事件。</param>
|
||||
/// <param name="Argument">上下文参数。</param>
|
||||
/// <param name="OldValue">旧值。</param>
|
||||
/// <param name="NewValue">新值。</param>
|
||||
public record ConfigEventArgs(
|
||||
ConfigItem Item,
|
||||
ConfigEvent Event,
|
||||
object? Argument,
|
||||
object? OldValue,
|
||||
object? NewValue
|
||||
) {
|
||||
/// <summary>
|
||||
/// 设置一个新值代替原来的值进行对应操作,只在 <see cref="ConfigObserver.IsPreview"/> 为 <c>true</c> 时有效。
|
||||
/// </summary>
|
||||
public object? NewValueReplacement { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// 是否取消事件,只在 <see cref="ConfigObserver.IsPreview"/> 为 <c>true</c> 时有效。
|
||||
/// </summary>
|
||||
public bool Cancelled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 配置当前的值。
|
||||
/// </summary>
|
||||
public object? Value => NewValueReplacement ?? NewValue;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 配置项监听委托。
|
||||
/// </summary>
|
||||
/// <param name="e">事件参数</param>
|
||||
public delegate void ConfigEventHandler(ConfigEventArgs e);
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
public class ConfigEventRegistry(
|
||||
IEnumerable<IConfigScope> scope,
|
||||
ConfigEventHandler handler,
|
||||
ConfigEvent trigger = ConfigEvent.Changed,
|
||||
bool isPreview = false)
|
||||
{
|
||||
public IEnumerable<IConfigScope> Scopes => scope;
|
||||
public ConfigEvent Trigger => trigger;
|
||||
public ConfigEventHandler Handler => handler;
|
||||
public bool IsPreview => isPreview;
|
||||
|
||||
public ConfigEventRegistry(
|
||||
IConfigScope scope,
|
||||
ConfigEventHandler handler,
|
||||
ConfigEvent trigger = ConfigEvent.Changed,
|
||||
bool isPreview = false
|
||||
) : this([scope], handler, trigger, isPreview) { }
|
||||
|
||||
public ConfigObserver ToObserver() => new(trigger, handler, isPreview);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 配置项。
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">值类型</typeparam>
|
||||
public class ConfigItem<TValue>(
|
||||
string key,
|
||||
Func<TValue> defaultValue,
|
||||
ConfigSource source
|
||||
) : IConfigScope, ConfigItem
|
||||
{
|
||||
public string Key { get; } = key;
|
||||
|
||||
public ConfigSource Source { get; } = source;
|
||||
|
||||
public Type Type => typeof(TValue);
|
||||
|
||||
private Func<TValue>? _defaultValueConstructor = defaultValue;
|
||||
private TValue? _defaultValue;
|
||||
private bool _defaultValueHasSet = false;
|
||||
|
||||
#region 默认值逻辑
|
||||
|
||||
private TValue _GetDefaultValue()
|
||||
{
|
||||
if (_defaultValueHasSet) return _defaultValue!;
|
||||
_defaultValue = _defaultValueConstructor!();
|
||||
_defaultValueHasSet = true;
|
||||
_defaultValueConstructor = null;
|
||||
return _defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 默认值。
|
||||
/// </summary>
|
||||
public TValue DefaultValue => _GetDefaultValue();
|
||||
|
||||
public object DefaultValueNoType => DefaultValue ?? default!;
|
||||
|
||||
#endregion
|
||||
|
||||
public ConfigItem(string key, TValue defaultValue, ConfigSource source)
|
||||
: this(key, () => defaultValue, source) { }
|
||||
|
||||
public IEnumerable<string> CheckScope(IReadOnlySet<string> keys) => keys.Contains(Key) ? [Key] : [];
|
||||
|
||||
#region 值获取和修改
|
||||
|
||||
private IConfigProvider _Provider { get => field ??= ConfigService.GetProvider(Source); } = null!;
|
||||
|
||||
private ConfigValueCache<TValue> _valueCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// 指定是否启用缓存。<br/>
|
||||
/// <b>NOTE</b>: 禁用缓存将造成一些功能(如自动监听内容更改)不按预期工作,请仅在真正需要的时候禁用。
|
||||
/// </summary>
|
||||
public bool EnableCache
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (!field) _valueCache.InvalidateAll();
|
||||
field = value;
|
||||
}
|
||||
} = true;
|
||||
|
||||
/// <summary>
|
||||
/// 处理看起来是新的值,并返回是否真的是新的。<br/>
|
||||
/// 只有启用缓存时该方法才会生效,未启用缓存将始终直接返回 <see langword="true"/>。
|
||||
/// </summary>
|
||||
private bool _ProcessNewCache(TValue newCache, object? argument, bool force = false)
|
||||
{
|
||||
if (!EnableCache) return true;
|
||||
if (!force)
|
||||
{
|
||||
// 判断是否是新值
|
||||
var existsOld = _valueCache.TryRead(out var oldCache, argument);
|
||||
if (existsOld && EqualityComparer<TValue>.Default.Equals(oldCache, newCache)) return false;
|
||||
}
|
||||
// 对新缓存值执行准备工作
|
||||
if (newCache is INotifyPropertyChanged reactive)
|
||||
reactive.PropertyChanged += (_, _) => OnContentChanged();
|
||||
else if (newCache is INotifyCollectionChanged reactiveCollection)
|
||||
reactiveCollection.CollectionChanged += (_, _) => OnContentChanged();
|
||||
// 写入缓存
|
||||
_valueCache.Write(newCache, argument);
|
||||
return true;
|
||||
void OnContentChanged() => SetValue(newCache, argument, bypassCache: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置值。
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <returns>已设置的配置值或默认值</returns>
|
||||
public TValue GetValue(object? argument = null)
|
||||
{
|
||||
TValue? value = default; // 这个初始化是多余的,但是煞笔巨硬不初始化会报错
|
||||
var exists = EnableCache && _valueCache.TryRead(out value, argument);
|
||||
var newValue = false;
|
||||
if (!exists)
|
||||
{
|
||||
newValue = true;
|
||||
exists = _Provider.GetValue(Key, out value, argument);
|
||||
}
|
||||
var e = _TriggerEvent(ConfigEvent.Get, argument, value, true);
|
||||
if (e is not null)
|
||||
{
|
||||
if (e.Cancelled) return DefaultValue;
|
||||
if (e.NewValueReplacement is not null) return (TValue)e.NewValueReplacement;
|
||||
}
|
||||
if (!exists) value = DefaultValue;
|
||||
if (newValue) _ProcessNewCache(value!, argument);
|
||||
return value!;
|
||||
}
|
||||
|
||||
public object GetValueNoType(object? argument = null)
|
||||
{
|
||||
return GetValue(argument) ?? default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置配置值。
|
||||
/// </summary>
|
||||
/// <param name="value">用于设置的值</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <param name="forceNewValue">强制将传入的值视为新值,不检查缓存,仅在 <see cref="EnableCache"/> 为 <see langword="true"/> 时生效</param>
|
||||
/// <param name="bypassCache">跳过缓存检查和写入,相当于对本次操作临时将 <see cref="EnableCache"/> 设为 <see langword="false"/></param>
|
||||
/// <returns>是否成功设置值,若成功则为 <c>true</c></returns>
|
||||
public bool SetValue(TValue value, object? argument = null, bool forceNewValue = false, bool bypassCache = false)
|
||||
{
|
||||
var e = _TriggerEvent(ConfigEvent.Set, argument, value, isPreview: true);
|
||||
if (e is not null)
|
||||
{
|
||||
if (e.Cancelled) return false;
|
||||
if (e.NewValueReplacement is not null) value = (TValue)e.NewValueReplacement;
|
||||
}
|
||||
if (bypassCache || _ProcessNewCache(value, argument, forceNewValue))
|
||||
_Provider.SetValue(Key, value, argument);
|
||||
_TriggerEvent(ConfigEvent.Set, argument, value, e: e, isPreview: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SetValueNoType(object value, object? argument = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return SetValue((TValue)value, argument);
|
||||
}
|
||||
catch (InvalidCastException)
|
||||
{
|
||||
// 兼容龙猫妙妙小代码直接传入 string 值的行为
|
||||
if (value is string v) return SetValue(v.Convert<TValue>()!, argument);
|
||||
var msg = $"Value convert failed (required: {Type.FullName}, provided: {value.GetType().FullName})";
|
||||
throw new InvalidCastException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetDefaultValue(object? argument = null, bool? forceNewValue = null)
|
||||
{
|
||||
return SetValue(DefaultValue, argument, forceNewValue ?? IsDefault(argument));
|
||||
}
|
||||
|
||||
public bool Reset(object? argument = null)
|
||||
{
|
||||
var e = _TriggerEvent(ConfigEvent.Reset, argument, null, isPreview: true);
|
||||
if (e is { Cancelled: true }) return false;
|
||||
_Provider.Delete(Key, argument);
|
||||
if (EnableCache) _valueCache.Invalidate(argument);
|
||||
_TriggerEvent(ConfigEvent.Reset, argument, DefaultValueNoType, isPreview: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool IsDefault(object? argument = null)
|
||||
{
|
||||
var result = !_Provider.Exists(Key, argument);
|
||||
var e = _TriggerEvent(ConfigEvent.CheckDefault, argument, result);
|
||||
if (e is { NewValueReplacement: not null }) result = (bool)e.NewValueReplacement;
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 事件处理
|
||||
|
||||
private readonly HashSet<ConfigObserver> _observers = [];
|
||||
private readonly HashSet<ConfigObserver> _previewObservers = [];
|
||||
|
||||
public void Observe(ConfigObserver observer)
|
||||
{
|
||||
if (observer.IsPreview) _previewObservers.Add(observer);
|
||||
else _observers.Add(observer);
|
||||
}
|
||||
|
||||
public bool Unobserve(ConfigObserver observer)
|
||||
=> observer.IsPreview ? _previewObservers.Remove(observer) : _observers.Remove(observer);
|
||||
|
||||
// 获取值,若未设置则返回 null
|
||||
private object? _GetValueOrNull(object? argument)
|
||||
{
|
||||
var exists = _Provider.GetValue<TValue>(Key, out var value, argument);
|
||||
return exists ? value : null;
|
||||
}
|
||||
|
||||
public ConfigEventArgs? TriggerEvent(
|
||||
ConfigEvent trigger, object? argument,
|
||||
bool bypassOldValue = false, bool fillNewValue = false)
|
||||
{
|
||||
return _TriggerEvent(trigger, argument, null, bypassOldValue, fillNewValue);
|
||||
}
|
||||
|
||||
private ConfigEventArgs? _TriggerEvent(
|
||||
ConfigEvent trigger, object? argument, object? newValue,
|
||||
bool bypassOldValue = false, bool fillNewValue = false,
|
||||
ConfigEventArgs? e = null, bool? isPreview = null)
|
||||
{
|
||||
var replaceNewValue = false;
|
||||
foreach (var observer in (
|
||||
from observer in (isPreview is { } p ? (p ? _previewObservers : _observers) : _previewObservers.Concat(_observers))
|
||||
let logic = (int)observer.Event & (int)trigger
|
||||
where logic > 0
|
||||
select observer
|
||||
)) {
|
||||
if (e is null)
|
||||
{
|
||||
if (isPreview == false && !bypassOldValue) bypassOldValue = true;
|
||||
var currentValue = (fillNewValue || !bypassOldValue) ? _GetValueOrNull(argument) : null;
|
||||
if (newValue is null && fillNewValue) newValue = currentValue ?? DefaultValue;
|
||||
e = new ConfigEventArgs(this, trigger, argument, bypassOldValue ? null : currentValue, newValue);
|
||||
}
|
||||
observer.Handler(e);
|
||||
// 对 preview 的特殊处理
|
||||
if (observer.IsPreview)
|
||||
{
|
||||
if (e.NewValueReplacement is not null) replaceNewValue = true; // 记录替换操作
|
||||
if (e.Cancelled) return e;
|
||||
}
|
||||
// 防止非 preview 事件传递替换值
|
||||
else if (!replaceNewValue && e.NewValueReplacement is not null) e.NewValueReplacement = null;
|
||||
}
|
||||
// 防止非 preview 事件传递取消状态
|
||||
if (e is { Cancelled: true }) e.Cancelled = false;
|
||||
return e;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ConfigItem{TValue}"/> 的非泛型方法抽象层,用于手动解决巨硬
|
||||
/// 2025 年仍未支持的极其先进的隐式去泛型化。
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public interface ConfigItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置键。
|
||||
/// </summary>
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 配置来源。
|
||||
/// </summary>
|
||||
public ConfigSource Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 配置的 CLR 类型。
|
||||
/// </summary>
|
||||
public Type Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 传入事件观察器以观察事件。
|
||||
/// </summary>
|
||||
public void Observe(ConfigObserver observer);
|
||||
|
||||
/// <summary>
|
||||
/// 取消观察事件。
|
||||
/// </summary>
|
||||
public bool Unobserve(ConfigObserver observer);
|
||||
|
||||
/// <summary>
|
||||
/// 触发配置项事件。
|
||||
/// </summary>
|
||||
/// <param name="trigger">触发事件</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <param name="bypassOldValue">若为 <c>true</c> 则向事件参数的旧值传递 <c>null</c>,否则传递当前值</param>
|
||||
/// <param name="fillNewValue">若为 <c>true</c>,当新值为 <c>null</c> 时将传递当前值或默认值</param>
|
||||
/// <returns></returns>
|
||||
public ConfigEventArgs? TriggerEvent(
|
||||
ConfigEvent trigger,
|
||||
object? argument,
|
||||
bool bypassOldValue = false,
|
||||
bool fillNewValue = false
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 传入事件类型与处理委托以观察事件。
|
||||
/// </summary>
|
||||
public ConfigObserver Observe(ConfigEvent trigger, ConfigEventHandler handler, bool isPreview = false)
|
||||
{
|
||||
var observer = new ConfigObserver(trigger, handler, isPreview);
|
||||
Observe(observer);
|
||||
return observer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传统的用于兼容的值改变事件。<br/>
|
||||
/// 请尽可能避免使用,而是使用 <see cref="RegisterConfigEventAttribute"/>
|
||||
/// 来声明事件观察,或使用 <see cref="Observe(ConfigObserver)"/> 和
|
||||
/// <see cref="Unobserve(ConfigObserver)"/> 来灵活管理事件。
|
||||
/// </summary>
|
||||
public event ConfigEventHandler Changed
|
||||
{
|
||||
add => Observe(ConfigEvent.Changed, value);
|
||||
remove => throw new NotSupportedException("Please use Observe() and Unobserve() to access advanced event management");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置配置值,使其变为未设置状态。
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <returns>是否成功重置值,若成功则为 <c>true</c></returns>
|
||||
public bool Reset(object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 检查配置值是否为默认值 (未设置状态)
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
public bool IsDefault(object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 将配置项的值设置为默认值,设置后 <see cref="IsDefault"/> 将返回 <c>false</c>。
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <param name="forceNewValue">强制视为新值,不检查缓存,仅在 <see cref="EnableCache"/> 为 <see langword="true"/> 时生效</param>
|
||||
/// <returns>是否成功设置值,若成功则为 <c>true</c></returns>
|
||||
public bool SetDefaultValue(object? argument = null, bool? forceNewValue = null);
|
||||
|
||||
/// <summary>
|
||||
/// 没有泛型的 <see cref="ConfigItem{T}.GetValue"/>。<br/>
|
||||
/// 我们都不想给非引用类型装箱,但是龙猫想。
|
||||
/// </summary>
|
||||
public object GetValueNoType(object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 没有泛型的 <see cref="ConfigItem{T}.SetValue"/>。<br/>
|
||||
/// 我们都不想给非引用类型装箱,但是龙猫想。
|
||||
/// </summary>
|
||||
public bool SetValueNoType(object value, object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 没有泛型的 <see cref="ConfigItem{T}.DefaultValue"/>。<br/>
|
||||
/// 我们都不想给非引用类型装箱,但是龙猫想。
|
||||
/// </summary>
|
||||
public object DefaultValueNoType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用值缓存,默认为 <c>true</c>。设为 <c>false</c> 将清除已存在的缓存。
|
||||
/// </summary>
|
||||
public bool EnableCache { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
public delegate void ConfigMigrationHandler(string from, string to);
|
||||
|
||||
/// <summary>
|
||||
/// 配置文件迁移模型与工具。
|
||||
/// </summary>
|
||||
public class ConfigMigration
|
||||
{
|
||||
/// <summary>
|
||||
/// 来源路径。
|
||||
/// </summary>
|
||||
public required string From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标路径。
|
||||
/// </summary>
|
||||
public required string To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级 (或称"权重"),数字越大越容易被使用。
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 迁移实现。
|
||||
/// </summary>
|
||||
public required ConfigMigrationHandler OnMigration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 执行配置文件迁移。
|
||||
/// </summary>
|
||||
/// <param name="target">最终目标路径</param>
|
||||
/// <param name="migrations">可用的迁移过程</param>
|
||||
/// <returns>若找到最简方案并迁移成功,则为 <c>true</c>,否则为 <c>false</c></returns>
|
||||
public static bool Migrate(string target, IEnumerable<ConfigMigration> migrations)
|
||||
{
|
||||
migrations = migrations as ConfigMigration[] ?? migrations.ToArray();
|
||||
IEnumerable<ConfigMigration>? solution = null;
|
||||
var found = (
|
||||
from migration in migrations.Reverse()
|
||||
let path = migration.From
|
||||
where File.Exists(path) && _TryFindShortestPath(path, target, migrations, out solution)
|
||||
select path
|
||||
).Any();
|
||||
if (!found) return false;
|
||||
foreach (var migration in solution!) migration.OnMigration(migration.From, migration.To);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 寻找最短路径
|
||||
// Partly generated by gpt-5 (20250904)
|
||||
// ReSharper disable InvertIf, ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator
|
||||
private static bool _TryFindShortestPath(string start, string end,
|
||||
IEnumerable<ConfigMigration> paths, [NotNullWhen(true)] out IEnumerable<ConfigMigration>? result)
|
||||
{
|
||||
// 起点即终点:最短过程为 0 条边
|
||||
if (start == end)
|
||||
{
|
||||
result = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
// 构建邻接表(有向图)
|
||||
var adj = new Dictionary<string, List<ConfigMigration>>(StringComparer.Ordinal);
|
||||
foreach (var p in paths)
|
||||
{
|
||||
if (!adj.TryGetValue(p.From, out var list))
|
||||
{
|
||||
list = [];
|
||||
adj[p.From] = list;
|
||||
}
|
||||
list.Add(p);
|
||||
}
|
||||
|
||||
// 第一阶段:BFS 计算从 start 到各点的最短边数 dist
|
||||
var dist = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var queue = new Queue<string>();
|
||||
dist[start] = 0;
|
||||
queue.Enqueue(start);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var u = queue.Dequeue();
|
||||
if (!adj.TryGetValue(u, out var outgoing))
|
||||
continue;
|
||||
|
||||
var du = dist[u];
|
||||
foreach (var e in outgoing)
|
||||
{
|
||||
var v = e.To;
|
||||
if (!dist.ContainsKey(v))
|
||||
{
|
||||
dist[v] = du + 1;
|
||||
queue.Enqueue(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不可达
|
||||
if (!dist.ContainsKey(end))
|
||||
{
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 将节点按深度分层,便于第二阶段的 DP
|
||||
var nodesByDepth = new Dictionary<int, List<string>>();
|
||||
foreach (var kv in dist)
|
||||
{
|
||||
if (!nodesByDepth.TryGetValue(kv.Value, out var list))
|
||||
{
|
||||
list = [];
|
||||
nodesByDepth[kv.Value] = list;
|
||||
}
|
||||
list.Add(kv.Key);
|
||||
}
|
||||
|
||||
// 第二阶段:在所有最短路径子图上,选择累计 Priority 之和最大的路径
|
||||
var bestPriority = new Dictionary<string, long>(StringComparer.Ordinal); // 累计优先级和
|
||||
var prevNode = new Dictionary<string, string>(StringComparer.Ordinal); // 重建路径
|
||||
var prevEdge = new Dictionary<string, ConfigMigration>(StringComparer.Ordinal);
|
||||
|
||||
bestPriority[start] = 0;
|
||||
var maxDepth = dist[end];
|
||||
|
||||
for (var d = 0; d < maxDepth; d++)
|
||||
{
|
||||
if (!nodesByDepth.TryGetValue(d, out var layer))
|
||||
continue;
|
||||
|
||||
foreach (var u in layer)
|
||||
{
|
||||
if (!bestPriority.ContainsKey(u)) continue;
|
||||
|
||||
if (!adj.TryGetValue(u, out var outgoing)) continue;
|
||||
|
||||
foreach (var e in outgoing)
|
||||
{
|
||||
if (!dist.TryGetValue(e.To, out var dv) || dv != d + 1)
|
||||
continue; // 只考虑保持最短性的边
|
||||
|
||||
var candidate = bestPriority[u] + e.Priority;
|
||||
if (!bestPriority.TryGetValue(e.To, out var cur) || candidate > cur)
|
||||
{
|
||||
bestPriority[e.To] = candidate;
|
||||
prevNode[e.To] = u;
|
||||
prevEdge[e.To] = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重建从 start 到 end 的路径(边序列)
|
||||
if (!prevEdge.ContainsKey(end)) { // 理论上不应发生
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
var path = new List<ConfigMigration>();
|
||||
var curr = end;
|
||||
while (curr != start)
|
||||
{
|
||||
var edge = prevEdge[curr];
|
||||
path.Add(edge);
|
||||
curr = prevNode[curr];
|
||||
}
|
||||
path.Reverse();
|
||||
result = path;
|
||||
return true;
|
||||
}
|
||||
// ReSharper restore InvertIf, ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 配置事件观察器。
|
||||
/// </summary>
|
||||
/// <param name="Event">观察的事件。</param>
|
||||
/// <param name="Handler">事件处理委托。</param>
|
||||
/// <param name="IsPreview">指定是否预览事件处理,预览事件可覆盖原有值或取消事件处理过程。</param>
|
||||
public record ConfigObserver(
|
||||
ConfigEvent Event,
|
||||
ConfigEventHandler Handler,
|
||||
bool IsPreview = false
|
||||
);
|
||||
@@ -0,0 +1,364 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.App.Configuration.Storage;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.App.IoC;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 全局配置服务。
|
||||
/// </summary>
|
||||
[LifecycleService(LifecycleState.Loading, Priority = 1919810)]
|
||||
[LifecycleScope("config", "配置")]
|
||||
public sealed partial class ConfigService
|
||||
{
|
||||
private static readonly Dictionary<string, ConfigItem> _Items = [];
|
||||
|
||||
private static readonly HashSet<string> _KeySet = [];
|
||||
|
||||
/// <summary>
|
||||
/// 配置键的集合。
|
||||
/// </summary>
|
||||
public static IReadOnlySet<string> KeySet => _KeySet;
|
||||
|
||||
/// <summary>
|
||||
/// 全局配置文件的版本号。
|
||||
/// </summary>
|
||||
[ConfigItem<int>("FileVersion", 1)] public static partial int SharedVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本地配置文件的版本号。
|
||||
/// </summary>
|
||||
[ConfigItem<int>("LocalFileVersion", 1, ConfigSource.Local)] public static partial int LocalVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 全局共享配置文件路径。
|
||||
/// </summary>
|
||||
public static string SharedConfigPath { get; } = Path.Combine(Paths.SharedData, "config.v1.json");
|
||||
|
||||
/// <summary>
|
||||
/// 本地配置文件路径。
|
||||
/// </summary>
|
||||
public static string LocalConfigPath { get; } = Path.Combine(Paths.Data, "config.v1.yml");
|
||||
|
||||
#region Getters & Setters
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取无泛型的配置项。
|
||||
/// </summary>
|
||||
/// <param name="key">配置键</param>
|
||||
/// <param name="item">返回可观察对象</param>
|
||||
/// <returns>若配置键存在,则为 <c>true</c>,否则为 <c>false</c></returns>
|
||||
public static bool TryGetConfigItemNoType(string key, [NotNullWhen(true)] out ConfigItem? item)
|
||||
=> _Items.TryGetValue(key, out item);
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取配置项。
|
||||
/// </summary>
|
||||
/// <param name="key">配置键</param>
|
||||
/// <param name="item">返回配置项,若类型不匹配则为 <c>null</c></param>
|
||||
/// <typeparam name="TValue">配置项的值类型</typeparam>
|
||||
/// <returns>若配置键存在,则为 <c>true</c>,否则为 <c>false</c></returns>
|
||||
/// <exception cref="InvalidOperationException">配置项尚未初始化完成</exception>
|
||||
public static bool TryGetConfigItem<TValue>(string key, out ConfigItem<TValue>? item)
|
||||
{
|
||||
if (!_isConfigItemsInitialized) throw new InvalidOperationException("Not initialized");
|
||||
var result = TryGetConfigItemNoType(key, out var value);
|
||||
item = result ? (value as ConfigItem<TValue>) : null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置项。
|
||||
/// </summary>
|
||||
/// <param name="key">配置键</param>
|
||||
/// <typeparam name="TValue">配置项的值类型</typeparam>
|
||||
/// <returns>配置项实例</returns>
|
||||
/// <exception cref="InvalidOperationException">配置项尚未初始化完成</exception>
|
||||
/// <exception cref="KeyNotFoundException">配置键不存在</exception>
|
||||
/// <exception cref="InvalidCastException">值类型参数与实际类型不匹配</exception>
|
||||
public static ConfigItem<TValue> GetConfigItem<TValue>(string key)
|
||||
{
|
||||
var result = TryGetConfigItem<TValue>(key, out var item);
|
||||
if (!result) throw new KeyNotFoundException($"Config key not found: '{key}'");
|
||||
return item ?? throw new InvalidCastException($"Type of '{key}' is incompatible with {typeof(TValue).FullName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按键设置配置值,自动处理类型匹配。若键不存在则静默失败。
|
||||
/// </summary>
|
||||
/// <param name="key">配置键</param>
|
||||
/// <param name="value">配置值</param>
|
||||
/// <param name="argument">上下文参数(实例路径等)</param>
|
||||
public static void TrySetValue(string key, object value, object? argument = null)
|
||||
{
|
||||
if (!TryGetConfigItemNoType(key, out var item)) return;
|
||||
if (item.Type.IsEnum && value is not string)
|
||||
item.SetValueNoType(Enum.ToObject(item.Type, value), argument);
|
||||
else
|
||||
item.SetValueNoType(value, argument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向指定作用域批量注册事件观察器。
|
||||
/// </summary>
|
||||
/// <param name="scope"><see cref="IConfigScope"/> 实例</param>
|
||||
/// <param name="observer">观察器实例</param>
|
||||
public static void RegisterObserver(IConfigScope scope, ConfigObserver observer)
|
||||
{
|
||||
var itemKeys = scope.CheckScope(KeySet);
|
||||
foreach (var key in itemKeys)
|
||||
{
|
||||
var item = _Items[key];
|
||||
item.Observe(observer);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Providers
|
||||
|
||||
private static ConfigStorage? _sharedConfigProvider;
|
||||
private static ConfigStorage? _sharedEncryptedConfigProvider;
|
||||
private static ConfigStorage? _localConfigProvider;
|
||||
private static ConfigStorage? _instanceConfigProvider;
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置提供方。
|
||||
/// </summary>
|
||||
/// <param name="source">来源定义</param>
|
||||
/// <returns>提供方实例</returns>
|
||||
/// <exception cref="InvalidOperationException">配置提供方尚未初始化完成</exception>
|
||||
/// <exception cref="ArgumentException">来源定义无效</exception>
|
||||
public static IConfigProvider GetProvider(ConfigSource source)
|
||||
{
|
||||
if (!_isProvidersInitialized) throw new InvalidOperationException("Not initialized");
|
||||
return source switch
|
||||
{
|
||||
ConfigSource.Shared => _sharedConfigProvider!,
|
||||
ConfigSource.SharedEncrypt => _sharedEncryptedConfigProvider!,
|
||||
ConfigSource.Local => _localConfigProvider!,
|
||||
ConfigSource.GameInstance => _instanceConfigProvider!,
|
||||
_ => throw new ArgumentException($"Invalid source: {source}")
|
||||
};
|
||||
}
|
||||
|
||||
private static void _InitializeProviders()
|
||||
{
|
||||
Action[] inits = [
|
||||
() => // shared config file
|
||||
{
|
||||
// try migrate
|
||||
if (!File.Exists(SharedConfigPath))
|
||||
{
|
||||
string[] oldPaths = [
|
||||
Path.Combine(Paths.OldSharedData, "Config.json"),
|
||||
Path.Combine(Paths.SharedData, "config.json")
|
||||
];
|
||||
_TryMigrate(SharedConfigPath, oldPaths.Select(path =>
|
||||
new ConfigMigration { From = path, To = SharedConfigPath, OnMigration = SharedJsonMigration }));
|
||||
}
|
||||
// load
|
||||
var fileProvider = new JsonFileProvider(SharedConfigPath);
|
||||
var storage = new FileConfigStorage(fileProvider);
|
||||
_sharedConfigProvider = storage;
|
||||
_sharedEncryptedConfigProvider = new EncryptedFileConfigStorage(storage);
|
||||
},
|
||||
() => // local config file
|
||||
{
|
||||
// try migrate
|
||||
if (!File.Exists(LocalConfigPath)) _TryMigrate(LocalConfigPath, [
|
||||
new ConfigMigration
|
||||
{
|
||||
From = Path.Combine(Paths.Data, "setup.ini"),
|
||||
To = LocalConfigPath,
|
||||
OnMigration = CatIniMigration
|
||||
}
|
||||
]);
|
||||
// load
|
||||
var fileProvider = new YamlFileProvider(LocalConfigPath);
|
||||
_localConfigProvider = new FileConfigStorage(fileProvider);
|
||||
},
|
||||
() => // instance config file(s)
|
||||
{
|
||||
_instanceConfigProvider = new DynamicCacheConfigStorage
|
||||
{
|
||||
StorageFactory = argument =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(argument);
|
||||
var dir = Path.GetFullPath(argument.ToString()!);
|
||||
var configPath = Path.Combine(dir, "PCL", "config.v1.yml");
|
||||
if (!File.Exists(dir)) _TryMigrate(dir, [
|
||||
new ConfigMigration
|
||||
{
|
||||
From = Path.Combine(dir, "PCL", "setup.ini"),
|
||||
To = configPath,
|
||||
OnMigration = CatIniMigration
|
||||
}
|
||||
]);
|
||||
var fileProvider = new YamlFileProvider(configPath);
|
||||
var storage = new FileConfigStorage(fileProvider);
|
||||
return storage;
|
||||
}
|
||||
};
|
||||
}
|
||||
];
|
||||
try { Task.WaitAll(inits.Select(Task.Run).ToArray()); }
|
||||
catch (AggregateException ex) { throw ex.GetBaseException(); }
|
||||
|
||||
return;
|
||||
void SharedJsonMigration(string from, string to)
|
||||
{
|
||||
File.Copy(from, to);
|
||||
}
|
||||
void CatIniMigration(string from, string to)
|
||||
{
|
||||
var lines = File.ReadAllLines(from);
|
||||
var yamlProvider = new YamlFileProvider(to);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.IsNullOrWhiteSpace()) continue;
|
||||
var kv = line.Split(':', 2);
|
||||
if (kv.Length != 2) continue;
|
||||
yamlProvider.Set(kv[0], kv[1]);
|
||||
}
|
||||
yamlProvider.Sync();
|
||||
}
|
||||
}
|
||||
|
||||
private static void _TryMigrate(string target, IEnumerable<ConfigMigration> migrations)
|
||||
{
|
||||
Context.Info($"Try migrating config: {target}");
|
||||
try
|
||||
{
|
||||
var result = ConfigMigration.Migrate(target, migrations);
|
||||
if (!result) Context.Info("No migration solution available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Context.Warn("Migration failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle & Initialization
|
||||
|
||||
/// <summary>
|
||||
/// 配置服务是否已加载完成。未加载完成时,调用与配置项相关的方法可能会抛出 <see cref="InvalidOperationException"/>。
|
||||
/// </summary>
|
||||
public static bool IsInitialized { get; private set; } = false;
|
||||
|
||||
private static bool _isProvidersInitialized = false;
|
||||
private static bool _isConfigItemsInitialized = false;
|
||||
|
||||
[LifecycleStart]
|
||||
private static void _Start()
|
||||
{
|
||||
if (IsInitialized) return;
|
||||
#if TRACE
|
||||
var timer = new Stopwatch();
|
||||
timer.Start();
|
||||
#endif
|
||||
Context.Info("Config initialization started");
|
||||
try
|
||||
{
|
||||
Context.Trace("Initializing config items...");
|
||||
_InitializeConfigItems();
|
||||
Context.Debug($"Finished initialize {_Items.Count} item(s)");
|
||||
_isConfigItemsInitialized = true;
|
||||
Context.Trace("Initializing providers...");
|
||||
_InitializeProviders();
|
||||
_isProvidersInitialized = true;
|
||||
Context.Trace("Initializing observers...");
|
||||
_InitializeObservers();
|
||||
Context.Info("Invoking init events...");
|
||||
foreach (var (_, item) in _Items)
|
||||
{
|
||||
item.TriggerEvent(ConfigEvent.Init, null, true, true);
|
||||
}
|
||||
IsInitialized = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var currentSection = _isConfigItemsInitialized ? "OBSERVER" :
|
||||
_isProvidersInitialized ? "CONFIG_ITEM" : "PROVIDER";
|
||||
string msg;
|
||||
#if DEBUG
|
||||
msg = Lang.Text("Config.Error.LoadFailed.DebugMessage", currentSection);
|
||||
#else
|
||||
if (ex is ConfigFileInitException e)
|
||||
{
|
||||
var filePath = e.Path;
|
||||
var backupPath = e.Path + ".failbackup";
|
||||
var bakPath = e.Path + ".bak";
|
||||
File.Move(filePath, backupPath, true);
|
||||
if (File.Exists(bakPath)) File.Copy(bakPath, filePath, true);
|
||||
msg = Lang.Text(
|
||||
"Config.Error.InvalidFormat.RecoveryMessage",
|
||||
currentSection,
|
||||
filePath,
|
||||
backupPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
msg = Lang.Text("Config.Error.LoadFailed.Message", currentSection);
|
||||
}
|
||||
#endif
|
||||
Context.Fatal(msg, ex);
|
||||
}
|
||||
#if TRACE
|
||||
timer.Stop();
|
||||
Context.Info($"Config initialization finished in {timer.ElapsedMilliseconds} ms");
|
||||
#endif
|
||||
}
|
||||
|
||||
[LifecycleStop]
|
||||
private static void _Stop()
|
||||
{
|
||||
// 检测是否初始化出错
|
||||
if (Lifecycle.GetServiceLastException(Service.Identifier) is { } ex)
|
||||
{
|
||||
Context.Fatal(Lang.Text("Config.Error.LoadFailed.Title"), ex);
|
||||
return;
|
||||
}
|
||||
|
||||
Context.Info("Saving config...");
|
||||
// 停止物流中心并释放资源
|
||||
_sharedConfigProvider?.Stop();
|
||||
_localConfigProvider?.Stop();
|
||||
_instanceConfigProvider?.Stop();
|
||||
}
|
||||
|
||||
[RegisterConfigEvent]
|
||||
public static ConfigEventRegistry SharedVersionInit => new(
|
||||
SharedVersionConfig,
|
||||
trigger: ConfigEvent.Init,
|
||||
handler: e => _UpdateConfigVersion(SharedVersionConfig, "全局", (int)e.NewValue!)
|
||||
);
|
||||
|
||||
[RegisterConfigEvent]
|
||||
public static ConfigEventRegistry LocalVersionInit => new(
|
||||
scope: LocalVersionConfig,
|
||||
trigger: ConfigEvent.Init,
|
||||
handler: e => _UpdateConfigVersion(LocalVersionConfig, "本地", (int)e.NewValue!)
|
||||
);
|
||||
|
||||
private static void _UpdateConfigVersion(ConfigItem<int> versionConfig, string name, int fileVersion)
|
||||
{
|
||||
var targetVersion = versionConfig.DefaultValue;
|
||||
var isUnset = versionConfig.IsDefault();
|
||||
LogWrapper.Info($"{name}配置: 文件版本 {(isUnset ? "UNSET" : fileVersion)}, 目标版本 {targetVersion}");
|
||||
if (isUnset || targetVersion != fileVersion) versionConfig.SetValue(targetVersion);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 配置来源。
|
||||
/// </summary>
|
||||
public enum ConfigSource
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局共享配置。
|
||||
/// </summary>
|
||||
Shared,
|
||||
|
||||
/// <summary>
|
||||
/// 加密的全局共享配置。
|
||||
/// </summary>
|
||||
SharedEncrypt,
|
||||
|
||||
/// <summary>
|
||||
/// 本地配置。
|
||||
/// </summary>
|
||||
Local,
|
||||
|
||||
/// <summary>
|
||||
/// 游戏实例特定配置。
|
||||
/// </summary>
|
||||
GameInstance
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
public struct ConfigValueCache<TValue>()
|
||||
{
|
||||
private TValue? _cachedValue;
|
||||
private bool _hasCachedValue = false;
|
||||
|
||||
private readonly ConcurrentDictionary<object, TValue> _cacheWithContext = [];
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定上下文参数的缓存是否存在。
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
public bool Exists(object? argument = null)
|
||||
{
|
||||
return (argument is null) ? _hasCachedValue : _cacheWithContext.ContainsKey(argument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试读取缓存值。
|
||||
/// </summary>
|
||||
/// <param name="value">若已缓存则为输出值,否则为默认值</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <returns>若已缓存则为 <c>true</c>,否则为 <c>false</c></returns>
|
||||
public bool TryRead(
|
||||
[NotNullWhen(true)] out TValue? value,
|
||||
object? argument = null)
|
||||
{
|
||||
bool result;
|
||||
if (argument is not null) result = _cacheWithContext.TryGetValue(argument, out value);
|
||||
else
|
||||
{
|
||||
if (_hasCachedValue)
|
||||
{
|
||||
value = _cachedValue!;
|
||||
result = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = default;
|
||||
result = false;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存值。
|
||||
/// </summary>
|
||||
/// <param name="value">输入值</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
public void Write(TValue value, object? argument = null)
|
||||
{
|
||||
if (argument is not null)
|
||||
{
|
||||
_cacheWithContext[argument] = value;
|
||||
return;
|
||||
}
|
||||
_hasCachedValue = true;
|
||||
_cachedValue = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存值。<br/>
|
||||
/// 若缓存存在则清除并返回 <c>true</c>,否则返回 <c>false</c>。
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
public bool Invalidate(object? argument)
|
||||
{
|
||||
if (argument is not null) return _cacheWithContext.TryRemove(argument, out _);
|
||||
if (!_hasCachedValue) return false;
|
||||
_cachedValue = default;
|
||||
_hasCachedValue = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void InvalidateAll()
|
||||
{
|
||||
_cacheWithContext.Clear();
|
||||
_cachedValue = default;
|
||||
_hasCachedValue = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
public interface IConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取一个值。
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <param name="value">返回值,若不存在则为该类型默认值</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <typeparam name="T">值的类型</typeparam>
|
||||
/// <returns>值是否存在,若存在则为 <c>true</c></returns>
|
||||
public bool GetValue<T>(string key, [NotNullWhen(true)] out T? value, object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 设置一个值。
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <param name="value">新值</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <typeparam name="T">值的类型</typeparam>
|
||||
public void SetValue<T>(string key, T value, object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 删除一个值。
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
public void Delete(string key, object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 判断一个值是否存在。
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <returns>值是否存在,若存在则为 <c>true</c></returns>
|
||||
public bool Exists(string key, object? argument = null);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 配置作用域。
|
||||
/// </summary>
|
||||
public interface IConfigScope
|
||||
{
|
||||
/// <summary>
|
||||
/// 检查指定的多个配置项是否在该作用域中。
|
||||
/// </summary>
|
||||
/// <param name="keys">配置键</param>
|
||||
/// <returns>所有存在于该作用域中的键的集合</returns>
|
||||
public IEnumerable<string> CheckScope(IReadOnlySet<string> keys);
|
||||
|
||||
/// <summary>
|
||||
/// 重置作用域,将使作用域中的所有值回到默认值状态。
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <returns>是否成功重置作用域,若成功则为 <c>true</c></returns>
|
||||
public bool Reset(object? argument = null);
|
||||
|
||||
/// <summary>
|
||||
/// 检查作用域是否为默认。
|
||||
/// </summary>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <returns>若作用域中的所有值均为默认值,则为 <c>true</c>,否则为 <c>false</c></returns>
|
||||
public bool IsDefault(object? argument = null);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 提供 LTCat-style ini 格式的键值文件读写。
|
||||
/// </summary>
|
||||
public class CatIniFileProvider : CommonFileProvider, IEnumerableKeyProvider
|
||||
{
|
||||
private readonly Dictionary<string, string> _dict = [];
|
||||
|
||||
public CatIniFileProvider(string path) : base(path)
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
if (line.IsNullOrWhiteSpace()) continue;
|
||||
var split = line.Split(':', 2);
|
||||
_dict[split[0]] = split[1];
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<string> Keys => _dict.Keys;
|
||||
|
||||
public override T Get<T>(string key)
|
||||
{
|
||||
if (!_dict.TryGetValue(key, out var value)) throw new KeyNotFoundException($"Not found: '{key}");
|
||||
return value.Convert<T>() ?? throw new NullReferenceException();
|
||||
}
|
||||
|
||||
public override void Set<T>(string key, T value)
|
||||
=> _dict[key] = value.ConvertToString() ?? throw new NullReferenceException();
|
||||
|
||||
public override bool Exists(string key) => _dict.ContainsKey(key);
|
||||
|
||||
public override void Remove(string key) => _dict.Remove(key);
|
||||
|
||||
protected override void WriteToStream(Stream stream)
|
||||
{
|
||||
var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
foreach (var (key, value) in _dict)
|
||||
{
|
||||
var keyStr = key.ReplaceLineBreak();
|
||||
var valueStr = value.ReplaceLineBreak();
|
||||
writer.WriteLine($"{keyStr}:{valueStr}");
|
||||
}
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.IO;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
public abstract class CommonFileProvider(string path) : IKeyValueFileProvider
|
||||
{
|
||||
public string FilePath { get; set; } = path;
|
||||
|
||||
public abstract T Get<T>(string key);
|
||||
public abstract void Set<T>(string key, T value);
|
||||
public abstract bool Exists(string key);
|
||||
public abstract void Remove(string key);
|
||||
|
||||
protected abstract void WriteToStream(Stream stream);
|
||||
|
||||
public void Sync()
|
||||
{
|
||||
if (!File.Exists(FilePath)) Directory.CreateDirectory(Basics.GetParentPath(FilePath)!);
|
||||
var tmpFile = $"{FilePath}.tmp{RandomUtils.NextInt(1, 99999):00000}";
|
||||
var bakFile = $"{FilePath}.bak";
|
||||
using (var stream = new FileStream(tmpFile, FileMode.Create, FileAccess.Write, FileShare.Read))
|
||||
{
|
||||
WriteToStream(stream);
|
||||
stream.Flush(true);
|
||||
}
|
||||
|
||||
if (File.Exists(FilePath)) File.Replace(tmpFile, FilePath, bakFile);
|
||||
else File.Move(tmpFile, FilePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
public class ConfigFileInitException(string path, string message, Exception? inner = null)
|
||||
: Exception(message, inner)
|
||||
{
|
||||
/// <summary>
|
||||
/// Relative file path.
|
||||
/// </summary>
|
||||
public string Path { get; } = path;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using PCL.Core.App.IoC;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Diagnostics;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
public enum StorageAction
|
||||
{
|
||||
Get,
|
||||
Exists,
|
||||
Set,
|
||||
Delete
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存取仓库模型实现与底层抽象。
|
||||
/// </summary>
|
||||
public abstract class ConfigStorage : IConfigProvider
|
||||
{
|
||||
protected abstract bool OnAccess<TKey, TValue>(
|
||||
StorageAction action,
|
||||
ref TKey key,
|
||||
[NotNullWhen(true)] ref TValue value,
|
||||
object? argument);
|
||||
|
||||
protected virtual void OnStop() { }
|
||||
|
||||
/// <summary>
|
||||
/// 停止存取工作,保存并释放资源。
|
||||
/// </summary>
|
||||
public void Stop() => OnStop();
|
||||
|
||||
#if DEBUG
|
||||
private static readonly bool _EnableTrace = Basics.CommandLineArguments.Contains("--trace-traffic");
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// 执行存取操作。
|
||||
/// </summary>
|
||||
/// <param name="action">操作类型</param>
|
||||
/// <param name="key">键</param>
|
||||
/// <param name="value">值,若无值则为该类型默认值</param>
|
||||
/// <param name="argument">上下文参数</param>
|
||||
/// <typeparam name="TKey">键的类型</typeparam>
|
||||
/// <typeparam name="TValue">值的类型</typeparam>
|
||||
/// <returns>是否有输出值</returns>
|
||||
public bool Access<TKey, TValue>(
|
||||
StorageAction action,
|
||||
ref TKey key,
|
||||
[NotNullWhen(true)] ref TValue value,
|
||||
object? argument)
|
||||
{
|
||||
const string logModule = "Config";
|
||||
var hasOutput = false;
|
||||
try
|
||||
{
|
||||
hasOutput = OnAccess(action, ref key, ref value, argument);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var msg = $"Config Storage Error Report\n" +
|
||||
$"A exception was thrown while processing an access.\n\n" +
|
||||
$"[Diagnostics Info]\n{_GenerateDiagnosticsInfo(action, key, value, hasOutput, argument, true)}\n\n" +
|
||||
$"[Exception Details]\n{ex}";
|
||||
LogWrapper.Fatal(logModule, msg);
|
||||
Lifecycle.ForceShutdown(-2);
|
||||
}
|
||||
#if DEBUG
|
||||
if (_EnableTrace)
|
||||
{
|
||||
LogWrapper.Trace(logModule, _GenerateDiagnosticsInfo(action, key, value, hasOutput, argument));
|
||||
}
|
||||
#endif
|
||||
return hasOutput;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions _SerializerOptions = new(JsonCompat.SerializerOptions)
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private string _GenerateDiagnosticsInfo<TKey, TValue>(
|
||||
StorageAction accessAction,
|
||||
TKey? accessKey,
|
||||
TValue? accessValue,
|
||||
bool accessHasValue,
|
||||
object? accessContext,
|
||||
bool appendCallStack = false)
|
||||
{
|
||||
#if TRACE
|
||||
const bool needFileInfo = true;
|
||||
#else
|
||||
const bool needFileInfo = false;
|
||||
#endif
|
||||
var context = JsonSerializer.Serialize(accessContext, _SerializerOptions);
|
||||
var key = JsonSerializer.Serialize(accessKey, _SerializerOptions);
|
||||
var value = JsonSerializer.Serialize(accessValue, _SerializerOptions);
|
||||
var caller = appendCallStack
|
||||
? "Stack:\n|=> " + string.Join("\n|=> ", StackHelper.GetStack(includeParameters: true, needFileInfo: needFileInfo).Skip(1))
|
||||
: "Caller: " + StackHelper.GetDirectCallerName(includeParameters: true, skipAppFrames: 2);
|
||||
var msg = $"Storage Access: {accessAction} {ToString()}\n" +
|
||||
$"|- Context: {(accessContext is null ? "" : "(" + accessContext.GetType().Name + ") ")}{context}\n" +
|
||||
$"|- Key: ({typeof(TKey).Name}) {key}\n" +
|
||||
$"|- Value: ({typeof(TValue).Name}) {(accessHasValue ? value : "undefined")}\n" +
|
||||
$"|- {caller}";
|
||||
return msg;
|
||||
}
|
||||
|
||||
public bool GetValue<T>(string key, [NotNullWhen(true)] out T? value, object? argument = null)
|
||||
{
|
||||
var keyRef = key;
|
||||
T? valueRef = default;
|
||||
var hasValue = Access(StorageAction.Get, ref keyRef, ref valueRef, argument);
|
||||
value = valueRef;
|
||||
return hasValue;
|
||||
}
|
||||
|
||||
public void SetValue<T>(string key, T value, object? argument = null)
|
||||
{
|
||||
var keyRef = key;
|
||||
var valueRef = value;
|
||||
Access(StorageAction.Set, ref keyRef, ref valueRef, argument);
|
||||
}
|
||||
|
||||
public void Delete(string key, object? argument = null)
|
||||
{
|
||||
var keyRef = key;
|
||||
object? valueRef = null;
|
||||
Access(StorageAction.Delete, ref keyRef, ref valueRef, argument);
|
||||
}
|
||||
|
||||
public bool Exists(string key, object? argument = null)
|
||||
{
|
||||
var keyRef = key;
|
||||
var resultRef = false;
|
||||
return Access(StorageAction.Exists, ref keyRef, ref resultRef, argument) && resultRef;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{GetType().Name}@{GetHashCode()}";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
public class DynamicCacheConfigStorage : ConfigStorage
|
||||
{
|
||||
private readonly Dictionary<object, ConfigStorage> _cache = [];
|
||||
private ConfigStorage? _nullContextCache;
|
||||
|
||||
/// <summary>
|
||||
/// 存取仓库工厂。在没有匹配的上下文实例时将被调用,以创建新的上下文实例。
|
||||
/// </summary>
|
||||
public required Func<object?, ConfigStorage> StorageFactory { get; init; }
|
||||
|
||||
protected override bool OnAccess<TKey, TValue>(StorageAction action, ref TKey key, [NotNullWhen(true)] ref TValue value, object? context)
|
||||
{
|
||||
ConfigStorage? storage;
|
||||
if (context is null) storage = _nullContextCache;
|
||||
else _cache.TryGetValue(context, out storage);
|
||||
if (storage is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
storage = StorageFactory(context);
|
||||
if (context is null) _nullContextCache = storage;
|
||||
else _cache[context] = storage;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception("Failed to invoke storage factory", ex);
|
||||
}
|
||||
}
|
||||
return storage.Access(action, ref key, ref value, context);
|
||||
}
|
||||
|
||||
protected override void OnStop()
|
||||
{
|
||||
foreach (var item in _cache.Values) item.Stop();
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
public bool InvalidateCache(object context)
|
||||
{
|
||||
var result = _cache.TryGetValue(context, out var center);
|
||||
if (result)
|
||||
{
|
||||
center?.Stop();
|
||||
_cache.Remove(context);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Secret;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
public class EncryptedFileConfigStorage(ConfigStorage source) : ConfigStorage
|
||||
{
|
||||
public ConfigStorage Source { get; } = source;
|
||||
|
||||
private static readonly JsonSerializerOptions _SerializerOptions = new(JsonCompat.SerializerOptions)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
AllowOutOfOrderMetadataProperties = true,
|
||||
};
|
||||
|
||||
protected override bool OnAccess<TKey, TValue>(StorageAction action, ref TKey key, [NotNullWhen(true)] ref TValue value, object? argument)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case StorageAction.Set:
|
||||
{
|
||||
// 序列化
|
||||
var type = typeof(TValue);
|
||||
string strValue;
|
||||
if (type == typeof(string)) strValue = value?.ToString() ?? string.Empty;
|
||||
else strValue = JsonSerializer.Serialize(value, _SerializerOptions);
|
||||
// 加密
|
||||
strValue = EncryptHelper.SecretEncrypt(strValue);
|
||||
return Source.Access(StorageAction.Set, ref key, ref strValue, argument);
|
||||
}
|
||||
case StorageAction.Get:
|
||||
{
|
||||
// 获取加密值
|
||||
string? raw = null;
|
||||
var hasOutput = Source.Access(StorageAction.Get, ref key, ref raw, argument);
|
||||
if (!hasOutput) return false;
|
||||
// 解密
|
||||
var decrypted = EncryptHelper.SecretDecrypt(raw);
|
||||
// 反序列化
|
||||
var type = typeof(TValue);
|
||||
if (type == typeof(bool)) Unsafe.As<TValue, bool>(ref value) = decrypted.ToLowerInvariant() is "true" or "1";
|
||||
else if (type == typeof(string)) Unsafe.As<TValue, string>(ref value) = decrypted;
|
||||
else value = JsonSerializer.Deserialize<TValue>(decrypted, _SerializerOptions) ?? throw new NullReferenceException("Decryption produced a null reference");
|
||||
return hasOutput;
|
||||
}
|
||||
default: return Source.Access(action, ref key, ref value, argument);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Config", "无法处理加解密");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.UI;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 文件存取仓库。
|
||||
/// </summary>
|
||||
public class FileConfigStorage : ConfigStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// 键值文件实例。
|
||||
/// </summary>
|
||||
public IKeyValueFileProvider File { get; }
|
||||
|
||||
private readonly Channel<(string, Action)> _writeActionChannel;
|
||||
private readonly CancellationTokenSource _writeActionCts;
|
||||
private readonly ManualResetEventSlim _writeStopEvent = new(true);
|
||||
|
||||
public FileConfigStorage(IKeyValueFileProvider file)
|
||||
{
|
||||
File = file;
|
||||
_writeActionChannel = Channel.CreateUnbounded<(string, Action)>();
|
||||
_writeActionCts = new CancellationTokenSource();
|
||||
Task.Run(async () =>
|
||||
{
|
||||
_writeStopEvent.Reset();
|
||||
const long syncInterval = 10000; // ms
|
||||
var lastSyncTick = 0L;
|
||||
var cancelToken = _writeActionCts.Token;
|
||||
var writeActionMap = new Dictionary<string, Action>();
|
||||
var reader = _writeActionChannel.Reader;
|
||||
try
|
||||
{
|
||||
while (!cancelToken.IsCancellationRequested)
|
||||
{
|
||||
// 读入并合并暂存操作
|
||||
var (key, action) = await reader.ReadAsync(cancelToken);
|
||||
writeActionMap[key] = action;
|
||||
if (Environment.TickCount64 - lastSyncTick < syncInterval || cancelToken.IsCancellationRequested) continue;
|
||||
// 同步文件
|
||||
Sync();
|
||||
lastSyncTick = Environment.TickCount64;
|
||||
writeActionMap.Clear();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* ignoring*/ }
|
||||
finally
|
||||
{
|
||||
// 结束时执行一次同步
|
||||
Sync();
|
||||
}
|
||||
_writeStopEvent.Set();
|
||||
return;
|
||||
void Sync()
|
||||
{
|
||||
try
|
||||
{
|
||||
LogWrapper.Trace("Config", $"正在保存 {File.FilePath}");
|
||||
foreach (var action in writeActionMap.Values) action();
|
||||
File.Sync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Config", "配置文件保存失败");
|
||||
var summary = Lang.Text("Config.Error.SaveFailed.Message", File.FilePath);
|
||||
var message = ExceptionDetails.Compose(summary, ex);
|
||||
MsgBoxWrapper.Show(
|
||||
message,
|
||||
Lang.Text("Config.Error.SaveFailed.Title"),
|
||||
MsgBoxTheme.Error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void OnStop()
|
||||
{
|
||||
_writeActionCts.Cancel();
|
||||
_writeStopEvent.Wait();
|
||||
_writeStopEvent.Dispose();
|
||||
}
|
||||
|
||||
protected override bool OnAccess<TKey, TValue>(
|
||||
StorageAction action,
|
||||
ref TKey key,
|
||||
[NotNullWhen(true)] ref TValue value,
|
||||
object? argument)
|
||||
{
|
||||
if (key is not string strKey) throw new NotSupportedException($"Key '{key}' is not supported");
|
||||
#pragma warning disable CS8762 // Parameter must have a non-null value when exiting in some condition.
|
||||
switch (action)
|
||||
{
|
||||
case StorageAction.Get:
|
||||
if (!File.Exists(strKey)) return false;
|
||||
try
|
||||
{
|
||||
value = File.Get<TValue>(strKey);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException
|
||||
or InvalidCastException
|
||||
or FormatException
|
||||
or OverflowException
|
||||
or ArgumentException
|
||||
or KeyNotFoundException
|
||||
or InvalidDataException)
|
||||
{
|
||||
LogWrapper.Warn(ex, "Config", $"配置项 {strKey} 读取失败(可能已损坏),重置为默认值");
|
||||
if (!_writeActionChannel.Writer.TryWrite((strKey, () => File.Remove(strKey))))
|
||||
{
|
||||
LogWrapper.Warn("Config", $"配置项 {strKey} 清理任务入队失败,改为同步删除");
|
||||
try
|
||||
{
|
||||
File.Remove(strKey);
|
||||
File.Sync();
|
||||
}
|
||||
catch (Exception cleanupEx)
|
||||
{
|
||||
LogWrapper.Error(cleanupEx, "Config", $"配置项 {strKey} 同步删除失败,可能需人工处理");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
case StorageAction.Exists:
|
||||
// 由于 Exists 的 value 类型一定是 bool,此处可 unsafe 直接赋值
|
||||
if (typeof(TValue) == typeof(bool)) Unsafe.As<TValue, bool>(ref value) = File.Exists(strKey);
|
||||
else throw new InvalidOperationException($"Storage action '{StorageAction.Exists}' must have a boolean value");
|
||||
return true;
|
||||
case StorageAction.Set:
|
||||
var localValue = value;
|
||||
_writeActionChannel.Writer.TryWrite((strKey, () => File.Set(strKey, localValue)));
|
||||
return false;
|
||||
case StorageAction.Delete:
|
||||
_writeActionChannel.Writer.TryWrite((strKey, () => File.Remove(strKey)));
|
||||
return false;
|
||||
default: throw new InvalidOperationException($"Invalid storage action: {action}");
|
||||
}
|
||||
#pragma warning restore CS8762 // Parameter must have a non-null value when exiting in some condition.
|
||||
}
|
||||
|
||||
public override string ToString() => $"{base.ToString()} ({File.FilePath})";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
public interface IEnumerableKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取文件包含的所有键。通常是一个耗时操作,慎用。
|
||||
/// </summary>
|
||||
public IEnumerable<string> Keys { get; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 键值文件模型。
|
||||
/// </summary>
|
||||
public interface IKeyValueFileProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件路径。
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值。
|
||||
/// </summary>
|
||||
public T Get<T>(string key);
|
||||
|
||||
/// <summary>
|
||||
/// 设置一个值。
|
||||
/// </summary>
|
||||
public void Set<T>(string key, T value);
|
||||
|
||||
/// <summary>
|
||||
/// 判断一个值是否存在。
|
||||
/// </summary>
|
||||
public bool Exists(string key);
|
||||
|
||||
/// <summary>
|
||||
/// 移除一个值。
|
||||
/// </summary>
|
||||
public void Remove(string key);
|
||||
|
||||
/// <summary>
|
||||
/// 写入文件。
|
||||
/// </summary>
|
||||
public void Sync();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 提供 JSON 格式的键值文件读写。
|
||||
/// </summary>
|
||||
public class JsonFileProvider : CommonFileProvider, IEnumerableKeyProvider
|
||||
{
|
||||
private readonly JsonObject _rootElement;
|
||||
|
||||
private static readonly JsonDocumentOptions _DocumentOptions = JsonCompat.DocumentOptions;
|
||||
|
||||
private static readonly JsonSerializerOptions _SerializerOptions = new(JsonCompat.SerializerOptions)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
AllowOutOfOrderMetadataProperties = true,
|
||||
};
|
||||
|
||||
private static readonly JsonWriterOptions _WriterOptions = new()
|
||||
{
|
||||
Indented = true
|
||||
};
|
||||
|
||||
public JsonFileProvider(string path) : base(path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
var parseResult = JsonNode.Parse(stream, JsonCompat.NodeOptions, _DocumentOptions);
|
||||
if (parseResult is not JsonObject root)
|
||||
throw new ConfigFileInitException(path,
|
||||
$"Invalid root element type: {parseResult?.GetValueKind().ToString() ?? "Empty"}");
|
||||
_rootElement = root;
|
||||
}
|
||||
else
|
||||
{
|
||||
using var stream = new FileStream(FilePath, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite);
|
||||
_rootElement = new JsonObject();
|
||||
JsonSerializer.Serialize(stream, _rootElement, _SerializerOptions);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is ConfigFileInitException) throw;
|
||||
throw new ConfigFileInitException(path, "Failed to read JSON file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public override T Get<T>(string key)
|
||||
{
|
||||
var result = _rootElement[key];
|
||||
if (result is null) throw new KeyNotFoundException($"Not found: '{key}'");
|
||||
try
|
||||
{
|
||||
var r = result.Deserialize<T>(_SerializerOptions);
|
||||
return r ?? throw GetNullException();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
T fallback;
|
||||
var type = typeof(T);
|
||||
if (type == typeof(string)) fallback = (T)(object)result.ToString();
|
||||
else
|
||||
{
|
||||
var jsonStr = result.Deserialize<string>(_SerializerOptions)!;
|
||||
if (type == typeof(bool)) fallback = (T)(object)(jsonStr.ToLowerInvariant() is "true" or "1");
|
||||
else fallback = JsonSerializer.Deserialize<T>(jsonStr, _SerializerOptions) ?? throw GetNullException();
|
||||
}
|
||||
Set(key, fallback);
|
||||
return fallback;
|
||||
}
|
||||
Exception GetNullException() => new InvalidDataException($"Deserialized value is null: '{key}'");
|
||||
}
|
||||
|
||||
public override void Set<T>(string key, T value)
|
||||
{
|
||||
_rootElement[key] = JsonSerializer.SerializeToNode(value, _SerializerOptions);
|
||||
}
|
||||
|
||||
public override bool Exists(string key)
|
||||
{
|
||||
return _rootElement.ContainsKey(key);
|
||||
}
|
||||
|
||||
public override void Remove(string key)
|
||||
{
|
||||
_rootElement.Remove(key);
|
||||
}
|
||||
|
||||
protected override void WriteToStream(Stream stream)
|
||||
{
|
||||
var writer = new Utf8JsonWriter(stream, _WriterOptions);
|
||||
_rootElement.WriteTo(writer, _SerializerOptions);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
public IEnumerable<string> Keys => _rootElement.Select(pair => pair.Key);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using YamlDotNet.Serialization;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
// Partly generated by gpt-5-mini (20250903)
|
||||
public static class JsonToYamlConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// 从 jsonInput 读取 JSON,转换为 YAML 写入 yamlOutput。
|
||||
/// </summary>
|
||||
/// <param name="jsonInput">可读的 JSON 输入流</param>
|
||||
/// <param name="yamlOutput">可写的 YAML 输出流</param>
|
||||
/// <param name="leaveOpen">是否在返回时保留输出流打开</param>
|
||||
public static void Convert(Stream jsonInput, Stream yamlOutput, bool leaveOpen = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(jsonInput);
|
||||
ArgumentNullException.ThrowIfNull(yamlOutput);
|
||||
if (!jsonInput.CanRead) throw new ArgumentException("must be readable", nameof(jsonInput));
|
||||
if (!yamlOutput.CanWrite) throw new ArgumentException("must be writable", nameof(yamlOutput));
|
||||
|
||||
using var doc = JsonDocument.Parse(jsonInput, JsonCompat.DocumentOptions);
|
||||
var obj = _ConvertElement(doc.RootElement);
|
||||
|
||||
var serializer = new SerializerBuilder().Build();
|
||||
using var writer = new StreamWriter(yamlOutput, new UTF8Encoding(false), 8192, leaveOpen);
|
||||
serializer.Serialize(writer, obj);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步从 jsonInput 读取 JSON,转换为 YAML 写入 yamlOutput。
|
||||
/// </summary>
|
||||
/// <param name="jsonInput">可读的 JSON 输入流</param>
|
||||
/// <param name="yamlOutput">可写的 YAML 输出流</param>
|
||||
/// <param name="leaveOpen">是否在返回时保留输出流打开</param>
|
||||
public static async Task ConvertAsync(Stream jsonInput, Stream yamlOutput, bool leaveOpen = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(jsonInput);
|
||||
ArgumentNullException.ThrowIfNull(yamlOutput);
|
||||
if (!jsonInput.CanRead) throw new ArgumentException("jsonInput must be readable", nameof(jsonInput));
|
||||
if (!yamlOutput.CanWrite) throw new ArgumentException("yamlOutput must be writable", nameof(yamlOutput));
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(jsonInput, JsonCompat.DocumentOptions).ConfigureAwait(false);
|
||||
var obj = _ConvertElement(doc.RootElement);
|
||||
|
||||
var serializer = new SerializerBuilder().Build();
|
||||
await using var streamWriter = new StreamWriter(yamlOutput, new UTF8Encoding(false), 8192, leaveOpen);
|
||||
serializer.Serialize(streamWriter, obj);
|
||||
await streamWriter.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static object? _ConvertElement(JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
{
|
||||
var dict = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
{
|
||||
dict[prop.Name] = _ConvertElement(prop.Value);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
case JsonValueKind.Array:
|
||||
{
|
||||
return element.EnumerateArray().Select(_ConvertElement).ToList();
|
||||
}
|
||||
|
||||
case JsonValueKind.String: return element.GetString();
|
||||
|
||||
case JsonValueKind.Number:
|
||||
{
|
||||
// 尽量保留数值类型:先尝试 Int64,再尝试 decimal(避免浮点精度丢失),最后尝试 double
|
||||
if (element.TryGetInt64(out var l)) return l;
|
||||
|
||||
var raw = element.GetRawText();
|
||||
if (decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) return dec;
|
||||
|
||||
if (element.TryGetDouble(out var d)) return d;
|
||||
|
||||
// 兜底:返回原始文本
|
||||
return raw;
|
||||
}
|
||||
|
||||
case JsonValueKind.True: return true;
|
||||
case JsonValueKind.False: return false;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
case JsonValueKind.Undefined:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using PCL.Core.Logging;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 提供 YAML 格式的键值文件读写。当提供的文件找不到时,将尝试读取其它同名文件并将其转换到 YAML。
|
||||
/// </summary>
|
||||
public class YamlFileProvider : CommonFileProvider, IEnumerableKeyProvider
|
||||
{
|
||||
private readonly YamlMappingNode _rootNode;
|
||||
|
||||
private static readonly IDeserializer _Deserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties().WithEnforceRequiredMembers().Build();
|
||||
|
||||
private static readonly ISerializer _Serializer = new SerializerBuilder()
|
||||
.DisableAliases().Build();
|
||||
|
||||
private static YamlMappingNode? _LoadFile(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
try
|
||||
{
|
||||
var yaml = new YamlStream();
|
||||
yaml.Load(reader);
|
||||
if (yaml.Documents.Count == 0) return [];
|
||||
var rootNode = yaml.Documents[0].RootNode;
|
||||
return rootNode as YamlMappingNode ?? throw new ConfigFileInitException(path, $"Invalid root node type: {rootNode.NodeType}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is ConfigFileInitException) throw;
|
||||
throw new ConfigFileInitException(path, "Failed to load YAML content", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public YamlFileProvider(string path) : base(path)
|
||||
{
|
||||
var rootNode = _LoadFile(path);
|
||||
if (rootNode is not null)
|
||||
{
|
||||
_rootNode = rootNode;
|
||||
return;
|
||||
}
|
||||
try // 尝试从 JSON 和 LTCat-style ini 转换
|
||||
{
|
||||
var jsonPath = Path.Combine(Basics.GetParentPath(path)!, Path.GetFileNameWithoutExtension(path) + ".json");
|
||||
if (File.Exists(jsonPath))
|
||||
{
|
||||
using var jsonStream = new FileStream(jsonPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var yamlStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
|
||||
JsonToYamlConverter.Convert(jsonStream, yamlStream); // yamlStream 会被自动关闭
|
||||
_rootNode = _LoadFile(path)!;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "转换失败,已忽略");
|
||||
}
|
||||
_rootNode = [];
|
||||
}
|
||||
|
||||
public override T Get<T>(string key)
|
||||
{
|
||||
var result = _rootNode.Children[key];
|
||||
var parser = result.ConvertToEventStream().GetParser();
|
||||
try
|
||||
{
|
||||
return _Deserializer.Deserialize<T>(parser);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
var type = typeof(T);
|
||||
var graphStr = result.ToString();
|
||||
var fallback = (type == typeof(bool))
|
||||
? (T)(object)(graphStr.ToLowerInvariant() is "true" or "1")
|
||||
: (T)(object)graphStr;
|
||||
Set(key, fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Set<T>(string key, T value)
|
||||
{
|
||||
var emitter = new YamlNodeEmitter();
|
||||
_Serializer.Serialize(emitter, value);
|
||||
_rootNode.Children[key] = emitter.SingleRootNode;
|
||||
}
|
||||
|
||||
public override bool Exists(string key)
|
||||
{
|
||||
return _rootNode.Children.ContainsKey(key);
|
||||
}
|
||||
|
||||
public override void Remove(string key)
|
||||
{
|
||||
_rootNode.Children.Remove(key);
|
||||
}
|
||||
|
||||
protected override void WriteToStream(Stream stream)
|
||||
{
|
||||
var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
_Serializer.Serialize(writer, _rootNode);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
public IEnumerable<string> Keys => _rootNode.Select(pair => pair.Key.ToString());
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Core.Events;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
// 修改自: https://dotnetfiddle.net/jaG1i1
|
||||
// 来源: https://stackoverflow.com/a/40727087
|
||||
// 原作者: Antoine Aubry (是 YamlDotNet 的作者, 真不懂都造出来了为什么不直接把它写到库里)
|
||||
public static class YamlNodeConverter
|
||||
{
|
||||
public class EventStreamParserAdapter(IEnumerable<ParsingEvent> events) : IParser
|
||||
{
|
||||
private readonly IEnumerator<ParsingEvent> _enumerator = events.GetEnumerator();
|
||||
|
||||
public ParsingEvent Current => _enumerator.Current;
|
||||
|
||||
public bool MoveNext() => _enumerator.MoveNext();
|
||||
}
|
||||
|
||||
public static IParser GetParser(this IEnumerable<ParsingEvent> eventStream)
|
||||
{
|
||||
return new EventStreamParserAdapter(eventStream);
|
||||
}
|
||||
|
||||
public static IEnumerable<ParsingEvent> ConvertToEventStream(this YamlStream stream)
|
||||
{
|
||||
yield return new StreamStart();
|
||||
foreach (var document in stream.Documents)
|
||||
{
|
||||
foreach (var evt in document.ConvertToEventStream())
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
yield return new StreamEnd();
|
||||
}
|
||||
|
||||
public static IEnumerable<ParsingEvent> ConvertToEventStream(this YamlDocument document)
|
||||
{
|
||||
yield return new DocumentStart();
|
||||
foreach (var evt in document.RootNode.ConvertToEventStream())
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
yield return new DocumentEnd(false);
|
||||
}
|
||||
|
||||
public static IEnumerable<ParsingEvent> ConvertToEventStream(this YamlNode node)
|
||||
{
|
||||
if (node is YamlScalarNode scalar)
|
||||
{
|
||||
return _ConvertToEventStream(scalar);
|
||||
}
|
||||
|
||||
if (node is YamlSequenceNode sequence)
|
||||
{
|
||||
return _ConvertToEventStream(sequence);
|
||||
}
|
||||
|
||||
if (node is YamlMappingNode mapping)
|
||||
{
|
||||
return _ConvertToEventStream(mapping);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Unsupported node type: {node.GetType().Name}");
|
||||
}
|
||||
|
||||
private static IEnumerable<ParsingEvent> _ConvertToEventStream(YamlScalarNode scalar)
|
||||
{
|
||||
yield return new Scalar(scalar.Anchor, scalar.Tag, scalar.Value!, scalar.Style, false, false);
|
||||
}
|
||||
|
||||
private static IEnumerable<ParsingEvent> _ConvertToEventStream(YamlSequenceNode sequence)
|
||||
{
|
||||
yield return new SequenceStart(sequence.Anchor, sequence.Tag, false, sequence.Style);
|
||||
foreach (var node in sequence.Children)
|
||||
{
|
||||
foreach (var evt in node.ConvertToEventStream())
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
yield return new SequenceEnd();
|
||||
}
|
||||
|
||||
private static IEnumerable<ParsingEvent> _ConvertToEventStream(YamlMappingNode mapping)
|
||||
{
|
||||
yield return new MappingStart(mapping.Anchor, mapping.Tag, false, mapping.Style);
|
||||
foreach (var pair in mapping.Children)
|
||||
{
|
||||
foreach (var evt in pair.Key.ConvertToEventStream())
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
foreach (var evt in pair.Value.ConvertToEventStream())
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
yield return new MappingEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Core.Events;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace PCL.Core.App.Configuration.Storage;
|
||||
|
||||
// Partly generated by gpt-5 (20250903)
|
||||
public sealed class YamlNodeEmitter : IEmitter
|
||||
{
|
||||
private readonly List<YamlDocument> _documents = [];
|
||||
private readonly Stack<Container> _stack = new();
|
||||
private readonly Dictionary<string, YamlNode> _anchors = new(StringComparer.Ordinal);
|
||||
private bool _inStream;
|
||||
|
||||
public IReadOnlyList<YamlDocument> Documents => _documents;
|
||||
|
||||
public YamlNode SingleRootNode => _documents.Count switch
|
||||
{
|
||||
0 => throw new InvalidOperationException("尚未产生任何文档。"),
|
||||
1 => _documents[0].RootNode,
|
||||
_ => throw new InvalidOperationException("存在多个文档,请使用 Documents 访问。")
|
||||
};
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_documents.Clear();
|
||||
_stack.Clear();
|
||||
_anchors.Clear();
|
||||
_inStream = false;
|
||||
}
|
||||
|
||||
public void Emit(ParsingEvent @event)
|
||||
{
|
||||
switch (@event)
|
||||
{
|
||||
case StreamStart:
|
||||
Reset();
|
||||
_inStream = true;
|
||||
break;
|
||||
|
||||
case StreamEnd:
|
||||
_inStream = false;
|
||||
if (_stack.Count != 0)
|
||||
throw new InvalidOperationException("事件结束时堆栈未清空,事件序列不平衡。");
|
||||
break;
|
||||
|
||||
case DocumentStart:
|
||||
_RequireStream();
|
||||
_stack.Push(Container.Document());
|
||||
break;
|
||||
|
||||
case DocumentEnd:
|
||||
{
|
||||
_RequireStream();
|
||||
if (_stack.Count == 0 || _stack.Peek().Kind != ContainerKind.Document)
|
||||
throw new InvalidOperationException("DocumentEnd 前缺少对应的 DocumentStart。");
|
||||
|
||||
var doc = _stack.Pop();
|
||||
if (doc.Node is null)
|
||||
throw new InvalidOperationException("空文档:未设置根节点。");
|
||||
|
||||
_documents.Add(new YamlDocument(doc.Node));
|
||||
}
|
||||
break;
|
||||
|
||||
case MappingStart mapStart:
|
||||
{
|
||||
var map = new YamlMappingNode();
|
||||
_ApplyAnchor(mapStart, map);
|
||||
_AttachToParent(map);
|
||||
_stack.Push(Container.Mapping(map));
|
||||
}
|
||||
break;
|
||||
|
||||
case MappingEnd:
|
||||
{
|
||||
if (_stack.Count == 0 || _stack.Peek().Kind != ContainerKind.Mapping)
|
||||
throw new InvalidOperationException("MappingEnd 前缺少对应的 MappingStart。");
|
||||
|
||||
var finished = _stack.Pop();
|
||||
// no-op: 已在 Start 时挂接到父级
|
||||
if (finished.PendingKey is not null)
|
||||
throw new InvalidOperationException("映射以键结尾,缺少对应的值。");
|
||||
}
|
||||
break;
|
||||
|
||||
case SequenceStart seqStart:
|
||||
{
|
||||
var seq = new YamlSequenceNode();
|
||||
_ApplyAnchor(seqStart, seq);
|
||||
_AttachToParent(seq);
|
||||
_stack.Push(Container.Sequence(seq));
|
||||
}
|
||||
break;
|
||||
|
||||
case SequenceEnd:
|
||||
{
|
||||
if (_stack.Count == 0 || _stack.Peek().Kind != ContainerKind.Sequence)
|
||||
throw new InvalidOperationException("SequenceEnd 前缺少对应的 SequenceStart。");
|
||||
|
||||
_stack.Pop(); // 已在 Start 时挂接到父级
|
||||
}
|
||||
break;
|
||||
|
||||
case Scalar scalar:
|
||||
{
|
||||
var node = new YamlScalarNode(scalar.Value);
|
||||
_ApplyAnchor(scalar, node);
|
||||
_AttachToParent(node);
|
||||
}
|
||||
break;
|
||||
|
||||
case AnchorAlias alias:
|
||||
{
|
||||
var anchorName = alias.Value.Value; // AnchorName.Value -> string
|
||||
if (!_anchors.TryGetValue(anchorName, out var target))
|
||||
throw new InvalidOperationException($"未找到锚点 '{anchorName}' 的定义。");
|
||||
_AttachToParent(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void _RequireStream()
|
||||
{
|
||||
if (!_inStream)
|
||||
throw new InvalidOperationException("必须在 StreamStart 与 StreamEnd 之间接收事件。");
|
||||
}
|
||||
|
||||
private void _ApplyAnchor(NodeEvent nodeEvent, YamlNode node)
|
||||
{
|
||||
// 仅在存在锚点时登记;Tag/Style 等可按需扩展
|
||||
if (nodeEvent.Anchor.IsEmpty) return;
|
||||
var name = nodeEvent.Anchor.Value;
|
||||
node.Anchor = name;
|
||||
// 最新 YamlDotNet 表示模型允许同名锚点复用同一节点引用;
|
||||
// 若重复定义同名锚点,认为是非法
|
||||
if (!_anchors.TryAdd(name, node))
|
||||
throw new InvalidOperationException($"锚点 '{name}' 被重复定义。");
|
||||
}
|
||||
|
||||
private void _AttachToParent(YamlNode node)
|
||||
{
|
||||
if (_stack.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("缺少 DocumentStart:无法确定根节点所属文档。");
|
||||
}
|
||||
|
||||
var parent = _stack.Peek();
|
||||
switch (parent.Kind)
|
||||
{
|
||||
case ContainerKind.Document:
|
||||
if (parent.Node is not null)
|
||||
throw new InvalidOperationException("一个文档只能包含一个根节点。");
|
||||
parent.Node = node;
|
||||
_stack.Pop();
|
||||
_stack.Push(parent); // 写回修改
|
||||
break;
|
||||
|
||||
case ContainerKind.Sequence:
|
||||
((YamlSequenceNode)parent.Node!).Add(node);
|
||||
break;
|
||||
|
||||
case ContainerKind.Mapping:
|
||||
if (parent.PendingKey is null)
|
||||
{
|
||||
parent.PendingKey = node; // 作为键
|
||||
}
|
||||
else
|
||||
{
|
||||
((YamlMappingNode)parent.Node!).Add(parent.PendingKey, node);
|
||||
parent.PendingKey = null;
|
||||
}
|
||||
_stack.Pop();
|
||||
_stack.Push(parent); // 写回修改
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
private enum ContainerKind { Document, Mapping, Sequence }
|
||||
|
||||
private struct Container
|
||||
{
|
||||
public ContainerKind Kind;
|
||||
public YamlNode? Node;
|
||||
public YamlNode? PendingKey;
|
||||
|
||||
public static Container Document() => new() { Kind = ContainerKind.Document, Node = null, PendingKey = null };
|
||||
public static Container Mapping(YamlMappingNode map) => new() { Kind = ContainerKind.Mapping, Node = map, PendingKey = null };
|
||||
public static Container Sequence(YamlSequenceNode seq) => new() { Kind = ContainerKind.Sequence, Node = seq, PendingKey = null };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user