fix(review-followup): 修复Godot安装顺序与日志工厂防御

- 修复 AbstractArchitecture 在锚点未初始化时先执行模块安装的顺序问题,并收紧 GodotYamlConfigEnvironment 的目录枚举异常处理
- 修复 ConfigurableLoggerFactory 对 null 集合、调用方 minLevel 与 AsyncLogAppender 释放路径的处理
- 补充 WeakTypePairCache 与 GodotLocalizationSettingsTests 的 XML 文档,并新增日志工厂回归测试
This commit is contained in:
GeWuYou 2026-04-18 20:07:07 +08:00
parent e3652db030
commit 05de6d1e15
6 changed files with 197 additions and 28 deletions

View File

@ -0,0 +1,117 @@
using System.Reflection;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
using GFramework.Core.Logging.Appenders;
namespace GFramework.Core.Tests.Logging;
/// <summary>
/// 验证可配置 Logger 工厂在配置归一化、级别合并与释放路径上的行为契约。
/// </summary>
[TestFixture]
public sealed class ConfigurableLoggerFactoryTests
{
/// <summary>
/// 验证当反序列化结果把集合字段写成 <see langword="null" /> 时,工厂会将其归一化为空集合而不是抛出空引用异常。
/// </summary>
[Test]
public void CreateFactory_ShouldNormalizeNullCollectionsFromConfiguration()
{
var config = LoggingConfigurationLoader.LoadFromJsonString(
"""
{
"minLevel": "Warning",
"appenders": null,
"loggerLevels": null
}
""");
var factory = LoggingConfigurationLoader.CreateFactory(config);
var logger = factory.GetLogger("TestLogger");
Assert.Multiple(() =>
{
Assert.That(config.Appenders, Is.Not.Null);
Assert.That(config.LoggerLevels, Is.Not.Null);
Assert.That(logger.IsInfoEnabled(), Is.False);
Assert.That(logger.IsWarnEnabled(), Is.True);
});
}
/// <summary>
/// 验证调用方传入的默认最小级别会作为配置级别的下限参与最终 logger 级别计算。
/// </summary>
[Test]
public void GetLogger_ShouldHonorStricterCallerMinLevelWhenNoOverrideMatches()
{
var config = LoggingConfigurationLoader.LoadFromJsonString(
"""
{
"minLevel": "Info",
"appenders": [
{
"type": "Console",
"formatter": "Default",
"useColors": false
}
]
}
""");
var factory = LoggingConfigurationLoader.CreateFactory(config);
var logger = factory.GetLogger("TestLogger", LogLevel.Warning);
Assert.Multiple(() =>
{
Assert.That(logger.IsInfoEnabled(), Is.False);
Assert.That(logger.IsWarnEnabled(), Is.True);
});
}
/// <summary>
/// 验证工厂释放时会兼容释放未实现 <see cref="IDisposable" /> 的 <see cref="AsyncLogAppender" />。
/// </summary>
[Test]
public void Dispose_ShouldDisposeAsyncLogAppenderCreatedFromConfiguration()
{
var config = LoggingConfigurationLoader.LoadFromJsonString(
"""
{
"appenders": [
{
"type": "Async",
"bufferSize": 8,
"innerAppender": {
"type": "Console",
"formatter": "Default",
"useColors": false
}
}
]
}
""");
var factory = LoggingConfigurationLoader.CreateFactory(config);
var logger = factory.GetLogger("AsyncLogger");
var asyncAppender = GetSingleAsyncAppender(factory);
logger.Info("dispose-path");
((IDisposable)factory).Dispose();
Assert.That(asyncAppender.IsCompleted, Is.True);
}
private static AsyncLogAppender GetSingleAsyncAppender(ILoggerFactory factory)
{
var appendersField = factory.GetType().GetField("_appenders", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(appendersField, Is.Not.Null);
var appenders = appendersField!.GetValue(factory) as ILogAppender[];
Assert.That(appenders, Is.Not.Null);
Assert.That(appenders, Has.Length.EqualTo(1));
Assert.That(appenders![0], Is.TypeOf<AsyncLogAppender>());
return (AsyncLogAppender)appenders[0];
}
}

View File

@ -19,7 +19,11 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable
public ConfigurableLoggerFactory(LoggingConfiguration config) public ConfigurableLoggerFactory(LoggingConfiguration config)
{ {
_config = config ?? throw new ArgumentNullException(nameof(config)); _config = config ?? throw new ArgumentNullException(nameof(config));
_appenders = config.Appenders.Select(LoggingConfigurationLoader.CreateAppender).ToArray();
// 反序列化输入可能显式把集合写成 null这里统一归一化为可安全枚举的空集合。
_config.Appenders ??= [];
_config.LoggerLevels ??= new Dictionary<string, LogLevel>(StringComparer.Ordinal);
_appenders = _config.Appenders.Select(LoggingConfigurationLoader.CreateAppender).ToArray();
} }
/// <summary> /// <summary>
@ -34,23 +38,31 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable
foreach (var appender in _appenders) foreach (var appender in _appenders)
{ {
if (appender is IDisposable disposable) switch (appender)
{ {
disposable.Dispose(); case AsyncLogAppender asyncLogAppender:
asyncLogAppender.Dispose();
break;
case IDisposable disposable:
disposable.Dispose();
break;
} }
} }
} }
/// <summary> /// <summary>
/// 为指定名称创建日志记录器,并应用最匹配的命名空间级别配置。 /// 为指定名称创建日志记录器,并应用最匹配的命名空间级别配置。
/// </summary> /// </summary>
/// <param name="name">日志记录器名称。</param> /// <param name="name">日志记录器名称。</param>
/// <param name="minLevel">调用方传入的默认最小级别。</param> /// <param name="minLevel">调用方要求的最小日志级别下限;最终级别不会低于该值。</param>
/// <returns>可写入日志的记录器实例。</returns> /// <returns>可写入日志的记录器实例。</returns>
/// <remarks>
/// 当配置文件与调用方同时提供默认级别时,会取两者中更严格的那一个;
/// 若命中更具体的命名空间级别覆盖,则以该覆盖配置为准。
/// </remarks>
public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info) public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info)
{ {
var effectiveLevel = _config.MinLevel; var effectiveLevel = _config.MinLevel > minLevel ? _config.MinLevel : minLevel;
var bestMatchLength = -1; var bestMatchLength = -1;
foreach (var kvp in _config.LoggerLevels) foreach (var kvp in _config.LoggerLevels)

View File

@ -81,6 +81,9 @@ internal sealed class WeakTypePairCache<TValue>
/// <param name="primaryType">第一段类型键。</param> /// <param name="primaryType">第一段类型键。</param>
/// <param name="secondaryType">第二段类型键。</param> /// <param name="secondaryType">第二段类型键。</param>
/// <returns>当前缓存对象,或 <see langword="null" />。</returns> /// <returns>当前缓存对象,或 <see langword="null" />。</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="primaryType" /> 或 <paramref name="secondaryType" /> 为 <see langword="null" />。
/// </exception>
/// <remarks> /// <remarks>
/// 该入口仅用于测试通过反射观察缓存状态,不应用于运行时代码路径。 /// 该入口仅用于测试通过反射观察缓存状态,不应用于运行时代码路径。
/// </remarks> /// </remarks>

View File

@ -12,6 +12,10 @@ namespace GFramework.Game.Tests.Setting;
[TestFixture] [TestFixture]
public sealed class GodotLocalizationSettingsTests public sealed class GodotLocalizationSettingsTests
{ {
/// <summary>
/// 验证应用英文设置时,会同时同步 Godot locale 与框架语言管理器。
/// </summary>
/// <returns>表示异步断言完成的任务。</returns>
[Test] [Test]
public async Task ApplyAsync_ShouldSyncEnglishToGodotLocaleAndFrameworkLanguage() public async Task ApplyAsync_ShouldSyncEnglishToGodotLocaleAndFrameworkLanguage()
{ {
@ -27,6 +31,10 @@ public sealed class GodotLocalizationSettingsTests
manager.Verify(it => it.SetLanguage("eng"), Times.Once); manager.Verify(it => it.SetLanguage("eng"), Times.Once);
} }
/// <summary>
/// 验证应用简体中文设置时,会同时同步 Godot locale 与框架语言管理器。
/// </summary>
/// <returns>表示异步断言完成的任务。</returns>
[Test] [Test]
public async Task ApplyAsync_ShouldSyncChineseToGodotLocaleAndFrameworkLanguage() public async Task ApplyAsync_ShouldSyncChineseToGodotLocaleAndFrameworkLanguage()
{ {
@ -42,6 +50,10 @@ public sealed class GodotLocalizationSettingsTests
manager.Verify(it => it.SetLanguage("zhs"), Times.Once); manager.Verify(it => it.SetLanguage("zhs"), Times.Once);
} }
/// <summary>
/// 验证未知语言会回退到英文 locale并同步默认框架语言代码。
/// </summary>
/// <returns>表示异步断言完成的任务。</returns>
[Test] [Test]
public async Task ApplyAsync_ShouldFallbackUnknownLanguageToEnglish() public async Task ApplyAsync_ShouldFallbackUnknownLanguageToEnglish()
{ {

View File

@ -106,17 +106,16 @@ public abstract class AbstractArchitecture(
{ {
ArgumentNullException.ThrowIfNull(module); ArgumentNullException.ThrowIfNull(module);
// 先确认锚点可用,避免模块安装产生副作用后再因架构未绑定场景树而失败。
var anchor = _anchor ?? throw new InvalidOperationException("Anchor not initialized");
module.Install(this); module.Install(this);
// 检查锚点是否已初始化,未初始化则抛出异常
if (_anchor == null)
throw new InvalidOperationException("Anchor not initialized");
// 等待锚点准备就绪,并保持 Godot 同步上下文,以便后续附加逻辑安全访问节点 API。 // 等待锚点准备就绪,并保持 Godot 同步上下文,以便后续附加逻辑安全访问节点 API。
await _anchor.WaitUntilReadyAsync(); await anchor.WaitUntilReadyAsync();
// 延迟调用将扩展节点添加为锚点的子节点 // 延迟调用将扩展节点添加为锚点的子节点
_anchor.CallDeferred(Node.MethodName.AddChild, module.Node); anchor.CallDeferred(Node.MethodName.AddChild, module.Node);
// 调用扩展的附加回调方法 // 调用扩展的附加回调方法
module.OnAttach(this); module.OnAttach(this);

View File

@ -106,17 +106,37 @@ internal sealed class GodotYamlConfigEnvironment
{ {
if (!path.IsGodotPath()) if (!path.IsGodotPath())
{ {
if (!Directory.Exists(path)) try
{
if (!Directory.Exists(path))
{
return null;
}
return Directory
.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly)
.Select(static entryPath => new GodotYamlConfigDirectoryEntry(
Path.GetFileName(entryPath),
Directory.Exists(entryPath)))
.ToArray();
}
catch (IOException)
{
// 非 Godot 路径分支与公开契约保持一致:宿主无法访问目录时返回 null而不是泄漏底层异常。
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
catch (ArgumentException)
{
return null;
}
catch (NotSupportedException)
{ {
return null; return null;
} }
return Directory
.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly)
.Select(static entryPath => new GodotYamlConfigDirectoryEntry(
Path.GetFileName(entryPath),
Directory.Exists(entryPath)))
.ToArray();
} }
using var directory = DirAccess.Open(path); using var directory = DirAccess.Open(path);
@ -132,18 +152,24 @@ internal sealed class GodotYamlConfigEnvironment
return null; return null;
} }
while (true) try
{ {
var name = directory.GetNext(); while (true)
if (string.IsNullOrEmpty(name))
{ {
break; var name = directory.GetNext();
if (string.IsNullOrEmpty(name))
{
break;
}
entries.Add(new GodotYamlConfigDirectoryEntry(name, directory.CurrentIsDir()));
} }
entries.Add(new GodotYamlConfigDirectoryEntry(name, directory.CurrentIsDir()));
} }
finally
directory.ListDirEnd(); {
// 目录枚举句柄必须成对结束,避免未来循环体扩展后在异常路径上遗留引擎状态。
directory.ListDirEnd();
}
return entries; return entries;
} }