手写迷你日志框架:拆解日志级别、格式化与输出管道
2026/9/9 5:47:37 网站建设 项目流程

做 .NET 开发这些年,日志框架几乎是每个项目都绕不开的基础设施。从最早用 Console.WriteLine 顶着,到后来引入 NLog、Serilog,再到排查线上问题时靠日志定位故障根因,我相信很多人都经历过这个过程。但日志框架到底是怎么工作的?为什么每条日志都会经历“过滤、格式化、输出”这些环节?不同框架的设计差异出现在哪?这就不一定每位开发者都能说清了。

这篇文章我准备换个思路,不直接讲某个具体框架怎么配置,而是先把日志框架的原理拆开讲清楚,再带着你从零手写一个可用的迷你日志框架。写完这个小框架,你再回去看 NLog、Serilog 之类的源码和配置,很多难以理解的概念就会豁然开朗。同时我也会分享一些日常排查日志问题时的诊断技巧,这些都是在实际项目中踩过坑总结出来的经验。

文章适合有 .NET 基础、想理解框架内部机制的开发人员,也适合那些正在看日志框架源码却不知从哪儿下手的同学。我们就用纯 .NET 自带的能力实现,不引入第三方 NuGet 包,保证过程干净可控。

1. 日志框架到底帮你解决了什么问题

1.1 一个最简单的日志需求是什么样的

先回到最原始的场景。你的应用出了 bug,你想知道程序执行到哪一步出了问题、当时的上下文是什么。最直接的做法是写一行代码输出信息:

Console.WriteLine($"[INFO] {DateTime.Now:yyyy-MM-dd HH:mm:ss} 用户下单成功,订单号={orderId}");

这段代码在开发调试时确实够用,但一旦放到生产环境,问题立刻暴露出来:

  • 你想把日志写入文件,而不是控制台,就要把所有输出代码换一遍。
  • 你想过滤掉 DEBUG 级别的噪声,只保留 WARN 以上信息,只能靠注释代码。
  • 你想记录线程 ID、调用方法、机器名等上下文,又得在每一处手工拼接。
  • 当异常发生时,StackTrace 和 InnerException 要如何格式化?自己拼很容易写漏。
  • 最致命的是,如果每分钟几万条请求,直接在文件中同步写入,会严重阻塞业务线程。

这些问题就是日志框架诞生的原因。日志框架本质上是一层“日志管道”,在业务代码和存储介质之间做了标准化处理。你唯一要做的事情就是调用logger.Info("..."),日志框架负责完成级别判断、上下文补充、格式化、目标输出、异步写入等所有脏活。

1.2 框架设计的三个核心职责

结合上面遇到的问题,我们可以把日志框架的核心职责归纳为三点:

第一,统一抽象输出目标。业务代码并不关心日志最终到底是写了控制台、文件还是数据库。框架通过接口设计,让输出目标可替换。这就是面向接口编程的直接体现。

第二,内置过滤机制。日志级别通常按 Trace、Debug、Info、Warn、Error、Fatal 这样的顺序排列。框架允许你在配置文件中设定一个最小级别,低于这个级别的日志直接被丢弃,避免无意义 IO。

第三,承担格式化与上下文处理。框架负责把时间、线程 ID、级别、日志内容、异常堆栈等内容按照指定模板组装成完整字符串。这里对性能要求很高,因为每条日志都会执行字符串拼接,设计不好会成为性能瓶颈。

理解这三个职责之后,手写一个日志框架的目标就非常清晰了。我们并不需要实现一个功能完备的商业框架,只需要把这几条核心链路打通,让代码能跑、能说明问题即可。而当你亲手搭出这条链路,再回头看 NLog、Serilog 这类框架,会发现它们只是在核心链路上扩展了更多功能点,底层思路惊人相似。

2. 手写日志框架的设计:先定“四件套”

动手写代码之前,我习惯把模块划分和工作流程先确定下来。日志框架的代码量不大,但模块边界的清晰度直接影响后续扩展能力。

2.1 模块划分与各自主责

一个精简的日志框架通常包含四个核心模块:

  • Logger:暴露给业务代码的门面对象,提供 Info、Warn、Error 等方法。
  • LogEvent:一条日志的完整描述,包含时间、级别、日志来源、消息、异常对象等数据,是所有后续处理的输入。
  • ILogOutput:输出目标抽象,负责把最终格式化好的文本写到具体介质。常见实现包括控制台输出、文件输出、数据库输出。
  • Layout(模板):负责把 LogEvent 渲染成最终文本。比如{time:yyyy-MM-dd HH:mm:ss.fff} {level} - {message}就是一个 Layout。

在实际工程中还有配置模块,用来描述日志级别阈值、输出目标列表、是否启用异步等参数。这里我建议用简单的代码配置来替代配置解析,因为我们关注的是原理,代码配置最直观。

2.2 工作流程:一条日志的旅行

当调用logger.Info("你好")时,日志的流转是这样的:

  1. Logger 先检查 Info 级别是否达到配置的全局最小级别,如果没达到,直接返回,不产生任何开销。
  2. 如果达到,则构造 LogEvent,收集当前时间、线程 ID、Logger 名称、消息文本等基础信息。
  3. 把 LogEvent 交给输出模块之前,由 Layout 将 LogEvent 渲染成一行可读文本,比如包含时间、级别、消息的字符串。
  4. 渲染后的文本被传递给每个注册的输出目标。如果是同步输出,直接调用目标的 Write 方法;如果是异步输出,则先写入内存队列,由后台线程统一消费。
  5. 输出目标拿到文本后,负责写入控制台、文件或者其他存储介质。

把流程理清楚之后,代码无非就是对上面每一步的实现。设计阶段最忌讳的是上来就写并发读写、加各种锁,先把单线程同步链路打通,再优化性能才是正路。

2.3 同步与异步的选择:先从同步开始

我在第一次手写日志框架时,上来就想用 BlockingCollection 做异步队列,结果被消费线程的异常处理、进程退出时队列残留等问题搞得焦头烂额。后来我调整为“先同步、后异步”的两步走方案。

同步模式指日志调用线程直接完成格式化和文件写入,这种方式代码简单、时序可靠,适合日志量不大的场景。异步模式则引入生产者和消费者队列,业务线程只负责入队,后台线程统一写文件,降低 IO 阻塞。但没有后台消费线程前,不要提前引入队列,否则一旦消费线程挂掉,日志会全部堆积在内存里,看起来程序没报错,日志却一条都没落盘,那才是最可怕的。

因此我们在代码实现上先完成同步可运行版本,再演进到异步队列版本。

3. 开始手写:一个极简日志框架的完整代码解析

下面我会用实际代码一步步搭建这个迷你框架。代码基于 .NET 8 控制台应用编写,普通 .NET 6+ 项目也能直接运行。

3.1 定义日志级别与日志事件

日志级别用枚举定义即可。这里额外为每个级别定义一个整数数值,方便通过比较控制过滤行为。注意,级别的顺序有讲究,Trace 是最低,Fatal 是最高。

public enum LogLevel { Trace = 0, Debug = 1, Info = 2, Warn = 3, Error = 4, Fatal = 5 }

LogEvent 类负责承载一条日志的“原始数据”。这里我故意区分了“消息模板”和“格式化消息”。如果你看过 Serilog,会发现它支持 message template 语法,可以在不产生字符串拼接的情况下传递结构化属性。在我们的迷你版本中,先保留一个 Message 字段保存格式化后的文本,同时保留 Exception 对象用于输出异常详情。

public sealed class LogEvent { public DateTime Timestamp { get; init; } = DateTime.Now; public int ThreadId { get; init; } public LogLevel Level { get; init; } public string LoggerName { get; init; } public string Message { get; init; } public Exception Exception { get; init; } }

3.2 设计输出目标接口

输出目标是框架中最值得抽象的部分,因为写文件、写控制台、写消息队列的差异非常大。用一个接口统一包裹,后续才能做到无缝替换。

public interface ILogOutput : IDisposable { void Write(string formattedLog); }

这里有人可能会问:为什么不把 LogEvent 作为参数传给 ILogOutput?那样输出目标不就可以自己决定格式了吗?这是个好问题。框架需要保证同一应用内日志格式一致,便于日志采集和检索。因此我采用的策略是:所有输出目标共享同一个 Layout 模板,各自只负责“把渲染好的字符串写出去”。如果你希望输出到 JSON 文件的格式和文本文件不同,那可以把 Layout 逻辑下沉到 Output 内部,在接口中传递 LogEvent,这也是很多高级框架的设计方式。但作为入门框架,用string作为写入参数最简单直观。

紧接着实现两个最基础的输出目标:

public sealed class ConsoleOutput : ILogOutput { private readonly object _locker = new object(); public void Write(string formattedLog) { lock (_locker) { Console.WriteLine(formattedLog); } } public void Dispose() { } } public sealed class FileOutput : ILogOutput { private readonly object _locker = new object(); private readonly string _directory; private string _currentFilePath; private StreamWriter _writer; public FileOutput(string directory) { _directory = directory; if (!Directory.Exists(directory)) Directory.CreateDirectory(directory); // 初始化打开当日文件 ReopenIfNeeded(); } public void Write(string formattedLog) { lock (_locker) { ReopenIfNeeded(); _writer.WriteLine(formattedLog); _writer.Flush(); } } private void ReopenIfNeeded() { var fileName = $"app-{DateTime.Now:yyyyMMdd}.log"; var path = Path.Combine(_directory, fileName); if (_writer != null && _currentFilePath == path) return; _writer?.Dispose(); _writer = new StreamWriter(new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read)) { AutoFlush = true }; _currentFilePath = path; } public void Dispose() { lock (_locker) { _writer?.Dispose(); _writer = null; } } }

文件输出里我做了按天滚动文件。每天的第一条日志如果发现日期变了,就关闭旧文件,创建新文件。这里有个很关键的小细节:FileShare.Read参数。很多初学者打开文件日志会写着写着抛 IOException,因为默认的文件打开方式不允许其他进程读取文件流,可能被日志查看工具占用。为了便于实时查看日志,我在 FileStream 中指定了FileShare.Read,允许其他进程以只读方式打开同一个文件。同时 Flush 频率也很重要,文件日志若长期不刷新,进程崩溃后会丢大量日志。这里先用每条日志都 Flush 的直写方式保证可靠性,后面再优化性能。

3.3 实现 Layout 模板渲染器

Layout 本质上就是一个“字符串模板引擎”。我们需要支持类似占位符替换的功能,让用户能自定义输出的格式。这里我实现了一个极简版本:定义{time}{level}{thread}{logger}{message}这几个固定字段。

为了性能考虑,我们没有使用 string.Replace 一轮轮替换,而是通过逐字符扫描方式一次性解析模板。虽然性能不如表达式树,但比多次 Replace 已经强很多。

public sealed class Layout { private readonly string _template; public Layout(string template) { _template = template; } public string Format(LogEvent logEvent) { var messageText = logEvent.Message ?? string.Empty; if (logEvent.Exception != null) { messageText += Environment.NewLine + logEvent.Exception; } var sb = new StringBuilder(_template.Length + messageText.Length + 32); for (var i = 0; i < _template.Length; i++) { if (_template[i] == '{') { var closeIndex = _template.IndexOf('}', i + 1); if (closeIndex > 0) { var field = _template.Substring(i + 1, closeIndex - i - 1); string replacement = null; switch (field) { case "time": replacement = logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"); break; case "level": replacement = logEvent.Level.ToString().ToUpperInvariant(); break; case "thread": replacement = logEvent.ThreadId.ToString(); break; case "logger": replacement = logEvent.LoggerName; break; case "message": replacement = messageText; break; } if (replacement != null) { sb.Append(replacement); i = closeIndex; continue; } } } sb.Append(_template[i]); } return sb.ToString(); } }

这个 Layout 的扩展点十分明显。如果你想加入机器名、进程 ID 等字段,只需在 switch 分支中增加 case 和处理逻辑即可。上面的解析过程还没有考虑{{转义,但这对于一个学习性质的框架已经足够。

3.4 核心:Logger 类的实现

Logger 是业务代码唯一会接触的核心类。它的职责非常集中:判断当前日志级别是否启用,如果启用就构造 LogEvent,交给 LogManager 进行格式化并投递到输出目标。

为了避免每次调用都创建大量临时对象,LogEvent 直接在调用线程中构造。对于高频调用场景,未来可以引入对象池复用 LogEvent,但这里先不做过早优化。

public sealed class Logger { private readonly string _name; private readonly MiniLoggerOptions _options; internal Logger(string name, MiniLoggerOptions options) { _name = name; _options = options; } public bool IsEnabled(LogLevel level) => level >= _options.MinLevel; public void Trace(string message) => WriteLog(LogLevel.Trace, message, null); public void Debug(string message) => WriteLog(LogLevel.Debug, message, null); public void Info(string message) => WriteLog(LogLevel.Info, message, null); public void Warn(string message) => WriteLog(LogLevel.Warn, message, null); public void Error(string message) => WriteLog(LogLevel.Error, message, null); public void Error(string message, Exception ex) => WriteLog(LogLevel.Error, message, ex); public void Fatal(string message, Exception ex = null) => WriteLog(LogLevel.Fatal, message, ex); private void WriteLog(LogLevel level, string message, Exception ex) { if (!IsEnabled(level)) return; var logEvent = new LogEvent { Timestamp = DateTime.Now, ThreadId = Environment.CurrentManagedThreadId, Level = level, LoggerName = _name, Message = message, Exception = ex }; _options.Write(logEvent); } }

这里的_options承担了“配置中心”作用。Logger 不需要自己去遍历输出列表,也不需要自己渲染 Layout,这些都集中在配置对象中完成,Logger 保持瘦身状态,后续更容易测试。这也体现了单一职责原则。

3.5 配置对象与日志工厂

工厂模式的引入是这个迷你框架走向工程化的转折点。业务代码不直接 new Logger,而是通过LogManager.GetLogger("OrderService")获取实例。好处是:Logger 的创建策略可以全局统一,同时便于将来按不同类别的 Logger 设置不同日志级别。

public sealed class MiniLoggerOptions { public LogLevel MinLevel { get; set; } = LogLevel.Debug; public Layout Layout { get; set; } = new Layout("{time:yyyy-MM-dd HH:mm:ss.fff} [{level}] [{thread}] {logger} - {message}"); public List<ILogOutput> Outputs { get; } = new List<ILogOutput>(); public void Write(LogEvent logEvent) { var content = Layout.Format(logEvent); foreach (var output in Outputs) { output.Write(content); } } }

注意这里 Layout 构造函数传递模板时写了{time:yyyy-MM-dd HH:mm:ss.fff},但我们 Layout 解析逻辑只识别“time”字段,不支持指令后的冒号参数。要支持带格式的时间,需要调整解析逻辑,支持time:格式化字符串这种带参数指令。我建议在实现中把 Layout 解析优化一下,支持从字段名解析出参数。这里我先提供一种调整后的写法:

case var f when f.StartsWith("time:"): var timeFormat = f.Substring("time:".Length); replacement = logEvent.Timestamp.ToString(timeFormat); break;

这样上面的模板就能正常工作了。同理也可以扩展message相关的截断处理等,不过这些属于锦上添花的功能,不展开讲了。

3.6 让框架支持异步写入队列

完成了同步框架后,我们来优化性能。业务线程调用logger.Info()时,如果文件写入速度很慢,整个业务线程会被阻塞。解决思路是引入一个内存队列,业务线程只负责把 LogEvent 推入队列,然后立刻返回;后台单线程负责从队列中取出日志,统一做格式化和文件写入。

这一步我选择使用System.Threading.Channels。它比 BlockingCollection 更轻,性能更高,且支持异步读写。在 .NET 中,Channel 的可读和可写部分相互独立,非常适合做生产者消费者模式。

public sealed class AsyncLogDispatcher : IDisposable { private readonly Channel<LogEvent> _channel; private readonly MiniLoggerOptions _options; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); private readonly Task _workerTask; public AsyncLogDispatcher(MiniLoggerOptions options, int capacity = 10000) { _options = options; var channelOptions = new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false }; _channel = Channel.CreateBounded<LogEvent>(channelOptions); _workerTask = Task.Factory.StartNew( ConsumeLoop, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } public void Enqueue(LogEvent logEvent) { if (!_channel.Writer.TryWrite(logEvent)) { // 队列已满时的兜底,防止日志丢失 try { _channel.Writer.WriteAsync(logEvent).AsTask().GetAwaiter().GetResult(); } catch (Exception ex) { // 入队异常时,输出到控制台避免彻底丢失 Console.Error.WriteLine($"Log enqueue failed: {ex}"); } } } private async Task ConsumeLoop() { try { await foreach (var logEvent in _channel.Reader.ReadAllAsync(_cts.Token)) { try { _options.WriteSync(logEvent); } catch (Exception ex) { Console.Error.WriteLine($"Log write failed: {ex}"); } } } catch (OperationCanceledException) { // 正常退出 } } public void Dispose() { _channel.Writer.TryComplete(); _cts.Cancel(); try { _workerTask.Wait(TimeSpan.FromSeconds(5)); } catch { // 忽略超时异常 } _options.DisposeOutputs(); } }

看到这里,细心的读者会问:入队使用 TryWrite 还是 WriteAsync?这里我做了个取舍。BoundedChannelFullMode.Wait模式下,TryWrite 在通道满时会失败,因此我在失败后改用异步写等待队列可用。虽然代码中为了简单用了.GetAwaiter().GetResult()同步等待,这会影响“业务线程不阻塞”的初衷,但在队列满的极端情况下阻塞是合理的背压保护手段,避免无限积压造成内存溢出。真实框架中一般也都有类似保护机制。

同样重要的一点是,异步日志带来的最大隐患是进程退出时队列中的数据可能来不及写入。解决办法是在进程退出前优雅关闭日志系统,调用LogManager.Shutdown(),等待后台线程把剩余任务处理完毕。这也是我在 Dispose 中调用_workerTask.Wait()的原因。

3.7 组合成 LogManager 门面

下面把这些模块组装起来,形成一个简单的门面类。业务代码可通过 LogManager 全局访问日志功能:

public static class LogManager { private static readonly object SyncRoot = new object(); private static MiniLoggerOptions _options; private static AsyncLogDispatcher _dispatcher; private static bool _asyncMode = true; private static readonly ConcurrentDictionary<string, Logger> Loggers = new ConcurrentDictionary<string, Logger>(); public static void Setup(Action<MiniLoggerOptions> configure, bool asyncMode = true) { lock (SyncRoot) { _dispatcher?.Dispose(); _options = new MiniLoggerOptions(); // 默认输出控制台,业务接入时再添加文件输出 _options.Outputs.Add(new ConsoleOutput()); configure?.Invoke(_options); _options.Outputs.TrimExcess(); _asyncMode = asyncMode; if (_asyncMode) { _dispatcher = new AsyncLogDispatcher(_options); } } } public static Logger GetLogger(string name) { return Loggers.GetOrAdd(name, n => new Logger(n, _options)); } }

这里有个初始化顺序问题:Logger内部的_options引用是在创建时固定下来的,如果之后调用Setup重新配置,已创建的 Logger 会继续引用旧配置。更健壮的做法是 Logger 内部每次调用时通过 LogManager 获取最新 Options,或者使用持有变更通知的配置对象。为了简洁,我在代码中默认业务只在启动时执行一次 Setup,后续不再调整。

另外,你可能会注意到我在构造 Logger 时直接把_options传进去。在并行调用LogManager.GetLogger时 Singleton 创建行为由字典保证,但_options的可见性依赖调用链上的锁保护,所以初始化时加锁是必要的。

4. 实操实测:运行效果与问题排查

4.1 最简单的使用流程

完成了上述代码,我们来写一个入口测试一下:

class Program { static void Main() { LogManager.Setup(options => { options.MinLevel = LogLevel.Debug; options.Layout = new Layout("{time:yyyy-MM-dd HH:mm:ss.fff} [{level}] [{thread}] {logger} - {message}"); options.Outputs.Add(new FileOutput("logs")); }, asyncMode: true); var logger = LogManager.GetLogger("OrderService"); logger.Info("用户下单,订单号 {0}", 10001); logger.Debug("数据库连接创建成功"); logger.Warn("查询缓存未命中,key = order:10001"); try { throw new InvalidOperationException("库存不足"); } catch (Exception ex) { logger.Error(ex, "处理订单失败"); } LogManager.Shutdown(); } }

输出日志会同时出现在控制台和日志文件中。文件输出效果大致如下:

2025-01-18 10:12:33.102 [INFO] [8] OrderService - 用户下单,订单号 10001 2025-01-18 10:12:33.105 [DEBUG] [8] OrderService - 数据库连接创建成功 2025-01-18 10:12:33.106 [WARN] [8] OrderService - 查询缓存未命中,key = order:10001 2025-01-18 10:12:33.108 [ERROR] [8] OrderService - 处理订单失败 System.InvalidOperationException: 库存不足 at Program.Main()

注意一个细节:我写的测试代码里用了logger.Info("用户下单,订单号 {0}", 10001),但上面的 Logger 类实现里根本没有提供带参数的重载。这里我建议你可以自行补充以下几个高频重载:

  • void Info(string message)
  • void Info(string message, params object[] args)
  • void Info(Exception ex, string message, params object[] args)

带参数版本的实现中,先调用string.Format生成完整消息再构造 LogEvent,这样可以保证调用日志时不会因为参数占位符错误而抛异常吞掉业务信息。当然也可以像 Serilog 那样不预格式化,把参数与模板原样保存到 LogEvent 中,到 Layout 阶段再渲染。

4.2 最常见的日志丢失问题排查

异步日志模式下,大家第一反映通常是:为什么我调用了 logger.Info,但文件里没有内容?这个问题在我自己开发的经历中遇到过很多次。完整排查链路大概是:

第一步,先查级别过滤。MinLevel 设置的级别高于当前调用级别,日志会被直接忽略。这种往往发生在调整全局日志级别后,忘记检查某个调用点。我在上文的 Logger.WriteLog 里把级别过滤放在最前面,就是为了尽早短路。

第二步,查输出目标注册。Setup 时如果只配置了 ConsoleOutput,忘记添加 FileOutput,那日志只会在控制台出现。排查这类问题把两个 Output 都注册上基本能定位。

第三步,查异步队列是否被消费。后台线程可能因未捕获异常提前退出。记住,Channel 的消费循环最外层一定要加上 try/catch,否则一旦消费线程死亡,整个队列会无限堆积。在 ConsumeLoop 代码中我特意用 try/catch 包住每条日志的写入逻辑,就是防止单条日志格式问题导致整个消费循环崩溃。

第四步,查进程退出时序。异步日志在进程强制退出时,队列中可能仍有大量日志没有落盘。解决方法是应用程序在退出前调用 LogManager.Shutdown,等待消费线程把队列耗尽。

大多数日志丢失问题逃不出这四个环节。

4.3 文件锁冲突和Windows日志文件占用

另一个高频问题是文件日志写入时报 IOException:文件正由另一进程使用。原因通常是某些日志查看工具(比如 LogViewer、甚至用记事本打开后不释放句柄)占用了日志文件。另外,我们自己的程序若同时打开了两个日志输出实例,指向同一个文件,也可能互斥。

在 FileOutput 中,我特意使用了 FileShare.Read,就是为了做到“写入进程持有写权限的同时,允许其他进程只读打开”。如果遇到文件占用问题,优先检查是不是有多个 FileOutput 实例在写同一个路径,或者有没有杀毒软件强制扫描日志文件。把 FileShare 改成FileShare.ReadWrite在 Windows 上也能缓解问题,但这个技巧并不能解决所有场景,关键还是路径统一和进程唯一。

4.4 高频写入的性能优化方向

手写框架的功能跑通后,可以想想它的性能瓶颈在哪些地方。我在验证中连续写入 10 万条日志,同步版本使用 StreamWriter 每条 Flush,性能非常差;改成异步队列后,消费线程成为瓶颈。进一步优化方向如下:

  • 批量写入:每次从 Channel 读出多条日志,一次性写入 StreamWriter,减少 IO 调用次数。
  • 对象池:LogEvent 和格式化字符串的 StringBuilder 可通过对象池复用,降低 GC 压力。
  • 减少 Flush 频率:比如累积 5 秒或 1MB 再 Flush 一次,牺牲少量可靠性换取吞吐量。
  • 文件滚动:按大小滚动日志文件(如 50MB 一个文件),避免单个文件无限膨胀。

在我们这个迷你框架中当然不需要把全部优化做一遍,但清楚哪里存在瓶颈,对你理解真实框架的配置项非常有帮助。NLog 中的AsyncWrapperBufferingWrapper本质上就是这些优化手段的工程化封装。

5. 从手写框架到理解主流框架,看原理如何平移

5.1 NLog、Serilog 与手写框架的架构对照

当你亲手实现了这套迷你框架后,再去阅读 NLog 或 Serilog 的源码,会发现大量熟悉的概念。我画一张对应关系表:

手写迷你框架NLog 对应概念Serilog 对应概念职责说明
LoggerNLog.LoggerSerilog.ILogger业务调用的门面对象
LogLevelLogLevelLogEventLevel日志级别定义
LogEventLogEventInfoLogEvent日志原始数据载体
LayoutLayout(MessageTemplate + output templates)日志格式化逻辑
ILogOutputTarget(FileTarget / ConsoleTarget)Sink(FileSink / ConsoleSink)输出介质抽象
MiniLoggerOptionsLoggingConfigurationLoggerConfiguration规则与目的地的配置聚合
AsyncLogDispatcherAsyncTargetWrapperSerilog.Sinks.Async异步队列处理

你会发现换了个名字,本质上还是同一套流水线逻辑。所以在 .NET 生态中,只要沉下心手写一个简单实现,再学习任何日志框架都像是看同一个故事的新版本。

5.2 .NET 诊断技巧的延伸:结构性日志与 DiagnosticSource 的价值

除了日志框架本身,实际项目排查问题还有一个强大组合:结构化日志 + DiagnosticContext。Serilog 支持把 LogEvent 中的属性作为结构化对象输出到日志平台,这样就可以在查询日志时按字段过滤。比如我传了一个OrderId=10086属性,日志平台中就能用OrderId=10086当过滤条件秒级检索。而 Console.WriteLine 拼出来的字符串就需要做全文模糊匹配,效率天差地别。

另外,在现代 .NET 中System.Diagnostics.DiagnosticSource提供一种更轻量的诊断方式。它和日志框架不同,不在于记录业务过程,而在于在进程内发布结构化事件,配合监听者使用。很多框架如 HttpClient、EF Core 都内置了 DiagnosticSource 事件。我在排查链路追踪问题时,会同时使用 Serilog 的日志管道和 DiagnosticSource 的事件监听,两者互补。

你可以把 DiagnosticSource 理解成一个“供程序内部消费的发布订阅总线”,而日志框架是“面向人读与外部日志平台的信息记录器”。原理相通,但目的和消费方完全不同。

5.3 写日志时容易被忽视的几个陷阱

最后分享几个实际项目中的诊断经验:

不要在生产环境输出敏感信息。日志中的用户手机号、身份证、支付信息往往需要脱敏处理。常见的做法是通过自定义 Layout Renderer 或者日志处理器,在格式化前将敏感字段打码。最好在代码评审阶段就约定日志规范,而不是等出了事故再脱敏。

日志消息尽量携带上下文标识。比如给每个请求生成一个 CorrelationId,贯穿整个调用链。这样排查问题时,用 ID 可以把一个请求涉及的所有日志过滤出来。在 ASP.NET Core 中,可以通过中间件为每个请求创建 ID,放进日志上下文中。

不要忽视日志框架自身的异常。输出目标抛出的异常如果直接暴露给业务线程,会导致业务失败。真实框架大多默认将内部异常写入内部日志,或者在调试模式下才向调用方抛出。我们手写框架时也应该保证:框架自身故障不能影响业务功能。在 Logger.WriteLog 的最外层 catch 住所有异常并输出到 Console.Error,是一种保守且安全的做法。

控制日志框架的配置热更新。NLog 可以在运行时重新加载配置文件,但如果你对日志写入了自定义的引用类型配置,要注意线程安全问题。比如 Logger 缓存了 Options 引用,而热更新时替换了 Options,就可能导致并发读写不一致。实际框架中多采用不可变配置结构,Logger 每次使用前从加载器获取最新快照。

我在实际使用中最深的一个体会是:日志框架虽然不直接产生业务价值,但它的设计质量直接决定了线上问题定位的速度。与其在项目技术债爆发时疯狂加班排查问题,不如从第一天就认真设计日志规范、了解框架的每个内部环节。这也是我写这篇文章的初衷。手写一次迷你日志框架,不只是练手,更是让你建立对日志基础设施的整体认知,往后查日志、调优性能时都会更有底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询