mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
Merge pull request #314 from GeWuYou/feat/godot-logging-compliance-polish
Feat/godot logging compliance polish
This commit is contained in:
commit
918a61f3b2
6
.github/workflows/license-compliance.yml
vendored
6
.github/workflows/license-compliance.yml
vendored
@ -62,6 +62,7 @@ jobs:
|
||||
# with: 配置上传的具体内容
|
||||
# name: 工件名称,用于标识上传的文件集合
|
||||
# path: 指定需要上传的文件路径列表(支持多行格式)
|
||||
# third-party-licenses/**: 手工维护的参考源码许可证原文
|
||||
- name: Upload compliance artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@ -69,6 +70,7 @@ jobs:
|
||||
path: |
|
||||
NOTICE
|
||||
THIRD_PARTY_LICENSES.md
|
||||
third-party-licenses/**
|
||||
sbom.spdx.json
|
||||
sbom.cyclonedx.json
|
||||
sbom-spdx-validation.txt
|
||||
@ -79,15 +81,17 @@ jobs:
|
||||
# 压缩包中包含以下文件:
|
||||
# - NOTICE: 项目声明文件
|
||||
# - THIRD_PARTY_LICENSES.md: 第三方许可证列表
|
||||
# - third-party-licenses/: 手工维护的参考源码许可证原文
|
||||
# - sbom.spdx.json: SPDX 格式的软件物料清单
|
||||
# - sbom.cyclonedx.json: CycloneDX 格式的软件物料清单
|
||||
# - sbom-spdx-validation.txt: SPDX 格式验证结果
|
||||
# - sbom-cyclonedx-validation.txt: CycloneDX 格式验证结果
|
||||
- name: Package compliance bundle
|
||||
run: |
|
||||
zip license-compliance.zip \
|
||||
zip -r license-compliance.zip \
|
||||
NOTICE \
|
||||
THIRD_PARTY_LICENSES.md \
|
||||
third-party-licenses \
|
||||
sbom.spdx.json \
|
||||
sbom.cyclonedx.json \
|
||||
sbom-spdx-validation.txt \
|
||||
|
||||
@ -6,16 +6,42 @@ namespace GFramework.Core.Logging;
|
||||
/// 日志抽象基类,封装日志级别判断、格式化与异常处理逻辑。
|
||||
/// 平台日志器只需实现 Write 方法即可。
|
||||
/// </summary>
|
||||
public abstract class AbstractLogger(
|
||||
string? name = null,
|
||||
LogLevel minLevel = LogLevel.Info) : IStructuredLogger
|
||||
public abstract class AbstractLogger : IStructuredLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// 根日志记录器的名称常量
|
||||
/// </summary>
|
||||
public const string RootLoggerName = "ROOT";
|
||||
|
||||
private readonly string _name = name ?? RootLoggerName;
|
||||
private readonly Func<LogLevel> _minLevelProvider;
|
||||
private readonly string _name;
|
||||
|
||||
/// <summary>
|
||||
/// 使用固定最小日志级别初始化日志器。
|
||||
/// </summary>
|
||||
/// <param name="name">日志器名称。</param>
|
||||
/// <param name="minLevel">最小日志级别。</param>
|
||||
protected AbstractLogger(
|
||||
string? name = null,
|
||||
LogLevel minLevel = LogLevel.Info)
|
||||
: this(name, () => minLevel)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用动态最小日志级别提供器初始化日志器。
|
||||
/// </summary>
|
||||
/// <param name="name">日志器名称。</param>
|
||||
/// <param name="minLevelProvider">最小日志级别提供器。</param>
|
||||
protected AbstractLogger(
|
||||
string? name,
|
||||
Func<LogLevel> minLevelProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(minLevelProvider);
|
||||
|
||||
_name = name ?? RootLoggerName;
|
||||
_minLevelProvider = minLevelProvider;
|
||||
}
|
||||
|
||||
#region Metadata
|
||||
|
||||
@ -47,7 +73,7 @@ public abstract class AbstractLogger(
|
||||
/// <returns>如果指定级别大于等于最小级别则返回true,否则返回false</returns>
|
||||
protected bool IsEnabled(LogLevel level)
|
||||
{
|
||||
return level >= minLevel;
|
||||
return level >= _minLevelProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
142
GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs
Normal file
142
GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs
Normal file
@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Godot.Logging;
|
||||
|
||||
namespace GFramework.Godot.Tests.Logging;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class GodotLogTemplateTests
|
||||
{
|
||||
[Test]
|
||||
public void Render_Should_Format_Timestamp_Level_Color_Category_And_Message()
|
||||
{
|
||||
var template = GodotLogTemplate.Parse("[{timestamp:yyyyMMdd}] [color={color}]{level:u3}[/color] [{category:l16}] {message}");
|
||||
var context = new GodotLogRenderContext(
|
||||
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
|
||||
LogLevel.Warning,
|
||||
"Game.Services.Inventory",
|
||||
"Loaded",
|
||||
"orange",
|
||||
string.Empty);
|
||||
|
||||
var result = template.Render(context);
|
||||
|
||||
Assert.That(result, Is.EqualTo("[20260502] [color=orange]WRN[/color] [G.S.Inventory ] Loaded"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_Should_Support_Lowercase_Level_Format()
|
||||
{
|
||||
var template = GodotLogTemplate.Parse("{level:l3}:{message}");
|
||||
var context = new GodotLogRenderContext(
|
||||
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
|
||||
LogLevel.Debug,
|
||||
"Game",
|
||||
"Ready",
|
||||
"cyan",
|
||||
string.Empty);
|
||||
|
||||
var result = template.Render(context);
|
||||
|
||||
Assert.That(result, Is.EqualTo("dbg:Ready"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_Should_Right_Align_Category()
|
||||
{
|
||||
var template = GodotLogTemplate.Parse("[{category:r10}]");
|
||||
var context = new GodotLogRenderContext(
|
||||
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
|
||||
LogLevel.Info,
|
||||
"UI",
|
||||
"Ready",
|
||||
"white",
|
||||
string.Empty);
|
||||
|
||||
var result = template.Render(context);
|
||||
|
||||
Assert.That(result, Is.EqualTo("[ UI]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_Should_Preserve_Unknown_Placeholders()
|
||||
{
|
||||
var template = GodotLogTemplate.Parse("{message} {unknown}");
|
||||
var context = new GodotLogRenderContext(
|
||||
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
|
||||
LogLevel.Info,
|
||||
"Game",
|
||||
"Ready",
|
||||
"white",
|
||||
string.Empty);
|
||||
|
||||
var result = template.Render(context);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Ready {unknown}"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_Should_Format_Padded_Level()
|
||||
{
|
||||
var template = GodotLogTemplate.Parse("{level:padded}");
|
||||
var context = new GodotLogRenderContext(
|
||||
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
|
||||
LogLevel.Info,
|
||||
"Game",
|
||||
"Ready",
|
||||
"white",
|
||||
string.Empty);
|
||||
|
||||
var result = template.Render(context);
|
||||
|
||||
Assert.That(result, Is.EqualTo("INFO "));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_Should_Append_Properties_Placeholder()
|
||||
{
|
||||
var template = GodotLogTemplate.Parse("{message}{properties}");
|
||||
var context = new GodotLogRenderContext(
|
||||
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
|
||||
LogLevel.Info,
|
||||
"Game",
|
||||
"Ready",
|
||||
"white",
|
||||
" | Scene=Boot");
|
||||
|
||||
var result = template.Render(context);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Ready | Scene=Boot"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Options_ForMinimumLevel_Should_Preserve_Fixed_Minimum_Level()
|
||||
{
|
||||
var options = GodotLoggerOptions.ForMinimumLevel(LogLevel.Warning);
|
||||
|
||||
Assert.That(options.Mode, Is.EqualTo(GodotLoggerMode.Debug));
|
||||
Assert.That(options.DebugMinLevel, Is.EqualTo(LogLevel.Warning));
|
||||
Assert.That(options.ReleaseMinLevel, Is.EqualTo(LogLevel.Warning));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Options_Should_Use_Default_Color_When_Configured_Color_Is_Missing()
|
||||
{
|
||||
var options = new GodotLoggerOptions();
|
||||
options.Colors.Remove(LogLevel.Error);
|
||||
|
||||
var result = options.GetColor(LogLevel.Error);
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
209
GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs
Normal file
209
GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs
Normal file
@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Godot.Logging;
|
||||
|
||||
namespace GFramework.Godot.Tests.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Godot logging configuration discovery, parsing, normalization, and live settings propagation.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class GodotLoggerSettingsLoaderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that configuration discovery honors the environment path, executable directory, and project path order.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that JSON settings bind Godot logger options and category log-level overrides.
|
||||
/// </summary>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that nullable JSON option fields are normalized before the runtime receives the settings snapshot.
|
||||
/// </summary>
|
||||
[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"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that numeric JSON log levels must map to defined <see cref="LogLevel"/> values.
|
||||
/// </summary>
|
||||
[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'"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that cached provider loggers read the latest settings after the provider snapshot changes.
|
||||
/// </summary>
|
||||
[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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that caller-supplied structured property keys cannot break Godot log rendering.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void StructuredProperties_Should_Skip_Blank_Keys_And_Trim_Valid_Keys()
|
||||
{
|
||||
var formatProperties = typeof(GodotLogger).GetMethod(
|
||||
"FormatProperties",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var properties = new (string Key, object? Value)[]
|
||||
{
|
||||
(null!, "ignored"),
|
||||
(" ", "ignored"),
|
||||
(" Player ", 42)
|
||||
};
|
||||
|
||||
var result = formatProperties?.Invoke(null, [properties]);
|
||||
|
||||
Assert.That(result, Is.EqualTo(" | Player=42"));
|
||||
}
|
||||
}
|
||||
597
GFramework.Godot/Logging/DeferredLogger.cs
Normal file
597
GFramework.Godot/Logging/DeferredLogger.cs
Normal file
@ -0,0 +1,597 @@
|
||||
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>
|
||||
/// <param name="category">The category passed to the provider when the real logger is first needed.</param>
|
||||
/// <param name="providerAccessor">The accessor that returns the current provider at first use.</param>
|
||||
internal sealed class DeferredLogger(string category, Func<ILoggerFactoryProvider> providerAccessor) : IStructuredLogger
|
||||
{
|
||||
private ILogger? _inner;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved inner logger, creating and atomically publishing it on first use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The property is intentionally the single resolution gate so all delegated members share the same thread-safe
|
||||
/// lazy initialization behavior.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the category name reported by the resolved logger.
|
||||
/// </summary>
|
||||
/// <returns>The logger category name.</returns>
|
||||
public string Name()
|
||||
{
|
||||
return Inner.Name();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether trace messages are enabled by the current provider settings.
|
||||
/// </summary>
|
||||
/// <returns>true when trace messages should be emitted; otherwise false.</returns>
|
||||
public bool IsTraceEnabled()
|
||||
{
|
||||
return Inner.IsTraceEnabled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether debug messages are enabled by the current provider settings.
|
||||
/// </summary>
|
||||
/// <returns>true when debug messages should be emitted; otherwise false.</returns>
|
||||
public bool IsDebugEnabled()
|
||||
{
|
||||
return Inner.IsDebugEnabled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether informational messages are enabled by the current provider settings.
|
||||
/// </summary>
|
||||
/// <returns>true when informational messages should be emitted; otherwise false.</returns>
|
||||
public bool IsInfoEnabled()
|
||||
{
|
||||
return Inner.IsInfoEnabled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether warning messages are enabled by the current provider settings.
|
||||
/// </summary>
|
||||
/// <returns>true when warning messages should be emitted; otherwise false.</returns>
|
||||
public bool IsWarnEnabled()
|
||||
{
|
||||
return Inner.IsWarnEnabled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether error messages are enabled by the current provider settings.
|
||||
/// </summary>
|
||||
/// <returns>true when error messages should be emitted; otherwise false.</returns>
|
||||
public bool IsErrorEnabled()
|
||||
{
|
||||
return Inner.IsErrorEnabled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether fatal messages are enabled by the current provider settings.
|
||||
/// </summary>
|
||||
/// <returns>true when fatal messages should be emitted; otherwise false.</returns>
|
||||
public bool IsFatalEnabled()
|
||||
{
|
||||
return Inner.IsFatalEnabled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the specified log level is enabled by the current provider settings.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to check.</param>
|
||||
/// <returns>true when the level should be emitted; otherwise false.</returns>
|
||||
public bool IsEnabledForLevel(LogLevel level)
|
||||
{
|
||||
return Inner.IsEnabledForLevel(level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a trace message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
public void Trace(string msg)
|
||||
{
|
||||
Inner.Trace(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted trace message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg">The first format argument.</param>
|
||||
public void Trace(string format, object arg)
|
||||
{
|
||||
Inner.Trace(format, arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted trace message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg1">The first format argument.</param>
|
||||
/// <param name="arg2">The second format argument.</param>
|
||||
public void Trace(string format, object arg1, object arg2)
|
||||
{
|
||||
Inner.Trace(format, arg1, arg2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted trace message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arguments">The format arguments.</param>
|
||||
public void Trace(string format, params object[] arguments)
|
||||
{
|
||||
Inner.Trace(format, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a trace message and exception through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
/// <param name="t">The exception to attach.</param>
|
||||
public void Trace(string msg, Exception t)
|
||||
{
|
||||
Inner.Trace(msg, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a debug message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
public void Debug(string msg)
|
||||
{
|
||||
Inner.Debug(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted debug message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg">The first format argument.</param>
|
||||
public void Debug(string format, object arg)
|
||||
{
|
||||
Inner.Debug(format, arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted debug message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg1">The first format argument.</param>
|
||||
/// <param name="arg2">The second format argument.</param>
|
||||
public void Debug(string format, object arg1, object arg2)
|
||||
{
|
||||
Inner.Debug(format, arg1, arg2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted debug message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arguments">The format arguments.</param>
|
||||
public void Debug(string format, params object[] arguments)
|
||||
{
|
||||
Inner.Debug(format, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a debug message and exception through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
/// <param name="t">The exception to attach.</param>
|
||||
public void Debug(string msg, Exception t)
|
||||
{
|
||||
Inner.Debug(msg, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an informational message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
public void Info(string msg)
|
||||
{
|
||||
Inner.Info(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted informational message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg">The first format argument.</param>
|
||||
public void Info(string format, object arg)
|
||||
{
|
||||
Inner.Info(format, arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted informational message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg1">The first format argument.</param>
|
||||
/// <param name="arg2">The second format argument.</param>
|
||||
public void Info(string format, object arg1, object arg2)
|
||||
{
|
||||
Inner.Info(format, arg1, arg2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted informational message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arguments">The format arguments.</param>
|
||||
public void Info(string format, params object[] arguments)
|
||||
{
|
||||
Inner.Info(format, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an informational message and exception through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
/// <param name="t">The exception to attach.</param>
|
||||
public void Info(string msg, Exception t)
|
||||
{
|
||||
Inner.Info(msg, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a warning message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
public void Warn(string msg)
|
||||
{
|
||||
Inner.Warn(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted warning message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg">The first format argument.</param>
|
||||
public void Warn(string format, object arg)
|
||||
{
|
||||
Inner.Warn(format, arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted warning message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg1">The first format argument.</param>
|
||||
/// <param name="arg2">The second format argument.</param>
|
||||
public void Warn(string format, object arg1, object arg2)
|
||||
{
|
||||
Inner.Warn(format, arg1, arg2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted warning message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arguments">The format arguments.</param>
|
||||
public void Warn(string format, params object[] arguments)
|
||||
{
|
||||
Inner.Warn(format, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a warning message and exception through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
/// <param name="t">The exception to attach.</param>
|
||||
public void Warn(string msg, Exception t)
|
||||
{
|
||||
Inner.Warn(msg, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an error message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
public void Error(string msg)
|
||||
{
|
||||
Inner.Error(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted error message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg">The first format argument.</param>
|
||||
public void Error(string format, object arg)
|
||||
{
|
||||
Inner.Error(format, arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted error message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg1">The first format argument.</param>
|
||||
/// <param name="arg2">The second format argument.</param>
|
||||
public void Error(string format, object arg1, object arg2)
|
||||
{
|
||||
Inner.Error(format, arg1, arg2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted error message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arguments">The format arguments.</param>
|
||||
public void Error(string format, params object[] arguments)
|
||||
{
|
||||
Inner.Error(format, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an error message and exception through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
/// <param name="t">The exception to attach.</param>
|
||||
public void Error(string msg, Exception t)
|
||||
{
|
||||
Inner.Error(msg, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a fatal message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
public void Fatal(string msg)
|
||||
{
|
||||
Inner.Fatal(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted fatal message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg">The first format argument.</param>
|
||||
public void Fatal(string format, object arg)
|
||||
{
|
||||
Inner.Fatal(format, arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted fatal message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg1">The first format argument.</param>
|
||||
/// <param name="arg2">The second format argument.</param>
|
||||
public void Fatal(string format, object arg1, object arg2)
|
||||
{
|
||||
Inner.Fatal(format, arg1, arg2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted fatal message through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arguments">The format arguments.</param>
|
||||
public void Fatal(string format, params object[] arguments)
|
||||
{
|
||||
Inner.Fatal(format, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a fatal message and exception through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="msg">The message to write.</param>
|
||||
/// <param name="t">The exception to attach.</param>
|
||||
public void Fatal(string msg, Exception t)
|
||||
{
|
||||
Inner.Fatal(msg, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a message at the specified level through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="message">The message to write.</param>
|
||||
public void Log(LogLevel level, string message)
|
||||
{
|
||||
LogFallback(level, message, exception: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted message at the specified level while preserving deferred formatting semantics.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg">The first format argument.</param>
|
||||
public void Log(LogLevel level, string format, object arg)
|
||||
{
|
||||
Inner.Log(level, format, arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted message at the specified level while preserving deferred formatting semantics.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arg1">The first format argument.</param>
|
||||
/// <param name="arg2">The second format argument.</param>
|
||||
public void Log(LogLevel level, string format, object arg1, object arg2)
|
||||
{
|
||||
Inner.Log(level, format, arg1, arg2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a formatted message at the specified level while preserving deferred formatting semantics.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="format">The format string interpreted by the resolved logger.</param>
|
||||
/// <param name="arguments">The format arguments.</param>
|
||||
public void Log(LogLevel level, string format, params object[] arguments)
|
||||
{
|
||||
Inner.Log(level, format, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a message and exception at the specified level through the resolved logger.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <param name="exception">The exception to attach.</param>
|
||||
public void Log(LogLevel level, string message, Exception exception)
|
||||
{
|
||||
LogFallback(level, message, exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a structured message through the resolved logger when it supports structured properties.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <param name="properties">The structured properties to attach.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a structured message and exception through the resolved logger when it supports structured properties.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <param name="exception">The optional exception to attach.</param>
|
||||
/// <param name="properties">The structured properties to attach.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the real logger from the current provider for the deferred category.
|
||||
/// </summary>
|
||||
/// <returns>The logger created by the current provider.</returns>
|
||||
private ILogger ResolveLogger()
|
||||
{
|
||||
return providerAccessor().CreateLogger(category);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a message through the non-structured logger surface when the resolved logger lacks structured support.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <param name="exception">The optional exception to attach.</param>
|
||||
/// <param name="properties">The structured properties rendered into a suffix for fallback loggers.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="level"/> is not supported.</exception>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a non-structured message through the fallback path.
|
||||
/// </summary>
|
||||
/// <param name="level">The level to write.</param>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <param name="exception">The optional exception to attach.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="level"/> is not supported.</exception>
|
||||
private void LogFallback(LogLevel level, string message, Exception? exception)
|
||||
{
|
||||
LogFallback(level, message, exception, []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chooses the message-only or exception-aware write delegate for fallback logging.
|
||||
/// </summary>
|
||||
/// <param name="message">The rendered fallback message.</param>
|
||||
/// <param name="exception">The optional exception to attach.</param>
|
||||
/// <param name="writeMessage">The delegate used when no exception is present.</param>
|
||||
/// <param name="writeException">The delegate used when an exception is present.</param>
|
||||
private static void WriteFallback(
|
||||
string message,
|
||||
Exception? exception,
|
||||
Action<string> writeMessage,
|
||||
Action<string, Exception> writeException)
|
||||
{
|
||||
if (exception == null)
|
||||
{
|
||||
writeMessage(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
writeException(message, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
127
GFramework.Godot/Logging/GodotLog.cs
Normal file
127
GFramework.Godot/Logging/GodotLog.cs
Normal file
@ -0,0 +1,127 @@
|
||||
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 || LazyConfigurationSource.IsValueCreated)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"GodotLog.Configure must be called before any GodotLog provider or configuration source 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, without materializing the global configuration source.
|
||||
/// </summary>
|
||||
/// <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.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
return new GodotLogConfigurationSource(_configure);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetCategoryName(Type type)
|
||||
{
|
||||
return type.FullName ?? type.Name;
|
||||
}
|
||||
}
|
||||
228
GFramework.Godot/Logging/GodotLogConfigurationSource.cs
Normal file
228
GFramework.Godot/Logging/GodotLogConfigurationSource.cs
Normal file
@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
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))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(configurationPath);
|
||||
var fileName = Path.GetFileName(configurationPath);
|
||||
if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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 = throwOnError ? LoadSettingsWithRetry() : LoadSettings();
|
||||
|
||||
// Volatile publication gives cached loggers a coherent replacement snapshot without per-log locks.
|
||||
Volatile.Write(ref _currentSettings, settings);
|
||||
}
|
||||
catch when (!throwOnError)
|
||||
{
|
||||
// Ignore transient parse or file-lock failures during hot reload and keep the last good snapshot.
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return LoadSettings();
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
|
||||
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)
|
||||
? GodotLoggerSettings.Default
|
||||
: GodotLoggerSettingsLoader.LoadFromJsonFile(ConfigurationPath);
|
||||
|
||||
if (_configure == null)
|
||||
{
|
||||
return settings;
|
||||
}
|
||||
|
||||
var configuredOptions = CloneOptions(settings.Options);
|
||||
_configure(configuredOptions);
|
||||
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
|
||||
{
|
||||
Mode = options.Mode,
|
||||
DebugMinLevel = options.DebugMinLevel,
|
||||
ReleaseMinLevel = options.ReleaseMinLevel,
|
||||
DebugOutputTemplate = options.DebugOutputTemplate,
|
||||
ReleaseOutputTemplate = options.ReleaseOutputTemplate,
|
||||
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)
|
||||
{
|
||||
var levels = new Dictionary<string, LogLevel>(StringComparer.Ordinal);
|
||||
foreach (var pair in settings.LoggerLevels)
|
||||
{
|
||||
levels[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
}
|
||||
21
GFramework.Godot/Logging/GodotLogRenderContext.cs
Normal file
21
GFramework.Godot/Logging/GodotLogRenderContext.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using System;
|
||||
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,
|
||||
string Category,
|
||||
string Message,
|
||||
string Color,
|
||||
string Properties);
|
||||
374
GFramework.Godot/Logging/GodotLogTemplate.cs
Normal file
374
GFramework.Godot/Logging/GodotLogTemplate.cs
Normal file
@ -0,0 +1,374 @@
|
||||
using System;
|
||||
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
|
||||
{
|
||||
/// <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);
|
||||
|
||||
/// <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;
|
||||
|
||||
private GodotLogTemplate(string template)
|
||||
{
|
||||
(_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, () => 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);
|
||||
foreach (var segment in _segments)
|
||||
{
|
||||
segment(builder, context);
|
||||
}
|
||||
|
||||
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>>();
|
||||
var literalLength = 0;
|
||||
var position = 0;
|
||||
|
||||
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)
|
||||
{
|
||||
AddLiteral(template[position..]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (open > position)
|
||||
{
|
||||
AddLiteral(template[position..open]);
|
||||
}
|
||||
|
||||
var close = template.IndexOf('}', open + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
AddLiteral(template[open..]);
|
||||
break;
|
||||
}
|
||||
|
||||
var key = template.Substring(open + 1, close - open - 1);
|
||||
segments.Add(CreateSegment(key));
|
||||
position = close + 1;
|
||||
}
|
||||
|
||||
return ([.. segments], literalLength);
|
||||
|
||||
void AddLiteral(string literal)
|
||||
{
|
||||
if (literal.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
"category" => static (builder, context) => builder.Append(context.Category),
|
||||
"color" => static (builder, context) => builder.Append(context.Color),
|
||||
"level" => static (builder, context) => builder.Append(context.Level),
|
||||
"message" => static (builder, context) => builder.Append(context.Message),
|
||||
"properties" => static (builder, context) => builder.Append(context.Properties),
|
||||
"timestamp" => static (builder, context) => builder.Append(context.Timestamp.ToString(
|
||||
"yyyy-MM-dd HH:mm:ss.fff",
|
||||
CultureInfo.InvariantCulture)),
|
||||
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))
|
||||
{
|
||||
return static (builder, context) => builder.Append(context.Timestamp.ToString(
|
||||
"yyyy-MM-dd HH:mm:ss.fff",
|
||||
CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
"u3" or "U3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: true)),
|
||||
"l3" or "L3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: false)),
|
||||
"padded" or "Padded" => static (builder, context) => builder.Append(ToPaddedLevel(context.Level)),
|
||||
_ => static (builder, context) => builder.Append(context.Level)
|
||||
};
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
return static (builder, context) => builder.Append(context.Category);
|
||||
}
|
||||
|
||||
var alignment = format[0];
|
||||
if (alignment is not 'l' and not 'r')
|
||||
{
|
||||
return static (builder, context) => builder.Append(context.Category);
|
||||
}
|
||||
|
||||
if (!int.TryParse(format[1..], NumberStyles.None, CultureInfo.InvariantCulture, out var width) || width <= 0)
|
||||
{
|
||||
return static (builder, context) => builder.Append(context.Category);
|
||||
}
|
||||
|
||||
return alignment == 'l'
|
||||
? (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: false))
|
||||
: (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, () =>
|
||||
{
|
||||
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)
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
var parts = category.Split('.');
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
return category[..maxLength];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
var start = 0;
|
||||
while (start < parts.Length - 1)
|
||||
{
|
||||
var joined = string.Join(".", parts, start, parts.Length - start);
|
||||
if (joined.Length <= maxLength)
|
||||
{
|
||||
return joined;
|
||||
}
|
||||
|
||||
start++;
|
||||
}
|
||||
|
||||
var last = parts[^1];
|
||||
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
|
||||
{
|
||||
LogLevel.Trace => "trc",
|
||||
LogLevel.Debug => "dbg",
|
||||
LogLevel.Info => "inf",
|
||||
LogLevel.Warning => "wrn",
|
||||
LogLevel.Error => "err",
|
||||
LogLevel.Fatal => "ftl",
|
||||
_ => "unk"
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
LogLevel.Trace => "TRACE ",
|
||||
LogLevel.Debug => "DEBUG ",
|
||||
LogLevel.Info => "INFO ",
|
||||
LogLevel.Warning => "WARNING",
|
||||
LogLevel.Error => "ERROR ",
|
||||
LogLevel.Fatal => "FATAL ",
|
||||
_ => 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);
|
||||
}
|
||||
@ -1,4 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
using Godot;
|
||||
@ -6,69 +9,215 @@ using Godot;
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Godot平台的日志记录器实现。
|
||||
/// 该类继承自 <see cref="AbstractLogger"/>,用于在 Godot 引擎中输出日志信息。
|
||||
/// 支持不同日志级别的输出,并根据级别调用 Godot 的相应方法。
|
||||
/// Godot platform logger implementation.
|
||||
/// </summary>
|
||||
/// <param name="name">日志记录器的名称,默认为根日志记录器名称。</param>
|
||||
/// <param name="minLevel">最低日志级别,默认为 <see cref="LogLevel.Info"/>。</param>
|
||||
public sealed class GodotLogger(
|
||||
string? name = null,
|
||||
LogLevel minLevel = LogLevel.Info) : AbstractLogger(name ?? RootLoggerName, minLevel)
|
||||
public sealed class GodotLogger : AbstractLogger
|
||||
{
|
||||
// 静态缓存日志级别字符串,避免重复格式化
|
||||
private static readonly string[] LevelStrings =
|
||||
[
|
||||
"TRACE ",
|
||||
"DEBUG ",
|
||||
"INFO ",
|
||||
"WARNING",
|
||||
"ERROR ",
|
||||
"FATAL "
|
||||
];
|
||||
private readonly Func<GodotLoggerOptions> _optionsProvider;
|
||||
|
||||
/// <summary>
|
||||
/// 写入日志的核心方法。
|
||||
/// 格式化日志消息并根据日志级别调用 Godot 的输出方法。
|
||||
/// Initializes a logger that preserves the historical fixed-format template.
|
||||
/// </summary>
|
||||
/// <param name="level">日志级别。</param>
|
||||
/// <param name="message">日志消息内容。</param>
|
||||
/// <param name="exception">可选的异常信息。</param>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <param name="minLevel">The minimum enabled log level.</param>
|
||||
public GodotLogger(string? name = null, LogLevel minLevel = LogLevel.Info)
|
||||
: this(
|
||||
name ?? RootLoggerName,
|
||||
CreateFixedOptionsProvider(minLevel),
|
||||
() => minLevel)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a logger with Godot-specific formatting options.
|
||||
/// </summary>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <param name="options">The logger options.</param>
|
||||
public GodotLogger(string? name, GodotLoggerOptions options)
|
||||
: this(
|
||||
name ?? RootLoggerName,
|
||||
CreateOptionsProvider(options),
|
||||
CreateMinLevelProvider(options))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the core logger with dynamic options and level providers.
|
||||
/// </summary>
|
||||
/// <param name="name">The resolved logger name used in rendered output.</param>
|
||||
/// <param name="optionsProvider">The provider that supplies the latest rendering options for each write.</param>
|
||||
/// <param name="minLevelProvider">The provider that supplies the latest effective minimum level.</param>
|
||||
/// <remarks>
|
||||
/// The Godot factory uses this constructor so cached logger instances can observe hot-reloaded settings without
|
||||
/// being recreated. The default public constructor supplies a fixed provider to avoid allocation on the log path.
|
||||
/// </remarks>
|
||||
internal GodotLogger(
|
||||
string name,
|
||||
Func<GodotLoggerOptions> optionsProvider,
|
||||
Func<LogLevel> minLevelProvider)
|
||||
: base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider)))
|
||||
{
|
||||
_optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a log entry to Godot.
|
||||
/// </summary>
|
||||
/// <param name="level">The log level.</param>
|
||||
/// <param name="message">The rendered message body.</param>
|
||||
/// <param name="exception">The optional exception.</param>
|
||||
protected override void Write(LogLevel level, string message, Exception? exception)
|
||||
{
|
||||
// 构造时间戳和日志前缀
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||
var levelStr = LevelStrings[(int)level];
|
||||
var logPrefix = $"[{timestamp}] {levelStr} [{Name()}]";
|
||||
WriteEntry(level, message, exception, properties: null);
|
||||
}
|
||||
|
||||
// 添加异常信息到日志消息中
|
||||
if (exception != null) message += "\n" + exception;
|
||||
/// <summary>
|
||||
/// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
|
||||
/// </summary>
|
||||
public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties)
|
||||
{
|
||||
if (!IsEnabled(level))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var logMessage = $"{logPrefix} {message}";
|
||||
WriteEntry(level, message, exception: null, properties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
|
||||
/// </summary>
|
||||
public override void Log(
|
||||
LogLevel level,
|
||||
string message,
|
||||
Exception? exception,
|
||||
params (string Key, object? Value)[] properties)
|
||||
{
|
||||
if (!IsEnabled(level))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteEntry(level, message, exception, properties);
|
||||
}
|
||||
|
||||
private void WriteEntry(
|
||||
LogLevel level,
|
||||
string message,
|
||||
Exception? exception,
|
||||
(string Key, object? Value)[]? properties)
|
||||
{
|
||||
var options = _optionsProvider();
|
||||
var templateText = options.Mode == GodotLoggerMode.Debug
|
||||
? options.DebugOutputTemplate
|
||||
: options.ReleaseOutputTemplate;
|
||||
var context = new GodotLogRenderContext(
|
||||
DateTime.UtcNow,
|
||||
level,
|
||||
Name(),
|
||||
message,
|
||||
options.GetColor(level),
|
||||
FormatProperties(properties));
|
||||
var rendered = GodotLogTemplate.Parse(templateText).Render(context);
|
||||
|
||||
if (options.Mode == GodotLoggerMode.Debug)
|
||||
{
|
||||
WriteDebug(level, rendered);
|
||||
}
|
||||
else
|
||||
{
|
||||
GD.Print(rendered);
|
||||
}
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
GD.PrintErr(exception.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatProperties((string Key, object? Value)[]? properties)
|
||||
{
|
||||
var merged = MergeProperties(properties);
|
||||
if (merged.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return " | " + string.Join(", ", merged.Select(static pair => $"{pair.Key}={FormatValue(pair.Value)}"));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> MergeProperties((string Key, object? Value)[]? properties)
|
||||
{
|
||||
var contextProperties = LogContext.Current;
|
||||
if ((properties == null || properties.Length == 0) && contextProperties.Count == 0)
|
||||
{
|
||||
return EmptyProperties;
|
||||
}
|
||||
|
||||
var merged = new Dictionary<string, object?>(contextProperties, StringComparer.Ordinal);
|
||||
if (properties != null)
|
||||
{
|
||||
foreach (var property in properties)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(property.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[property.Key.Trim()] = property.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyProperties =
|
||||
new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
|
||||
private static Func<GodotLoggerOptions> CreateFixedOptionsProvider(LogLevel minLevel)
|
||||
{
|
||||
var options = GodotLoggerOptions.ForMinimumLevel(minLevel);
|
||||
return () => options;
|
||||
}
|
||||
|
||||
private static Func<GodotLoggerOptions> CreateOptionsProvider(GodotLoggerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return () => options;
|
||||
}
|
||||
|
||||
private static Func<LogLevel> CreateMinLevelProvider(GodotLoggerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return () => options.GetEffectiveMinLevel();
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static void WriteDebug(LogLevel level, string rendered)
|
||||
{
|
||||
GD.PrintRich(rendered);
|
||||
|
||||
// 根据日志级别选择 Godot 输出方法
|
||||
switch (level)
|
||||
{
|
||||
case LogLevel.Fatal:
|
||||
GD.PushError(logMessage);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
GD.PrintErr(logMessage);
|
||||
GD.PushError(rendered);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
GD.PushWarning(logMessage);
|
||||
break;
|
||||
case LogLevel.Trace:
|
||||
GD.PrintRich($"[color=gray]{logMessage}[/color]");
|
||||
break;
|
||||
case LogLevel.Debug:
|
||||
GD.PrintRich($"[color=cyan]{logMessage}[/color]");
|
||||
break;
|
||||
case LogLevel.Info:
|
||||
GD.Print(logMessage);
|
||||
break;
|
||||
default:
|
||||
GD.Print(logMessage);
|
||||
GD.PushWarning(rendered);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,46 @@
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using System;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Godot日志工厂类,用于创建Godot平台专用的日志记录器实例
|
||||
/// Creates Godot platform logger instances.
|
||||
/// </summary>
|
||||
public class GodotLoggerFactory : ILoggerFactory
|
||||
public sealed class GodotLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly GodotLoggerOptions? _options;
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定名称的日志记录器实例
|
||||
/// Initializes a factory that preserves the historical fixed-format logger behavior.
|
||||
/// </summary>
|
||||
/// <param name="name">日志记录器的名称</param>
|
||||
/// <param name="minLevel">日志记录器的最小日志级别</param>
|
||||
/// <returns>返回GodotLogger类型的日志记录器实例</returns>
|
||||
public GodotLoggerFactory()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a factory with Godot-specific formatting options.
|
||||
/// </summary>
|
||||
/// <param name="options">The logger options.</param>
|
||||
public GodotLoggerFactory(GodotLoggerOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a logger with the specified name.
|
||||
/// </summary>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <param name="minLevel">The minimum enabled level.</param>
|
||||
/// <returns>A Godot logger instance.</returns>
|
||||
public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info)
|
||||
{
|
||||
return new GodotLogger(name, minLevel);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
if (_options == null)
|
||||
{
|
||||
return new GodotLogger(name, minLevel);
|
||||
}
|
||||
|
||||
return new GodotLogger(name, _options.WithMinimumLevelFloor(minLevel));
|
||||
}
|
||||
}
|
||||
@ -1,35 +1,76 @@
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Godot日志工厂提供程序,用于创建Godot日志记录器实例
|
||||
/// Provides cached Godot logger instances.
|
||||
/// </summary>
|
||||
public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
|
||||
{
|
||||
private readonly ILoggerFactory _cachedFactory;
|
||||
private readonly ConcurrentDictionary<string, ILogger> _loggers = new(StringComparer.Ordinal);
|
||||
private readonly Func<GodotLoggerSettings> _settingsProvider;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化Godot日志记录器工厂提供程序
|
||||
/// Initializes a Godot logger provider with the default logger factory.
|
||||
/// </summary>
|
||||
public GodotLoggerFactoryProvider()
|
||||
: this(static () => GodotLoggerSettings.Default)
|
||||
{
|
||||
_cachedFactory = new CachedLoggerFactory(new GodotLoggerFactory());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置最小日志级别
|
||||
/// Initializes a Godot logger provider with Godot-specific formatting options.
|
||||
/// </summary>
|
||||
/// <param name="options">The logger options.</param>
|
||||
public GodotLoggerFactoryProvider(GodotLoggerOptions options)
|
||||
: this(CreateStaticSettingsProvider(options))
|
||||
{
|
||||
}
|
||||
|
||||
internal GodotLoggerFactoryProvider(Func<GodotLoggerSettings> settingsProvider)
|
||||
{
|
||||
_settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the provider minimum level.
|
||||
/// </summary>
|
||||
public LogLevel MinLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建指定名称的日志记录器实例(带缓存)
|
||||
/// Creates a cached logger with the specified name.
|
||||
/// </summary>
|
||||
/// <param name="name">日志记录器的名称</param>
|
||||
/// <returns>返回配置了最小日志级别的Godot日志记录器实例</returns>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <returns>A logger configured with <see cref="MinLevel"/>.</returns>
|
||||
public ILogger CreateLogger(string name)
|
||||
{
|
||||
return _cachedFactory.GetLogger(name, MinLevel);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
return _loggers.GetOrAdd(
|
||||
name,
|
||||
static (loggerName, provider) => new GodotLogger(
|
||||
loggerName,
|
||||
provider.GetOptions,
|
||||
() => provider.GetEffectiveMinLevel(loggerName)),
|
||||
this);
|
||||
}
|
||||
|
||||
private GodotLoggerOptions GetOptions()
|
||||
{
|
||||
return _settingsProvider().Options;
|
||||
}
|
||||
|
||||
private LogLevel GetEffectiveMinLevel(string categoryName)
|
||||
{
|
||||
return _settingsProvider().GetEffectiveMinLevel(categoryName, MinLevel);
|
||||
}
|
||||
|
||||
private static Func<GodotLoggerSettings> CreateStaticSettingsProvider(GodotLoggerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var settings = GodotLoggerSettings.FromOptions(options);
|
||||
return () => settings;
|
||||
}
|
||||
}
|
||||
17
GFramework.Godot/Logging/GodotLoggerMode.cs
Normal file
17
GFramework.Godot/Logging/GodotLoggerMode.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the Godot logger output behavior.
|
||||
/// </summary>
|
||||
public enum GodotLoggerMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Uses rich BBCode console output and mirrors warnings/errors to the Godot debugger panel.
|
||||
/// </summary>
|
||||
Debug,
|
||||
|
||||
/// <summary>
|
||||
/// Uses plain console output without rich text or debugger panel mirroring.
|
||||
/// </summary>
|
||||
Release
|
||||
}
|
||||
182
GFramework.Godot/Logging/GodotLoggerOptions.cs
Normal file
182
GFramework.Godot/Logging/GodotLoggerOptions.cs
Normal file
@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Godot.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Godot logger formatting and routing options.
|
||||
/// </summary>
|
||||
public sealed class GodotLoggerOptions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<LogLevel, string> DefaultColors = new Dictionary<LogLevel, string>
|
||||
{
|
||||
[LogLevel.Trace] = "gray",
|
||||
[LogLevel.Debug] = "cyan",
|
||||
[LogLevel.Info] = "white",
|
||||
[LogLevel.Warning] = "orange",
|
||||
[LogLevel.Error] = "red",
|
||||
[LogLevel.Fatal] = "deep_pink"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the output mode.
|
||||
/// </summary>
|
||||
public GodotLoggerMode Mode { get; set; } = GodotLoggerMode.Debug;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum level used by <see cref="GodotLoggerMode.Debug"/>.
|
||||
/// </summary>
|
||||
public LogLevel DebugMinLevel { get; set; } = LogLevel.Debug;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum level used by <see cref="GodotLoggerMode.Release"/>.
|
||||
/// </summary>
|
||||
public LogLevel ReleaseMinLevel { get; set; } = LogLevel.Info;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the BBCode-capable template used by <see cref="GodotLoggerMode.Debug"/>.
|
||||
/// </summary>
|
||||
#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
|
||||
public string DebugOutputTemplate { get; set; } =
|
||||
"[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}";
|
||||
#pragma warning restore MA0016
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the plain text template used by <see cref="GodotLoggerMode.Release"/>.
|
||||
/// </summary>
|
||||
#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
|
||||
public string ReleaseOutputTemplate { get; set; } =
|
||||
"[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}";
|
||||
#pragma warning restore MA0016
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets Godot named colors by log level.
|
||||
/// </summary>
|
||||
#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
|
||||
public Dictionary<LogLevel, string> Colors { get; set; } = new(DefaultColors);
|
||||
#pragma warning restore MA0016
|
||||
|
||||
/// <summary>
|
||||
/// Creates options that preserve the previous Godot logger defaults for a fixed minimum level.
|
||||
/// </summary>
|
||||
/// <param name="minLevel">The minimum enabled level.</param>
|
||||
/// <returns>Options equivalent to the previous fixed-format logger behavior.</returns>
|
||||
public static GodotLoggerOptions ForMinimumLevel(LogLevel minLevel)
|
||||
{
|
||||
return new GodotLoggerOptions
|
||||
{
|
||||
Mode = GodotLoggerMode.Debug,
|
||||
DebugMinLevel = minLevel,
|
||||
ReleaseMinLevel = minLevel,
|
||||
DebugOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}{properties}",
|
||||
ReleaseOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}{properties}",
|
||||
Colors = new Dictionary<LogLevel, string>(DefaultColors)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured color for the specified level.
|
||||
/// </summary>
|
||||
/// <param name="level">The level.</param>
|
||||
/// <returns>The Godot named color.</returns>
|
||||
public string GetColor(LogLevel level)
|
||||
{
|
||||
if (Colors is { } colors && colors.TryGetValue(level, out var color) && !string.IsNullOrWhiteSpace(color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Mode = Mode,
|
||||
DebugMinLevel = Max(DebugMinLevel, minLevel),
|
||||
ReleaseMinLevel = Max(ReleaseMinLevel, minLevel),
|
||||
DebugOutputTemplate = DebugOutputTemplate,
|
||||
ReleaseOutputTemplate = ReleaseOutputTemplate,
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
private static LogLevel Max(LogLevel left, LogLevel right)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
138
GFramework.Godot/Logging/GodotLoggerSettings.cs
Normal file
138
GFramework.Godot/Logging/GodotLoggerSettings.cs
Normal file
@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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))).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);
|
||||
|
||||
var effective = Max(Options.GetEffectiveMinLevel(), providerMinLevel);
|
||||
var configuredLevel = GetConfiguredMinLevel(categoryName);
|
||||
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;
|
||||
}
|
||||
|
||||
var bestMatchLength = -1;
|
||||
LogLevel? bestMatchLevel = DefaultLogLevel;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (pair.Key.Length <= bestMatchLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestMatchLength = pair.Key.Length;
|
||||
bestMatchLevel = pair.Value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
211
GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs
Normal file
211
GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs
Normal file
@ -0,0 +1,211 @@
|
||||
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;
|
||||
|
||||
/// <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()
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
Converters =
|
||||
{
|
||||
new GodotLogLevelJsonConverter(),
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: true)
|
||||
}
|
||||
};
|
||||
|
||||
/// <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,
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Configuration file not found: {filePath}", filePath);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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()).CreateNormalizedCopy();
|
||||
|
||||
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))
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(LogLevel), numericValue))
|
||||
{
|
||||
throw new JsonException(
|
||||
$"Unsupported numeric {nameof(LogLevel)} value '{numericValue}'. Expected a defined {nameof(LogLevel)} value.");
|
||||
}
|
||||
|
||||
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}'.")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,10 @@ help the current worktree land on the right recovery documents without scanning
|
||||
- Purpose: continue the data repository persistence hardening plus the settings / serialization follow-up backlog.
|
||||
- Tracking: `ai-plan/public/data-repository-persistence/todos/data-repository-persistence-tracking.md`
|
||||
- Trace: `ai-plan/public/data-repository-persistence/traces/data-repository-persistence-trace.md`
|
||||
- `godot-logging-compliance-polish`
|
||||
- Purpose: continue Godot logging host integration, configuration reload, structured-output polish, and follow-up work without forking the Core logging model.
|
||||
- Tracking: `ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md`
|
||||
- Trace: `ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md`
|
||||
- `semantic-release-versioning`
|
||||
- Purpose: migrate release version calculation from fixed patch bumps to semantic-release while keeping the existing tag-driven NuGet publish flow.
|
||||
- Tracking: `ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md`
|
||||
@ -59,10 +63,10 @@ help the current worktree land on the right recovery documents without scanning
|
||||
- Branch: `feat/data-repository-persistence`
|
||||
- Worktree hint: `GFramework-data-repository-persistence`
|
||||
- Priority 1: `data-repository-persistence`
|
||||
- Branch: `feat/semantic-release-versioning`
|
||||
- Branch: `feat/godot-logging-compliance-polish`
|
||||
- Worktree hint: `GFramework`
|
||||
- Priority 1: `semantic-release-versioning`
|
||||
- Branch: `feat/release-summary-notes`
|
||||
- Priority 1: `godot-logging-compliance-polish`
|
||||
- Branch: `feat/semantic-release-versioning`
|
||||
- Worktree hint: `GFramework`
|
||||
- Priority 1: `semantic-release-versioning`
|
||||
- Branch: `docs/sdk-update-documentation`
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
# Godot Logging Compliance Polish 跟踪
|
||||
|
||||
## 目标
|
||||
|
||||
继续把 `GFramework.Godot.Logging` 从“基础可用的 Godot 输出适配”收敛成“对齐 `GodotLogger` 优点、但保持
|
||||
GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot / Core 日志统一留下清晰恢复点。
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`GODOT-LOGGING-COMPLIANCE-POLISH-RP-003`
|
||||
- 当前阶段:`PR review follow-up`
|
||||
- 当前焦点:
|
||||
- 已补齐 `GodotLog` 静态入口、延迟 logger 解析、配置自动发现与热重载
|
||||
- 已让 `GodotLoggerFactoryProvider` 对已缓存 logger 生效动态配置,而不是只在新建 logger 时读快照
|
||||
- 已让 `GodotLogger` 支持 `{properties}` 占位符,并把 `IStructuredLogger` / `LogContext` 属性落到 Godot 输出
|
||||
- 已兼容 `GodotLogger` 风格配置值,如 `Information` / `Critical`
|
||||
- 已处理 PR #314 最新 AI review 中仍适用的 XML docs、热路径分配、结构化属性兜底、文档示例和 tracking 精简问题
|
||||
- 下一轮优先刷新 PR review / CI 反馈,避免继续扩大 Godot logging API 面
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
- `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 时间戳语义,没有为了对齐原项目而默认切回本地时间
|
||||
- `GodotLog.ConfigurationPath` 现在不会提前 materialize 全局配置源;`GodotLog.Shutdown()` 可释放已创建配置源的 watcher
|
||||
- 配置 JSON 会先归一化模板和颜色字典,并拒绝未定义的数字 `LogLevel`
|
||||
- `GodotLogTemplate` 的模板缓存和分类格式缓存已改为有界并发缓存,避免热重载或动态 category 长期单向增长
|
||||
- `refactor-scripts/update-namespaces.py` 已移除本机绝对路径默认值,并会把文件处理失败汇总成非零退出码
|
||||
- PR #314 最新 review 中,GodotLog watcher 释放、热重载回调阻塞、配置归一化、数字 `LogLevel` 校验、
|
||||
模板缓存和脚本健壮性问题已在当前 head 验证为已处理;本轮只继续修仍适用的问题
|
||||
- PR #314 最新 follow-up 中,`DeferredLogger` 格式化重载现在委托给 inner logger,`GodotLogger` 默认 options
|
||||
provider 已改为构造时缓存,结构化属性会跳过空白 key 并使用 trimmed key
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 双入口生命周期风险:如果同一宿主同时混用 `LoggerFactoryResolver.Provider` 与 `GodotLog`,需要明确谁是最终默认 provider
|
||||
- 缓解措施:当前文档与实现都保留 `GodotLog.UseAsDefaultProvider()`,并继续把 `ArchitectureConfiguration` 方式写成默认推荐路径
|
||||
- Core / Godot 管线分离风险:Godot 侧虽然已有热重载与配置发现,但还没有变成 Core 可组合 appender
|
||||
- 缓解措施:下一轮只评估“Godot sink / appender 化”,不再继续扩张独立的 Godot logging 面
|
||||
- 配置热重载的宿主差异风险:Godot 编辑器、导出包和测试宿主的文件系统语义不完全一致
|
||||
- 缓解措施:active 入口先锁定 discovery / reload 语义,后续若遇到平台差异,再用定向回归和文档补充收口
|
||||
- `GodotLog.ConfigurationPath` 的“不会 materialize”语义没有加入自动化测试
|
||||
- 缓解措施:直接调用会触碰 Godot project path resolver,在普通 test host 中可能崩溃;当前以实现和文档约束记录,后续若增加 Godot 宿主集成测试再覆盖
|
||||
|
||||
## 活跃文档
|
||||
|
||||
- 当前跟踪:[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`
|
||||
- 结果:通过
|
||||
- 备注:RP-003 follow-up 后 Godot logging 定向测试共 `15` 项通过
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
|
||||
- 结果:通过
|
||||
- 备注:RP-003 follow-up 后 Godot 测试项目共 `73` 项通过
|
||||
- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore --include ...`
|
||||
- 结果:通过
|
||||
- 备注:include 范围为本轮修改的 C# 文件;全项目 format 仍命中既有行尾 / 编码问题,详见 trace
|
||||
- 历史验证明细已保留在 [执行 trace](../traces/godot-logging-compliance-polish-trace.md) 的 `RP-001 验证` 与
|
||||
`RP-002 验证` 小节,active tracking 入口只保留当前恢复点相关结果
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 提交 RP-003 review follow-up 改动
|
||||
2. 刷新 PR review / CI 状态,确认最新 head 上 CodeRabbit 与 Greptile 线程是否关闭或变为 stale
|
||||
3. 若 CI 仍报 MegaLinter `dotnet-format` restore 失败,优先复核 Actions restore 环境,而不是继续改本地格式
|
||||
@ -0,0 +1,119 @@
|
||||
# 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`
|
||||
|
||||
### RP-001 验证
|
||||
|
||||
- `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)
|
||||
|
||||
### RP-001 下一步
|
||||
|
||||
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 用法
|
||||
|
||||
### RP-002 验证
|
||||
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
|
||||
- 结果:通过(15/15)
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
|
||||
- 结果:通过(73/73)
|
||||
- `python3 -B refactor-scripts/update-namespaces.py --help`
|
||||
- 结果:通过
|
||||
|
||||
### RP-002 下一步
|
||||
|
||||
1. 提交 RP-002 review hardening 改动
|
||||
2. 刷新 PR review / CI,确认最新 head 是否关闭已处理线程
|
||||
3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,优先定位 Actions restore 环境
|
||||
|
||||
## 2026-05-03
|
||||
|
||||
### 阶段:PR review follow-up(RP-003)
|
||||
|
||||
- 再次使用 `$gframework-pr-review` 抓取 PR #314 最新 review payload,确认当前 head 上仍有 CodeRabbit 与
|
||||
Greptile 未解决线程
|
||||
- 本轮验证后接受并处理仍适用的 review 结论:
|
||||
- `GodotLoggerSettingsLoaderTests` 公开测试类型与公开测试方法需要 XML 文档
|
||||
- `DeferredLogger` 的公开接口成员需要 XML 文档,并且格式化 `Log(...)` 重载不应提前执行 `string.Format`
|
||||
- `GodotLogger` 默认构造器不应在每条日志上重新创建 options,结构化属性 key 需要跳过空白并做 trim
|
||||
- Godot logging 文档需要给出最小 `appsettings.json` 示例、放置约定和热重载覆盖说明
|
||||
- active tracking 不应同时保留 RP-001 与 RP-002 的详细验证计数,trace 重复标题需要消除
|
||||
- 本轮验证后确认以下旧 review 结论在当前 head 已处理,无需重复改动:
|
||||
- `GodotLog.Shutdown()` 已可释放 materialized configuration source 的 watcher
|
||||
- hot-reload callback 已走无 `Thread.Sleep` 的 `LoadSettings()`,`Thread.Sleep` 只保留在 startup strict load retry
|
||||
- JSON options 归一化、数字 `LogLevel` 校验、GodotLogTemplate 缓存和 namespace 脚本健壮性已在当前 head 存在
|
||||
|
||||
### RP-003 验证
|
||||
|
||||
- `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)
|
||||
- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore --include ...`
|
||||
- 结果:通过,include 范围为本轮修改的三个 C# 文件
|
||||
- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore`
|
||||
- 结果:未通过;命中既有 `GFramework.Godot.Tests/Coroutine/GodotTimeSourceTests.cs` 行尾与
|
||||
`GFramework.Godot.Tests/GlobalUsings.cs` 编码问题,本轮未把该历史格式清理并入 PR review follow-up
|
||||
|
||||
### RP-003 下一步
|
||||
|
||||
1. 提交 RP-003 review follow-up 改动
|
||||
2. 刷新 PR review,确认 CodeRabbit / Greptile 线程是否关闭或 stale
|
||||
3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,继续定位 Actions restore 环境而不是扩大本地格式清理范围
|
||||
@ -0,0 +1,23 @@
|
||||
# Semantic Release 版本迁移归档(SEMREL-RP-004,2026-05-02)
|
||||
|
||||
## 归档范围
|
||||
|
||||
- `feat/release-summary-notes` 分支的 release notes 模板修复
|
||||
- PR 归属展示与重复 commit 输出问题收敛
|
||||
- `SEMREL-RP-004` 的本地验证与合并后分支收尾
|
||||
|
||||
## 历史完成项
|
||||
|
||||
- 已确认 `.github/cliff.toml` 中旧模板会先输出未分组 commit 列表,再输出 grouped commit 列表,导致同一批变更重复出现。
|
||||
- 已移除未分组 commit 循环,只保留按 Conventional Commit group 分类后的 `What's Changed` 输出。
|
||||
- 已保留每条变更末尾的 `by @user in #PR` 输出,避免新增独立 PR 索引章节造成重复。
|
||||
- 已将 `.github/workflows/publish.yml` 的 GitHub Release 正文改为 `body_path: RELEASE_NOTES.md`,复用 `git-cliff-action` 生成的文件。
|
||||
- 已将 `feat/release-summary-notes` 合入 `main`,本地 `main` 已快进到合并提交 `35a62e6b`。
|
||||
- 已从 `ai-plan/public/README.md` 移除 `feat/release-summary-notes` 的 active topic 映射;`semantic-release-versioning` 主题本身仍保持 active。
|
||||
|
||||
## 历史验证
|
||||
|
||||
- `.github/cliff.toml` 通过 Python `tomllib` 解析。
|
||||
- `.github/workflows/publish.yml` 通过 PyYAML 解析。
|
||||
- `yq` 确认 GitHub Release step 使用 `body_path: RELEASE_NOTES.md`。
|
||||
- `dotnet build GFramework.sln -c Release` 通过,`0 warning / 0 error`。
|
||||
@ -13,12 +13,12 @@
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`SEMREL-RP-004`
|
||||
- 当前阶段:`Phase 2`
|
||||
- 恢复点编号:暂无
|
||||
- 当前阶段:等待下一轮 semantic-release 维护任务
|
||||
- 当前焦点:
|
||||
- 收敛 release notes 的 PR 归属展示方式
|
||||
- 确保 `.github/cliff.toml` 不新增独立 PR 索引导致重复输出同一批 commits
|
||||
- 让分类变更列表本身承担 `What's Changed` 语义,并继续在每条 entry 末尾展示作者与 PR 链接
|
||||
- `SEMREL-RP-004` 已归档到
|
||||
`ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md`
|
||||
- 后续如 CI 或发布流程继续暴露 semantic-release 问题,再从新的恢复点编号开始记录
|
||||
|
||||
### 已知风险
|
||||
|
||||
@ -28,7 +28,6 @@
|
||||
- `semantic-release` 的版本判断完全依赖 Conventional Commits;不规范提交会直接影响版本计算
|
||||
- `cycjimmy/semantic-release-action@v6` 需要在 preview / release 两端都安装 `conventional-changelog-conventionalcommits`
|
||||
以保证 `conventionalcommits` preset 在 GitHub Actions 中可解析
|
||||
- 当前仓库本地 `dotnet clean/build` 会带出既有 analyzer warnings;本轮仅修正发版配置与文档,不额外处理这些历史 warning
|
||||
- `git-cliff-action` 的 `OUTPUT` 文件需要在 `softprops/action-gh-release` 执行时保留在当前工作目录,后续如调整
|
||||
working-directory 或 artifact 路径,需要同步复查 `body_path`
|
||||
|
||||
@ -40,39 +39,16 @@
|
||||
- 已在 PAT 校验中补充 `permissions.push` 断言,避免 read-only token 通过 API 探活却在
|
||||
`semantic-release` 的 `git push --dry-run` 阶段才失败
|
||||
- 已为 PAT 校验的 `mktemp` 文件补充 `trap` 清理,避免异常退出时遗留临时文件路径干扰日志
|
||||
- 已同步更新 active trace 到 `SEMREL-RP-004`,记录本轮 PR review 收敛结果
|
||||
- 已用 `$gframework-pr-review` 抓取 PR #312 最新 review payload,确认未失败测试、未发现 MegaLinter 明细,仍有
|
||||
CodeRabbit / Greptile 针对 release notes 的未解决线程
|
||||
- 已移除 `.github/cliff.toml` 中 `## What's Changed` 下的未分组 commit 循环,仅保留按 Conventional Commit group
|
||||
分类后的输出,避免每个 commit 在生成的 changelog 中出现两次
|
||||
- 已将 `.github/cliff.toml` 的分类变更列表重新置于 `## What's Changed` 下,但没有恢复未分组平铺列表;
|
||||
每条变更继续通过 `print_commit` 输出 `by @user in #PR` 链接,满足 PR 追溯需求同时避免重复章节
|
||||
- 已将 `.github/workflows/publish.yml` 的 GitHub Release 正文从多行 expression 改为 `body_path: RELEASE_NOTES.md`,
|
||||
复用 `git-cliff-action` 写出的 release notes 文件
|
||||
- 已在 `ai-plan/public/README.md` 中将 `feat/release-summary-notes` 映射到 `semantic-release-versioning`,便于后续
|
||||
`boot` 直接找到本次发布说明模板上下文
|
||||
- `SEMREL-RP-004` 的 release notes 模板修复、验证和合并后分支收尾已归档到
|
||||
`ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md`
|
||||
|
||||
## 验证
|
||||
|
||||
- `python3 -c 'import tomllib; tomllib.load(open(".github/cliff.toml", "rb")); print("cliff.toml OK")'`
|
||||
- 结果:通过
|
||||
- 备注:确认 `.github/cliff.toml` 仍为合法 TOML
|
||||
- `command -v git-cliff`
|
||||
- 结果:未找到本地 `git-cliff`
|
||||
- 备注:本地无法直接预览 git-cliff 输出,发布 workflow 仍通过 `orhun/git-cliff-action@v4` 提供运行时二进制
|
||||
- `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/publish.yml", encoding="utf-8")); print("publish.yml OK")'`
|
||||
- 结果:通过
|
||||
- 备注:确认 `.github/workflows/publish.yml` 仍可解析为 YAML
|
||||
- `yq '.jobs."create-release".steps[] | select(.name == "Create GitHub Release and Upload Assets") | .with' .github/workflows/publish.yml`
|
||||
- 结果:通过
|
||||
- 备注:确认 release step 现在使用 `body_path: RELEASE_NOTES.md`
|
||||
- `dotnet build GFramework.sln -c Release`
|
||||
- 结果:通过
|
||||
- 备注:Release 构建通过,`0 warning / 0 error`;本轮只改动 GitHub Actions / git-cliff 配置与恢复文档
|
||||
- `SEMREL-RP-004` 的本地验证结果已归档。
|
||||
- 更早阶段的 dry-run / tag /抽象项目验证已归档到
|
||||
`ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-2026-04-26.md`
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 提交并推送本轮 release notes 模板调整
|
||||
2. 如 CI 仍报告 release notes 发布问题,再优先复查 `git-cliff-action` 输出文件路径与模板渲染结果
|
||||
1. 如 CI 仍报告 release notes 发布问题,再优先复查 `git-cliff-action` 输出文件路径与模板渲染结果
|
||||
2. 下一轮 semantic-release 调整开始时创建新的恢复点编号
|
||||
|
||||
@ -40,6 +40,16 @@
|
||||
- `dotnet build GFramework.sln -c Release` 通过,`0 warning / 0 error`。
|
||||
- 下一步是提交并推送本轮 PR review 修复,然后重新抓取 PR review 确认相关线程状态。
|
||||
|
||||
## 2026-05-02
|
||||
|
||||
### SEMREL-RP-004 合并后归档
|
||||
|
||||
- `feat/release-summary-notes` 已合入 `main`,本地 `main` 快进到合并提交 `35a62e6b`。
|
||||
- 已将 `SEMREL-RP-004` 的 release notes 模板修复、验证和分支收尾记录归档到
|
||||
`ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md`。
|
||||
- 已从 `ai-plan/public/README.md` 移除 `feat/release-summary-notes` 到 `semantic-release-versioning` 的 active topic 映射。
|
||||
- `semantic-release-versioning` 主题仍保持 active,等待下一轮 semantic-release 维护任务。
|
||||
|
||||
## 2026-04-26
|
||||
|
||||
### 当前恢复点(SEMREL-RP-004)
|
||||
|
||||
@ -5,11 +5,16 @@ description: 以当前 GFramework.Godot.Logging 源码与 CoreGrid 接线为准
|
||||
|
||||
# Godot 日志系统
|
||||
|
||||
`GFramework.Godot` 当前的日志能力很收敛:它不是一套独立于 Core 的新日志框架,而是把现有 `ILogger` 调用面接到
|
||||
Godot 控制台。
|
||||
`GFramework.Godot` 当前的日志能力仍然以 Core 的 `ILogger` 调用面为中心,但已经不再只是一个薄输出适配层。
|
||||
除了把日志写到 Godot 控制台,它现在还补上了 Godot 宿主常见的接入便利层:
|
||||
|
||||
换句话说,Godot 侧真正新增的是 provider / factory / logger 这层输出适配,而不是新的日志 API。业务代码仍然继续使用
|
||||
`LoggerFactoryResolver.Provider.CreateLogger(...)` 或 `[Log]` 生成的 `ILogger` 字段。
|
||||
- `GodotLog` 静态入口
|
||||
- 配置文件自动发现
|
||||
- 运行期配置热重载
|
||||
- 延迟 logger 解析,适合 `static readonly` 字段
|
||||
|
||||
业务代码仍然继续使用 `LoggerFactoryResolver.Provider.CreateLogger(...)`、`GodotLog.CreateLogger(...)` 或 `[Log]`
|
||||
生成的 `ILogger` 字段;Godot 侧没有额外引入第二套业务日志 API。
|
||||
|
||||
## 当前公开入口
|
||||
|
||||
@ -57,11 +62,79 @@ public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
|
||||
}
|
||||
```
|
||||
|
||||
它内部用 `CachedLoggerFactory` 包装 `GodotLoggerFactory`。缓存 key 由 `name` 和 `MinLevel` 共同组成,所以:
|
||||
当前 provider 会按 logger 名称缓存实例,但 logger 本身会在写入时读取当前配置快照,所以:
|
||||
|
||||
- 同名、同 `MinLevel` 的 logger 会复用实例
|
||||
- 调整 `MinLevel` 后,新创建的 logger 会走新的缓存 key
|
||||
- 已经持有的旧 logger 不会被原地改写
|
||||
- 同名 logger 会复用实例
|
||||
- 调整 provider 最小级别或热更新配置后,已持有的 logger 会立即看到新行为
|
||||
- 不需要为了刷新模板、颜色或级别而重新创建 logger
|
||||
|
||||
### `GodotLog`
|
||||
|
||||
`GodotLog` 是新增的 Godot 宿主友好入口:
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.Logging;
|
||||
|
||||
GodotLog.Configure(options =>
|
||||
{
|
||||
options.Mode = GodotLoggerMode.Debug;
|
||||
});
|
||||
|
||||
GodotLog.UseAsDefaultProvider();
|
||||
|
||||
var logger = GodotLog.CreateLogger<Main>();
|
||||
```
|
||||
|
||||
它提供三件事:
|
||||
|
||||
- 在第一次真正创建 provider 前允许代码覆写 `GodotLoggerOptions`
|
||||
- 自动按 `GODOT_LOGGER_CONFIG` -> 可执行目录 `appsettings.json` -> `res://appsettings.json` 顺序发现配置
|
||||
- 返回延迟解析 logger,避免 `static readonly` 字段过早锁死配置
|
||||
|
||||
`GodotLog.ConfigurationPath` 可以用于诊断当前会命中的配置文件路径;读取它不会提前创建全局配置源,也不会让后续
|
||||
`GodotLog.Configure(...)` 失效。长生命周期服务器或测试宿主如果需要在退出时主动释放配置文件 watcher,可以调用
|
||||
`GodotLog.Shutdown()`;它会停止热重载监听,已创建 logger 仍然继续使用最后一次成功发布的配置快照。
|
||||
|
||||
最小可复制的 `appsettings.json` 可以只包含 `Logging` 根节点。`LogLevel` 使用 `Default` 和类别名控制过滤阈值,
|
||||
`GodotLogger` 控制 Godot 输出模式、模板和颜色:
|
||||
|
||||
```json
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Info",
|
||||
"Game.Services": "Debug"
|
||||
},
|
||||
"GodotLogger": {
|
||||
"Mode": "Debug",
|
||||
"DebugMinLevel": "Debug",
|
||||
"ReleaseMinLevel": "Info",
|
||||
"DebugOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}",
|
||||
"ReleaseOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}",
|
||||
"Colors": {
|
||||
"Info": "white",
|
||||
"Warning": "orange",
|
||||
"Error": "red"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置文件发现顺序固定为:
|
||||
|
||||
1. `GODOT_LOGGER_CONFIG` 指向的文件
|
||||
2. 导出程序或测试进程所在目录的 `appsettings.json`
|
||||
3. Godot 项目资源根目录的 `res://appsettings.json`
|
||||
|
||||
在编辑器项目里,`res://appsettings.json` 放在项目根目录;在导出包或专用服务器里,优先把
|
||||
`appsettings.json` 放到可执行文件同目录,便于运维脚本替换。运行中修改已发现的配置文件会热重载
|
||||
`Logging:LogLevel` 与 `Logging:GodotLogger` 下的模式、最小级别、模板和颜色;已创建 logger 不会重新实例化,
|
||||
但下一次级别判定和写入会读取最新成功发布的配置快照。热重载解析失败或文件被短暂锁定时会保留上一份可用配置。
|
||||
|
||||
`GodotLog.Configure(...)` 适合在没有配置文件或需要代码覆盖默认值时使用,并且必须在首次创建 provider 或配置源前调用。
|
||||
`GodotLog.ConfigurationPath` 适合启动诊断和测试断言;`GodotLog.Shutdown()` 适合测试 teardown 或长生命周期服务器退出时释放
|
||||
文件 watcher,不会清空已经发布给 logger 的最后一份配置。
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
@ -121,7 +194,7 @@ public partial class SettingsPanel : Control
|
||||
```
|
||||
|
||||
如果你已经在用 `GFramework.Core.SourceGenerators`,也可以继续让 `[Log]` 生成字段。Godot provider 只改变输出落点,
|
||||
不会改变 `[Log]` 的生成契约。
|
||||
不会改变 `[Log]` 的生成契约。需要静态字段延迟初始化时,也可以直接用 `GodotLog.CreateLogger<T>()`。
|
||||
|
||||
### 3. Scene / UI 迁移日志会自动复用同一套 provider
|
||||
|
||||
@ -152,7 +225,9 @@ RegisterHandler(new LoggingTransitionHandler());
|
||||
| `Error` | `GD.PrintErr(...)` | 输出到错误流 |
|
||||
| `Fatal` | `GD.PushError(...)` | 进入 Godot 错误通道 |
|
||||
|
||||
异常追加格式也来自当前实现本身:
|
||||
结构化属性如果通过 `IStructuredLogger` 或 `LogContext` 传入,也会追加到模板里的 `{properties}` 占位符。
|
||||
|
||||
异常追加格式仍然来自当前实现本身:
|
||||
|
||||
```text
|
||||
[2026-04-22 10:30:47.012] ERROR [SaveSystem] 保存游戏失败
|
||||
@ -182,14 +257,16 @@ System.IO.IOException: ...
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 当前推荐接法是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;直接赋值
|
||||
`LoggerFactoryResolver.Provider` 仍然可用,但不该再写成默认采用路径
|
||||
- 当前推荐接法仍然是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;如果项目是纯
|
||||
Godot 宿主,也可以在入口直接调用 `GodotLog.UseAsDefaultProvider()`
|
||||
- `GFramework.Godot.Logging` 只解决 Godot 控制台输出,不提供文件落盘、JSON formatter、异步 appender 或按 namespace
|
||||
的复杂过滤
|
||||
- `GodotLogger` 只改变输出方式,不改变 `ILogger` 接口本身;业务代码不需要切换到 Godot 专用日志 API
|
||||
- `[Log]`、`[ContextAware]` 这类字段注入能力不属于 `GFramework.Godot.Logging`
|
||||
- Scene / UI 的 `LoggingTransitionHandler` 位于 `GFramework.Game`,Godot 侧只是通过 provider 让它们输出到 Godot 控制台
|
||||
- 当前 `GodotLogger` 使用的是 UTC 时间戳;如果项目需要本地时区展示,需要自定义 provider / logger,而不是假定当前实现会自动转换
|
||||
- 当前配置热重载只覆盖 Godot logger 自身的模板、颜色、模式和级别;它没有把 `Microsoft.Extensions.Logging` 的整个
|
||||
options / builder 模型搬进来
|
||||
|
||||
## 继续阅读
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
21
third-party-licenses/GodotLogger/LICENSE
Normal file
21
third-party-licenses/GodotLogger/LICENSE
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user