feat(godot): 补齐 GodotLogger 接入文件

- 新增 GodotLogger 的配置加载、延迟入口和宿主输出适配实现。
- 复制 ai-libs/GodotLogger 的 MIT 许可证到 third-party-licenses/GodotLogger/LICENSE。
- 补充 active tracking 与 trace,保留下一阶段对齐点。
This commit is contained in:
gewuyou 2026-05-02 21:39:26 +08:00
parent 748bb714fb
commit a52f3c6fec
9 changed files with 1092 additions and 0 deletions

View File

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.IO;
using GFramework.Core.Abstractions.Logging;
using GFramework.Godot.Logging;
namespace GFramework.Godot.Tests.Logging;
[TestFixture]
public sealed class GodotLoggerSettingsLoaderTests
{
[Test]
public void DiscoverConfigurationPath_Should_Prefer_EnvironmentVariable_Then_ProcessPath_Then_ProjectPath()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var envPath = Path.Combine(root, "env.json");
File.WriteAllText(envPath, "{}");
var executableDirectory = Path.Combine(root, "bin");
Directory.CreateDirectory(executableDirectory);
var processPath = Path.Combine(executableDirectory, "game.exe");
var executableConfigPath = Path.Combine(executableDirectory, "appsettings.json");
File.WriteAllText(executableConfigPath, "{}");
var projectPath = Path.Combine(root, "project-appsettings.json");
File.WriteAllText(projectPath, "{}");
var discoveredFromEnvironment = GodotLoggerSettingsLoader.DiscoverConfigurationPath(
environmentPath: envPath,
processPath: processPath,
projectPathResolver: _ => projectPath);
var discoveredFromProcess = GodotLoggerSettingsLoader.DiscoverConfigurationPath(
environmentPath: Path.Combine(root, "missing-env.json"),
processPath: processPath,
projectPathResolver: _ => projectPath);
var discoveredFromProject = GodotLoggerSettingsLoader.DiscoverConfigurationPath(
environmentPath: Path.Combine(root, "missing-env.json"),
processPath: Path.Combine(root, "missing", "game.exe"),
projectPathResolver: _ => projectPath);
Assert.That(discoveredFromEnvironment, Is.EqualTo(envPath));
Assert.That(discoveredFromProcess, Is.EqualTo(executableConfigPath));
Assert.That(discoveredFromProject, Is.EqualTo(projectPath));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Test]
public void LoadFromJsonString_Should_Read_GodotLogger_Options_And_Category_Levels()
{
const string json = """
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Game.Services": "Error"
},
"GodotLogger": {
"Mode": "Release",
"DebugMinLevel": "Debug",
"ReleaseMinLevel": "Information",
"DebugOutputTemplate": "[dbg] {message}",
"ReleaseOutputTemplate": "[rel] {message}{properties}",
"Colors": {
"Info": "aqua"
}
}
}
}
""";
var settings = GodotLoggerSettingsLoader.LoadFromJsonString(json);
Assert.Multiple(() =>
{
Assert.That(settings.Options.Mode, Is.EqualTo(GodotLoggerMode.Release));
Assert.That(settings.Options.ReleaseOutputTemplate, Is.EqualTo("[rel] {message}{properties}"));
Assert.That(settings.Options.GetColor(LogLevel.Info), Is.EqualTo("aqua"));
Assert.That(settings.GetEffectiveMinLevel("Game.Services.Inventory", LogLevel.Trace), Is.EqualTo(LogLevel.Error));
Assert.That(settings.GetEffectiveMinLevel("Game.Other", LogLevel.Trace), Is.EqualTo(LogLevel.Warning));
});
}
[Test]
public void Provider_Should_Apply_Updated_Settings_To_Existing_Loggers()
{
var settings = new GodotLoggerSettings(new GodotLoggerOptions
{
Mode = GodotLoggerMode.Debug,
DebugMinLevel = LogLevel.Error,
ReleaseMinLevel = LogLevel.Error
});
var provider = new GodotLoggerFactoryProvider(() => settings);
var logger = provider.CreateLogger("Game.Services.Inventory");
Assert.That(logger.IsInfoEnabled(), Is.False);
settings = new GodotLoggerSettings(
new GodotLoggerOptions
{
Mode = GodotLoggerMode.Debug,
DebugMinLevel = LogLevel.Info,
ReleaseMinLevel = LogLevel.Info
},
defaultLogLevel: LogLevel.Trace,
loggerLevels: new Dictionary<string, LogLevel>(StringComparer.Ordinal)
{
["Game.Services"] = LogLevel.Debug
});
Assert.Multiple(() =>
{
Assert.That(logger.IsInfoEnabled(), Is.True);
Assert.That(logger.IsDebugEnabled(), Is.False);
});
}
}

View File

@ -0,0 +1,311 @@
using System;
using System.Linq;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
internal sealed class DeferredLogger(string category, Func<ILoggerFactoryProvider> providerAccessor) : IStructuredLogger
{
private ILogger? _inner;
private ILogger Inner => _inner ??= ResolveLogger();
public string Name()
{
return Inner.Name();
}
public bool IsTraceEnabled()
{
return Inner.IsTraceEnabled();
}
public bool IsDebugEnabled()
{
return Inner.IsDebugEnabled();
}
public bool IsInfoEnabled()
{
return Inner.IsInfoEnabled();
}
public bool IsWarnEnabled()
{
return Inner.IsWarnEnabled();
}
public bool IsErrorEnabled()
{
return Inner.IsErrorEnabled();
}
public bool IsFatalEnabled()
{
return Inner.IsFatalEnabled();
}
public bool IsEnabledForLevel(LogLevel level)
{
return Inner.IsEnabledForLevel(level);
}
public void Trace(string msg)
{
Inner.Trace(msg);
}
public void Trace(string format, object arg)
{
Inner.Trace(format, arg);
}
public void Trace(string format, object arg1, object arg2)
{
Inner.Trace(format, arg1, arg2);
}
public void Trace(string format, params object[] arguments)
{
Inner.Trace(format, arguments);
}
public void Trace(string msg, Exception t)
{
Inner.Trace(msg, t);
}
public void Debug(string msg)
{
Inner.Debug(msg);
}
public void Debug(string format, object arg)
{
Inner.Debug(format, arg);
}
public void Debug(string format, object arg1, object arg2)
{
Inner.Debug(format, arg1, arg2);
}
public void Debug(string format, params object[] arguments)
{
Inner.Debug(format, arguments);
}
public void Debug(string msg, Exception t)
{
Inner.Debug(msg, t);
}
public void Info(string msg)
{
Inner.Info(msg);
}
public void Info(string format, object arg)
{
Inner.Info(format, arg);
}
public void Info(string format, object arg1, object arg2)
{
Inner.Info(format, arg1, arg2);
}
public void Info(string format, params object[] arguments)
{
Inner.Info(format, arguments);
}
public void Info(string msg, Exception t)
{
Inner.Info(msg, t);
}
public void Warn(string msg)
{
Inner.Warn(msg);
}
public void Warn(string format, object arg)
{
Inner.Warn(format, arg);
}
public void Warn(string format, object arg1, object arg2)
{
Inner.Warn(format, arg1, arg2);
}
public void Warn(string format, params object[] arguments)
{
Inner.Warn(format, arguments);
}
public void Warn(string msg, Exception t)
{
Inner.Warn(msg, t);
}
public void Error(string msg)
{
Inner.Error(msg);
}
public void Error(string format, object arg)
{
Inner.Error(format, arg);
}
public void Error(string format, object arg1, object arg2)
{
Inner.Error(format, arg1, arg2);
}
public void Error(string format, params object[] arguments)
{
Inner.Error(format, arguments);
}
public void Error(string msg, Exception t)
{
Inner.Error(msg, t);
}
public void Fatal(string msg)
{
Inner.Fatal(msg);
}
public void Fatal(string format, object arg)
{
Inner.Fatal(format, arg);
}
public void Fatal(string format, object arg1, object arg2)
{
Inner.Fatal(format, arg1, arg2);
}
public void Fatal(string format, params object[] arguments)
{
Inner.Fatal(format, arguments);
}
public void Fatal(string msg, Exception t)
{
Inner.Fatal(msg, t);
}
public void Log(LogLevel level, string message)
{
LogFallback(level, message, exception: null);
}
public void Log(LogLevel level, string format, object arg)
{
LogFallback(level, string.Format(format, arg), exception: null);
}
public void Log(LogLevel level, string format, object arg1, object arg2)
{
LogFallback(level, string.Format(format, arg1, arg2), exception: null);
}
public void Log(LogLevel level, string format, params object[] arguments)
{
LogFallback(level, string.Format(format, arguments), exception: null);
}
public void Log(LogLevel level, string message, Exception exception)
{
LogFallback(level, message, exception);
}
public void Log(LogLevel level, string message, params (string Key, object? Value)[] properties)
{
if (Inner is IStructuredLogger structuredLogger)
{
structuredLogger.Log(level, message, properties);
return;
}
LogFallback(level, message, exception: null, properties);
}
public void Log(LogLevel level, string message, Exception? exception, params (string Key, object? Value)[] properties)
{
if (Inner is IStructuredLogger structuredLogger)
{
structuredLogger.Log(level, message, exception, properties);
return;
}
LogFallback(level, message, exception, properties);
}
private ILogger ResolveLogger()
{
return providerAccessor().CreateLogger(category);
}
private void LogFallback(
LogLevel level,
string message,
Exception? exception,
params (string Key, object? Value)[] properties)
{
var suffix = properties.Length == 0
? string.Empty
: " | " + string.Join(", ", properties.Select(static property => $"{property.Key}={property.Value}"));
var rendered = message + suffix;
switch (level)
{
case LogLevel.Trace:
WriteFallback(rendered, exception, Inner.Trace, Inner.Trace);
break;
case LogLevel.Debug:
WriteFallback(rendered, exception, Inner.Debug, Inner.Debug);
break;
case LogLevel.Info:
WriteFallback(rendered, exception, Inner.Info, Inner.Info);
break;
case LogLevel.Warning:
WriteFallback(rendered, exception, Inner.Warn, Inner.Warn);
break;
case LogLevel.Error:
WriteFallback(rendered, exception, Inner.Error, Inner.Error);
break;
case LogLevel.Fatal:
WriteFallback(rendered, exception, Inner.Fatal, Inner.Fatal);
break;
default:
throw new ArgumentOutOfRangeException(nameof(level), level, "Unsupported log level.");
}
}
private void LogFallback(LogLevel level, string message, Exception? exception)
{
LogFallback(level, message, exception, []);
}
private static void WriteFallback(
string message,
Exception? exception,
Action<string> writeMessage,
Action<string, Exception> writeException)
{
if (exception == null)
{
writeMessage(message);
}
else
{
writeException(message, exception);
}
}
}

View File

@ -0,0 +1,103 @@
using System;
using System.Threading;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Static Godot logging entry point with auto-discovered configuration and deferred logger creation.
/// </summary>
public static class GodotLog
{
#if NET9_0_OR_GREATER
private static readonly System.Threading.Lock ConfigureLock = new();
#else
private static readonly object ConfigureLock = new();
#endif
private static Action<GodotLoggerOptions>? _configure;
private static readonly Lazy<GodotLogConfigurationSource> LazyConfigurationSource = new(
CreateConfigurationSource,
LazyThreadSafetyMode.ExecutionAndPublication);
private static readonly Lazy<GodotLoggerFactoryProvider> LazyProvider = new(
static () => new GodotLoggerFactoryProvider(() => LazyConfigurationSource.Value.CurrentSettings),
LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>
/// Applies imperative option overrides before the global Godot logger provider is materialized.
/// </summary>
/// <param name="configure">The options mutator.</param>
public static void Configure(Action<GodotLoggerOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
lock (ConfigureLock)
{
if (LazyProvider.IsValueCreated)
{
throw new InvalidOperationException(
"GodotLog.Configure must be called before any GodotLog logger is materialized.");
}
_configure = configure;
}
}
/// <summary>
/// Gets the lazily-configured Godot logger provider.
/// </summary>
public static ILoggerFactoryProvider Provider
{
get
{
lock (ConfigureLock)
{
return LazyProvider.Value;
}
}
}
/// <summary>
/// Gets the discovered configuration file path, if any.
/// </summary>
public static string? ConfigurationPath => LazyConfigurationSource.Value.ConfigurationPath;
/// <summary>
/// Creates a logger for the specified category without materializing the provider until first use.
/// </summary>
public static ILogger CreateLogger(string category)
{
ArgumentException.ThrowIfNullOrWhiteSpace(category);
return new DeferredLogger(category, static () => Provider);
}
/// <summary>
/// Creates a logger for the specified type without materializing the provider until first use.
/// </summary>
public static ILogger CreateLogger<T>()
{
return CreateLogger(GetCategoryName(typeof(T)));
}
/// <summary>
/// Installs the Godot provider as the current global resolver provider.
/// </summary>
public static void UseAsDefaultProvider()
{
LoggerFactoryResolver.Provider = Provider;
}
private static GodotLogConfigurationSource CreateConfigurationSource()
{
lock (ConfigureLock)
{
return new GodotLogConfigurationSource(_configure);
}
}
private static string GetCategoryName(Type type)
{
return type.FullName ?? type.Name;
}
}

View File

@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
internal sealed class GodotLogConfigurationSource : IDisposable
{
private readonly Action<GodotLoggerOptions>? _configure;
private readonly FileSystemWatcher? _watcher;
private GodotLoggerSettings _currentSettings = GodotLoggerSettings.Default;
public GodotLogConfigurationSource(Action<GodotLoggerOptions>? configure)
{
_configure = configure;
ConfigurationPath = GodotLoggerSettingsLoader.DiscoverConfigurationPath();
Reload(throwOnError: true);
_watcher = CreateWatcher(ConfigurationPath);
}
public string? ConfigurationPath { get; }
public GodotLoggerSettings CurrentSettings => Volatile.Read(ref _currentSettings);
public void Dispose()
{
_watcher?.Dispose();
}
private FileSystemWatcher? CreateWatcher(string? configurationPath)
{
if (string.IsNullOrWhiteSpace(configurationPath))
{
return null;
}
var directory = Path.GetDirectoryName(configurationPath);
var fileName = Path.GetFileName(configurationPath);
if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var watcher = new FileSystemWatcher(directory, fileName)
{
EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.CreationTime
| NotifyFilters.FileName
| NotifyFilters.LastWrite
| NotifyFilters.Size
};
watcher.Changed += OnConfigurationChanged;
watcher.Created += OnConfigurationChanged;
watcher.Deleted += OnConfigurationChanged;
watcher.Renamed += OnConfigurationRenamed;
return watcher;
}
private void OnConfigurationChanged(object sender, FileSystemEventArgs e)
{
Reload(throwOnError: false);
}
private void OnConfigurationRenamed(object sender, RenamedEventArgs e)
{
Reload(throwOnError: false);
}
private void Reload(bool throwOnError)
{
try
{
var settings = LoadSettingsWithRetry();
Volatile.Write(ref _currentSettings, settings);
}
catch when (!throwOnError)
{
// Ignore transient parse or file-lock failures during hot reload and keep the last good snapshot.
}
}
private GodotLoggerSettings LoadSettingsWithRetry()
{
Exception? lastError = null;
for (var attempt = 0; attempt < 3; attempt++)
{
try
{
return LoadSettings();
}
catch (IOException ex)
{
lastError = ex;
}
catch (UnauthorizedAccessException ex)
{
lastError = ex;
}
Thread.Sleep(50);
}
throw lastError ?? new InvalidOperationException("Failed to load Godot logging configuration.");
}
private GodotLoggerSettings LoadSettings()
{
var settings = string.IsNullOrWhiteSpace(ConfigurationPath) || !File.Exists(ConfigurationPath)
? GodotLoggerSettings.Default
: GodotLoggerSettingsLoader.LoadFromJsonFile(ConfigurationPath);
if (_configure == null)
{
return settings;
}
var configuredOptions = CloneOptions(settings.Options);
_configure(configuredOptions);
return new GodotLoggerSettings(configuredOptions, settings.DefaultLogLevel, CopyLoggerLevels(settings));
}
private static GodotLoggerOptions CloneOptions(GodotLoggerOptions options)
{
return new GodotLoggerOptions
{
Mode = options.Mode,
DebugMinLevel = options.DebugMinLevel,
ReleaseMinLevel = options.ReleaseMinLevel,
DebugOutputTemplate = options.DebugOutputTemplate,
ReleaseOutputTemplate = options.ReleaseOutputTemplate,
Colors = new Dictionary<GFramework.Core.Abstractions.Logging.LogLevel, string>(options.Colors)
};
}
private static IReadOnlyDictionary<string, LogLevel> CopyLoggerLevels(
GodotLoggerSettings settings)
{
var levels = new Dictionary<string, LogLevel>(StringComparer.Ordinal);
foreach (var pair in settings.LoggerLevels)
{
levels[pair.Key] = pair.Value;
}
return levels;
}
}

View File

@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
internal sealed class GodotLoggerSettings
{
private readonly IReadOnlyDictionary<string, LogLevel> _loggerLevels;
public static GodotLoggerSettings Default { get; } = new(new GodotLoggerOptions());
public GodotLoggerSettings(
GodotLoggerOptions options,
LogLevel? defaultLogLevel = null,
IReadOnlyDictionary<string, LogLevel>? loggerLevels = null)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
DefaultLogLevel = defaultLogLevel;
_loggerLevels = loggerLevels ?? new Dictionary<string, LogLevel>(StringComparer.Ordinal);
}
public LogLevel? DefaultLogLevel { get; }
public GodotLoggerOptions Options { get; }
public IReadOnlyDictionary<string, LogLevel> LoggerLevels => _loggerLevels;
public static GodotLoggerSettings FromOptions(GodotLoggerOptions options)
{
return new GodotLoggerSettings(options);
}
public LogLevel GetEffectiveMinLevel(string categoryName, LogLevel providerMinLevel)
{
ArgumentNullException.ThrowIfNull(categoryName);
var effective = Max(Options.GetEffectiveMinLevel(), providerMinLevel);
var configuredLevel = GetConfiguredMinLevel(categoryName);
return configuredLevel.HasValue ? Max(effective, configuredLevel.Value) : effective;
}
private LogLevel? GetConfiguredMinLevel(string categoryName)
{
if (_loggerLevels.TryGetValue(categoryName, out var exactLevel))
{
return exactLevel;
}
var bestMatchLength = -1;
LogLevel? bestMatchLevel = DefaultLogLevel;
foreach (var pair in _loggerLevels)
{
if (!categoryName.StartsWith(pair.Key + ".", StringComparison.Ordinal))
{
continue;
}
if (pair.Key.Length <= bestMatchLength)
{
continue;
}
bestMatchLength = pair.Key.Length;
bestMatchLevel = pair.Value;
}
return bestMatchLevel;
}
private static LogLevel Max(LogLevel left, LogLevel right)
{
return left > right ? left : right;
}
}

View File

@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using GFramework.Core.Abstractions.Logging;
using Godot;
namespace GFramework.Godot.Logging;
internal static class GodotLoggerSettingsLoader
{
internal const string ConfigEnvironmentVariableName = "GODOT_LOGGER_CONFIG";
private static readonly JsonSerializerOptions JsonOptions = new()
{
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
Converters =
{
new GodotLogLevelJsonConverter(),
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: true)
}
};
public static string? DiscoverConfigurationPath(
string? environmentPath = null,
string? processPath = null,
Func<string, string?>? projectPathResolver = null)
{
var envPath = environmentPath ?? System.Environment.GetEnvironmentVariable(ConfigEnvironmentVariableName);
if (!string.IsNullOrWhiteSpace(envPath) && File.Exists(envPath))
{
return envPath;
}
var resolvedProcessPath = processPath ?? System.Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
{
var executableDirectory = Path.GetDirectoryName(resolvedProcessPath);
if (!string.IsNullOrWhiteSpace(executableDirectory))
{
var candidate = Path.Combine(executableDirectory, "appsettings.json");
if (File.Exists(candidate))
{
return candidate;
}
}
}
var resolver = projectPathResolver ?? SafeGlobalizeProjectPath;
var projectCandidate = resolver("res://appsettings.json");
if (!string.IsNullOrWhiteSpace(projectCandidate) && File.Exists(projectCandidate))
{
return projectCandidate;
}
return null;
}
public static GodotLoggerSettings LoadFromJsonFile(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Configuration file not found: {filePath}", filePath);
}
return LoadFromJsonString(File.ReadAllText(filePath));
}
public static GodotLoggerSettings LoadFromJsonString(string json)
{
ArgumentNullException.ThrowIfNull(json);
var root = JsonSerializer.Deserialize<GodotLoggerSettingsDocument>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize Godot logging configuration.");
var logging = root.Logging;
var options = logging?.GodotLogger ?? new GodotLoggerOptions();
LogLevel? defaultLogLevel = null;
var loggerLevels = new Dictionary<string, LogLevel>(StringComparer.Ordinal);
if (logging?.LogLevel != null)
{
foreach (var pair in logging.LogLevel)
{
if (string.Equals(pair.Key, "Default", StringComparison.OrdinalIgnoreCase))
{
defaultLogLevel = pair.Value;
}
else
{
loggerLevels[pair.Key] = pair.Value;
}
}
}
return new GodotLoggerSettings(options, defaultLogLevel, loggerLevels);
}
private static string? SafeGlobalizeProjectPath(string path)
{
try
{
return ProjectSettings.GlobalizePath(path);
}
catch
{
return null;
}
}
private sealed class GodotLoggerSettingsDocument
{
public LoggingDocument? Logging { get; set; }
}
private sealed class LoggingDocument
{
public GodotLoggerOptions? GodotLogger { get; set; }
public Dictionary<string, LogLevel>? LogLevel { get; set; }
}
private sealed class GodotLogLevelJsonConverter : JsonConverter<LogLevel>
{
public override LogLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numericValue))
{
return (LogLevel)numericValue;
}
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"Unexpected token {reader.TokenType} when parsing {nameof(LogLevel)}.");
}
return Parse(reader.GetString());
}
public override void Write(Utf8JsonWriter writer, LogLevel value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
public override LogLevel ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert,
JsonSerializerOptions options)
{
return Parse(reader.GetString());
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, LogLevel value, JsonSerializerOptions options)
{
writer.WritePropertyName(value.ToString());
}
private static LogLevel Parse(string? value)
{
return value?.Trim() switch
{
"Trace" or "trace" => LogLevel.Trace,
"Debug" or "debug" => LogLevel.Debug,
"Info" or "info" or "Information" or "information" => LogLevel.Info,
"Warning" or "warning" or "Warn" or "warn" => LogLevel.Warning,
"Error" or "error" => LogLevel.Error,
"Fatal" or "fatal" or "Critical" or "critical" => LogLevel.Fatal,
_ => throw new JsonException($"Unsupported log level '{value}'.")
};
}
}
}

View File

@ -0,0 +1,77 @@
# Godot Logging Compliance Polish 跟踪
## 目标
继续把 `GFramework.Godot.Logging` 从“基础可用的 Godot 输出适配”收敛成“对齐 `GodotLogger` 优点、但保持
GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot / Core 日志统一留下清晰恢复点。
## 当前恢复点
- 恢复点编号:`GODOT-LOGGING-COMPLIANCE-POLISH-RP-001`
- 当前阶段:`Phase 1`
- 当前焦点:
- 已补齐 `GodotLog` 静态入口、延迟 logger 解析、配置自动发现与热重载
- 已让 `GodotLoggerFactoryProvider` 对已缓存 logger 生效动态配置,而不是只在新建 logger 时读快照
- 已让 `GodotLogger` 支持 `{properties}` 占位符,并把 `IStructuredLogger` / `LogContext` 属性落到 Godot 输出
- 已兼容 `GodotLogger` 风格配置值,如 `Information` / `Critical`
- 下一轮优先评估是否把 Godot 输出进一步并入 Core 的 appender / formatter / filter 组合管线
## 当前状态摘要
- `GFramework.Core` 仍是主日志框架;`GFramework.Godot` 没有引入第二套业务日志 API
- `GFramework.Godot.Logging` 现在已经补上原 `GodotLogger` 项目最有价值的宿主便利层:
- `GodotLog`
- `DeferredLogger`
- 配置文件自动发现
- 文件热重载
- 结构化属性渲染
- 本轮没有把 `Microsoft.Extensions.Logging``ILoggingBuilder` / `ILoggerProvider` 生态原样搬入 GFramework
- `AbstractLogger` 已支持动态最小级别提供器,为 Godot 配置热更新生效打通基础能力
## 当前活跃事实
- 当前主题由分支 `feat/godot-logging-compliance-polish` 驱动,并已在 `ai-plan/public/README.md` 建立映射
- `ai-libs/GodotLogger` 的 MIT 许可证已复制到 `third-party-licenses/GodotLogger/LICENSE`
- `GodotLog` 当前的配置发现顺序为:
- `GODOT_LOGGER_CONFIG`
- 可执行目录 `appsettings.json`
- `res://appsettings.json`
- `GodotLog.Configure(...)` 仍要求在首次 materialize provider 前调用;延迟 logger 会避免 `static readonly` 字段过早锁死配置
- `GodotLoggerFactoryProvider` 当前按 logger 名称缓存实例,但每次判定级别和写日志都会读取最新 `GodotLoggerSettings`
- Godot 模板默认已扩展为包含 `{properties}`,因此结构化属性和 `LogContext` 会进入渲染结果
- 配置加载兼容两套级别命名:
- GFramework 风格:`Info` / `Fatal`
- `GodotLogger` 风格:`Information` / `Critical`
- 现有设计仍保留 UTC 时间戳语义,没有为了对齐原项目而默认切回本地时间
## 当前风险
- 双入口生命周期风险:如果同一宿主同时混用 `LoggerFactoryResolver.Provider``GodotLog`,需要明确谁是最终默认 provider
- 缓解措施:当前文档与实现都保留 `GodotLog.UseAsDefaultProvider()`,并继续把 `ArchitectureConfiguration` 方式写成默认推荐路径
- Core / Godot 管线分离风险Godot 侧虽然已有热重载与配置发现,但还没有变成 Core 可组合 appender
- 缓解措施下一轮只评估“Godot sink / appender 化”,不再继续扩张独立的 Godot logging 面
- 配置热重载的宿主差异风险Godot 编辑器、导出包和测试宿主的文件系统语义不完全一致
- 缓解措施active 入口先锁定 discovery / reload 语义,后续若遇到平台差异,再用定向回归和文档补充收口
## 活跃文档
- 当前跟踪:[godot-logging-compliance-polish-tracking.md](./godot-logging-compliance-polish-tracking.md)
- 当前 trace[godot-logging-compliance-polish-trace.md](../traces/godot-logging-compliance-polish-trace.md)
## 验证说明
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
- 结果:通过
- 备注:定向新增 Godot logging 配置 / 模板回归共 `11` 项通过
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
- 结果:通过
- 备注Godot 测试项目共 `69` 项通过
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~Logging -nologo`
- 结果:通过
- 备注Core logging 相关测试共 `214` 项通过,覆盖 `AbstractLogger` 动态最小级别改造回归
## 下一步
1. 评估是否需要把 Godot 控制台输出收敛成 Core 可组合 sink / appender而不是继续扩张独立 provider 逻辑
2. 若继续做 Godot logger 能力,优先补真实宿主下的配置 reload / 输出行为回归,而不是再添加新的公开入口
3. 若本轮改动进入 PR后续 review / follow-up 继续写回本 topic而不是另开第二份 Godot logging 追踪

View File

@ -0,0 +1,54 @@
# Godot Logging Compliance Polish 追踪
## 2026-05-02
### 阶段GodotLogger 优点吸收与宿主层补齐RP-001
- 复核 `GodotLogger``GFramework.Godot.Logging` 后确认:
- 模板、颜色、Debug/Release 双模式、类别缩写和 Godot 输出路由已基本吸收
- 真正缺口主要在宿主接入与运行期配置层,而不是输出格式层
- 本轮新建 `godot-logging-compliance-polish` topic并将当前分支
`feat/godot-logging-compliance-polish` 映射到该主题
- 为 `GFramework.Godot.Logging` 新增:
- `GodotLog`
- `DeferredLogger`
- `GodotLogConfigurationSource`
- `GodotLoggerSettings`
- `GodotLoggerSettingsLoader`
- 关键实现决策:
- 不把 `Microsoft.Extensions.Logging` 的 builder / provider 生态整套移植进来
- 保持 `LoggerFactoryResolver``ArchitectureConfiguration` 仍是主接线方式
- 只吸收 `GodotLogger` 里对 GFramework 现有模型真正有价值的部分:
- 配置自动发现
- 热重载
- 延迟 logger 初始化
- 配置命名兼容
- 为让热重载作用于已缓存 logger调整 `AbstractLogger` 支持动态最小级别提供器,并让
`GodotLoggerFactoryProvider` / `GodotLogger` 在写入和级别判定时读取最新设置
- 为让结构化日志在 Godot 侧不再退化成纯字符串,扩展:
- `GodotLogRenderContext`
- `GodotLogTemplate`
- `GodotLoggerOptions`
- `GodotLogger`
使默认模板支持 `{properties}`,并将 `IStructuredLogger` / `LogContext` 属性渲染到输出中
- 为兼容 `GodotLogger` 原项目配置习惯,在 `GodotLoggerSettingsLoader` 中补充枚举解析兼容:
- `Info` / `Information`
- `Fatal` / `Critical`
- `Warn` / `Warning`
- 同步更新 `docs/zh-CN/godot/logging.md`,把文档结论从“只有薄适配层”刷新成“已具备宿主便利层和热重载语义”
- 已从 `ai-libs/GodotLogger` 复制 MIT 许可证到 `third-party-licenses/GodotLogger/LICENSE`
### 验证
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
- 结果通过11/11
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
- 结果通过69/69
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~Logging -nologo`
- 结果通过214/214
### 下一步
1. 若继续推进本主题,优先评估 Godot 输出是否应变成 Core 可组合 appender / sink
2. 若出现后续 review 反馈,直接在本 topic 追加 RP-002而不是重新开临时 local-plan
3. 若本主题阶段性完成,再把详细实现 history 迁入 `archive/`active 入口只保留恢复点与风险

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 gamedo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.