• 如何基于Microsoft.Extensions.Logging实现日志扩展功能
  • 发布于 1周前
  • 56 热度
    0 评论
前言
某些情况下我们可能希望基于文件类导出日志,这样我们可以避免 console 的日志太多不好查找,基于文件就可以比较方便的查看和操作了,于是动手写了一个简单的基于文件的 Microsoft.Extensions.Logging 的日志扩展。

思考
为了避免所有的日志信息都记录到一个文件里导致文件太大,我们可以考虑支持按日期 rolling update,不同日期的日志存在不同的日志文件中,这样也比较清晰和便于查找有时候可能只想高级别的日志记录到文件,我们可以增加一个最小的日志级别,默认设置为 Information,用户可以根据需要自行调整。最后为了支持比较好的扩展和自定义,日志的格式允许自定义,默认输出为 JSON Line,用户可以自定义输出格式为自己想要的格式,另外如果想要忽略某一个日志,可以返回 null 就认为忽略这条日志。

使用起来应该和 Console 差别不大,API 保持一致,使用示例如下:
var services = new ServiceCollection()
    .AddLogging(builder =>
        // builder.AddConsole()
        builder.AddFile()
        );
实施
为了自定义方便,我们准备一个 FileLoggingOptions
public 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<string, LogLevel, Exception?, string, DateTimeOffset, string?>? LogFormatter { get; set; }
}
API 使用上保持和 AddConsole 之类的风格,我们添加一个 AddFile 的扩展方法,基于 ILoggerBuild 进行扩展,并且提供一个可选的委托参数用来自定义配置。
public static ILoggingBuilder AddFile(this ILoggingBuilder loggingBuilder, Action<FileLoggingOptions>? optionsConfigure = null)
{
    var options = new FileLoggingOptions();
    optionsConfigure?.Invoke(options);
    return loggingBuilder.AddProvider(new FileLoggerProvider(options));
}
扩展方法和自定义配置是 public 的部分,接着来实现非 public 的部分,logging 的重要组成部分分为三部分:
1.ILoggerFactory
2.ILoggerProvider

3.ILogger

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

实现如下:
[ProviderAlias("File")]
internal sealed class FileLoggerProvider : ILoggerProvider
{
    private readonly FileLoggingOptions _options;
    private readonly FileLoggingProcessor _loggingProcessor;
    private readonly ConcurrentDictionary<string, FileLogger> _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));
    }
}
FileLogger 实现如下:
internal sealed class FileLogger(string categoryName, FileLoggingOptions options, FileLoggingProcessor processor) : ILogger
{
    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
    {
        if (logLevel < options.MinimumLevel)
            return;
        // 堆代码 duidaima.com
        var timestamp = DateTimeOffset.Now;
        var msg = formatter(state, exception);
        var log = options.LogFormatter!.Invoke(categoryName, logLevel, exception, msg, timestamp);
        if (log is not null)
        {
            processor.EnqueueLog(log, timestamp);
        }
    }
    public bool IsEnabled(LogLevel logLevel) => logLevel >= options.MinimumLevel;
    IDisposable ILogger.BeginScope<TState>(TState state) => NullScope.Instance;
}
这里的逻辑可以看到还是比较简单的,主要的逻辑看来是 FileLoggingProcessor log 的处理都在这个 FileLoggingProcessor 之中,实现如下:
internal sealed class FileLoggingProcessor : DisposableBase
{
    private readonly FileLoggingOptions _options;
    private readonly BlockingCollection<(string log, DateTimeOffset timestamp)> _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
            {
                _messageQueue.CompleteAdding();
            }
            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 null)
                {
                    originalWriter.Flush();
                    originalWriter.Dispose();
                }
            }

            Guard.NotNull(_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);
        }
    }
}
FileLoggingProcessor 主要参考了 Console 里的 processor,为了避免多线程写文件冲突,我们只在一个线程中写文件。为了支持 rolling update 我们会根据 log file format 判断当前要写入的文件名称是否发生变化,如果发生了变化需要先将之前的文件流释放,针对新文件开启新的文件流。

实例
我们来看一个简单使用示例和效果:
using var services = new ServiceCollection()
    .AddLogging(builder =>
        builder.AddFile()
        )
    .BuildServiceProvider();

var logger = services.GetRequiredService<ILoggerFactory>()
    .CreateLogger("test");
while (!ApplicationHelper.ExitToken.IsCancellationRequested)
{
    logger.LogInformation("Echo time: {Time}", DateTimeOffset.Now);
    Thread.Sleep(500);
}
这里写了一个简单的 log 示例,输出一下当前的时间

log file sample

log 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 格式:

其它
目前的实现主要是为了示例应用,没有做太多的优化,还有一些可以优化的地方。前面实现的 FileLoggerProvider 只做了基本的实现,如果要支持记录 ActivityId/TraceId 的话还要实现 ISupportExternalScope ,感兴趣的话可以自己探索一下。每一个日志都做了一次写入 Flush 可以考虑批量的写入以减少文件写入的次数从而提升文件操作的性能,可以基于时间和 Batch Size 两个维度来做一个批量的操作,感兴趣可以自己研究一下。

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