feat(godot): 收敛 GodotLogger 宿主能力

- 新增 GodotLog、DeferredLogger 和配置自动发现、热重载接线。
- 修复已缓存 logger 的级别判定与输出路径,使动态配置生效。
- 更新文档与追踪记录,明确当前收敛边界和恢复点。
This commit is contained in:
gewuyou 2026-05-02 21:33:28 +08:00
parent 36e1ae5f32
commit 748bb714fb
10 changed files with 421 additions and 199 deletions

View File

@ -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>

View File

@ -16,7 +16,8 @@ public sealed class GodotLogTemplateTests
LogLevel.Warning,
"Game.Services.Inventory",
"Loaded",
"orange");
"orange",
string.Empty);
var result = template.Render(context);
@ -32,7 +33,8 @@ public sealed class GodotLogTemplateTests
LogLevel.Debug,
"Game",
"Ready",
"cyan");
"cyan",
string.Empty);
var result = template.Render(context);
@ -48,7 +50,8 @@ public sealed class GodotLogTemplateTests
LogLevel.Info,
"UI",
"Ready",
"white");
"white",
string.Empty);
var result = template.Render(context);
@ -64,7 +67,8 @@ public sealed class GodotLogTemplateTests
LogLevel.Info,
"Game",
"Ready",
"white");
"white",
string.Empty);
var result = template.Render(context);
@ -80,13 +84,31 @@ public sealed class GodotLogTemplateTests
LogLevel.Info,
"Game",
"Ready",
"white");
"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()
{

View File

@ -8,4 +8,5 @@ internal readonly record struct GodotLogRenderContext(
LogLevel Level,
string Category,
string Message,
string Color);
string Color,
string Properties);

View File

@ -91,6 +91,7 @@ internal sealed class GodotLogTemplate
"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)),

View File

@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
using Godot;
@ -10,7 +13,8 @@ namespace GFramework.Godot.Logging;
/// </summary>
public sealed class GodotLogger : AbstractLogger
{
private readonly GodotLoggerOptions _options;
private readonly Func<LogLevel> _minLevelProvider;
private readonly Func<GodotLoggerOptions> _optionsProvider;
/// <summary>
/// Initializes a logger that preserves the historical fixed-format template.
@ -18,7 +22,10 @@ public sealed class GodotLogger : AbstractLogger
/// <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, GodotLoggerOptions.ForMinimumLevel(minLevel))
: this(
name ?? RootLoggerName,
() => GodotLoggerOptions.ForMinimumLevel(minLevel),
() => minLevel)
{
}
@ -28,9 +35,21 @@ public sealed class GodotLogger : AbstractLogger
/// <param name="name">The logger name.</param>
/// <param name="options">The logger options.</param>
public GodotLogger(string? name, GodotLoggerOptions options)
: base(name ?? RootLoggerName, (options ?? throw new ArgumentNullException(nameof(options))).GetEffectiveMinLevel())
: this(
name ?? RootLoggerName,
() => options ?? throw new ArgumentNullException(nameof(options)),
() => (options ?? throw new ArgumentNullException(nameof(options))).GetEffectiveMinLevel())
{
_options = options;
}
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));
_minLevelProvider = minLevelProvider;
}
/// <summary>
@ -41,18 +60,59 @@ public sealed class GodotLogger : AbstractLogger
/// <param name="exception">The optional exception.</param>
protected override void Write(LogLevel level, string message, Exception? exception)
{
var templateText = _options.Mode == GodotLoggerMode.Debug
? _options.DebugOutputTemplate
: _options.ReleaseOutputTemplate;
WriteEntry(level, message, exception, properties: null);
}
/// <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 (level < _minLevelProvider())
{
return;
}
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 (level < _minLevelProvider())
{
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));
options.GetColor(level),
FormatProperties(properties));
var rendered = GodotLogTemplate.Parse(templateText).Render(context);
if (_options.Mode == GodotLoggerMode.Debug)
if (options.Mode == GodotLoggerMode.Debug)
{
WriteDebug(level, rendered);
}
@ -67,6 +127,54 @@ public sealed class GodotLogger : AbstractLogger
}
}
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)
{
merged[property.Key] = property.Value;
}
}
return merged;
}
private static readonly IReadOnlyDictionary<string, object?> EmptyProperties =
new Dictionary<string, object?>(StringComparer.Ordinal);
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);

View File

@ -1,6 +1,6 @@
using System;
using System.Collections.Concurrent;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
namespace GFramework.Godot.Logging;
@ -9,14 +9,15 @@ namespace GFramework.Godot.Logging;
/// </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>
/// Initializes a Godot logger provider with the default logger factory.
/// </summary>
public GodotLoggerFactoryProvider()
: this(static () => GodotLoggerSettings.Default)
{
_cachedFactory = CreateCachedFactory(new GodotLoggerFactory());
}
/// <summary>
@ -24,8 +25,13 @@ public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
/// </summary>
/// <param name="options">The logger options.</param>
public GodotLoggerFactoryProvider(GodotLoggerOptions options)
: this(CreateStaticSettingsProvider(options))
{
_cachedFactory = CreateCachedFactory(new GodotLoggerFactory(options));
}
internal GodotLoggerFactoryProvider(Func<GodotLoggerSettings> settingsProvider)
{
_settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider));
}
/// <summary>
@ -40,12 +46,31 @@ public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
/// <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 static ILoggerFactory CreateCachedFactory(ILoggerFactory innerFactory)
private GodotLoggerOptions GetOptions()
{
ArgumentNullException.ThrowIfNull(innerFactory);
return new CachedLoggerFactory(innerFactory);
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;
}
}

View File

@ -39,7 +39,7 @@ public sealed class GodotLoggerOptions
/// </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}";
"[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}";
#pragma warning restore MA0016
/// <summary>
@ -47,7 +47,7 @@ public sealed class GodotLoggerOptions
/// </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}";
"[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}";
#pragma warning restore MA0016
/// <summary>
@ -69,8 +69,8 @@ public sealed class GodotLoggerOptions
Mode = GodotLoggerMode.Debug,
DebugMinLevel = minLevel,
ReleaseMinLevel = minLevel,
DebugOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}",
ReleaseOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}",
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)
};
}

View File

@ -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,6 +63,9 @@ 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/godot-logging-compliance-polish`
- Worktree hint: `GFramework`
- Priority 1: `godot-logging-compliance-polish`
- Branch: `feat/semantic-release-versioning`
- Worktree hint: `GFramework`
- Priority 1: `semantic-release-versioning`

View File

@ -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,34 @@ 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` 字段过早锁死配置
## 最小接入路径
@ -121,7 +149,7 @@ public partial class SettingsPanel : Control
```
如果你已经在用 `GFramework.Core.SourceGenerators`,也可以继续让 `[Log]` 生成字段。Godot provider 只改变输出落点,
不会改变 `[Log]` 的生成契约。
不会改变 `[Log]` 的生成契约。需要静态字段延迟初始化时,也可以直接用 `GodotLog.CreateLogger<T>()`
### 3. Scene / UI 迁移日志会自动复用同一套 provider
@ -152,7 +180,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 +212,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 模型搬进来
## 继续阅读