动手造轮子 - 实现基于文件的日志扩展

B站影视 2024-12-20 08:41 2

摘要:某些情况下我们可能希望基于文件类导出日志,这样我们可以避免 console 的日志太多不好查找,基于文件就可以比较方便的查看和操作了,于是动手写了一个简单的基于文件的

某些情况下我们可能希望基于文件类导出日志,这样我们可以避免 console 的日志太多不好查找,基于文件就可以比较方便的查看和操作了,于是动手写了一个简单的基于文件的Microsoft.Extensions.Logging的日志扩展Thoughts

为了避免所有的日志信息都记录到一个文件里导致文件太大,我们可以考虑支持按日期 rolling update,不同日期的日志存在不同的日志文件中,这样也比较清晰和便于查找

有时候可能只想高级别的日志记录到文件,我们可以增加一个最小的日志级别,默认设置为 Information,用户可以根据需要自行调整

最后为了支持比较好的扩展和自定义,日志的格式允许自定义,默认输出为 JSON Line,用户可以自定义输出格式为自己想要的格式,另外如果想要忽略某一个日志,可以返回 就认为忽略这条日志

使用起来应该和Console差别不大,API 保持一致,使用示例如下:var services = new ServiceCollection
.AddLogging(builder =>
// builder.AddConsole
builder.AddFile
);
ImplementationFileLoggingOptionspublic sealed class FileLoggingOptions
{
public string LogsDirectory { get; set; } = "Logs";
public string FileFormat { get; set; } = "app-logs-{date}.log";
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
public Func? LogFormatter { get; set; }
}
API 使用上保持和AddConsole之类的风格,我们添加一个AddFile的扩展方法,基于ILoggerBuild进行扩展,并且提供一个可选的委托参数用来自定义配置public static ILoggingBuilder AddFile(this ILoggingBuilder loggingBuilder, Action? optionsConfigure = )
{
var options = new FileLoggingOptions;
optionsConfigure?.Invoke(options);
return loggingBuilder.AddProvider(new FileLoggerProvider(options));
}

扩展方法和自定义配置是 public 的部分,接着来实现非 public 的部分,logging 的重要组成部分分为三部分

ILoggerFactory

ILoggerProvider

ILogger

而我们的 file logging 只是其中一种ILoggerProvider,主要提供ILogger示例来记录具体的日志

实现如下:

[ProviderAlias("File")]
internal sealed class FileLoggerProvider : ILoggerProvider
{
private readonly FileLoggingOptions _options;
private readonly FileLoggingProcessor _loggingProcessor;
private readonly ConcurrentDictionary _loggers = new;
public FileLoggerProvider(FileLoggingOptions options)
{
_options = options;
_options.LogFormatter ??= (category, level, exception, msg, timestamp) => JsonConvert.SerializeObject(new
{
level,
timestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"),
category,
msg,
exception = exception?.ToString
}, JsonSerializeExtension.DefaultSerializerSettings);
_loggingProcessor = new FileLoggingProcessor(options);
}

public void Dispose => _loggingProcessor.Dispose;

public ILogger CreateLogger(string categoryName)
{
return _loggers.GetOrAdd(categoryName, category => new FileLogger(category, _options, _loggingProcessor));
}
}
internal sealed class FileLogger(string categoryName, FileLoggingOptions options, FileLoggingProcessor processor) : ILogger
{
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
if (logLevel < options.MinimumLevel)
return;

var timestamp = DateTimeOffset.Now;
var msg = formatter(state, exception);
var log = options.LogFormatter!.Invoke(categoryName, logLevel, exception, msg, timestamp);
if (log is not )
{
processor.EnqueueLog(log, timestamp);
}
}

public bool IsEnabled(LogLevel logLevel) => logLevel >= options.MinimumLevel;

IDisposable ILogger.BeginScope(TState state) => Scope.Instance;
}
这里的逻辑可以看到还是比较简单的,主要的逻辑看来是FileLoggingProcessorlog 的处理都在这个之中,实现如下:internal sealed class FileLoggingProcessor : DisposableBase
{
private readonly FileLoggingOptions _options;
private readonly BlockingCollection _messageQueue = ;
private readonly Thread _outputThread;

private FileStream? _fileStream;
private string? _logFileName;

public FileLoggingProcessor(FileLoggingOptions options)
{
if (!Directory.Exists(options.LogsDirectory))
{
try
{
Directory.CreateDirectory(options.LogsDirectory);
}
catch (Exception e)
{
throw new InvalidOperationException("Failed to create log directory", e);
}
}

_options = options;
_outputThread = new Thread(ProcessLogQueue)
{
IsBackground = true,
Priority = ThreadPriority.BelowNormal,
Name = "FileLoggingProcessor"
};
_outputThread.Start;
}

public void EnqueueLog(string log, DateTimeOffset dateTimeOffset)
{
if (_messageQueue.IsAddingCompleted) return;

try
{
_messageQueue.Add((log, dateTimeOffset));
}
catch (InvalidOperationException) { }
}

protected override void Dispose(bool disposing)
{
if (!disposing) return;

_messageQueue.CompleteAdding;
_fileStream?.Flush;
_fileStream?.Dispose;
_messageQueue.Dispose;
}

private void ProcessLogQueue
{
try
{
foreach (var message in _messageQueue.GetConsumingEnumerable)
{
WriteLoggingEvent(message.log, message.timestamp);
}
}
catch
{
try
{

}
catch
{
// ignored
}
}
}

private void WriteLoggingEvent(string log, DateTimeOffset timestamp)
{
var fileName = _options.FileFormat.Replace("{date}", timestamp.ToString("yyyyMMdd"));
var fileInfo = new FileInfo(Path.Combine(_options.LogsDirectory, fileName));

try
{
var previousFileName = Interlocked.CompareExchange(ref _logFileName, fileInfo.FullName, _logFileName);
if (_logFileName != previousFileName)
{
// file name changed
var fs = File.OpenWrite(fileInfo.FullName);
var originalWriter = Interlocked.Exchange(ref _fileStream, fs);
if (originalWriter is not )
{
originalWriter.Flush;
originalWriter.Dispose;
}
}

Guard.Not(_fileStream);
var bytes = Encoding.UTF8.GetBytes(log);
_fileStream.Write(bytes, 0, bytes.Length);
_fileStream.Flush;
}
catch (Exception ex)
{
Console.WriteLine($@"Error when trying to log to file({fileInfo.FullName}) \n" + log + Environment.NewLine + ex);
}
}
}
主要参考了 Console 里的 processor,为了避免多线程写文件冲突,我们只在一个线程中写文件

为了支持 rolling update 我们会根据 log file format 判断当前要写入的文件名称是否发生变化,如果发生了变化需要先将之前的文件流释放,针对新文件开启新的文件流

Sample

我们来看一个简单使用示例和效果:

using var services = new ServiceCollection
.AddLogging(builder =>
builder.AddFile
)
.BuildServiceProvider;

var logger = services.GetRequiredService
.CreateLogger("test");
while (!ApplicationHelper.ExitToken.IsCancellationRequested)
{
logger.LogInformation("Echo time: {Time}", DateTimeOffset.Now);
Thread.Sleep(500);
}

这里写了一个简单的 log 示例,输出一下当前的时间

log file samplelog file sample2

可以看到我们的日志正常输出到了文件~

如果我们不想使用默认的 JSON 输出格式,也可以自定义输出的 format

using var services = new ServiceCollection
.AddLogging(builder =>
// builder.AddConsole
builder.AddFile(options => options.LogFormatter = (category, level, exception, msg, timestamp) =>
$"{timestamp} - [{category}] {level} - {msg}\n{exception}")
)
.BuildServiceProvider;

此时输出的日志格式就不再是 JSON 格式:

log-file-sample-3More

目前的实现主要是为了示例应用,没有做太多的优化,还有一些可以优化的地方

前面实现的FileLoggerProvider只做了基本的实现,如果要支持记录ActivityId/TraceId的话还要实现ISupportExternalScope,感兴趣的话可以自己探索一下。

每一个日志都做了一次写入 Flush 可以考虑批量的写入以减少文件写入的次数从而提升文件操作的性能,可以基于时间和 Batch Size 两个维度来做一个批量的操作,感兴趣可以自己研究一下。

如果生产使用建议使用 Serilog 等成熟日志框架,本地调试可以的话还是推荐本地使用 aspire-dashboard 来查看本地的 console 的 log,而且可以和 trace 做一些关联,可以查看之前 aspire-dashboard 的分享 使用 aspire-dashboard 展示 open-telemetry trace/logging/metrics

References

https://github.com/WeihanLi/WeihanLi.Common/blob/9529873d4d58ea2c0f8482bc64febe83c9a3f4f6/src/WeihanLi.Common/Logging/MicrosoftLoggingLoggerExtensions.cs#L99

https://github.com/WeihanLi/WeihanLi.Common/blob/9529873d4d58ea2c0f8482bc64febe83c9a3f4f6/samples/DotNetCoreSample/LoggerTest.cs#L35

使用 aspire-dashboard 展示 open-telemetry trace/logging/metrics

来源:opendotnet

相关推荐