From a52f3c6fec89068abdcfca46b8b3dc8d47570bdf Mon Sep 17 00:00:00 2001 From: gewuyou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 2 May 2026 21:39:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(godot):=20=E8=A1=A5=E9=BD=90=20GodotLogger?= =?UTF-8?q?=20=E6=8E=A5=E5=85=A5=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GodotLogger 的配置加载、延迟入口和宿主输出适配实现。 - 复制 ai-libs/GodotLogger 的 MIT 许可证到 third-party-licenses/GodotLogger/LICENSE。 - 补充 active tracking 与 trace,保留下一阶段对齐点。 --- .../Logging/GodotLoggerSettingsLoaderTests.cs | 124 +++++++ GFramework.Godot/Logging/DeferredLogger.cs | 311 ++++++++++++++++++ GFramework.Godot/Logging/GodotLog.cs | 103 ++++++ .../Logging/GodotLogConfigurationSource.cs | 150 +++++++++ .../Logging/GodotLoggerSettings.cs | 76 +++++ .../Logging/GodotLoggerSettingsLoader.cs | 176 ++++++++++ ...odot-logging-compliance-polish-tracking.md | 77 +++++ .../godot-logging-compliance-polish-trace.md | 54 +++ third-party-licenses/GodotLogger/LICENSE | 21 ++ 9 files changed, 1092 insertions(+) create mode 100644 GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs create mode 100644 GFramework.Godot/Logging/DeferredLogger.cs create mode 100644 GFramework.Godot/Logging/GodotLog.cs create mode 100644 GFramework.Godot/Logging/GodotLogConfigurationSource.cs create mode 100644 GFramework.Godot/Logging/GodotLoggerSettings.cs create mode 100644 GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs create mode 100644 ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md create mode 100644 ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md create mode 100644 third-party-licenses/GodotLogger/LICENSE diff --git a/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs new file mode 100644 index 00000000..caebf7e3 --- /dev/null +++ b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs @@ -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(StringComparer.Ordinal) + { + ["Game.Services"] = LogLevel.Debug + }); + + Assert.Multiple(() => + { + Assert.That(logger.IsInfoEnabled(), Is.True); + Assert.That(logger.IsDebugEnabled(), Is.False); + }); + } +} diff --git a/GFramework.Godot/Logging/DeferredLogger.cs b/GFramework.Godot/Logging/DeferredLogger.cs new file mode 100644 index 00000000..5507fc75 --- /dev/null +++ b/GFramework.Godot/Logging/DeferredLogger.cs @@ -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 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 writeMessage, + Action writeException) + { + if (exception == null) + { + writeMessage(message); + } + else + { + writeException(message, exception); + } + } +} diff --git a/GFramework.Godot/Logging/GodotLog.cs b/GFramework.Godot/Logging/GodotLog.cs new file mode 100644 index 00000000..7659e547 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLog.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Static Godot logging entry point with auto-discovered configuration and deferred logger creation. +/// +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? _configure; + + private static readonly Lazy LazyConfigurationSource = new( + CreateConfigurationSource, + LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly Lazy LazyProvider = new( + static () => new GodotLoggerFactoryProvider(() => LazyConfigurationSource.Value.CurrentSettings), + LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Applies imperative option overrides before the global Godot logger provider is materialized. + /// + /// The options mutator. + public static void Configure(Action 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; + } + } + + /// + /// Gets the lazily-configured Godot logger provider. + /// + public static ILoggerFactoryProvider Provider + { + get + { + lock (ConfigureLock) + { + return LazyProvider.Value; + } + } + } + + /// + /// Gets the discovered configuration file path, if any. + /// + public static string? ConfigurationPath => LazyConfigurationSource.Value.ConfigurationPath; + + /// + /// Creates a logger for the specified category without materializing the provider until first use. + /// + public static ILogger CreateLogger(string category) + { + ArgumentException.ThrowIfNullOrWhiteSpace(category); + return new DeferredLogger(category, static () => Provider); + } + + /// + /// Creates a logger for the specified type without materializing the provider until first use. + /// + public static ILogger CreateLogger() + { + return CreateLogger(GetCategoryName(typeof(T))); + } + + /// + /// Installs the Godot provider as the current global resolver provider. + /// + 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; + } +} diff --git a/GFramework.Godot/Logging/GodotLogConfigurationSource.cs b/GFramework.Godot/Logging/GodotLogConfigurationSource.cs new file mode 100644 index 00000000..7f2a908e --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogConfigurationSource.cs @@ -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? _configure; + private readonly FileSystemWatcher? _watcher; + private GodotLoggerSettings _currentSettings = GodotLoggerSettings.Default; + + public GodotLogConfigurationSource(Action? 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(options.Colors) + }; + } + + private static IReadOnlyDictionary CopyLoggerLevels( + GodotLoggerSettings settings) + { + var levels = new Dictionary(StringComparer.Ordinal); + foreach (var pair in settings.LoggerLevels) + { + levels[pair.Key] = pair.Value; + } + + return levels; + } +} diff --git a/GFramework.Godot/Logging/GodotLoggerSettings.cs b/GFramework.Godot/Logging/GodotLoggerSettings.cs new file mode 100644 index 00000000..df4b27e7 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLoggerSettings.cs @@ -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 _loggerLevels; + + public static GodotLoggerSettings Default { get; } = new(new GodotLoggerOptions()); + + public GodotLoggerSettings( + GodotLoggerOptions options, + LogLevel? defaultLogLevel = null, + IReadOnlyDictionary? loggerLevels = null) + { + Options = options ?? throw new ArgumentNullException(nameof(options)); + DefaultLogLevel = defaultLogLevel; + _loggerLevels = loggerLevels ?? new Dictionary(StringComparer.Ordinal); + } + + public LogLevel? DefaultLogLevel { get; } + + public GodotLoggerOptions Options { get; } + + public IReadOnlyDictionary 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; + } +} diff --git a/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs b/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs new file mode 100644 index 00000000..a5402a05 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs @@ -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? 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(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(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? LogLevel { get; set; } + } + + private sealed class GodotLogLevelJsonConverter : JsonConverter + { + 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}'.") + }; + } + } +} diff --git a/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md b/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md new file mode 100644 index 00000000..b852408e --- /dev/null +++ b/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md @@ -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 追踪 diff --git a/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md b/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md new file mode 100644 index 00000000..de768d8f --- /dev/null +++ b/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md @@ -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 入口只保留恢复点与风险 diff --git a/third-party-licenses/GodotLogger/LICENSE b/third-party-licenses/GodotLogger/LICENSE new file mode 100644 index 00000000..2d55aadd --- /dev/null +++ b/third-party-licenses/GodotLogger/LICENSE @@ -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.