防御性编码:手搓NLog日志+Seq分布式

     关于日志,历时这么多年,我发现很多地方还是没有被普及,仍然采用传统的手搓IO日志的方式,一个IO日志方法被各个业务多次复制,此种现象原因无非以下三点:

① 复制大法好,固步自封,不愿意改变,去接受新鲜事务,全是拿来主义,自己的业务需要时,直接拷贝一份,改个路径名字就接着用

② 没有意识到日志的重要性,线上有问题,直接抛出异常,但是没有文件记录,线上环境常常难以模拟测试,抓瞎靠想象,缺乏必要的日志来提供线索

③ 缺乏统筹意识,大批量日志记录时, 出现阻塞,影响正常流程,造成不可预料的风险

④ 防御性代码,毕竟老板经常降本增笑,流水的人员也没必要如此上心,七天内只有上帝和他自己才能懂的代码才能有稳定的位置

错误示范:如下,是否似曾相识

    public static class LogHelper
    {
        static object lockLog = new object();
        /// 写入日志文件
        public static void WriteLog(string fileContent, string fileName, string path)
        {
            try
            {
                lock (lockLog)
                {
                    StreamWriter sr;
                    StringBuilder sb = new StringBuilder();
                    sb.AppendLine(fileContent);
                    string fn = fileName + "_" + DateTime.Now.ToString("yyyyMMdd") + ".txt";
                    if (!Directory.Exists(path))
                    {
                        Directory.CreateDirectory(path);
                    }
                    sr = File.AppendText(path + fn);
                    sr.WriteLine($"{DateTime.Now}:{fileContent}\r\n");
                    sr.Close();
                }
            }
            catch
            {
            }
        }
}

其实很容易改造,只需要站在巨人的肩膀上,如NLog 、Log4Net 等稍微进行改造,加入自定义文件名即可,这里,比如先引用一下 NLog.Web.AspNetCore 包 

image

 然后移除默认配置文件NLog.config 的内容,只保留一个 nlog 壳子就好,因为我们后面直接通过代码动态控制(当然这里配置记录级别、规则、路径、格式也可以)

<?xml version="1.0" encoding="utf-8"?>

<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    autoReload="true" throwExceptions="false" internalLogLevel="Off" internalLogFile="c:\temp\nlog-internal.log">

</nlog>

剩下的就是来一个Nlog 的帮助类,加入安全队列 和异步,动态匹配传入的文件名称

    public static class NLogHelper
    {
        // 日志器缓存:按 logger 名称缓存对应的 ILogger 实例,忽略大小写
        private static readonly ConcurrentDictionary<string, ILogger> _cache = new(StringComparer.OrdinalIgnoreCase);

        // 保护 _cache 之外的全局共享资源(NLog Configuration)的互斥锁
        private static readonly object _sync = new();

        // 日志根目录,支持 NLog Layout 占位符 ${basedir}
        private const string LogDir = "${basedir}/Logs";

        /// <summary>
        /// 为指定名称和级别的 logger 配置 FileTarget & AsyncTargetWrapper,
        /// 并将规则写入 NLog 全局配置。
        /// 该方法幂等:重复调用不会重复创建同名 target。
        /// </summary>
        /// <param name="name">Logger 名称(命名空间或自定义名)</param>
        /// <param name="level">最低日志级别</param>
        private static void ConfigureLogger(string name, LogLevel level)
        {
            // 构造 target 名称,形如 "Error_MyService"
            var targetName = $"{level.Name}_{name}".TrimEnd('_');

            // 获取或新建 NLog 配置对象
            var config = LogManager.Configuration ?? new LoggingConfiguration();

            // 第一次快速检查:如果已存在该 target 直接返回(无锁路径)
            if (config.FindTargetByName(targetName) != null) return;

            // 双检锁,确保并发时只创建一次
            lock (_sync)
            {
                // 再次确认,防止锁期间被其他线程抢先创建
                if (config.FindTargetByName(targetName) != null) return;

                // 创建文件目标
                var target = new FileTarget(targetName)
                {
                    // 日志文件路径:LogDir/Level/Name/yyyy-MM-dd.log
                    FileName = Path.Combine(LogDir, level.Name, string.IsNullOrEmpty(name) ? "" : name, "${date:format=yyyy-MM-dd}.log"),
                    Layout = "${longdate} ${level} ${message}${newline}",
                    KeepFileOpen = true,     // 提升性能,避免频繁打开/关闭文件
                    ConcurrentWrites = false // 由 AsyncTargetWrapper 串行写,无需并发
                };

                // 包装为异步目标,防止 I/O 阻塞调用线程
                var asyncTarget = new AsyncTargetWrapper(targetName, target)
                {
                    QueueLimit = 10000,                             // 队列最大长度
                    OverflowAction = AsyncTargetWrapperOverflowAction.Block // 队列满时阻塞调用者
                };

                // 将同步与异步 target 都注册到配置(部分场景可直接引用同步 target)
                config.AddTarget(target);
                config.AddTarget(asyncTarget);

                // 添加规则:名称匹配的 logger 在 >=level 时输出到 asyncTarget
                config.LoggingRules.Add(new LoggingRule(name, level, asyncTarget));

                // 重新应用配置(线程安全)
                LogManager.Configuration = config;
            }
        }

        /// <summary>
        /// 获取或创建指定名称和级别的 ILogger。
        /// 内部先确保 logger 所需的 target 与规则已就绪,再从缓存返回实例。
        /// </summary>
        /// <param name="name">Logger 名称</param>
        /// <param name="level">最低日志级别</param>
        /// <returns>配置好的 ILogger</returns>
        private static ILogger GetLogger(string name, LogLevel level)
        {
            // 确保配置存在(幂等)
            ConfigureLogger(name, level);

            // 从缓存中获取或添加 logger 实例
            return _cache.GetOrAdd(name, _ => LogManager.GetLogger(name));
        }


        public static void WriteError(string message, string loggerName = "")
        {
            GetLogger(loggerName, LogLevel.Error).Error(message);
        }

        public static void WriteInfo(string message, string loggerName = "")
        {
            GetLogger(loggerName, LogLevel.Info).Info(message);
        }

        public static void WriteWarn(string message, string loggerName = "")
        {
            GetLogger(loggerName, LogLevel.Warn).Warn(message);
        }
    }

已经完成了,直接调用对应的日志方法即可

    NLogHelper.WriteInfo($"操作日志,{sign} ", ParamConst.DOORAPP);
    NLogHelper.WriteWarn($"警告日志,{sign} ", ParamConst.DOORAPP);
    NLogHelper.WriteError($"错误日志,{sign} ", ParamConst.OtherOpenRoom);

会根据动态输入的文件名,自动生成对应的日志,简单又高效,可以看到,这个操作步骤非常简单,却很实用,起码能保证基本的数据留痕,走到这一步,是否还能更进一步?当然!

我们的系统很多时候是分布式的,排查线上问题时,经常要切到各个服务器上去搜罗文件日志,非常繁琐,不利于统一定位数据,常见的日志系统有 Loki (最轻量)、Filebeat+ES 、Graylog 、Seq ,本文推荐使用Seq,因为他使用起来妙不可言,尤其在查询语法、界面、报警逻辑方面,都是专门为 .NET / 结构化日志设计的。接入十分便捷,几分钟就能搞定,步骤如下:

1. 下载安装Seq  (windows \dock 一键操作都很简单)

image

 

安装成功后,会以服务的方式,常驻服务器,比如:

image

 

2.  参数配置

一般情况下,直接在Nlog.config 里面添加几行seq的参数即可,如下图所示

image

 按照上面的风格,不想走配置路线,当然也是可以直接通过代码来控制,如下图,直接编写一个seq的处理方法

private static void EnsureSeq()
{
    if (_seqInitialized) return;

    lock (_seqInitSync)
    {
        if (_seqInitialized) return;

        var cfg = LogManager.Configuration ?? new LoggingConfiguration();

        // 如果还没注册过 Seq,就加进去
        if (cfg.FindTargetByName("Seq") == null)
        {
            var seqTarget = new SeqTarget
            {
                Name = "Seq",
                ServerUrl = "http://localhost:5341", // 如端口改过请改这里
                ApiKey = ""     // 需要时再填
            };

            // 可选:包一层异步提高吞吐
            var asyncSeq = new AsyncTargetWrapper("SeqAsync", seqTarget)
            {
                QueueLimit = 10000,
                OverflowAction = AsyncTargetWrapperOverflowAction.Block
            };

            cfg.AddTarget(seqTarget);
            cfg.AddTarget(asyncSeq);


            // 给所有 logger 都发一份到 Seq
            cfg.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, asyncSeq));

            LogManager.Configuration = cfg;
        }

        _seqInitialized = true;
    }
}

在日志帮助类第一次启动时调用即可

image

3. 运行项目,走个写入日志的方法

 NLogHelper.WriteInfo($"记录请求的日志,{obj.sign}", ParamConst.PathKiaser);

4. 线上查看,登录设置的seq地址 http://localhost:5341/ , 账号和密码取自安装时所设置的值,也可以根据需要考虑是否创建个人的 ApiKey

image

到这里,基本就结束了,可以简单的使用seq上直接对日志进行筛选查看了,而不是傻傻的逐个一台一台服务器上去查看实际记录的情况 ,后面排查线上问题就会方便很多

 

是不是感觉平时用的日志系统貌似也没有这么多,你的感觉是对的,下一篇 真实性编码:配置路线异步Nlog+seq, 无缝衔接,走配置路线,移除大量的业务代码

 

posted @ 2025-08-20 16:27  郎中令  阅读(31)  评论(0)    收藏  举报