Files
aibis-dream/Assets/Scripts/Framework/LogKit/FileLogger.cs
T
dingyuntian 11d88c146c feat(log-kit): 重构文件日志系统支持异步结构化日志
- 使用 ConcurrentQueue + 后台线程批量写入
- 新增 LogLevel、LogCategory、LogEntry、LogFormatter
- 新增 GameLog 业务日志入口
- 新增 LogSettingsProfiles 平台化配置
- 支持按 session 分文件、大小轮转与队列满时低优先级丢弃
2026-07-08 15:20:53 +08:00

261 lines
7.5 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace AibisDream.Kit
{
internal sealed class FileLogger
{
private readonly ConcurrentQueue<LogEntry> _queue = new();
private readonly AutoResetEvent _wakeEvent = new(false);
private readonly ManualResetEventSlim _drainedEvent = new(true);
private readonly List<LogEntry> _batch = new();
private readonly string _directory;
private readonly LogSettings _settings;
private readonly Thread _worker;
private readonly object _writerLock = new();
private volatile bool _isRunning = true;
private StreamWriter _writer;
private string _currentFilePath;
private long _currentFileBytes;
private int _queuedCount;
private int _droppedLowPriorityCount;
public string CurrentFilePath => _currentFilePath;
public FileLogger(string directory, LogSettings settings)
{
_directory = directory;
_settings = settings ?? LogSettings.CreateDefault();
Directory.CreateDirectory(_directory);
DeleteOldFiles();
OpenNewFile();
_worker = new Thread(WriterLoop)
{
IsBackground = true,
Name = "AIBIS File Logger"
};
_worker.Start();
}
public void Write(string content)
{
Write(LogEntry.System(LogLevel.Info, LogCategory.General, content));
}
public void Write(LogEntry entry)
{
if (!_isRunning || !_settings.ShouldWrite(entry.Level))
return;
if (TryEnqueue(entry))
{
if (entry.Level >= LogLevel.Error)
_wakeEvent.Set();
return;
}
if (entry.Level < LogLevel.Error)
Interlocked.Increment(ref _droppedLowPriorityCount);
}
public void Flush(TimeSpan timeout)
{
_wakeEvent.Set();
_drainedEvent.Wait(timeout);
lock (_writerLock)
{
_writer?.Flush();
}
}
public void OnDestroy()
{
_isRunning = false;
_wakeEvent.Set();
if (!_worker.Join(_settings.ShutdownDrainMilliseconds))
Flush(TimeSpan.FromMilliseconds(_settings.ShutdownDrainMilliseconds));
lock (_writerLock)
{
_writer?.Flush();
_writer?.Dispose();
_writer = null;
}
if (!_worker.IsAlive)
{
_wakeEvent.Dispose();
_drainedEvent.Dispose();
}
}
private bool TryEnqueue(LogEntry entry)
{
while (true)
{
var current = Volatile.Read(ref _queuedCount);
if (current >= _settings.QueueCapacity && entry.Level < LogLevel.Error)
return false;
if (Interlocked.CompareExchange(ref _queuedCount, current + 1, current) == current)
break;
}
_drainedEvent.Reset();
_queue.Enqueue(entry);
_wakeEvent.Set();
return true;
}
private void WriterLoop()
{
while (_isRunning || !_queue.IsEmpty)
{
_wakeEvent.WaitOne(_settings.FlushIntervalMilliseconds);
DrainQueue();
}
DrainQueue();
}
private void DrainQueue()
{
_batch.Clear();
while (_batch.Count < _settings.BatchSize && _queue.TryDequeue(out var entry))
{
Interlocked.Decrement(ref _queuedCount);
_batch.Add(entry);
}
var dropped = Interlocked.Exchange(ref _droppedLowPriorityCount, 0);
if (dropped > 0)
{
_batch.Add(LogEntry.System(
LogLevel.Warning,
LogCategory.General,
$"Dropped {dropped} low-priority log entries because the queue was full."));
}
if (_batch.Count == 0)
{
if (_queue.IsEmpty)
_drainedEvent.Set();
return;
}
lock (_writerLock)
{
foreach (var entry in _batch)
{
WriteEntry(entry);
}
_writer?.Flush();
}
if (!_queue.IsEmpty)
_wakeEvent.Set();
if (_queue.IsEmpty)
_drainedEvent.Set();
}
private void WriteEntry(LogEntry entry)
{
try
{
var line = LogFormatter.Format(entry);
var byteCount = Encoding.UTF8.GetByteCount(line) + Environment.NewLine.Length;
if (_currentFileBytes + byteCount > _settings.MaxFileBytes)
OpenNewFile();
_writer.WriteLine(line);
_currentFileBytes += byteCount;
}
catch
{
_isRunning = false;
}
}
private void OpenNewFile()
{
_writer?.Flush();
_writer?.Dispose();
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var platform = _settings.Platform;
var sessionId = _settings.SessionId;
var baseFileName = $"{timestamp}_{platform}_v{_settings.AppVersion}_session-{sessionId}";
_currentFilePath = BuildUniquePath(baseFileName);
_writer = new StreamWriter(new FileStream(_currentFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read), Encoding.UTF8)
{
AutoFlush = false
};
_currentFileBytes = 0;
DeleteOldFiles();
}
private void DeleteOldFiles()
{
var directory = new DirectoryInfo(_directory);
if (!directory.Exists)
return;
var files = directory.GetFiles("*.log");
Array.Sort(files, (a, b) => b.LastWriteTimeUtc.CompareTo(a.LastWriteTimeUtc));
long totalBytes = 0;
for (var i = 0; i < files.Length; i++)
{
totalBytes += files[i].Length;
var shouldDelete = i >= _settings.MaxSessionFiles || totalBytes > _settings.MaxTotalBytes;
if (!shouldDelete)
continue;
try
{
files[i].Delete();
}
catch
{
// Best-effort cleanup only.
}
}
}
private static string SanitizeFileName(string fileName)
{
foreach (var invalid in Path.GetInvalidFileNameChars())
{
fileName = fileName.Replace(invalid, '_');
}
return fileName;
}
private string BuildUniquePath(string baseFileName)
{
var safeBase = SanitizeFileName(baseFileName);
var path = Path.Combine(_directory, safeBase + ".log");
var index = 1;
while (File.Exists(path))
{
path = Path.Combine(_directory, $"{safeBase}_{index}.log");
index++;
}
return path;
}
}
}