Serilog 是 dotnet 日志生态中的重要角色。它能够生成结构化日志,并提供了各类 sink 集成各类日志采集组件。
在实体机部署的场景下,它能每日生成独立的日志文件,自带文件保留天数功能。
在云原生环境下,它也能够和 ElasticSearch、SEQ 等日志采集中间件集成,实现日志集中查询以及链路追踪。

在后端项目开发初始,将 Serilog 正确的集成至关重要。 本文将讲解在 asp.net core 项目中集成 Serilog 的两种方式,以及相关的技术细节。

文中的代码样例可访问 https://github.com/wswind/SerilogSample 下载。

Serilog.AspNetCore

Serilog 集成 asp.net core 仅需要安装 nuget 包 Serilog.AspNetCore 项目地址为: https://github.com/serilog/serilog-aspnetcore

从源码实现上看,该代码库中仅包含请求日志(SerilogRequestLogging)的功能。也就是当后端接口响应时的提示日志:

# 打印日志样例
[12:01:47 INF] HTTP GET / responded 200 in 95.0581 ms

虽然该项目本身没有包含集成所需的所有代码,可是项目所依赖的包中,包含了所需要的其他 Serilog 包。这些包也都能在 GitHub 上找到源码。

  <ItemGroup>
    <PackageReference Include="Serilog" Version="4.3.0" />
    <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
    <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
    <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
    <PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
  </ItemGroup>

  <ItemGroup>
    <!-- The versions of all references in this group must match the major and minor components of the package version prefix. -->
    <PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
    <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
  </ItemGroup>

项目 README 中为我们提供了两种集成方式:

  1. 方式一直接创建 Logger,给静态的 Log.Logger 实例进行赋值。之后通过 AddSerilog() 将构建的静态 Logger 实例,注册到 IoC 容器。
  2. 方式二是两步创建(Two-stage):先创建一个简单的 Logger 临时用于启动过程中的日志记录,再在服务启动后通过 IoC 容器构建出完整的 Logger,重载替换最开始创建的临时实例。

下面我们对这两种方式进行差异比较。

方式一 直接创建

本方法的完整代码样例,可以查看:https://github.com/wswind/SerilogSample/tree/main/initwithconfig

方式一的好处是简单直接,一开就手动创建 Logger,然后一直用到最后。

# 创建静态的 Logger 实例
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateLogger();

# IoC 注册
builder.Services.AddSerilog();

坏处也很明显,Serilog 需要使用的配置信息,在刚初始化的时候,并没有完成 IoC 容器的注册。

而自己读取 appsetting.json 文件也需要实现一系列的代码,还需要考虑很多问题:

  1. 由于 asp.net core 不同环境下会读取不同的附加配置文件,测试读 appsettings.Development.json,预发布读 appsettings.Staging.json,生产读 appsettings.Production.json。需要依据当前环境类型,读取不同的配置文件;
  2. 不同的项目模板,Web API 项目和 Worker 项目所读取的环境变量也不尽相同:前者基于 ASPNETCORE_ENVIRONMENT 环境变量的值认定当前环境是测试还是生产,不过后者会默认读取 DOTNET_ENVIRONMENT;
  3. Windows IIS 部署的时候,程序运行的当前目录会切换到系统目录。这个时候,appsettings.json 文件的路径的认定也许额外编码处理。

所以你可能需要单独封装一个 ConfigTool 来处理上述逻辑,再借助它,实现 Logger 实例的创建。

public static class ConfigTool
{
    public static IConfiguration GetConfiguration(string? basePath = null)
    {
        basePath ??= AppContext.BaseDirectory;

        string envName = GetEnvironmentName();

        var configBuilder = new ConfigurationBuilder()
                                .SetBasePath(basePath)
                                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
                                .AddJsonFile($"appsettings.{envName}.json", optional: true)
                                .AddEnvironmentVariables();

        return configBuilder.Build();
    }

    public static string GetEnvironmentName()
    {
        var envNames = new List<string>(){"ASPNETCORE_ENVIRONMENT", "DOTNET_ENVIRONMENT"};
        string envValue = "Production";
        
        foreach (var envName in envNames)
        {
            var envVar = Environment.GetEnvironmentVariable(envName);
            if (!string.IsNullOrEmpty(envVar))
            {
                envValue = envVar;
                break;
            }
        }

        return envValue;
    }
}
// create logger
var config = ConfigTool.GetConfiguration();
Log.Logger = new LoggerConfiguration()
            .ReadFrom.Configuration(config)
            .CreateLogger();
// add serilog 
builder.Services.AddSerilog(); 

如果说读取上面的问题还勉强可以解决,当你需要使用 ReadFrom.Services(services) 来做一些自定义的拓展实现时,依赖这种方式就会更加复杂。脱离了 IoC 容器,所有东西就都要从自己从头创建和配置。

方式二 两步创建

为了解决上面提到的难题,你也可以使用两步创建的模式。本方法的完整代码样例,可以查看:https://github.com/wswind/SerilogSample/tree/main/twostage

方式二的集成方法,相比方式一有一些调整:

  1. CreateLogger 改成了 CreateBootstrapLogger()
  2. AddSerilog() 调整为
builder.Services.AddSerilog(
    (services, lc) => lc
        .ReadFrom.Configuration(builder.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext()
    );

方式二中,将 CreateLogger 替换为 CreateBootstrapLogger 配合新的 AddSerilog 方法,实现了在运行过程中替换日志对象实例的功能。

我们来深入看看这个方式的调整。看看 Serilog 是如何实现可重载的 Logger 实例的。

方式一中使用的 CreateLogger 方法定义十分简单,他是 LoggerConfiguration 类中定义的方法,在 Serilog 主项目中实现:

// https://github.com/serilog/serilog/blob/6c3fbcf636b0671bbd6f5032b61a2254937d8408/src/Serilog/LoggerConfiguration.cs#L127
public Logger CreateLogger()

方式二中的 CreateBootstrapLogger 方法是一个拓展方法,它返回的是一个可重载的 Logger。这个方法在项目 Serilog.Extensions.Hosting 中实现:

// https://github.com/serilog/serilog-extensions-hosting/blob/0d142314fdef1b60e3b7703fa5cd84ddc74c4ffc/src/Serilog.Extensions.Hosting/LoggerConfigurationExtensions.cs#L37
public static ReloadableLogger CreateBootstrapLogger(this LoggerConfiguration loggerConfiguration)

ReloadableLogger 是对 Logger 的包装,并且提供了 Reload 方法,能替换当前生效的 logger 实例。
Logger 和 ReloadableLogger 都是对 ILogger 接口的实现。 Log.Logger 记录的是 ILogger 实例。

// https://github.com/serilog/serilog/blob/6c3fbcf636b0671bbd6f5032b61a2254937d8408/src/Serilog/Core/Logger.cs#L26
public sealed class Logger : ILogger, ILogEventSink, IDisposable {}

// https://github.com/serilog/serilog-extensions-hosting/blob/0d142314fdef1b60e3b7703fa5cd84ddc74c4ffc/src/Serilog.Extensions.Hosting/Extensions/Hosting/ReloadableLogger.cs#L28
public sealed class ReloadableLogger : LoggerBase, ILogger, IReloadableLogger, IDisposable
{
    Logger _logger;
    ...
    /// <summary>
    /// Reload the logger using the supplied configuration delegate.
    /// </summary>
    /// <param name="configure">A callback in which the logger is reconfigured.</param>
    /// <exception cref="ArgumentNullException"><paramref name="configure"/> is null.</exception>
    public void Reload(Func<LoggerConfiguration, LoggerConfiguration> configure)
    {
        if (configure == null) throw new ArgumentNullException(nameof(configure));
        
        lock (_sync)
        {
            _logger.Dispose();
            _logger = configure(new LoggerConfiguration()).CreateLogger();
        }
    }
    ...
}

方式一使用的 AddSerilog 方法,没有任何参数传入,程序会默认使用 Log.Logger 来处理所有的日志逻辑。 此处的代码实现流程主要包括:

// 1. 注册 SerilogLoggerFactory
// https://github.com/serilog/serilog-extensions-hosting/blob/0d142314fdef1b60e3b7703fa5cd84ddc74c4ffc/src/Serilog.Extensions.Hosting/SerilogServiceCollectionExtensions.cs#L76
collection.AddSingleton<ILoggerFactory>(_ => new SerilogLoggerFactory(logger, dispose));


// 2. 创建 SerilogLoggerProvider
// https://github.com/serilog/serilog-extensions-logging/blob/16e10484aed1dec6504c55fa585b2a29f3ffd31d/src/Serilog.Extensions.Logging/Extensions/Logging/SerilogLoggerFactory.cs#L36
public SerilogLoggerFactory(ILogger? logger = null, bool dispose = false, LoggerProviderCollection? providerCollection = null)
{
    _provider = new SerilogLoggerProvider(logger, dispose);
    _providerCollection = providerCollection;
}

// 3. SerilogLoggerProvider 提供的 CreateLogger 方法会返回静态的 Log.Logger 日志对象实例

// SerilogLoggerProvider.CreateLogger
// https://github.com/serilog/serilog-extensions-logging/blob/16e10484aed1dec6504c55fa585b2a29f3ffd31d/src/Serilog.Extensions.Logging/Extensions/Logging/SerilogLoggerProvider.cs#L73
public FrameworkLogger CreateLogger(string name)
{
    return new SerilogLogger(this, _logger, name);
}

// SerilogLogger 构造方法
// https://github.com/serilog/serilog-extensions-logging/blob/16e10484aed1dec6504c55fa585b2a29f3ffd31d/src/Serilog.Extensions.Logging/Extensions/Logging/SerilogLogger.cs#L43
public SerilogLogger(
    SerilogLoggerProvider provider,
    ILogger? logger = null,
    string? name = null)
{
    _provider = provider ?? throw new ArgumentNullException(nameof(provider));

    // 如果 logger 为 null 则使用 Log.Logger
    _logger = logger ?? Serilog.Log.Logger.ForContext([provider]);

    if (name != null)
    {
        _logger = _logger.ForContext(Constants.SourceContextPropertyName, name);
    }
}

方式二使用的 AddSerilog 拓展方法中,程序会判断 Log.Logger 实例是不是可重载的。如果是,则调用上面提到的 Reload 方法,进行对象实例的重载。也就是将完全配置的日志对象实例,替换之前临时创建的实例。

// https://github.com/serilog/serilog-extensions-hosting/blob/0d142314fdef1b60e3b7703fa5cd84ddc74c4ffc/src/Serilog.Extensions.Hosting/SerilogServiceCollectionExtensions.cs#L129
public static IServiceCollection AddSerilog(
    this IServiceCollection collection,
    Action<IServiceProvider, LoggerConfiguration> configureLogger,
    bool preserveStaticLogger = false,
    bool writeToProviders = false)
{
    var reloadable = Log.Logger as ReloadableLogger;
    var useReload = reloadable != null && !preserveStaticLogger;
    ...
    collection.AddSingleton(services =>
    {
        ILogger logger;
        if (useReload)
        {
            reloadable!.Reload(cfg =>
            {
                if (loggerProviders != null)
                    cfg.WriteTo.Providers(loggerProviders);
                    
                configureLogger(services, cfg);
                return cfg;
            });
                
            logger = reloadable.Freeze();
        }
        else
        {
            var loggerConfiguration = new LoggerConfiguration();

            if (loggerProviders != null)
                loggerConfiguration.WriteTo.Providers(loggerProviders);

            configureLogger(services, loggerConfiguration);
            logger = loggerConfiguration.CreateLogger();
        }

        return new RegisteredLogger(logger);
    }
    ...
}

上面基本讲清楚了方式一和方式二各自的实现方法与核心区别。在日常开发中,建议优先选择方式二。

preserveStaticLogger

可以看到,上面的 AddSerilog 方法还提供了 preserveStaticLogger 参数,传 true 可以让 Log.Logger 静态对象实例和 asp.net core 容器中的日志对象实例不一致,不执行 reload 逻辑。

在 AddSerilog 方法中,程序调用了 reloadable.Freeze() ,来对 Serilog 进行冻结。冻结后不允许后续配置。

有的时候,部分开发者会在 AddService 的时候,使用 BuildServiceProvider 来创建临时容器,以便获取对象。这类处理逻辑属于 Bad Practise。它会导致容器被多次创建,Freeze 被多次执行。 Serilog会因此报错 “The logger is already frozen”。

如果不想处理此类遗留代码,可以在 preserveStaticLogger 参数传 true 。不过传 true 后,其实体现不出 ReloadableLogger 的优势,此时和使用方式一的 CreateLogger 加上方式二的 AddSerilog 效果相同。

更多细节,可查看这个 issue: https://github.com/serilog/serilog-aspnetcore/issues/251

集成请求日志

上面提到过,Serilog.AspNetCore 项目中实现了请求日志功能,可以查看请求记录以及请求耗时计算。
这个功能基于 asp.net core 中间件实现。集成的方法很简单,使用 app.UseSerilogRequestLogging(); 就行了。 不过需要注意注册顺序,注册它之前的中间件处理不会被计时或显示日志。

结语

本文讲解了集成 Serilog.AspNetCore 的两种方法及代码实现细节。希望对你的开发工作有帮助。