所有分类
  • 所有分类
  • 游戏源码
  • 网站源码
  • 单机游戏
  • 游戏素材
  • 搭建教程
  • 精品工具

.NET Core 实现自定义日志记录器:从0到1完整实战教程

.NET Core 实现自定义日志记录器:从0到1完整实战教程 一

文章目录CloseOpen

我们会从日志系统的核心逻辑讲起——拆解.NET Core日志框架的“底层拼图”(比如日志提供器、记录器的角色分工),再一步步带你实战:从定义自己的ILoggerProviderILogger实现,到配置个性化规则,甚至搞定自定义日志格式、多目标输出这些实用需求。全程没有抽象的理论堆砌,每一步都有可运行的代码示例,哪怕是刚接触日志定制的开发者,跟着走也能从0到1搭出专属的日志记录器。

等你学完,不管是想让日志按订单ID分组,还是把关键操作日志同步到ELK,都能靠自己的定制方案轻松实现—— 最懂业务需求的,永远是你自己写的代码。

你有没有过这种情况?用.NET Core的默认日志打日志,要么格式太丑——比如默认的Console日志全是白色,想看Error级别的日志得眯着眼睛找;要么想把日志同时写到数据库和文件里,得装一堆第三方插件,配置文件绕得像迷宫;甚至想给日志加个业务唯一标识(比如订单ID),都得在每个Log语句里手动拼字符串?我去年帮朋友的电商系统做性能优化时就碰到这事儿——他们想把订单支付的日志标红显示,还得同步到ELK用于排查问题,默认的日志组件根本扛不住,最后还是自己写了个自定义日志记录器才搞定。今天我把当时的实战步骤拆得明明白白,没学过日志框架底层原理也能跟着做,亲测能解决90%的自定义需求。

先搞懂.NET Core日志框架的“底层拼图”:别上来就写代码

我刚开始学自定义日志时,直接翻微软文档(https://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/logging?view=aspnetcore-8.0nofollow),看了半天才明白——.NET Core的日志系统其实是“工厂+工人”的模式:ILoggerProvider是工厂,负责创建日志记录器(ILogger);ILogger是工人,真正执行打日志的操作。打个比方,你去蛋糕店买蛋糕,ILoggerProvider就是做蛋糕的师傅,ILogger是做好的蛋糕——你要巧克力味(写文件)就找巧克力师傅,要草莓味(写数据库)就找草莓师傅,每个师傅做出来的蛋糕(ILogger)才是你真正拿到手的东西。

为什么要先搞懂这个?因为自定义日志的核心就是写一个自己的“蛋糕师傅”(ILoggerProvider)和“蛋糕”(ILogger)。我之前踩过坑——没理解这层关系就直接写代码,结果创建了一堆重复的ILogger实例,内存涨了20%还不知道问题在哪。后来才明白:ILoggerProvider是单例的,负责管理ILogger的生命周期,而ILogger应该缓存起来,避免每次请求都创建新实例。

从0到1写自定义日志记录器: step by step 实战

搞懂原理后,接下来就是实战。我把当时的步骤拆成了3步,每一步都有可运行的代码,你跟着复制粘贴就能用。

第一步:定义配置类——把可变参数“拎出来”

先写个配置类,把日志的级别、输出格式、目标地址这些可变参数装进去,不然每次改需求都要动核心代码。我当时写的是这样的:

// 日志输出目标(用 flags 支持多目标)

[Flags]

public enum LogTarget

{

Console = 1,

File = 2,

Database = 4,

ELK = 8

}

// 自定义日志配置类

public class CustomLoggerConfiguration

{

// 最低日志级别(低于这个级别的日志不打)

public LogLevel LogLevel { get; set; } = LogLevel.Information;

// 日志输出模板({0}=时间, {1}=级别, {2}=Category, {3}=Scope, {4}=内容)

public string OutputTemplate { get; set; } = "{0} [{1}] {2} {3}

  • {4}";
  • // 输出目标(支持多目标,比如 Console | File)

    public LogTarget Target { get; set; } = LogTarget.Console;

    // 文件输出路径(Target包含File时生效)

    public string FilePath { get; set; } = "./logs/custom.log";

    // 数据库连接字符串(Target包含Database时生效)

    public string DbConnectionString { get; set; } = string.Empty;

    }

    这个类的作用是“把可变的东西和不变的代码分开”——比如你想把日志级别从Information改成Debug,只需要改配置类的LogLevel,不用动Logger的代码。我朋友的系统后来要加ELK输出,就是在LogTarget里加了个ELK枚举,再改配置类,特别方便。

    第二步:写“蛋糕师傅”——实现ILoggerProvider

    接下来写ILoggerProvider的实现类CustomLoggerProvider,负责创建和管理ILogger实例。代码长这样:

    public class CustomLoggerProvider ILoggerProvider
    

    {

    // 配置类实例(从外部传入)

    private readonly CustomLoggerConfiguration _config;

    // 缓存Logger实例(避免重复创建)

    private readonly ConcurrentDictionary _loggers = new();

    public CustomLoggerProvider(CustomLoggerConfiguration config)

    {

    _config = config ?? throw new ArgumentNullException(nameof(config));

    }

    // 创建Logger:根据CategoryName(比如控制器名称)缓存

    public ILogger CreateLogger(string categoryName)

    {

    // 用GetOrAdd避免重复创建——我之前没加这个,内存直接涨了20%

    return _loggers.GetOrAdd(categoryName, name => new CustomLogger(name, _config));

    }

    // 释放资源

    public void Dispose()

    {

    _loggers.Clear();

    GC.SuppressFinalize(this);

    }

    }

    这里要注意ConcurrentDictionary的缓存逻辑——每个CategoryName对应一个Logger实例(比如OrderController的日志用OrderController的Logger),这样能减少对象创建的开销。我刚开始没加缓存,每个请求都创建新Logger,结果朋友的系统在大促时内存占用直接飙升,后来加了这个才降下来。

    第三步:写“蛋糕”——实现ILogger

    最核心的部分来了:写CustomLogger类,继承ILogger接口,真正执行打日志的操作。代码有点长,但我拆成了几个部分讲:

    public class CustomLogger ILogger
    

    {

    // 当前Logger的Category(比如"OrderController")

    private readonly string _categoryName;

    // 配置类实例

    private readonly CustomLoggerConfiguration _config;

    // 用于处理Scope(比如业务唯一标识:OrderId=123)

    private readonly IExternalScopeProvider _scopeProvider;

    public CustomLogger(string categoryName, CustomLoggerConfiguration config)

    {

    _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName));

    _config = config ?? throw new ArgumentNullException(nameof(config));

    // 初始化Scope Provider(用来存上下文信息)

    _scopeProvider = new LoggerExternalScopeProvider();

    }

    #region 接口方法实现

    // 判断日志级别是否开启(比如配置的是Information,Debug级别的日志就不打)

    public bool IsEnabled(LogLevel logLevel)

    {

    return logLevel >= _config.LogLevel;

    }

    // 开始一个Scope(比如using (logger.BeginScope("OrderId:123")))

    public IDisposable BeginScope(TState state)

    {

    return _scopeProvider.Push(state);

    }

    // 核心:打日志的方法

    public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)

    {

    //

  • 先判断日志级别是否开启,没开启直接返回
  • if (!IsEnabled(logLevel)) return;

    //

  • 检查formatter是否为空(微软要求的)
  • formatter ??= (s, e) => s?.ToString() ?? string.Empty;

    //

  • 解析Scope信息(比如"OrderId:123")
  • var scopeInfo = GetScopeInfo();

    //

  • 格式化日志内容(用配置的OutputTemplate)
  • var logMessage = string.Format(_config.OutputTemplate,

    DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), // 时间

    logLevel.ToString().ToUpper(), // 日志级别(大写)

    _categoryName, // CategoryName(比如控制器名称)

    scopeInfo, // Scope信息(业务唯一标识)

    formatter(state, exception) // 日志内容(替换{OrderId}这类占位符)

    );

    //

  • 根据配置的Target输出日志
  • WriteLogToTarget(logMessage, logLevel);

    }

    #endregion

    #region 私有方法

    ///

    /// 获取Scope信息(比如"[OrderId:123]")

    ///

    private string GetScopeInfo()

    {

    var scopeBuilder = new StringBuilder();

    _scopeProvider.ForEachScope((scope, _) =>

    {

    scopeBuilder.Append($"[{scope}] ");

    }, null);

    return scopeBuilder.ToString().TrimEnd();

    }

    ///

    /// 根据Target输出日志(多目标支持)

    ///

    private void WriteLogToTarget(string logMessage, LogLevel logLevel)

    {

    // 检查Target是否包含Console

    if (_config.Target.HasFlag(LogTarget.Console))

    {

    WriteToConsole(logMessage, logLevel);

    }

    // 检查Target是否包含File

    if (_config.Target.HasFlag(LogTarget.File))

    {

    WriteToFile(logMessage);

    }

    // 检查Target是否包含Database(可以扩展)

    if (_config.Target.HasFlag(LogTarget.Database))

    {

    WriteToDatabase(logMessage, logLevel);

    }

    }

    ///

    /// 输出到控制台(可以自定义颜色)

    ///

    private void WriteToConsole(string logMessage, LogLevel logLevel)

    {

    // 根据日志级别设置颜色

    var originalColor = Console.ForegroundColor;

    Console.ForegroundColor = logLevel switch

    {

    LogLevel.Error or LogLevel.Critical => ConsoleColor.Red,

    LogLevel.Warning => ConsoleColor.Yellow,

    LogLevel.Information => ConsoleColor.Green,

    _ => originalColor

    };

    Console.WriteLine(logMessage);

    Console.ForegroundColor = originalColor;

    }

    ///

    /// 输出到文件(异步写入,避免阻塞)

    ///

    private void WriteToFile(string logMessage)

    {

    try

    {

    // 确保目录存在

    var directory = Path.GetDirectoryName(_config.FilePath);

    if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))

    {

    Directory.CreateDirectory(directory);

    }

    // 异步写入文件(用AppendAllTextAsync,避免阻塞主线程)

    File.AppendAllTextAsync(_config.FilePath, logMessage + Environment.NewLine)

    .ConfigureAwait(false); // 避免死锁

    }

    catch (Exception ex)

    {

    // 日志写入失败不能影响主流程

    Console.WriteLine($"写入文件失败:{ex.Message}");

    }

    }

    ///

    /// 输出到数据库(示例:写入Log表)

    ///

    private void WriteToDatabase(string logMessage, LogLevel logLevel)

    {

    try

    {

    // 用Dapper简化数据库操作(需要安装Dapper包)

    using var conn = new SqlConnection(_config.DbConnectionString);

    conn.Open();

    var sql = @"INSERT INTO Logs (LogTime, LogLevel, CategoryName, ScopeInfo, Message, Exception)

    VALUES (@LogTime, @LogLevel, @CategoryName, @ScopeInfo, @Message, @Exception)";

    conn.ExecuteAsync(sql, new

    {

    LogTime = DateTime.Now,

    LogLevel = logLevel.ToString(),

    CategoryName = _categoryName,

    ScopeInfo = GetScopeInfo(),

    Message = logMessage,

    Exception = string.Empty // 可以扩展处理exception

    }).ConfigureAwait(false);

    }

    catch (Exception ex)

    {

    Console.WriteLine($"写入数据库失败:{ex.Message}");

    }

    }

    #endregion

    }

    我重点讲几个关键部分:

  • Scope的作用:比如你在接口里写using (logger.BeginScope("OrderId:123")),然后打日志,ScopeInfo就会包含”OrderId:123″——我朋友的系统就是用这个给每个订单的日志加唯一标识,查日志时直接搜OrderId就能找到所有相关记录,比之前翻整个日志文件快10倍。
  • 多目标输出:WriteLogToTarget方法里用了HasFlag判断Target,支持同时输出到Console、File、Database——比如朋友的系统要把Info级别的日志写到File,Error级别的日志写到Database和发邮件,就是在这里加的逻辑。
  • 异步写入:WriteToFile和WriteToDatabase用了异步方法(AppendAllTextAsync、ExecuteAsync),还加了ConfigureAwait(false)避免死锁——我之前踩过坑,同步写入导致接口响应时间从200ms涨到1s,改成异步后才降回去。
  • 配置和使用:把自定义日志加到.NET Core项目里

    写完代码,接下来要把自定义日志注册到项目里。以.NET Core 8的Web项目为例,在Program.cs里加这些代码:

    var builder = WebApplication.CreateBuilder(args);
    

    //

  • 配置自定义日志的参数
  • var customLogConfig = new CustomLoggerConfiguration

    {

    LogLevel = LogLevel.Information,

    OutputTemplate = "{0} [{1}] {2} {3}

  • {4}", // 时间 [级别] Category Scope
  • 内容
  • Target = LogTarget.Console | LogTarget.File | LogTarget.Database, // 多目标输出

    FilePath = "./logs/custom.log", // 文件路径

    DbConnectionString = "Server=localhost;Database=Demo;User Id=sa;Password=123456;" // 数据库连接字符串

    };

    //

  • 清除默认的日志提供器(比如Console、Debug),只保留自定义的
  • builder.Logging.ClearProviders();

    //

  • 添加自定义日志提供器
  • builder.Logging.AddProvider(new CustomLoggerProvider(customLogConfig));

    //

  • 注册其他服务(比如控制器)
  • builder.Services.AddControllers();

    var app = builder.Build();

    // 测试:写个接口打日志

    app.MapGet("/api/order/pay", (ILogger logger, int orderId) =>

    {

    // 用BeginScope加订单ID

    using (logger.BeginScope($"OrderId:{orderId}"))

    {

    logger.LogInformation("订单支付成功,金额:{Amount}元", 100); // 格式化日志内容

    logger.LogWarning("订单支付超时,已重试1次");

    logger.LogError(new Exception("支付失败"), "订单支付失败,原因:{Reason}", "余额不足");

    }

    return Results.Ok(new { Message = "支付成功" });

    });

    app.Run();

    运行项目,访问/api/order/pay?orderId=123,你会在Console里看到这样的日志:

    2024-05-20 15:30:00.123 [INFORMATION] Program [OrderId:123] 
  • 订单支付成功,金额:100元
  • 2024-05-20 15:30:00.124 [WARNING] Program [OrderId:123]

  • 订单支付超时,已重试1次
  • 2024-05-20 15:30:00.125 [ERROR] Program [OrderId:123]

  • 订单支付失败,原因:余额不足
  • File里会生成custom.log文件,数据库的Logs表也会插入对应的记录——这就是自定义日志的威力:想怎么输出就怎么输出,完全跟着业务需求走。

    最后说点经验:避免踩坑

    我去年做这个功能时踩了不少坑,给你提几个醒:

  • 不要阻塞主线程:打日志的操作一定要异步,或者放到队列里后台处理——比如WriteToDatabase如果用同步方法,会导致接口响应变慢,我朋友的系统之前就是这么垮的。
  • 处理异常:打日志的代码里一定要加try-catch,不然日志写入失败会导致整个请求崩溃——比如WriteToFile时目录不存在,没加try-catch会抛出异常,接口直接返回500。
  • 扩展输出目标:如果要加新的输出目标(比如发邮件、推送到MQ),可以把输出逻辑抽象成接口(比如IOutputTarget),然后实现EmailTarget、MQTarget,这样不用改Logger的代码——我后来就是这么优化的,现在加个新目标只要写几行代码,特别方便。
  • 如果你按这些步骤试了,欢迎回来告诉我效果!比如你有没有遇到异步写入的问题,或者想加其他输出目标,都可以留言——我帮你想想办法。对了,要是你觉得写输出逻辑太麻烦,可以用依赖注入把IOutputTarget注入到Logger里,这样扩展起来更灵活,比如:

    csharp

    // 定义输出目标接口

    public interface IOutputTarget

    {

    void Write(string logMessage, LogLevel logLevel);

    }

    // 实现Console输出目标

    public class ConsoleOutputTarget IOutputTarget

    {

    public void Write(string logMessage, LogLevel logLevel)

    {

    // 写Console的逻辑

    }

    }

    // 在Logger里注入IEnumerable

    public class CustomLogger ILogger

    {

    private readonly IEnumerable _outputTargets;

    public CustomLogger(IEnumerable outputTargets)

    {

    _outputTargets = outputTargets;

    }

    public void Log(…)

    {

    // 格式化日志后,遍历


    本文常见问题(FAQ)

    为什么要自己写.NET Core自定义日志记录器?

    因为用默认日志组件可能遇到不少痛点——比如默认Console日志格式单一,Error级别日志混在一堆白色文字里难找;想同时把日志写到文件和数据库,得装一堆第三方插件,配置还复杂;要给日志加业务唯一标识(比如订单ID),得在每个Log语句里手动拼字符串。这些默认组件解决不了的需求,自己写自定义日志记录器就能搞定。

    .NET Core日志框架里的ILoggerProvider和ILogger是什么关系?

    其实是“工厂+工人”的模式——ILoggerProvider是“工厂”,负责创建和管理日志记录器(ILogger);ILogger是“工人”,真正执行打日志的操作。比如你要不同输出目标的日志(像文件、数据库),就需要不同的“工厂”(ILoggerProvider)来生成对应的“工人”(ILogger)。

    自定义日志时加Scope有什么用?

    Scope能给日志加业务上下文信息,比如用using (logger.BeginScope(“OrderId:123”)),之后打的日志都会带上这个OrderId标识。这样查日志时,直接搜OrderId就能找到所有相关记录,不用翻整个日志文件,排查问题效率高很多。

    自定义日志写入文件或数据库时,为什么要用异步方法?

    因为同步写入会阻塞主线程,导致接口响应变慢——比如之前有个项目用同步写入文件,接口响应时间从200ms涨到1s,改成异步方法(像AppendAllTextAsync、ExecuteAsync)后,响应时间才降回去。而且异步方法加ConfigureAwait(false)还能避免死锁,更稳定。

    怎么把自定义日志记录器加到.NET Core项目里?

    以.NET Core 8 Web项目为例,先在Program.cs里配置自定义日志的参数(比如日志级别、输出模板、目标),然后清除默认的日志提供器(builder.Logging.ClearProviders()),再添加自定义日志提供器(builder.Logging.AddProvider(new CustomLoggerProvider(customLogConfig))),最后注册其他服务就行。

    原文链接:https://www.mayiym.com/52070.html,转载请注明出处。
    0
    显示验证码
    没有账号?注册  忘记密码?

    社交账号快速登录

    微信扫一扫关注
    如已关注,请回复“登录”二字获取验证码