mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
fix(godot): 收敛日志配置评审问题
- 修复 GodotLog 配置源生命周期、Shutdown 释放与延迟 logger 并发发布问题 - 修复 Godot logger 配置归一化、无效数字级别校验和未知级别颜色回退 - 优化 Godot 日志模板缓存边界、内部文档和 update-namespaces 脚本失败传播 - 补充 Godot logging 回归测试、用户文档与 active ai-plan 恢复记录
This commit is contained in:
parent
a52f3c6fec
commit
b4b3538b21
@ -129,4 +129,14 @@ public sealed class GodotLogTemplateTests
|
||||
|
||||
Assert.That(result, Is.EqualTo("red"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Options_Should_Use_White_Color_When_Level_Is_Not_Defined()
|
||||
{
|
||||
var options = new GodotLoggerOptions();
|
||||
|
||||
var result = options.GetColor((LogLevel)999);
|
||||
|
||||
Assert.That(result, Is.EqualTo("white"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Godot.Logging;
|
||||
|
||||
@ -88,6 +89,50 @@ public sealed class GodotLoggerSettingsLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoadFromJsonString_Should_Normalize_Null_GodotLogger_Options()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Logging": {
|
||||
"GodotLogger": {
|
||||
"DebugOutputTemplate": null,
|
||||
"ReleaseOutputTemplate": null,
|
||||
"Colors": null
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GodotLoggerSettingsLoader.LoadFromJsonString(json);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(settings.Options.DebugOutputTemplate, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(settings.Options.ReleaseOutputTemplate, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(settings.Options.GetColor(LogLevel.Info), Is.EqualTo("white"));
|
||||
Assert.That(settings.Options.GetColor(LogLevel.Error), Is.EqualTo("red"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoadFromJsonString_Should_Reject_Invalid_Numeric_LogLevel()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": 999
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var error = Assert.Throws<JsonException>(() => GodotLoggerSettingsLoader.LoadFromJsonString(json));
|
||||
|
||||
Assert.That(error?.Message, Does.Contain("Unsupported numeric LogLevel value '999'"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Provider_Should_Apply_Updated_Settings_To_Existing_Loggers()
|
||||
{
|
||||
|
||||
@ -1,14 +1,39 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Defers resolving the real Godot logger until the first logging operation needs it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This wrapper allows static logger fields to be created before <see cref="GodotLog.Configure"/> or
|
||||
/// <see cref="GodotLog.UseAsDefaultProvider"/> runs. The resolved inner logger is published with an atomic compare
|
||||
/// exchange so concurrent first-use calls converge on one cached instance without relying on the non-atomic
|
||||
/// null-coalescing assignment pattern.
|
||||
/// </remarks>
|
||||
internal sealed class DeferredLogger(string category, Func<ILoggerFactoryProvider> providerAccessor) : IStructuredLogger
|
||||
{
|
||||
private ILogger? _inner;
|
||||
|
||||
private ILogger Inner => _inner ??= ResolveLogger();
|
||||
private ILogger Inner
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = Volatile.Read(ref _inner);
|
||||
if (current != null)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var createdLogger = ResolveLogger();
|
||||
|
||||
// Multiple callers can resolve concurrently; only one publishes the cached reference.
|
||||
return Interlocked.CompareExchange(ref _inner, createdLogger, null) ?? createdLogger;
|
||||
}
|
||||
}
|
||||
|
||||
public string Name()
|
||||
{
|
||||
|
||||
@ -34,10 +34,10 @@ public static class GodotLog
|
||||
|
||||
lock (ConfigureLock)
|
||||
{
|
||||
if (LazyProvider.IsValueCreated)
|
||||
if (LazyProvider.IsValueCreated || LazyConfigurationSource.IsValueCreated)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"GodotLog.Configure must be called before any GodotLog logger is materialized.");
|
||||
"GodotLog.Configure must be called before any GodotLog provider or configuration source is materialized.");
|
||||
}
|
||||
|
||||
_configure = configure;
|
||||
@ -59,9 +59,16 @@ public static class GodotLog
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the discovered configuration file path, if any.
|
||||
/// Gets the discovered configuration file path, if any, without materializing the global configuration source.
|
||||
/// </summary>
|
||||
public static string? ConfigurationPath => LazyConfigurationSource.Value.ConfigurationPath;
|
||||
/// <remarks>
|
||||
/// This property is safe for diagnostics before <see cref="Configure"/> runs. When the source is not created
|
||||
/// yet, it performs discovery directly instead of touching <c>LazyConfigurationSource.Value</c>, so callers do
|
||||
/// not accidentally lock in the default options before configuring <see cref="Provider"/>.
|
||||
/// </remarks>
|
||||
public static string? ConfigurationPath => LazyConfigurationSource.IsValueCreated
|
||||
? LazyConfigurationSource.Value.ConfigurationPath
|
||||
: GodotLoggerSettingsLoader.DiscoverConfigurationPath();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a logger for the specified category without materializing the provider until first use.
|
||||
@ -88,6 +95,23 @@ public static class GodotLog
|
||||
LoggerFactoryResolver.Provider = Provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the file watcher owned by the materialized configuration source, if the source has been created.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Godot hosts often keep process-wide logging for the whole game lifetime. Dedicated servers and tests can call
|
||||
/// this method during teardown to release the watcher handle deterministically. The static lazy source is not
|
||||
/// reset; later logger usage continues with the last published settings snapshot but no longer receives reload
|
||||
/// notifications from the disposed watcher.
|
||||
/// </remarks>
|
||||
public static void Shutdown()
|
||||
{
|
||||
if (LazyConfigurationSource.IsValueCreated)
|
||||
{
|
||||
LazyConfigurationSource.Value.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static GodotLogConfigurationSource CreateConfigurationSource()
|
||||
{
|
||||
lock (ConfigureLock)
|
||||
|
||||
@ -6,29 +6,72 @@ using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Owns discovery, loading, hot reload, and publication of the current Godot logger settings snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Construction follows a fixed lifecycle: discover the configuration path, perform an initial strict load, then
|
||||
/// subscribe a <see cref="FileSystemWatcher"/> when a concrete file exists. <see cref="CurrentSettings"/> is
|
||||
/// published through <see cref="Volatile"/> so cached loggers can read a last-good immutable snapshot without
|
||||
/// locking. Hot reload keeps the previous settings when a transient parse or file-system error occurs.
|
||||
/// </remarks>
|
||||
internal sealed class GodotLogConfigurationSource : IDisposable
|
||||
{
|
||||
private readonly Action<GodotLoggerOptions>? _configure;
|
||||
private readonly FileSystemWatcher? _watcher;
|
||||
private GodotLoggerSettings _currentSettings = GodotLoggerSettings.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the configuration source and starts watching the discovered file when one is available.
|
||||
/// </summary>
|
||||
/// <param name="configure">Optional imperative option overrides applied after file settings are loaded.</param>
|
||||
/// <exception cref="IOException">Thrown during initial loading when the configuration file cannot be read.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">Thrown during initial loading when the configuration file is locked.</exception>
|
||||
/// <remarks>
|
||||
/// Initial loading uses retry/backoff and propagates the final error because startup configuration failures should
|
||||
/// be visible. Watcher callbacks use the hot-reload path and preserve the previous snapshot on failure.
|
||||
/// </remarks>
|
||||
public GodotLogConfigurationSource(Action<GodotLoggerOptions>? configure)
|
||||
{
|
||||
_configure = configure;
|
||||
|
||||
// Discovery is done before the first strict reload so startup reports invalid files immediately.
|
||||
ConfigurationPath = GodotLoggerSettingsLoader.DiscoverConfigurationPath();
|
||||
Reload(throwOnError: true);
|
||||
_watcher = CreateWatcher(ConfigurationPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the discovered configuration file path, or null when no supported location contains a file.
|
||||
/// </summary>
|
||||
public string? ConfigurationPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last successfully loaded settings snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The snapshot is read through <c>Volatile.Read</c> so logger instances running on other
|
||||
/// threads observe settings published by reload callbacks without taking the configuration lock.
|
||||
/// </remarks>
|
||||
public GodotLoggerSettings CurrentSettings => Volatile.Read(ref _currentSettings);
|
||||
|
||||
/// <summary>
|
||||
/// Stops the file watcher before the source is abandoned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disposal does not clear <see cref="CurrentSettings"/>; existing loggers can continue using the last published
|
||||
/// snapshot after watcher notifications have been stopped.
|
||||
/// </remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the watcher that drives hot reload for the discovered configuration file.
|
||||
/// </summary>
|
||||
/// <param name="configurationPath">The configuration file to watch.</param>
|
||||
/// <returns>A configured watcher, or null when no stable directory and file name can be resolved.</returns>
|
||||
private FileSystemWatcher? CreateWatcher(string? configurationPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configurationPath))
|
||||
@ -43,6 +86,7 @@ internal sealed class GodotLogConfigurationSource : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
// FileSystemWatcher raises callbacks on thread-pool threads; callbacks keep reload work short and non-blocking.
|
||||
var watcher = new FileSystemWatcher(directory, fileName)
|
||||
{
|
||||
EnableRaisingEvents = true,
|
||||
@ -69,11 +113,17 @@ internal sealed class GodotLogConfigurationSource : IDisposable
|
||||
Reload(throwOnError: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads settings and publishes them when loading succeeds.
|
||||
/// </summary>
|
||||
/// <param name="throwOnError">Whether load errors should escape to the caller.</param>
|
||||
private void Reload(bool throwOnError)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = LoadSettingsWithRetry();
|
||||
var settings = throwOnError ? LoadSettingsWithRetry() : LoadSettings();
|
||||
|
||||
// Volatile publication gives cached loggers a coherent replacement snapshot without per-log locks.
|
||||
Volatile.Write(ref _currentSettings, settings);
|
||||
}
|
||||
catch when (!throwOnError)
|
||||
@ -82,6 +132,11 @@ internal sealed class GodotLogConfigurationSource : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads settings with short retry/backoff for startup races with file writers or deployment tools.
|
||||
/// </summary>
|
||||
/// <returns>The loaded settings snapshot.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no retry produced a usable settings snapshot.</exception>
|
||||
private GodotLoggerSettings LoadSettingsWithRetry()
|
||||
{
|
||||
Exception? lastError = null;
|
||||
@ -101,12 +156,20 @@ internal sealed class GodotLogConfigurationSource : IDisposable
|
||||
lastError = ex;
|
||||
}
|
||||
|
||||
Thread.Sleep(50);
|
||||
if (attempt < 2)
|
||||
{
|
||||
// Startup can race with a writer finishing appsettings.json; keep the retry bounded and deterministic.
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new InvalidOperationException("Failed to load Godot logging configuration.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads settings from disk or defaults, then applies imperative overrides.
|
||||
/// </summary>
|
||||
/// <returns>The settings snapshot to publish.</returns>
|
||||
private GodotLoggerSettings LoadSettings()
|
||||
{
|
||||
var settings = string.IsNullOrWhiteSpace(ConfigurationPath) || !File.Exists(ConfigurationPath)
|
||||
@ -120,9 +183,17 @@ internal sealed class GodotLogConfigurationSource : IDisposable
|
||||
|
||||
var configuredOptions = CloneOptions(settings.Options);
|
||||
_configure(configuredOptions);
|
||||
return new GodotLoggerSettings(configuredOptions, settings.DefaultLogLevel, CopyLoggerLevels(settings));
|
||||
return new GodotLoggerSettings(
|
||||
configuredOptions.CreateNormalizedCopy(),
|
||||
settings.DefaultLogLevel,
|
||||
CopyLoggerLevels(settings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mutable options copy before user overrides are applied.
|
||||
/// </summary>
|
||||
/// <param name="options">The options from the file or default settings.</param>
|
||||
/// <returns>A normalized mutable copy.</returns>
|
||||
private static GodotLoggerOptions CloneOptions(GodotLoggerOptions options)
|
||||
{
|
||||
return new GodotLoggerOptions
|
||||
@ -132,10 +203,17 @@ internal sealed class GodotLogConfigurationSource : IDisposable
|
||||
ReleaseMinLevel = options.ReleaseMinLevel,
|
||||
DebugOutputTemplate = options.DebugOutputTemplate,
|
||||
ReleaseOutputTemplate = options.ReleaseOutputTemplate,
|
||||
Colors = new Dictionary<GFramework.Core.Abstractions.Logging.LogLevel, string>(options.Colors)
|
||||
};
|
||||
Colors = options.Colors is { } colors
|
||||
? new Dictionary<GFramework.Core.Abstractions.Logging.LogLevel, string>(colors)
|
||||
: []
|
||||
}.CreateNormalizedCopy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies category log level overrides into an ordinal dictionary.
|
||||
/// </summary>
|
||||
/// <param name="settings">The source settings snapshot.</param>
|
||||
/// <returns>A copy that preserves exact and prefix matching semantics.</returns>
|
||||
private static IReadOnlyDictionary<string, LogLevel> CopyLoggerLevels(
|
||||
GodotLoggerSettings settings)
|
||||
{
|
||||
|
||||
@ -3,6 +3,15 @@ using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Carries the already-resolved values that <see cref="GodotLogTemplate"/> needs to render one log line.
|
||||
/// </summary>
|
||||
/// <param name="Timestamp">The UTC timestamp assigned to the log entry.</param>
|
||||
/// <param name="Level">The severity level used for filtering, formatting, and Godot debug routing.</param>
|
||||
/// <param name="Category">The source logger category name.</param>
|
||||
/// <param name="Message">The formatted log message body.</param>
|
||||
/// <param name="Color">The Godot BBCode color name resolved for <paramref name="Level"/>.</param>
|
||||
/// <param name="Properties">The preformatted structured property suffix, or an empty string when none exist.</param>
|
||||
internal readonly record struct GodotLogRenderContext(
|
||||
DateTime Timestamp,
|
||||
LogLevel Level,
|
||||
|
||||
@ -3,15 +3,41 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Parses and renders Godot logger output templates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Supported placeholders include <c>{timestamp}</c>, <c>{timestamp:format}</c>, <c>{level}</c>,
|
||||
/// <c>{level:u3}</c>, <c>{level:l3}</c>, <c>{level:padded}</c>, <c>{category}</c>,
|
||||
/// <c>{category:lN}</c>, <c>{category:rN}</c>, <c>{color}</c>, <c>{message}</c>, and
|
||||
/// <c>{properties}</c>. Unknown placeholders are rendered back as <c>{key}</c> so configuration mistakes stay
|
||||
/// visible instead of silently deleting text. Parsed templates and category formatting results use bounded
|
||||
/// concurrent caches to avoid unbounded growth across hot reloads or dynamic category names.
|
||||
/// </remarks>
|
||||
internal sealed class GodotLogTemplate
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, GodotLogTemplate> Cache = new(StringComparer.Ordinal);
|
||||
/// <summary>
|
||||
/// Caches parsed template instances by the raw template text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cache is process-wide because templates are immutable after parsing. It is bounded so repeated hot reloads
|
||||
/// with unique template strings cannot grow memory without limit.
|
||||
/// </remarks>
|
||||
private static readonly BoundedCache<GodotLogTemplate> Cache = new(maxEntries: 256);
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _categoryCache = new(StringComparer.Ordinal);
|
||||
/// <summary>
|
||||
/// Caches formatted category names for this template instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Category formatting depends on the template segment and category name. The per-template cache is bounded to
|
||||
/// protect long-running hosts that create loggers with dynamic category names.
|
||||
/// </remarks>
|
||||
private readonly BoundedCache<string> _categoryCache = new(maxEntries: 1024);
|
||||
private readonly int _literalLength;
|
||||
private readonly Action<StringBuilder, GodotLogRenderContext>[] _segments;
|
||||
|
||||
@ -20,12 +46,22 @@ internal sealed class GodotLogTemplate
|
||||
(_segments, _literalLength) = ParseCore(template);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses or retrieves a cached template.
|
||||
/// </summary>
|
||||
/// <param name="template">The template text.</param>
|
||||
/// <returns>An immutable parsed template.</returns>
|
||||
public static GodotLogTemplate Parse(string template)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
return Cache.GetOrAdd(template, static value => new GodotLogTemplate(value));
|
||||
return Cache.GetOrAdd(template, () => new GodotLogTemplate(template));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the template against a concrete log context.
|
||||
/// </summary>
|
||||
/// <param name="context">The resolved values for the log entry.</param>
|
||||
/// <returns>The rendered Godot log line.</returns>
|
||||
public string Render(GodotLogRenderContext context)
|
||||
{
|
||||
var builder = new StringBuilder(_literalLength + context.Category.Length + context.Message.Length + 48);
|
||||
@ -37,6 +73,11 @@ internal sealed class GodotLogTemplate
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts template text into literal and placeholder render segments.
|
||||
/// </summary>
|
||||
/// <param name="template">The template text to parse.</param>
|
||||
/// <returns>The render segments and total literal length used to size the output builder.</returns>
|
||||
private (Action<StringBuilder, GodotLogRenderContext>[] Segments, int LiteralLength) ParseCore(string template)
|
||||
{
|
||||
var segments = new List<Action<StringBuilder, GodotLogRenderContext>>();
|
||||
@ -45,6 +86,7 @@ internal sealed class GodotLogTemplate
|
||||
|
||||
while (position < template.Length)
|
||||
{
|
||||
// The parser is deliberately small: scan literal runs, then turn balanced placeholders into delegates.
|
||||
var open = template.IndexOf('{', position);
|
||||
if (open < 0)
|
||||
{
|
||||
@ -79,10 +121,16 @@ internal sealed class GodotLogTemplate
|
||||
}
|
||||
|
||||
literalLength += literal.Length;
|
||||
// Capturing the literal string once avoids reparsing or slicing it on each rendered log entry.
|
||||
segments.Add((builder, _) => builder.Append(literal));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the render delegate for one placeholder key.
|
||||
/// </summary>
|
||||
/// <param name="key">The placeholder name and optional format suffix.</param>
|
||||
/// <returns>A delegate that appends the placeholder value.</returns>
|
||||
private Action<StringBuilder, GodotLogRenderContext> CreateSegment(string key)
|
||||
{
|
||||
return key switch
|
||||
@ -98,10 +146,16 @@ internal sealed class GodotLogTemplate
|
||||
not null when key.StartsWith("category:", StringComparison.Ordinal) => CreateCategorySegment(key[9..]),
|
||||
not null when key.StartsWith("level:", StringComparison.Ordinal) => CreateLevelSegment(key[6..]),
|
||||
not null when key.StartsWith("timestamp:", StringComparison.Ordinal) => CreateTimestampSegment(key[10..]),
|
||||
// Preserve unknown placeholders so configuration errors are visible in the rendered log line.
|
||||
_ => (builder, _) => builder.Append('{').Append(key).Append('}')
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the render delegate for a timestamp placeholder.
|
||||
/// </summary>
|
||||
/// <param name="format">The optional .NET timestamp format.</param>
|
||||
/// <returns>A delegate that appends the formatted timestamp using invariant culture.</returns>
|
||||
private Action<StringBuilder, GodotLogRenderContext> CreateTimestampSegment(string format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
@ -114,6 +168,11 @@ internal sealed class GodotLogTemplate
|
||||
return (builder, context) => builder.Append(context.Timestamp.ToString(format, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the render delegate for a level placeholder.
|
||||
/// </summary>
|
||||
/// <param name="format">The level format, such as <c>u3</c>, <c>l3</c>, or <c>padded</c>.</param>
|
||||
/// <returns>A delegate that appends the formatted level.</returns>
|
||||
private static Action<StringBuilder, GodotLogRenderContext> CreateLevelSegment(string format)
|
||||
{
|
||||
return format switch
|
||||
@ -125,6 +184,11 @@ internal sealed class GodotLogTemplate
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the render delegate for a category placeholder.
|
||||
/// </summary>
|
||||
/// <param name="format">The category alignment format, such as <c>l16</c> or <c>r32</c>.</param>
|
||||
/// <returns>A delegate that appends the category with optional abbreviation and padding.</returns>
|
||||
private Action<StringBuilder, GodotLogRenderContext> CreateCategorySegment(string format)
|
||||
{
|
||||
if (format.Length < 2)
|
||||
@ -148,16 +212,31 @@ internal sealed class GodotLogTemplate
|
||||
: (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats and caches one category for a category alignment segment.
|
||||
/// </summary>
|
||||
/// <param name="category">The full category name.</param>
|
||||
/// <param name="format">The original segment format used as part of the cache key.</param>
|
||||
/// <param name="width">The desired category width.</param>
|
||||
/// <param name="padLeft">Whether the result is left-padded instead of right-padded.</param>
|
||||
/// <returns>The abbreviated and padded category string.</returns>
|
||||
private string GetFormattedCategory(string category, string format, int width, bool padLeft)
|
||||
{
|
||||
// Include the format in the key because the same category can render differently per width and alignment.
|
||||
var cacheKey = string.Concat(format, "\0", category);
|
||||
return _categoryCache.GetOrAdd(cacheKey, _ =>
|
||||
return _categoryCache.GetOrAdd(cacheKey, () =>
|
||||
{
|
||||
var abbreviated = AbbreviateCategory(category, width);
|
||||
return padLeft ? abbreviated.PadLeft(width) : abbreviated.PadRight(width);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Abbreviates dotted category names to fit a target width.
|
||||
/// </summary>
|
||||
/// <param name="category">The category to abbreviate.</param>
|
||||
/// <param name="maxLength">The maximum rendered length.</param>
|
||||
/// <returns>The category shortened by initials, dropped prefixes, or final-segment truncation.</returns>
|
||||
private static string AbbreviateCategory(string category, int maxLength)
|
||||
{
|
||||
if (category.Length <= maxLength)
|
||||
@ -173,6 +252,7 @@ internal sealed class GodotLogTemplate
|
||||
|
||||
for (var i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
// Collapse namespace-like prefixes first so the most specific final segment remains readable.
|
||||
if (parts[i].Length > 1)
|
||||
{
|
||||
parts[i] = parts[i][..1];
|
||||
@ -195,6 +275,12 @@ internal sealed class GodotLogTemplate
|
||||
return last.Length > maxLength ? last[..maxLength] : last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a level to its three-character form.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to format.</param>
|
||||
/// <param name="upper">Whether the result should use uppercase letters.</param>
|
||||
/// <returns>A three-character level label, or <c>unk</c> for undefined enum values.</returns>
|
||||
private static string ToShortLevel(LogLevel level, bool upper)
|
||||
{
|
||||
var value = level switch
|
||||
@ -211,6 +297,11 @@ internal sealed class GodotLogTemplate
|
||||
return upper ? value.ToUpperInvariant() : value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a level to the fixed-width historical Godot logger label.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to format.</param>
|
||||
/// <returns>A padded level label, or <see cref="object.ToString"/> output for undefined enum values.</returns>
|
||||
private static string ToPaddedLevel(LogLevel level)
|
||||
{
|
||||
return level switch
|
||||
@ -224,4 +315,60 @@ internal sealed class GodotLogTemplate
|
||||
_ => level.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class BoundedCache<TValue>
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry<TValue>> _entries = new(StringComparer.Ordinal);
|
||||
private readonly int _maxEntries;
|
||||
private long _sequence;
|
||||
|
||||
internal BoundedCache(int maxEntries)
|
||||
{
|
||||
_maxEntries = maxEntries;
|
||||
}
|
||||
|
||||
internal TValue GetOrAdd(string key, Func<TValue> valueFactory)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing.Value;
|
||||
}
|
||||
|
||||
var created = new CacheEntry<TValue>(valueFactory(), Interlocked.Increment(ref _sequence));
|
||||
var stored = _entries.GetOrAdd(key, created);
|
||||
if (stored.Sequence == created.Sequence)
|
||||
{
|
||||
Trim();
|
||||
}
|
||||
|
||||
return stored.Value;
|
||||
}
|
||||
|
||||
private void Trim()
|
||||
{
|
||||
while (_entries.Count > _maxEntries)
|
||||
{
|
||||
var oldestKey = string.Empty;
|
||||
var oldestSequence = long.MaxValue;
|
||||
|
||||
foreach (var pair in _entries)
|
||||
{
|
||||
if (pair.Value.Sequence >= oldestSequence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
oldestKey = pair.Key;
|
||||
oldestSequence = pair.Value.Sequence;
|
||||
}
|
||||
|
||||
if (oldestSequence == long.MaxValue || !_entries.TryRemove(oldestKey, out _))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CacheEntry<TValue>(TValue Value, long Sequence);
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ namespace GFramework.Godot.Logging;
|
||||
/// </summary>
|
||||
public sealed class GodotLogger : AbstractLogger
|
||||
{
|
||||
private readonly Func<LogLevel> _minLevelProvider;
|
||||
private readonly Func<GodotLoggerOptions> _optionsProvider;
|
||||
|
||||
/// <summary>
|
||||
@ -37,9 +36,10 @@ public sealed class GodotLogger : AbstractLogger
|
||||
public GodotLogger(string? name, GodotLoggerOptions options)
|
||||
: this(
|
||||
name ?? RootLoggerName,
|
||||
() => options ?? throw new ArgumentNullException(nameof(options)),
|
||||
() => (options ?? throw new ArgumentNullException(nameof(options))).GetEffectiveMinLevel())
|
||||
() => options,
|
||||
() => options.GetEffectiveMinLevel())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
}
|
||||
|
||||
internal GodotLogger(
|
||||
@ -49,7 +49,6 @@ public sealed class GodotLogger : AbstractLogger
|
||||
: base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider)))
|
||||
{
|
||||
_optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider));
|
||||
_minLevelProvider = minLevelProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -68,7 +67,7 @@ public sealed class GodotLogger : AbstractLogger
|
||||
/// </summary>
|
||||
public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties)
|
||||
{
|
||||
if (level < _minLevelProvider())
|
||||
if (!IsEnabled(level))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -85,7 +84,7 @@ public sealed class GodotLogger : AbstractLogger
|
||||
Exception? exception,
|
||||
params (string Key, object? Value)[] properties)
|
||||
{
|
||||
if (level < _minLevelProvider())
|
||||
if (!IsEnabled(level))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@ -82,19 +82,39 @@ public sealed class GodotLoggerOptions
|
||||
/// <returns>The Godot named color.</returns>
|
||||
public string GetColor(LogLevel level)
|
||||
{
|
||||
if (Colors.TryGetValue(level, out var color) && !string.IsNullOrWhiteSpace(color))
|
||||
if (Colors is { } colors && colors.TryGetValue(level, out var color) && !string.IsNullOrWhiteSpace(color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
return DefaultColors[level];
|
||||
return DefaultColors.TryGetValue(level, out var fallback) ? fallback : "white";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active minimum level for the current <see cref="Mode"/>.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see cref="DebugMinLevel"/> when <see cref="Mode"/> is <see cref="GodotLoggerMode.Debug"/>; otherwise
|
||||
/// <see cref="ReleaseMinLevel"/>.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Factories use this value as the option-level floor before category-specific settings are applied.
|
||||
/// </remarks>
|
||||
internal LogLevel GetEffectiveMinLevel()
|
||||
{
|
||||
return Mode == GodotLoggerMode.Debug ? DebugMinLevel : ReleaseMinLevel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy whose debug and release floors are at least <paramref name="minLevel"/>.
|
||||
/// </summary>
|
||||
/// <param name="minLevel">The minimum level that both mode-specific floors must satisfy.</param>
|
||||
/// <returns>A normalized copy with stricter or equal mode-specific minimum levels.</returns>
|
||||
/// <remarks>
|
||||
/// The operation can raise <see cref="DebugMinLevel"/> and <see cref="ReleaseMinLevel"/> through
|
||||
/// <see cref="Max(LogLevel, LogLevel)"/>, but it never lowers them. <see cref="DebugOutputTemplate"/>,
|
||||
/// <see cref="ReleaseOutputTemplate"/>, and <see cref="Colors"/> are preserved through a defensive copy.
|
||||
/// </remarks>
|
||||
internal GodotLoggerOptions WithMinimumLevelFloor(LogLevel minLevel)
|
||||
{
|
||||
return new GodotLoggerOptions
|
||||
@ -104,7 +124,35 @@ public sealed class GodotLoggerOptions
|
||||
ReleaseMinLevel = Max(ReleaseMinLevel, minLevel),
|
||||
DebugOutputTemplate = DebugOutputTemplate,
|
||||
ReleaseOutputTemplate = ReleaseOutputTemplate,
|
||||
Colors = new Dictionary<LogLevel, string>(Colors)
|
||||
Colors = CopyColorsWithDefaults(Colors)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy that replaces missing templates or color mappings with safe defaults.
|
||||
/// </summary>
|
||||
/// <returns>A normalized copy suitable for runtime rendering.</returns>
|
||||
/// <remarks>
|
||||
/// JSON input can set <see cref="DebugOutputTemplate"/>, <see cref="ReleaseOutputTemplate"/>, or
|
||||
/// <see cref="Colors"/> to null even though the public API treats them as non-null. This method keeps
|
||||
/// deserialization and imperative configuration from publishing values that would fail during rendering.
|
||||
/// </remarks>
|
||||
internal GodotLoggerOptions CreateNormalizedCopy()
|
||||
{
|
||||
var defaults = new GodotLoggerOptions();
|
||||
|
||||
return new GodotLoggerOptions
|
||||
{
|
||||
Mode = Mode,
|
||||
DebugMinLevel = DebugMinLevel,
|
||||
ReleaseMinLevel = ReleaseMinLevel,
|
||||
DebugOutputTemplate = string.IsNullOrWhiteSpace(DebugOutputTemplate)
|
||||
? defaults.DebugOutputTemplate
|
||||
: DebugOutputTemplate,
|
||||
ReleaseOutputTemplate = string.IsNullOrWhiteSpace(ReleaseOutputTemplate)
|
||||
? defaults.ReleaseOutputTemplate
|
||||
: ReleaseOutputTemplate,
|
||||
Colors = CopyColorsWithDefaults(Colors)
|
||||
};
|
||||
}
|
||||
|
||||
@ -112,4 +160,23 @@ public sealed class GodotLoggerOptions
|
||||
{
|
||||
return left > right ? left : right;
|
||||
}
|
||||
|
||||
private static Dictionary<LogLevel, string> CopyColorsWithDefaults(Dictionary<LogLevel, string>? colors)
|
||||
{
|
||||
var merged = new Dictionary<LogLevel, string>(DefaultColors);
|
||||
if (colors == null)
|
||||
{
|
||||
return merged;
|
||||
}
|
||||
|
||||
foreach (var pair in colors)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pair.Value))
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,33 +4,82 @@ using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Represents one immutable Godot logger configuration snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A snapshot combines mode-specific <see cref="Options"/>, an optional default log level, and category overrides.
|
||||
/// Category matching is ordinal and deterministic: exact matches win first, then the longest dotted prefix such as
|
||||
/// <c>Game.Services</c> for <c>Game.Services.Inventory</c>, and finally <see cref="DefaultLogLevel"/> is used when
|
||||
/// present.
|
||||
/// </remarks>
|
||||
internal sealed class GodotLoggerSettings
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, LogLevel> _loggerLevels;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default settings snapshot used when no configuration file is available.
|
||||
/// </summary>
|
||||
public static GodotLoggerSettings Default { get; } = new(new GodotLoggerOptions());
|
||||
|
||||
/// <summary>
|
||||
/// Creates a settings snapshot from normalized options and optional category thresholds.
|
||||
/// </summary>
|
||||
/// <param name="options">The formatting and mode options for this snapshot.</param>
|
||||
/// <param name="defaultLogLevel">The optional fallback level used when no category override matches.</param>
|
||||
/// <param name="loggerLevels">Exact category names or dotted prefixes mapped to minimum levels.</param>
|
||||
public GodotLoggerSettings(
|
||||
GodotLoggerOptions options,
|
||||
LogLevel? defaultLogLevel = null,
|
||||
IReadOnlyDictionary<string, LogLevel>? loggerLevels = null)
|
||||
{
|
||||
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
Options = (options ?? throw new ArgumentNullException(nameof(options))).CreateNormalizedCopy();
|
||||
DefaultLogLevel = defaultLogLevel;
|
||||
_loggerLevels = loggerLevels ?? new Dictionary<string, LogLevel>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional fallback minimum level for categories without exact or prefix overrides.
|
||||
/// </summary>
|
||||
public LogLevel? DefaultLogLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets normalized rendering and mode options for this snapshot.
|
||||
/// </summary>
|
||||
public GodotLoggerOptions Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets exact and dotted-prefix category level overrides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keys are interpreted with <see cref="StringComparer.Ordinal"/> semantics. A key only matches a child category
|
||||
/// when the category starts with the key plus a dot, which prevents <c>Game.Service</c> from matching
|
||||
/// <c>Game.Services</c> accidentally.
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, LogLevel> LoggerLevels => _loggerLevels;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a settings snapshot from options without any category overrides.
|
||||
/// </summary>
|
||||
/// <param name="options">The options to normalize and wrap.</param>
|
||||
/// <returns>A settings snapshot that relies only on the option-level minimum level.</returns>
|
||||
public static GodotLoggerSettings FromOptions(GodotLoggerOptions options)
|
||||
{
|
||||
return new GodotLoggerSettings(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the effective minimum level for a category.
|
||||
/// </summary>
|
||||
/// <param name="categoryName">The logger category name.</param>
|
||||
/// <param name="providerMinLevel">The provider-level floor captured by the logger.</param>
|
||||
/// <returns>The strictest level selected from options, provider floor, and category configuration.</returns>
|
||||
/// <remarks>
|
||||
/// The merge starts with <see cref="GodotLoggerOptions.GetEffectiveMinLevel"/> and
|
||||
/// <paramref name="providerMinLevel"/>, then applies <see cref="GetConfiguredMinLevel"/> when it returns a
|
||||
/// value. <see cref="Max(LogLevel, LogLevel)"/> is used at each step so configuration can only make a logger
|
||||
/// stricter, never more verbose than the active floor.
|
||||
/// </remarks>
|
||||
public LogLevel GetEffectiveMinLevel(string categoryName, LogLevel providerMinLevel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(categoryName);
|
||||
@ -40,8 +89,14 @@ internal sealed class GodotLoggerSettings
|
||||
return configuredLevel.HasValue ? Max(effective, configuredLevel.Value) : effective;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the configured category level using exact match, longest dotted-prefix match, then default fallback.
|
||||
/// </summary>
|
||||
/// <param name="categoryName">The category to resolve.</param>
|
||||
/// <returns>The configured level, or null when no default or override applies.</returns>
|
||||
private LogLevel? GetConfiguredMinLevel(string categoryName)
|
||||
{
|
||||
// Exact category configuration is the most specific and avoids unnecessary prefix scans.
|
||||
if (_loggerLevels.TryGetValue(categoryName, out var exactLevel))
|
||||
{
|
||||
return exactLevel;
|
||||
@ -52,6 +107,7 @@ internal sealed class GodotLoggerSettings
|
||||
|
||||
foreach (var pair in _loggerLevels)
|
||||
{
|
||||
// The dotted boundary keeps sibling categories from matching by raw string prefix alone.
|
||||
if (!categoryName.StartsWith(pair.Key + ".", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
@ -69,6 +125,12 @@ internal sealed class GodotLoggerSettings
|
||||
return bestMatchLevel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stricter of two log levels.
|
||||
/// </summary>
|
||||
/// <param name="left">The first level.</param>
|
||||
/// <param name="right">The second level.</param>
|
||||
/// <returns>The level with the higher severity ordering.</returns>
|
||||
private static LogLevel Max(LogLevel left, LogLevel right)
|
||||
{
|
||||
return left > right ? left : right;
|
||||
|
||||
@ -8,8 +8,18 @@ using Godot;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and parses Godot logging configuration documents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The loader treats JSON as external input: enum values are validated, nullable serializer output is normalized,
|
||||
/// and unsupported values produce clear exceptions before a settings snapshot reaches runtime log rendering.
|
||||
/// </remarks>
|
||||
internal static class GodotLoggerSettingsLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Names the environment variable that can point to an explicit Godot logging configuration file.
|
||||
/// </summary>
|
||||
internal const string ConfigEnvironmentVariableName = "GODOT_LOGGER_CONFIG";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@ -24,6 +34,13 @@ internal static class GodotLoggerSettingsLoader
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first supported configuration file location.
|
||||
/// </summary>
|
||||
/// <param name="environmentPath">Optional explicit path used instead of reading the environment variable.</param>
|
||||
/// <param name="processPath">Optional process path used when checking the executable directory.</param>
|
||||
/// <param name="projectPathResolver">Optional resolver for Godot <c>res://</c> paths.</param>
|
||||
/// <returns>The first existing configuration path, or null when none exists.</returns>
|
||||
public static string? DiscoverConfigurationPath(
|
||||
string? environmentPath = null,
|
||||
string? processPath = null,
|
||||
@ -59,6 +76,12 @@ internal static class GodotLoggerSettingsLoader
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a settings snapshot from a JSON file.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The configuration file path.</param>
|
||||
/// <returns>The parsed and normalized settings snapshot.</returns>
|
||||
/// <exception cref="FileNotFoundException">Thrown when <paramref name="filePath"/> does not exist.</exception>
|
||||
public static GodotLoggerSettings LoadFromJsonFile(string filePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
@ -71,6 +94,12 @@ internal static class GodotLoggerSettingsLoader
|
||||
return LoadFromJsonString(File.ReadAllText(filePath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a settings snapshot from a JSON string.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON configuration content.</param>
|
||||
/// <returns>The parsed and normalized settings snapshot.</returns>
|
||||
/// <exception cref="JsonException">Thrown when an unsupported log level or malformed document is encountered.</exception>
|
||||
public static GodotLoggerSettings LoadFromJsonString(string json)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(json);
|
||||
@ -79,7 +108,7 @@ internal static class GodotLoggerSettingsLoader
|
||||
?? throw new InvalidOperationException("Failed to deserialize Godot logging configuration.");
|
||||
|
||||
var logging = root.Logging;
|
||||
var options = logging?.GodotLogger ?? new GodotLoggerOptions();
|
||||
var options = (logging?.GodotLogger ?? new GodotLoggerOptions()).CreateNormalizedCopy();
|
||||
|
||||
LogLevel? defaultLogLevel = null;
|
||||
var loggerLevels = new Dictionary<string, LogLevel>(StringComparer.Ordinal);
|
||||
@ -132,6 +161,12 @@ internal static class GodotLoggerSettingsLoader
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numericValue))
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(LogLevel), numericValue))
|
||||
{
|
||||
throw new JsonException(
|
||||
$"Unsupported numeric {nameof(LogLevel)} value '{numericValue}'. Expected a defined {nameof(LogLevel)} value.");
|
||||
}
|
||||
|
||||
return (LogLevel)numericValue;
|
||||
}
|
||||
|
||||
|
||||
@ -7,14 +7,15 @@ GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`GODOT-LOGGING-COMPLIANCE-POLISH-RP-001`
|
||||
- 当前阶段:`Phase 1`
|
||||
- 恢复点编号:`GODOT-LOGGING-COMPLIANCE-POLISH-RP-002`
|
||||
- 当前阶段:`PR review hardening`
|
||||
- 当前焦点:
|
||||
- 已补齐 `GodotLog` 静态入口、延迟 logger 解析、配置自动发现与热重载
|
||||
- 已让 `GodotLoggerFactoryProvider` 对已缓存 logger 生效动态配置,而不是只在新建 logger 时读快照
|
||||
- 已让 `GodotLogger` 支持 `{properties}` 占位符,并把 `IStructuredLogger` / `LogContext` 属性落到 Godot 输出
|
||||
- 已兼容 `GodotLogger` 风格配置值,如 `Information` / `Critical`
|
||||
- 下一轮优先评估是否把 Godot 输出进一步并入 Core 的 appender / formatter / filter 组合管线
|
||||
- 已处理 PR #314 最新 AI review 中仍适用的生命周期、配置输入、缓存边界、注释和脚本健壮性问题
|
||||
- 下一轮优先只复核 CI 反馈是否已收敛,避免继续扩大 Godot logging API 面
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
@ -43,6 +44,10 @@ GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot
|
||||
- GFramework 风格:`Info` / `Fatal`
|
||||
- `GodotLogger` 风格:`Information` / `Critical`
|
||||
- 现有设计仍保留 UTC 时间戳语义,没有为了对齐原项目而默认切回本地时间
|
||||
- `GodotLog.ConfigurationPath` 现在不会提前 materialize 全局配置源;`GodotLog.Shutdown()` 可释放已创建配置源的 watcher
|
||||
- 配置 JSON 会先归一化模板和颜色字典,并拒绝未定义的数字 `LogLevel`
|
||||
- `GodotLogTemplate` 的模板缓存和分类格式缓存已改为有界并发缓存,避免热重载或动态 category 长期单向增长
|
||||
- `refactor-scripts/update-namespaces.py` 已移除本机绝对路径默认值,并会把文件处理失败汇总成非零退出码
|
||||
|
||||
## 当前风险
|
||||
|
||||
@ -52,6 +57,8 @@ GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot
|
||||
- 缓解措施:下一轮只评估“Godot sink / appender 化”,不再继续扩张独立的 Godot logging 面
|
||||
- 配置热重载的宿主差异风险:Godot 编辑器、导出包和测试宿主的文件系统语义不完全一致
|
||||
- 缓解措施:active 入口先锁定 discovery / reload 语义,后续若遇到平台差异,再用定向回归和文档补充收口
|
||||
- `GodotLog.ConfigurationPath` 的“不会 materialize”语义没有加入自动化测试
|
||||
- 缓解措施:直接调用会触碰 Godot project path resolver,在普通 test host 中可能崩溃;当前以实现和文档约束记录,后续若增加 Godot 宿主集成测试再覆盖
|
||||
|
||||
## 活跃文档
|
||||
|
||||
@ -69,9 +76,18 @@ GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~Logging -nologo`
|
||||
- 结果:通过
|
||||
- 备注:Core logging 相关测试共 `214` 项通过,覆盖 `AbstractLogger` 动态最小级别改造回归
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
|
||||
- 结果:通过
|
||||
- 备注:PR review hardening 后 Godot logging 定向测试共 `14` 项通过
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
|
||||
- 结果:通过
|
||||
- 备注:PR review hardening 后 Godot 测试项目共 `72` 项通过
|
||||
- `python3 -B refactor-scripts/update-namespaces.py --help`
|
||||
- 结果:通过
|
||||
- 备注:确认脚本 CLI 参数解析可用
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 评估是否需要把 Godot 控制台输出收敛成 Core 可组合 sink / appender,而不是继续扩张独立 provider 逻辑
|
||||
2. 若继续做 Godot logger 能力,优先补真实宿主下的配置 reload / 输出行为回归,而不是再添加新的公开入口
|
||||
3. 若本轮改动进入 PR,后续 review / follow-up 继续写回本 topic,而不是另开第二份 Godot logging 追踪
|
||||
1. 刷新 PR review / CI 状态,确认最新 head 上 CodeRabbit 与 Greptile 线程是否关闭或变为 stale
|
||||
2. 若 CI 仍报 MegaLinter `dotnet-format` restore 失败,优先复核 Actions restore 环境,而不是继续改本地格式
|
||||
3. 后续若继续推进能力设计,再评估 Godot 输出是否应变成 Core 可组合 sink / appender
|
||||
|
||||
@ -52,3 +52,33 @@
|
||||
1. 若继续推进本主题,优先评估 Godot 输出是否应变成 Core 可组合 appender / sink
|
||||
2. 若出现后续 review 反馈,直接在本 topic 追加 RP-002,而不是重新开临时 local-plan
|
||||
3. 若本主题阶段性完成,再把详细实现 history 迁入 `archive/`,active 入口只保留恢复点与风险
|
||||
|
||||
### 阶段:PR review hardening(RP-002)
|
||||
|
||||
- 使用 `$gframework-pr-review` 抓取 PR #314 最新 review payload,确认当前 head 上仍有 CodeRabbit 与 Greptile
|
||||
未解决线程
|
||||
- 接受并处理仍适用的 review 结论:
|
||||
- `GodotLog.ConfigurationPath` 不应提前创建全局配置源,`Configure(...)` 需要在 provider 或配置源已创建后 fail-fast
|
||||
- 静态配置源需要可显式释放 watcher,因此新增 `GodotLog.Shutdown()`
|
||||
- `DeferredLogger` 首次解析改为 `Interlocked.CompareExchange` 发布,避免 `_inner ??=` 并发竞态
|
||||
- `GodotLogger` 结构化 `Log(...)` 覆写改为复用 `IsEnabled(level)`,删除重复的最小级别 provider 字段
|
||||
- JSON 配置输入需要归一化模板和颜色字典,并拒绝未定义的数字 `LogLevel`
|
||||
- `GodotLogTemplate` 模板缓存和分类缓存需要有界,避免热重载或动态 category 长期增长
|
||||
- `refactor-scripts/update-namespaces.py` 不能依赖本机绝对路径,也不能把文件处理异常吞成 0 次替换
|
||||
- 同步补充 Godot logging 内部类型和关键方法 XML 文档,说明热重载、快照发布、分类匹配和模板缓存语义
|
||||
- 同步更新 `docs/zh-CN/godot/logging.md`,记录 `ConfigurationPath` 的诊断语义和 `Shutdown()` teardown 用法
|
||||
|
||||
### 验证
|
||||
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
|
||||
- 结果:通过(14/14)
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
|
||||
- 结果:通过(72/72)
|
||||
- `python3 -B refactor-scripts/update-namespaces.py --help`
|
||||
- 结果:通过
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 提交 RP-002 review hardening 改动
|
||||
2. 刷新 PR review / CI,确认最新 head 是否关闭已处理线程
|
||||
3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,优先定位 Actions restore 环境
|
||||
|
||||
@ -91,6 +91,10 @@ var logger = GodotLog.CreateLogger<Main>();
|
||||
- 自动按 `GODOT_LOGGER_CONFIG` -> 可执行目录 `appsettings.json` -> `res://appsettings.json` 顺序发现配置
|
||||
- 返回延迟解析 logger,避免 `static readonly` 字段过早锁死配置
|
||||
|
||||
`GodotLog.ConfigurationPath` 可以用于诊断当前会命中的配置文件路径;读取它不会提前创建全局配置源,也不会让后续
|
||||
`GodotLog.Configure(...)` 失效。长生命周期服务器或测试宿主如果需要在退出时主动释放配置文件 watcher,可以调用
|
||||
`GodotLog.Shutdown()`;它会停止热重载监听,已创建 logger 仍然继续使用最后一次成功发布的配置快照。
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
### 1. 在 `ArchitectureConfiguration` 中挂上 Godot provider
|
||||
|
||||
@ -5,9 +5,10 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
|
||||
ROOT_DIR = "/mnt/f/gewuyou/System/Documents/WorkSpace/GameDev/GFramework"
|
||||
DEFAULT_ROOT_DIR = os.getcwd()
|
||||
|
||||
# 命名空间替换规则(按优先级排序,长的先匹配)
|
||||
NAMESPACE_RULES = [
|
||||
@ -120,15 +121,26 @@ def update_file(file_path):
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"错误处理文件 {file_path}: {e}")
|
||||
return 0
|
||||
raise RuntimeError(f"错误处理文件 {file_path}: {e}") from e
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser(description="更新 C# 文件中的命名空间声明和 using 语句")
|
||||
parser.add_argument(
|
||||
"--root-dir",
|
||||
default=os.getenv("ROOT_DIR", DEFAULT_ROOT_DIR),
|
||||
help="要扫描的仓库根目录,默认使用 ROOT_DIR 环境变量或当前工作目录")
|
||||
args = parser.parse_args()
|
||||
root_dir = os.path.abspath(args.root_dir)
|
||||
|
||||
if not os.path.isdir(root_dir):
|
||||
print(f"根目录不存在或不是目录: {root_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print("开始更新命名空间...")
|
||||
|
||||
# 查找所有 C# 文件
|
||||
cs_files = []
|
||||
for root, dirs, files in os.walk(ROOT_DIR):
|
||||
for root, dirs, files in os.walk(root_dir):
|
||||
# 跳过 bin, obj, Generated 目录
|
||||
dirs[:] = [d for d in dirs if d not in ['bin', 'obj', 'Generated', '.git', 'node_modules']]
|
||||
|
||||
@ -140,15 +152,28 @@ def main():
|
||||
|
||||
updated_files = 0
|
||||
total_replacements = 0
|
||||
failed_files = []
|
||||
|
||||
for file_path in cs_files:
|
||||
replacements = update_file(file_path)
|
||||
try:
|
||||
replacements = update_file(file_path)
|
||||
except RuntimeError as e:
|
||||
failed_files.append((file_path, str(e)))
|
||||
continue
|
||||
|
||||
if replacements > 0:
|
||||
updated_files += 1
|
||||
total_replacements += replacements
|
||||
print(f"更新: {os.path.basename(file_path)} ({replacements} 处替换)")
|
||||
|
||||
print(f"\n完成!更新了 {updated_files} 个文件,共 {total_replacements} 处替换")
|
||||
if failed_files:
|
||||
print(f"失败文件数: {len(failed_files)}", file=sys.stderr)
|
||||
for file_path, error in failed_files:
|
||||
print(f"- {file_path}: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user