65.9K
CodeProject 正在变化。 阅读更多。
Home

如何在 ASP.NET Core 中编写自定义日志提供程序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2019 年 4 月 15 日

CPOL

4分钟阅读

viewsIcon

88199

如何为 ASP.NET Core 编写自定义日志提供程序

引言

源代码可以在 github 上找到。

目前在可用的 文档 中,还没有关于如何在 ASP.NET Core 中编写自定义日志提供程序的官方说明。因此,如果有人需要为 ASP.NET Core 编写自定义日志提供程序,他必须研究该文档和框架的相关源代码。

所需的组件是

  • 一个简单的日志选项类,即一个 POCO
  • ILogger 接口的实现
  • ILoggerProvider 接口的实现
  • 一些用于将日志提供程序注册到框架的扩展方法

让我们来看看这两个接口

namespace Microsoft.Extensions.Logging
{
    public interface ILogger
    {
        IDisposable BeginScope<TState>(TState state);
        bool IsEnabled(LogLevel logLevel);
        void Log<TState>(LogLevel logLevel, EventId eventId, 
             TState state, Exception exception, Func<TState, Exception, string> formatter);
    }
}
 
namespace Microsoft.Extensions.Logging
{
    public interface ILoggerProvider : IDisposable
    {
        ILogger CreateLogger(string categoryName);
    }
}

如何实现必需的接口

ILoggerProvider 的唯一目的是在框架请求时创建 ILogger 实例。

ILogger 提供了 Log() 方法。对 Log() 的调用会产生一个日志信息单元,即一条日志条目。

这两个代码元素中的哪一个应该负责显示或持久化该日志条目?

通过研究 ASP.NET Core 代码,很明显这个责任归属于 ILogger 的实现,例如 ConsoleLoggerDebugLoggerEventLogLogger 类。我们也应该这样做吗?

如果答案是肯定的,那么对于任何介质,例如文本文件、数据库或消息队列,我们需要一个 ILogger **和**一个 ILoggerProvider 实现。

如果答案是否定的,那么我们只需要一个通用的 ILogger 实现和一个 ILoggerProvider 实现来处理任何不同的介质。

我们将遵循第二种方法。

一个单一的通用 Logger 类,它产生一个日志信息单元,将该信息打包到一个 LogEntry 类的实例中,然后将该实例传递给其创建者 LoggerProvider 进行进一步处理。该 LoggerProvider 将作为一个基类,因此任何针对不同介质(文本文件、数据库等)的特殊化都将转到派生的 LoggerProvider 类。

我们将应用上述思想并创建一个 FileLoggerProvider 类。

基类和辅助组件

LogEntry 代表日志条目的信息。当调用 LoggerLog() 方法时,Logger 会创建一个该类的实例,填充属性,然后通过调用 WriteLog() 将该信息传递给提供程序。

public class LogEntry
{ 
    public LogEntry()
    {
        TimeStampUtc = DateTime.UtcNow;
        UserName = Environment.UserName;
    }
 
    static public readonly string StaticHostName = System.Net.Dns.GetHostName();
 
    public string UserName { get; private set; }
    public string HostName { get { return StaticHostName; } }
    public DateTime TimeStampUtc { get; private set; }
    public string Category { get; set; }
    public LogLevel Level { get; set; }
    public string Text { get; set; }
    public Exception Exception { get; set; }
    public EventId EventId { get; set; }
    public object State { get; set; }
    public string StateText { get; set; }
    public Dictionary<string, object> StateProperties { get; set; }
    public List<LogScopeInfo> Scopes { get; set; }
}

LogScopeInfo 代表与 LogEntry 相关的 Scope 信息。

public class LogScopeInfo
{ 
    public LogScopeInfo()
    {
    }
 
    public string Text { get; set; }
    public Dictionary<string, object> Properties { get; set; }
}

Logger 代表一个处理日志信息的对象。此类**不**在某个介质中保存日志信息。它的唯一职责是创建一个 LogEntry。然后它填充该实例的属性,然后将其传递给关联的日志提供程序进行进一步处理。

internal class Logger : ILogger
{ 
    public Logger(LoggerProvider Provider, string Category)
    {
        this.Provider = Provider;
        this.Category = Category;
    }
 
    IDisposable ILogger.BeginScope<TState>(TState state)
    {
        return Provider.ScopeProvider.Push(state);
    }
 
    bool ILogger.IsEnabled(LogLevel logLevel)
    {
        return Provider.IsEnabled(logLevel);
    }
 
    void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, 
        TState state, Exception exception, Func<TState, Exception, string> formatter)
    {
        if ((this as ILogger).IsEnabled(logLevel))
        { 
            LogEntry Info = new LogEntry();
            Info.Category = this.Category;
            Info.Level = logLevel;
            // well, the passed default formatter function 
            // does not take the exception into account
            // SEE: https://github.com/aspnet/Extensions/blob/master/src/
              Logging/Logging.Abstractions/src/LoggerExtensions.cs
            Info.Text = exception?.Message ?? state.ToString(); // formatter(state, exception)
            Info.Exception = exception;
            Info.EventId = eventId;
            Info.State = state;
 
            // well, you never know what it really is
            if (state is string)   
            {
                Info.StateText = state.ToString();
            }
            // in case we have to do with a message template, 
            // let's get the keys and values (for Structured Logging providers)
            // SEE: https://docs.microsoft.com/en-us/aspnet/core/
            // fundamentals/logging#log-message-template
            // SEE: https://softwareengineering.stackexchange.com/
            // questions/312197/benefits-of-structured-logging-vs-basic-logging
            else if (state is IEnumerable<KeyValuePair<string, object>> Properties)
            {
                Info.StateProperties = new Dictionary<string, object>();
 
                foreach (KeyValuePair<string, object> item in Properties)
                {
                    Info.StateProperties[item.Key] = item.Value;
                }
            }
 
            // gather info about scope(s), if any
            if (Provider.ScopeProvider != null)
            {
                Provider.ScopeProvider.ForEachScope((value, loggingProps) =>
                {
                    if (Info.Scopes == null)
                        Info.Scopes = new List<LogScopeInfo>();
 
                    LogScopeInfo Scope = new LogScopeInfo();
                    Info.Scopes.Add(Scope);
 
                    if (value is string)
                    {
                        Scope.Text = value.ToString();
                    }
                    else if (value is IEnumerable<KeyValuePair<string, object>> props)
                    {
                        if (Scope.Properties == null)
                            Scope.Properties = new Dictionary<string, object>();
 
                        foreach (var pair in props)
                        {
                            Scope.Properties[pair.Key] = pair.Value;
                        }
                    }
                },
                state); 
            }
 
            Provider.WriteLog(Info); 
        }
    }
 
    public LoggerProvider Provider { get; private set; }
    public string Category { get; private set; }
}

LoggerProvider 是一个abstract基日志提供程序类。日志提供程序本质上代表保存或显示日志信息的介质。此类可用作编写文件或数据库日志提供程序的基类。

public abstract class LoggerProvider : IDisposable, ILoggerProvider, ISupportExternalScope
{
    ConcurrentDictionary<string, Logger> loggers = new ConcurrentDictionary<string, Logger>();
    IExternalScopeProvider fScopeProvider;
    protected IDisposable SettingsChangeToken;
 
    void ISupportExternalScope.SetScopeProvider(IExternalScopeProvider scopeProvider)
    {
        fScopeProvider = scopeProvider;
    }
 
    ILogger ILoggerProvider.CreateLogger(string Category)
    {
        return loggers.GetOrAdd(Category,
        (category) => {
            return new Logger(this, category);
        });
    }
 
    void IDisposable.Dispose()
    {
        if (!this.IsDisposed)
        {
            try
            {
                Dispose(true);
            }
            catch
            {
            }
 
            this.IsDisposed = true;
            GC.SuppressFinalize(this);  // instructs GC not bother to call the destructor   
        }
    }
 
    protected virtual void Dispose(bool disposing)
    {
        if (SettingsChangeToken != null)
        {
            SettingsChangeToken.Dispose();
            SettingsChangeToken = null;
        }
    } 
 
    public LoggerProvider()
    {
    }
 
    ~LoggerProvider()
    {
        if (!this.IsDisposed)
        {
            Dispose(false);
        }
    }
 
    public abstract bool IsEnabled(LogLevel logLevel);
 
    public abstract void WriteLog(LogEntry Info);
 
    internal IExternalScopeProvider ScopeProvider
    {
        get
        {
            if (fScopeProvider == null)
                fScopeProvider = new LoggerExternalScopeProvider();
            return fScopeProvider;
        }
    }
 
    public bool IsDisposed { get; protected set; }
}

FileLoggerProvider 具体类及其辅助组件

FileLoggerOptions 是文件日志记录器的 Options 类。

public class FileLoggerOptions
{
    string fFolder;
    int fMaxFileSizeInMB;
    int fRetainPolicyFileCount;
 
    public FileLoggerOptions()
    {
    }
 
    public LogLevel LogLevel { get; set; } = Microsoft.Extensions.Logging.LogLevel.Information;
 
    public string Folder
    {
        get { return !string.IsNullOrWhiteSpace(fFolder) ? 
              fFolder : System.IO.Path.GetDirectoryName(this.GetType().Assembly.Location); }
        set { fFolder = value; }
    }
 
    public int MaxFileSizeInMB
    {
        get { return fMaxFileSizeInMB > 0 ? fMaxFileSizeInMB : 2; }
        set { fMaxFileSizeInMB = value; }
    }
 
    public int RetainPolicyFileCount
    {
        get { return fRetainPolicyFileCount < 5 ? 5 : fRetainPolicyFileCount; }
        set { fRetainPolicyFileCount = value; }
    }
}

有两种方法可以配置文件日志记录器的选项

  1. Program.cs 中使用 ConfigureLogging() 并调用 AddFileLogger() 的第二个版本,即带有选项委托的版本,或者
  2. 使用 appsettings.json 文件。

1. ConfigureLogging()

.ConfigureLogging(logging =>
{
    logging.ClearProviders();
    // logging.AddFileLogger(); 
    logging.AddFileLogger(options => {
        options.MaxFileSizeInMB = 5;
    });
}) 

2. appsettings.json 文件

"Logging": {
    "LogLevel": {
      "Default": "Warning"
    },
    "File": {
      "LogLevel": "Debug",
      "MaxFileSizeInMB": 5
    }
  }, 

FileLoggerOptionsSetup 使用 ConfigurationBinder.Bind()IConfiguration 进行配置 FileLoggerOptions 实例。FileLoggerOptionsSetup 类本质上将 FileLoggerOptions 实例与 appsettings.json 文件中的一个节绑定。这是一个至关重要的连接,特别是如果我们想在 appsettings.json 中收到关于我们日志提供程序更改的通知。别担心,这只是管道。

internal class FileLoggerOptionsSetup : ConfigureFromConfigurationOptions<FileLoggerOptions>
{
    public FileLoggerOptionsSetup(ILoggerProviderConfiguration<FileLoggerProvider> 
                                  providerConfiguration)
        : base(providerConfiguration.Configuration)
    {
    }
}

FileLoggerProvider 是一个将日志条目写入文本文件的日志提供程序。File 是该提供程序的别名,可以在 appsettings.jsonLogging 节中使用,参见上文。

FileLoggerProvider 会做一些有趣的事情。

它将 Logger 传递给它的每个 LogEntry 写入一个扩展名为 *.log* 的文本文件,该文件位于 FileLoggerOptions 指定的文件夹中(或者位于 FileLoggerOptions 从中读取的 appsettings.json 中)。

实际上,Logger 调用 abstract LoggerProvider.WriteLog(LogEntry Info)。被重写的 FileLoggerProvider.WriteLog(LogEntry Info) 不会阻塞,因为它将传递的 LogEntry 推送到一个线程安全的队列。稍后,一个线程会检查该队列,弹出 LogEntry 并将其写入文本文件。这是一个异步操作。

FileLoggerProvider 还关心其创建的日志文件的保留策略。它这样做是为了遵守 FileLoggerOptions 中一些与保留策略相关的设置。

FileLoggerProvider,得益于上面显示的 FileLoggerOptionsSetup 以及传递给其构造函数的 IOptionsMonitor,能够收到关于 appsettings.json 文件更改的通知并做出相应的响应。

[Microsoft.Extensions.Logging.ProviderAlias("File")]
public class FileLoggerProvider : LoggerProvider
{ 
    bool Terminated;
    int Counter = 0;
    string FilePath;
    Dictionary<string, int> Lengths = new Dictionary<string, int>();
    
    ConcurrentQueue<LogEntry> InfoQueue = new ConcurrentQueue<LogEntry>();
 
    void ApplyRetainPolicy()
    {
        FileInfo FI;
        try
        {
            List<FileInfo> FileList = new DirectoryInfo(Settings.Folder)
            .GetFiles("*.log", SearchOption.TopDirectoryOnly)
            .OrderBy(fi => fi.CreationTime)
            .ToList();
 
            while (FileList.Count >= Settings.RetainPolicyFileCount)
            {
                FI = FileList.First();
                FI.Delete();
                FileList.Remove(FI);
            }
        }
        catch
        {
        } 
    }
 
    void WriteLine(string Text)
    {
        // check the file size after any 100 writes
        Counter++;
        if (Counter % 100 == 0)
        {
            FileInfo FI = new FileInfo(FilePath);
            if (FI.Length > (1024 * 1024 * Settings.MaxFileSizeInMB))
            {                   
                BeginFile();
            }
        }
 
        File.AppendAllText(FilePath, Text);
    }
 
    string Pad(string Text, int MaxLength)
    {
        if (string.IsNullOrWhiteSpace(Text))
            return "".PadRight(MaxLength);
 
        if (Text.Length > MaxLength)
            return Text.Substring(0, MaxLength);
 
        return Text.PadRight(MaxLength);
    }
 
    void PrepareLengths()
    {
        // prepare the lengs table
        Lengths["Time"] = 24;
        Lengths["Host"] = 16;
        Lengths["User"] = 16;
        Lengths["Level"] = 14;
        Lengths["EventId"] = 32;
        Lengths["Category"] = 92;
        Lengths["Scope"] = 64;
    }
 
    void BeginFile()
    {
        Directory.CreateDirectory(Settings.Folder);
        FilePath = Path.Combine(Settings.Folder, LogEntry.StaticHostName + 
                   "-" + DateTime.Now.ToString("yyyyMMdd-HHmm") + ".log");
 
        // titles
        StringBuilder SB = new StringBuilder();
        SB.Append(Pad("Time", Lengths["Time"]));
        SB.Append(Pad("Host", Lengths["Host"]));
        SB.Append(Pad("User", Lengths["User"]));
        SB.Append(Pad("Level", Lengths["Level"]));
        SB.Append(Pad("EventId", Lengths["EventId"]));
        SB.Append(Pad("Category", Lengths["Category"]));
        SB.Append(Pad("Scope", Lengths["Scope"]));
        SB.AppendLine("Text");
 
        File.WriteAllText(FilePath, SB.ToString());
 
        ApplyRetainPolicy();
    }
 
    void WriteLogLine()
    {
        LogEntry Info = null;
        if (InfoQueue.TryDequeue(out Info))
        {
            string S;
            StringBuilder SB = new StringBuilder();
            SB.Append(Pad(Info.TimeStampUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.ff"), 
                      Lengths["Time"]));
            SB.Append(Pad(Info.HostName, Lengths["Host"]));
            SB.Append(Pad(Info.UserName, Lengths["User"]));
            SB.Append(Pad(Info.Level.ToString(), Lengths["Level"]));
            SB.Append(Pad(Info.EventId != null ? Info.EventId.ToString() : "", 
                      Lengths["EventId"]));
            SB.Append(Pad(Info.Category, Lengths["Category"]));
 
            S = "";
            if (Info.Scopes != null && Info.Scopes.Count > 0)
            {
                LogScopeInfo SI = Info.Scopes.Last();
                if (!string.IsNullOrWhiteSpace(SI.Text))
                {
                    S = SI.Text;
                }
                else
                {
                }
            }
            SB.Append(Pad(S, Lengths["Scope"]));
 
            string Text = Info.Text;
 
            /* writing properties is too much for a text file logger
            if (Info.StateProperties != null && Info.StateProperties.Count > 0)
            {
                Text = Text + " Properties = " + 
                       Newtonsoft.Json.JsonConvert.SerializeObject(Info.StateProperties);
            }                
                */
 
            if (!string.IsNullOrWhiteSpace(Text))
            {
                SB.Append(Text.Replace("\r\n", " ").Replace("\r", " ").Replace("\n", " "));
            }
 
            SB.AppendLine();
            WriteLine(SB.ToString());
        } 
    }
    void ThreadProc()
    {
        Task.Run(() => {
 
            while (!Terminated)
            {
                try
                {
                    WriteLogLine();
                    System.Threading.Thread.Sleep(100);
                }
                catch // (Exception ex)
                {
                }
            } 
        });
    }
 
    protected override void Dispose(bool disposing)
    {
        Terminated = true;
        base.Dispose(disposing);
    }
 
 
    public FileLoggerProvider(IOptionsMonitor<FileLoggerOptions> Settings)
        : this(Settings.CurrentValue)
    {  
        // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/change-tokens
        SettingsChangeToken = Settings.OnChange(settings => {      
            this.Settings = settings;                  
        });
    }
 
    public FileLoggerProvider(FileLoggerOptions Settings)
    {
        PrepareLengths();
        this.Settings = Settings;
 
        // create the first file
        BeginFile();
 
        ThreadProc();
    } 
 
    public override bool IsEnabled(LogLevel logLevel)
    {
        bool Result = logLevel != LogLevel.None
            && this.Settings.LogLevel != LogLevel.None
            && Convert.ToInt32(logLevel) >= Convert.ToInt32(this.Settings.LogLevel);
 
        return Result;
    }
 
    public override void WriteLog(LogEntry Info)
    {
        InfoQueue.Enqueue(Info);
    } 
 
    internal FileLoggerOptions Settings { get; private set; } 
}

FileLoggerExtensions 包含用于将文件日志提供程序(别名为 'File')添加为单例到可用服务中的方法,并将文件日志记录器选项类绑定到 appsettings.json 文件的 'File' 节。

如您所见,没有 ILoggerFactory 扩展,只有 ILoggingBuilder 扩展。这意味着您应该在 Program.cs 中注册文件日志提供程序,如上所示,而不是在 Startup 类中。通过检查 AspNet.Core 代码,关于类似的扩展方法,似乎通过 ILoggerFactory 注册日志提供程序已经过时了。

static public class FileLoggerExtensions
{ 
    static public ILoggingBuilder AddFileLogger(this ILoggingBuilder builder)
    {
        builder.AddConfiguration();
 
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ILoggerProvider, 
                                          FileLoggerProvider>());
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton
           <IConfigureOptions<FileLoggerOptions>, FileLoggerOptionsSetup>());
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton
           <IOptionsChangeTokenSource<FileLoggerOptions>, 
           LoggerProviderOptionsChangeTokenSource<FileLoggerOptions, FileLoggerProvider>>());
        return builder;
    }
 
    static public ILoggingBuilder AddFileLogger
           (this ILoggingBuilder builder, Action<FileLoggerOptions> configure)
    {
        if (configure == null)
        {
            throw new ArgumentNullException(nameof(configure));
        }
 
        builder.AddFileLogger();
        builder.Services.Configure(configure);
 
        return builder;
    }
}

结语

这篇文章很长,但我希望您喜欢它。使用以上内容,您可以根据 abstract LoggerProvider 为任何您喜欢的介质编写自己的日志提供程序。如果您这样做了,请通知我。

上面的代码几乎是完整的源代码。为了清晰起见,我删除了注释。

测试于

  • Visual Studio 2019 Community
  • ASP.NET Core SDK 2.2.2
© . All rights reserved.