尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
.NET 日志框架核心原理与手写迷你框架实战
.NET 诊断技巧 | 日志框架原理、手写日志框架学习我一直觉得会写日志和懂日志中间隔着一整个框架的距离。很多 .NET 开发者每天都在用 NLog、Serilog_logger.LogInformation(xxx)打得飞起但一旦被问到“日志框架是怎么工作的”“为什么日志框架敢把整个流程写得这么复杂”往往只能挤出几个词汇反射、队列、异步……然后就没有然后了。这篇东西我想写很久了。我把它定位成一份带实操的学习笔记而不是科普向的文档。我会从零开始用手写一个迷你日志框架的方式把日志框架的核心原理拆开来讲清楚。适合那些已经能熟练使用日志库、但对内部机制始终“有点感觉但说不透”的开发者也适合准备面试时被问到日志相关原理的人。我会先谈为什么值得写一遍日志框架然后拆清楚日志框架该干哪些事接着是一整个可以复现的手写实现最后讲讲真实框架会怎么补全你的设计以及我在实际使用中踩过的一些坑。1. 为什么我建议每个 .NET 开发者都手写一次日志框架1.1 日志框架到底解决什么问题先问你一个问题如果让你在一个 .NET 应用里面记录日志最快的方式是什么你会说Console.WriteLine啊。再讲究一点File.AppendAllText也还行。但稍微正经的日志场景一上来这套立刻就不够用了日志要分级 Debug、Info、Error要按天切文件要过滤某些模块的日志要带时间戳和上下文还要在多线程环境下面保证日志不乱。这时候你开始意识到日志不是“写一行字”而是一套管道。日志框架本质上解决的是三件事格式化、路由、生命周期管理。格式化决定一条日志长什么样路由决定这条日志写到哪去生命周期管理决定日志什么时候创建、什么时候释放、什么时候轮转。这三件事听起来各自很简单但拼在一起复杂度瞬间就上来了。我一开始也以为日志框架就是“循环写文件”直到我在一个并发场景下亲眼看到日志互相穿插才明白框架里那些看着冗余的设计——锁、缓冲、队列——每一样都有它的道理。1.2 只会用和能复现之间的差距“会用框架”和“能复现框架”这两者之间的差距如果不亲自写一遍是很难被察觉的。举个例子。你用 Serilog 的时候注意到过它的ILogEventEnricher吗知道为什么要把LogEvent和Logger分离吗再比如NLog 的配置文件里面asynctrue和asyncfalse的差别你真的理解背后的线程模型吗这些细节在日常编程里根本不会暴露问题因为框架把一切都包装好了。但到了调优和排查的阶段你如果不懂底层的排队机制、输出器的调用时机你会连日志变慢的原因都找不到。我自己带的团队里就发生过这么一件事某个服务在生产环境突然日志写不进去了好几个人围着 NLog 的配置翻来覆去地改都没有搞定。后来发现是内部队列满了之后丢弃策略起的作用。不懂原理的人连“队列满”这个方向都想不到。所以手写日志框架不是重复造轮子而是给自己一次机会把那些隐藏的问题逼到明面上来。1.3 手写日志框架的正确学习心态我知道现在只要你愿意一天就能搭建一个“能用”的日志框架。但这不够。真正有价值的是你亲手做完之后再回过头去打开 NLog 和 Serilog 的源码那时候你的视角会发生一个本质变化——从“它在做什么”变成“我当初为什么没想到这一点”。这个转变才是学习的核心产物。所以我建议你的学习路径是先限制自己只能使用最基础的工具类写一版能用就行然后逐步给它加功能级别过滤、格式化、多输出端、并发保护最后再对照真实框架找出自己的差距。这个过程走完你会收获一种“看框架源码像看自己代码”的错觉而这种错觉是真的会给你自信的。2. 日志框架的核心设计拆解2.1 日志记录的最小链路记录器、格式器、输出器在动手写代码之前先把概念理清楚。任何日志框架不管它的架构多花哨最底层都是这条链创建日志记录 → 格式化 → 过滤 → 输出。创建记录就是把开发者的参数级别、消息、异常、附加字段包成一个对象格式化就是把对象渲染成一段可读的文本过滤是决定这条日志够不够资格被输出输出就是真正把文本写到控制台、文件、数据库、消息队列或者其它地方。这里有一个新手很容易忽略的点日志对象和最终的文本字符串应当是两个概念。为什么因为中间隔着过滤和格式化两个环节。如果一进来就把日志格式化成字符串那么那些没达到输出级别的日志字符串格式化的开销就白花了。在一个每秒产生百万条 Debug 日志的系统里这个浪费会被放大到不可接受。所以正确设计是先创建结构化的LogEntry对象等到框架确认这条日志需要被输出再去做格式化。这一点Serilog 做得尤为彻底它整条管道传的都是结构化的LogEvent而不是字符串。2.2 日志级别为什么需要等级过滤日志级别这套设计看起来只是一种约定实际上它是日志框架最高效的“粗粒度拦截器”。public enum LogLevel { Debug 0, Info 1, Warn 2, Error 3, Critical 4 }每个LogLevel之间的数字大小不是随便定的它给框架提供了一个快速比较的机会。当配置的全局最低级别是Info时任何Debug级别的日志在“创建对象”之后就可以直接丢弃完全不需要进入格式化阶段。这个整型比较的开销是纳秒级别的而字符串格式化则是微秒甚至毫秒级别的。因为有了级别日志框架还有一个进阶能力按级别路由。比如Info日志只写控制台Error日志要同时写文件和发邮件。真实框架里这个路由是配置驱动的。我们在手写时可以先把它写成硬编码的分支等理解了再抽象出去。2.3 日志上下文结构化数据的价值接下来的问题是一条日志除了文本消息还应该带什么传统程序员写日志喜欢用字符串拼接_logger.LogError($用户 {userId} 在 {time} 下单失败原因是 {reason});这段文字是给人看的但机器不太好看。一旦你需要在几百 GB 的日志里做检索、聚合、告警这种纯文本的弊端就非常明显。现代日志框架的做法是保留“字段”和“值”的结构化信息。比如 Serilog 的LogInformation(用户 {UserId} 下单失败, userId)框架会保存UserId这个键和对应的值输出到控制台时是文本但输出到 Elasticsearch 时会变成 JSON 字段。这样一来同一行代码在人和机器两个视角下都有了价值。在手写的迷你框架里我建议你也加一个Dictionarystring, object? ExtraData属性。即使第一版不用它后续做结构化输出的扩展也会舒服很多。2.4 从复杂度看框架的真正价值有人可能会想这链路也不复杂啊为啥实际框架这么庞大因为生产环境的约束比你想的多得多。要满足高吞吐得有缓冲和批量写入要支持多输出端得有 Target/Sink 的抽象要动态调整级别得有配置热更新要应对崩溃日志写入本身还得尽量少抛异常。每个约束都会把框架往复杂方向推一把。这也是为什么我一直强调不要满足于“实现了基本链路”。你写日志框架的过程中每遇到一个“这个地方好像有点麻烦”的时刻那恰恰是整个学习过程中最值钱的时刻。因为真实框架的每个设计都是在解决这类麻烦。3. 手写一个迷你日志框架完整实操3.1 搭建项目与定义核心类型先说一下实操环境我用的是 .NET 8创建一个控制台项目项目名就叫MiniLogger。为了后面方便我会建三个核心类型LogLevel枚举、LogEntry日志条目类、ILogger日志记录器接口。namespace MiniLogger; public enum LogLevel { Trace 0, Debug 1, Info 2, Warn 3, Error 4, Critical 5 } public sealed class LogEntry { public DateTimeOffset Timestamp { get; init; } public LogLevel Level { get; init; } public string Message { get; init; } public Exception? Exception { get; init; } public IDictionarystring, object? ExtraData { get; init; } public LogEntry( LogLevel level, string message, Exception? exception, IDictionarystring, object?? extraData null) { Timestamp DateTimeOffset.UtcNow; Level level; Message message; Exception exception; ExtraData extraData ?? new Dictionarystring, object?(); } }LogEntry的设计里我特意把Timestamp放在构造函数里直接赋值。这里有两个原因第一保证每一条日志被创建时立刻记下时间避免在排队过程中时间失真第二后续如果要按时间排序或者按天分文件这个时间戳就是依据。另一个细节是ExtraData。这个字典允许使用者附带任意的上下文信息这是后续结构化日志的雏形。当时我写这个类型的时候还没有觉得它有多重要直到后面做 JSON 输出的时候才发现没有它日志的扩展性会非常别扭。接着定义ILogger接口public interface ILogger { void Log(LogLevel level, string message, Exception? exception null, IDictionarystring, object?? extraData null); void Trace(string message) Log(LogLevel.Trace, message); void Debug(string message) Log(LogLevel.Debug, message); void Info(string message) Log(LogLevel.Info, message); void Warn(string message) Log(LogLevel.Warn, message); void Error(string message, Exception? ex null) Log(LogLevel.Error, message, ex); void Critical(string message, Exception? ex null) Log(LogLevel.Critical, message, ex); }接口默认实现这个特性挺适合做日志方法的不需要每个方法都在实现类里写一堆样板代码。我知道有些人会把这个接口设计成泛型版本ILoggerT那是为了按类型做分类过滤。第一版可以不做后面我会在进阶方向里提到。3.2 设计输出端抽象目标接口与多路输出日志要写到很多地方。为了不在Logger类里堆一堆if (level ...) WriteToFile(...)这种代码我们有更好的做法——把“输出到哪里”抽象成独立的组件。在 NLog 里叫 Target在 Serilog 里叫 Sink我们这里简单点叫ILogWriter。public interface ILogWriter { void Write(LogEntry entry); }ILogWriter只负责“把LogEntry写出去”格式怎么处理是它自己的事。这样设计的好处是后续新增输出端时只需要实现这个接口完全不影响 Logger 主逻辑。Logger类维护一个ListILogWriter调用日志方法时遍历所有 Writer 并交给它们处理public sealed class Logger : ILogger { private readonly LogLevel _minLevel; private readonly ListILogWriter _writers; public Logger(LogLevel minLevel, IEnumerableILogWriter writers) { _minLevel minLevel; _writers writers.ToList(); } public void Log(LogLevel level, string message, Exception? exception null, IDictionarystring, object?? extraData null) { if (level _minLevel) return; var entry new LogEntry(level, message, exception, extraData); foreach (var writer in _writers) { try { writer.Write(entry); } catch (Exception ex) { // 日志写入失败绝不能影响业务主流程 Console.Error.WriteLine($日志写入失败: {ex.Message}); } } } }这里有三个非常关键的设计点。第一质量关卡在入口。if (level _minLevel) return;这行代码避免了创建一个无用对象、避免遍历 Writer 列表是整个框架性能的第一道保险线。你也许会好奇为什么已经走了这层过滤不少框架还会在 Message 参数上做延迟计算那是因为很多调用方是用字符串插值传参的比如logger.LogError($....{xxx})插值动作发生在Log被调用之前框架压根拦不住。正因为如此微软官方的LoggerMessage.Define才有存在的意义——它在编译期就避免了字符串转换。第二输出端的异常隔离。写日志这个动作绝不能反过来把业务代码搞挂。我给每个 Writer 包了一层 try/catch并且在Log方法内部做了循环即使第一个 Writer 崩了后面的 Writer 还能继续写。这个理念我很早就用上了不然后面在生产环境里被日志插件搞挂服务就不是段子而是事故了。第三每次调用都实时遍历 Writer 列表。这样做在 Writer 数量少的时候没有问题但如果日志量极大、Writer 数量又多这个遍历也会有一点点开销。真实框架会把这层裁掉一部分比如按级别预编译输出策略或者把 Writer 列表缓存在某个聚合对象里。我们第一版先把正确性放在第一位。3.3 实现两个核心 Writer控制台与滚动文件3.3.1 控制台输出器控制台 Writer 是所有输出端里最简单的一种。但即便简单它也藏着一个容易犯的毛病格式化逻辑到底放哪。public sealed class ConsoleWriter : ILogWriter { private static readonly DictionaryLogLevel, ConsoleColor ColorMap new() { [LogLevel.Trace] ConsoleColor.DarkGray, [LogLevel.Debug] ConsoleColor.Gray, [LogLevel.Info] ConsoleColor.White, [LogLevel.Warn] ConsoleColor.Yellow, [LogLevel.Error] ConsoleColor.Red, [LogLevel.Critical] ConsoleColor.DarkRed }; private readonly bool _useColor; public ConsoleWriter(bool useColor true) { _useColor useColor; } public void Write(LogEntry entry) { string line Formatter.Format(entry); if (!_useColor) { Console.WriteLine(line); return; } var originalColor Console.ForegroundColor; try { Console.ForegroundColor ColorMap.TryGetValue(entry.Level, out var color) ? color : ConsoleColor.White; Console.WriteLine(line); } finally { Console.ForegroundColor originalColor; } } }为什么格式化的逻辑要单独抽到Formatter而不是直接在 Writer 里写因为同一个LogEntry在不同的输出端希望的样子不一样控制台里要颜色、时间文件里通常要一个清晰可解析的单行文本发送到消息队列时甚至可能是 JSON。如果格式化逻辑被绑死在某个 Writer 里其它 Writer 就没法复用了。我的经验是第一版可以把格式直接写在 Writer 里图省事但一旦你要加第二个 Writer马上就能感受到抽离的必要。这里建议一步到位。Formatter的具体实现也不复杂public static class Formatter { public static string Format(LogEntry entry) { var sb new StringBuilder(); sb.Append([) .Append(entry.Timestamp.LocalDateTime.ToString(yyyy-MM-dd HH:mm:ss.fff)) .Append(] [) .Append(entry.Level.ToString().ToUpper()) .Append(] ) .Append(entry.Message); if (entry.Exception ! null) { sb.AppendLine().Append(entry.Exception); } if (entry.ExtraData.Count 0) { sb.AppendLine(); foreach (var kv in entry.ExtraData) { sb.Append( ) .Append(kv.Key) .Append(: ) .Append(kv.Value) .AppendLine(); } } return sb.ToString(); } }我对这个Formatter有三个执着一是时间格式里必须带毫秒fff。没有毫秒的日志在排查高并发问题时根本没法判断两条日志的真实先后。我调试过太多的死锁和竞态问题靠的都是毫秒级时间戳这条经验非常值钱。二是异常对象要完整的ToString()而不是只取Message。StackTrace和InnerException的信息往往才是排查的关键。三是用StringBuilder而不是字符串插值拼接。原因在于StringBuilder可以避免产生多个临时字符串对象虽然在单条日志上没有多大差别但放到每秒数千条日志的场景下GC 压力完全是两个级别。3.3.2 滚动文件输出器文件输出是所有日志场景里最刚需的同时也是最容易被写坏的。直接File.AppendAllText看起来能用但程序运行几天后你就会被一个巨型单文件折磨疯。正确的做法是做一个按日期滚动的文件 Writerpublic sealed class RollingFileWriter : ILogWriter, IDisposable { private readonly string _directory; private readonly string _filePrefix; private readonly object _lock new(); private StreamWriter? _writer; private string? _currentDateStr; public RollingFileWriter(string directory, string filePrefix app) { _directory directory; _filePrefix filePrefix; Directory.CreateDirectory(directory); } public void Write(LogEntry entry) { string dateStr entry.Timestamp.LocalDateTime.ToString(yyyy-MM-dd); lock (_lock) { if (_currentDateStr ! dateStr) { Rollover(dateStr); } _writer?.WriteLine(Formatter.Format(entry)); _writer?.Flush(); } } private void Rollover(string dateStr) { _writer?.Dispose(); string fileName ${_filePrefix}_{dateStr}.log; string fullPath Path.Combine(_directory, fileName); _writer new StreamWriter(fullPath, append: true) { AutoFlush false }; _currentDateStr dateStr; } public void Dispose() { lock (_lock) { _writer?.Dispose(); _writer null; } } }这里有两个细节值得展开。第一个跨天滚动靠日志时间而不是系统时间判断。_currentDateStr的值来自entry.Timestamp这样即使程序跨天运行到凌晨也能在写入第一条新日志时正确切换文件。如果你用DateTime.Now去做判断在高并发和批量回放历史日志的场景下文件归属就会出现偏差。第二个lock的粒度。我把lock (_lock)放在 Write 方法内部锁内执行文件写入和日志格式化。这个实现简单粗暴但问题是它会阻塞调用线程而且格式化动作也锁在里面。第一版这么做是合理的因为你还没引入异步队列阻塞就是保证文件写入不出现交错最简单的方式。但这里同时也埋了性能隐患我在第 4 章会专门展开讲。别忘了本小节最后要实现IDisposable应用退出时把文件句柄干净地释放掉。很多半路出家的日志库都会在这里栽跟头进程还没退出就把日志文件锁住导致删除备份文件时提示“文件被占用”。说多了都是泪。3.4 组装与快速验证核心组件都写完了现在把它们组装起来跑一个冒烟测试。在Program.cs里using MiniLogger; var logger new Logger( minLevel: LogLevel.Debug, writers: new ILogWriter[] { new ConsoleWriter(useColor: true), new RollingFileWriter(logs, demo) }); logger.Info(应用启动成功); logger.Debug(加载配置 {Path}, appsettings.json); try { throw new InvalidOperationException(模拟业务异常); } catch (Exception ex) { logger.Error(业务处理失败, ex); }把这一跑起来你会看到控制台里带颜色的日志输出文件目录下出现一个demo_2025-xx-xx.log里面是格式化之后的文本日志。此刻你已经拥有了一个“麻雀虽小五脏俱全”的日志框架。它支持级别过滤、多输出端、异常捕获、上下文附带字段、按日滚动文件。接下来我们可以继续做性能优化和问题排查这个过程里你会踩到很多真实日志框架中也会遇到的坑。4. 性能优化与常见坑点排查实录4.1 日志热路径上的字符串拼接陷阱框架第一版跑通之后第一件要改的事情就是性能。你可能会问日志又不是业务主链路性能优化有意义吗有意义而且意义重大。一个繁忙的 Web 应用每秒要写几千条甚至几万条日志。此时日志代码本身就进入了热路径任何一点无谓的开销都会被放大。最典型的反例是调用方在日志方法传参时用了字符串插值logger.Debug($当前用户: {user.Name}, 角色: {string.Join(,, roles)});JavaScript 圈子管这个叫 premature optimization is the root of all evil但在 .NET 日志这里情况恰恰相反这个插值开销发生在框架的关卡之前你无论把框架写得多么节省都没法阻止调用方在传入前就把字符串拼好。为什么框架厂商没把这块做成担子因为params object[]传参就能延迟求值的方案在 C# 里受限于方法调用规则logger.Debug(当前用户: {User}, user.Name)这种写法在传入时只发生了装箱和一次引用传递要比拼好整个字符串便宜得多。我建议你的迷你框架从一开始就把这个习惯带到调用方代码里并且在 README 里写明这一点。我自己就是这么做的代价是团队成员初期总是抱怨“为什么写日志还要这么谨慎”但半年后线上日志延迟和 GC 压力双双下降大家就理解了。4.2 锁的粒度要线程安全还是要吞吐在RollingFileWriter里我用了最朴素的lock。它的优点是绝对安全任何时刻只有一个线程在写文件行与行之间绝对不会互相穿插。代价是吞吐量受限因为锁的临界区里包含了字符串格式化、文件打开与写入。优化方案是缩小临界区把格式化和文件写入拆分。先拿到锁写文件格式化放到锁外做public void Write(LogEntry entry) { string line Formatter.Format(entry); // 锁外执行 lock (_lock) { // 只做文件切换 写入 } }为什么格式化可以放到锁外因为Formatter.Format(entry)只操作entry这个对象不访问共享可变状态理论上不会产生写交错。做完这个调整之后锁的持有时间大幅缩短吞吐量能上几个台阶。但还要注意StreamWriter自身不是线程安全的我在写文件时仍然必须锁。有些开发者以为StreamWriter内部有缓冲就一定线程安全于是去掉锁结果高并发下出现乱码和文件损坏。这个坑我踩过一次记忆非常深刻。更先进的做法是用队列把写日志变成生产者-消费者模型调用线程只入队后台线程统一消费。这样一来调用线程几乎不阻塞吞吐量和延迟都得到巨大改善。这个思路我在 4.3 展开。4.3 异步日志的正确打开方式很多人一听说异步日志就以为把File.AppendAllTextAsync用在日志方法上就是异步日志。这个理解是片面的。真正高吞吐的日志框架通常会引入一个内部队列。调用线程把LogEntry丢进队列就返回后台消费者线程负责批量取出日志组合成一批再一次性写入文件或网络流。这样做的好处有三个第一调用线程永远不会被磁盘 IO 阻塞。第二批写入大大减少了系统调用次数。第三日志风暴时队列可以起到削峰填谷的作用。我基于上面这个思路用一个BlockingCollectionLogEntry写过一个最简单的异步版 Writerpublic sealed class AsyncFileWriter : ILogWriter, IDisposable { private readonly BlockingCollectionLogEntry _queue new(boundedCapacity: 10000); private readonly Thread _workerThread; private readonly string _directory; private readonly string _filePrefix; private StreamWriter? _writer; private string? _currentDateStr; private readonly object _sync new(); public AsyncFileWriter(string directory, string filePrefix app) { _directory directory; _filePrefix filePrefix; _workerThread new Thread(ProcessQueue) { IsBackground true, Name MiniLogger.FileWriter }; _workerThread.Start(); } public void Write(LogEntry entry) { if (!_queue.TryAdd(entry)) { // 队列已满这里可以执行丢弃策略 Console.Error.WriteLine(日志队列已满丢弃日志); } } private void ProcessQueue() { foreach (var entry in _queue.GetConsumingEnumerable()) { var line Formatter.Format(entry); var dateStr entry.Timestamp.LocalDateTime.ToString(yyyy-MM-dd); lock (_sync) { if (_currentDateStr ! dateStr) { _writer?.Dispose(); _writer new StreamWriter(Path.Combine(_directory, ${_filePrefix}_{dateStr}.log), append: true); _currentDateStr dateStr; } _writer?.WriteLine(line); _writer?.Flush(); // 生产环境可以做成定时批量 flush 或按条数批量 flush } } } public void Dispose() { _queue.CompleteAdding(); _workerThread.Join(TimeSpan.FromSeconds(5)); lock (_sync) { _writer?.Dispose(); _writer null; } } }写这个AsyncFileWriter时有三个点你需要额外注意。队列容量要有限制。boundedCapacity: 10000的意义不是让你存更多而是防止内存被日志撑爆。假设单条日志平均 1KB10000 条就是约 10MB 的堆积量尚且可控如果不限制日志风暴时内存会被打到几个 GB。真实框架例如 NLog 的 async 包装器也是这个思路队列 丢弃策略。队列满时的处理策略要想清楚。有丢弃新日志、丢弃最旧日志、阻塞调用方三种常见选择。业务系统里我比较推荐“丢弃新日志并计数”因为日志量异常飙升时最重要的事情是保证服务自身稳定哪怕丢几条日志也不能把请求线程拖垮。当然如果你做的是金融级审计日志这条策略就要完全反过来。后台线程必须做优雅退出。Dispose里先CompleteAdding再Join等待后台线程把队列里剩下的日志全部写完。如果直接强杀进程队列里尚未落盘的几百条日志就会消失。这个细节等你真的丢过日志之后就会刻骨铭心。4.4 日志文件轮转与磁盘填满问题滚动文件有一个隐藏的坑按天切分文件之后如果日志量巨大单个日志文件可能在一天内膨胀到几个 GB。磁盘被日志写满之后最轻的症状是日志写不进去最重的症状是整个服务器崩溃。要是在生产环境遇到“磁盘满了”的情况第一反应该是先把日志级别调高比如从 Debug 调到 Warn立刻减少写入量。第二步是清理不再需要的旧日志第三步才是去定位为什么产生了这么多日志。真实框架里文件轮转的配置非常细按大小滚动、按日期滚动、保留最近 N 个文件、达到阈值压缩。我们这套迷你框架目前只做了按日期滚动你也可以加一个简单的文件大小判断if (_writer.BaseStream.Length _maxFileSizeBytes) { Rollover(dateStr _ DateTime.Now.ToString(HHmmss)); }这个判断在锁内做一次BaseStream.Length取值开销极小但对磁盘保护的意义非常重大。我当初写这个判断时觉得多此一举直到某个环境磁盘被测试日志打满才明白这种“多此一举”在生产中就是救命稻草。5. 与真实框架对比你的设计与 NLog/Serilog 差在哪5.1 Serilog 的事件管道与结构化日志你现在已经有一个能跑的迷你框架了。接下来最该做的事不是继续往自己的框架里疯狂堆功能而是打开 Serilog 的源码看看自己的设计跟它差在哪些关键点上。Serilog 最核心的抽象是LogEvent这个对象对应我们写的LogEntry但它更丰富除了Timestamp、Level、MessageTemplate、Exception还带了一个Dictionarystring, LogEventProperty Properties集合。有意思的是它的MessageTemplate不是一个普通字符串而是一组 token 的集合。日志文本本身是模板属性值被单独存放。Serilog 的ILogEventSink接口负责输出对应了我们的ILogWriter。整个管道它是反过来设计的——Log方法把LogEvent交给LoggerLogger经过过滤后分别走Sink。这就是我们前面链路设计只是它把每一步都抽象成了独立的接口。它比我做得更彻底的地方是LogContext和 Enricher 体系。你可以在一个作用域内给所有日志追加“请求 ID”“用户 ID”然后这些附加信息自动出现在日志事件里。这个能力在我们手写版本里也有雏形——ExtraData字典——但 Serilog 把它做成了一个完整的扩展点。5.2 NLog 的配置化与 Target/布局体系NLog 的思路和 Serilog 不太一样。它更像一个“配出来的框架”所有行为都由配置文件驱动。它的核心概念是Target对应我们的 Writer、Layout对应我们 Formatter 的一部分和Rule路由规则。NLog 里的每个 Target 可以自定义布局比如target namefile typeFile layout${longdate}|${level:uppercasetrue}|${logger}|${message} /这套设计的优势在于你可以不重新编译程序就改变日志行为。线上出了问题改一下配置文件马上调整级别、格式、输出目标。对比之下我们的迷你框架把格式器和 Writer 牢牢绑在一起改动就需要重新编译部署。如果你要深入框架设计NLog 的“布局系统”是一个绕不开的模仿对象。它本质上就是一套“字符串模板引擎”但支持函数式的扩展比如level的格式化选项和参数。5.3 结构化日志与现代日志管理系统如果你把日志写到文件里然后靠 grep 去查问题在小规模场景是够用的。但在稍微大型一点的生产环境几个服务的日志分散在各台机器的文件里排查一个跨服务调用链的问题会非常痛苦。这时候日志就得走向汇聚和管理也就是常说的集中式日志。现代日志系统的数据形态是结构化 JSON。比如{ timestamp: 2025-01-15T10:23:45.123Z, level: error, message: 订单处理失败, orderId: ORD-1024, durationMs: 2350 }这种格式可以被 Elasticsearch 这类引擎直接索引支持按字段检索、聚合统计、告警规则。它和纯文本日志的区别就像数据库的行和一段散文的区别。这也是为什么我在 3.1 里给LogEntry设计了ExtraData——它天然可以序列化成 JSON。如果你给迷你框架加一个JsonFileWriter用System.Text.Json把LogEntry序列化成 JSON 行你就已经为自己的应用打好结构化日志的地基了。我当时加了这个 Writer 之后立刻就能在 KQL 查询里按orderId定位日志了这个体验是文本 grep 给不了的。这里给你一个加在JsonFileWriter里的序列化小技巧序列化时用JsonSerializerOptions的DefaultIgnoreCondition设置为WhenWritingNull这样没有携带ExtraData的日志事件输出不会是一堆ExtraData: {}的噪音。细节虽小但对日志体积的影响其实是持续的。5.4 进阶学习路线建议如果你认真写完了这个迷你框架接下来可以参考这个顺序继续钻先从 NLog 的 Target 和 Layout 系统开始理解配置驱动的日志设计推荐看它预置的几十种 Target你会很直观地看到“原来日志可以输出到这么多地方”。然后切换到 Serilog 的 Enricher 和 Sink 源码理解结构化管道和上下文机制。再往后可以去看微软官方的Microsoft.Extensions.Logging和LoggerMessage搞清楚依赖注入下的通用日志抽象是如何设计的。最后如果对性能和极端场景有兴趣就看高性能日志库的实现理解无锁队列和批处理在日志框架里的应用。别忘了 CPU 采样和内存分配分析。你可以在自己的迷你框架上做一次基准测试统计每秒能写多少条日志、分配了多少字节然后把这个数字和 NLog 对比一下。数字之间的差距就是设计差距最直观的体现。写在最后手写这个迷你日志框架我想你已经体会到了写日志这么“简单”的事情背后有这么多值得琢磨的细节。在我原来的团队里我让新同事在入职第一周做这个练习算是某种“扫盲程序”。有的人觉得浪费时间但凡是认认真真手写过一遍的人在之后遇到线上日志问题时的处理速度和准确性明显要比没写过的人高一截。最后再给你一个实操层面非常实用的小建议不要等到“有空了”再去写这个迷你框架。挑选一个周末拿出两三个小时从LogEntry写到RollingFileWriter把本文的代码亲手敲一遍、跑一遍。然后再花半小时打开 NLog 的源码对照着看你会瞬间明白我之前说的“原来我没想到这一点”是种什么样的体验。这个两小时比你读十篇日志框架原理的文章都更值。
RELATED

相关推荐

三菱PLC时间锁机程序2.0:三期表驱动与防回拨设计

三菱PLC时间锁机程序2.0:三期表驱动与防回拨设计

最近一直在折腾三菱PLC的时间锁机程序,这次做的2.0版本算是把“时间锁”这个功能彻底玩明白了。从一开始只做一个固定到期日停机,到现在的三期时间锁、按表驱动动态扩展期数,中间的思路变化和踩坑记录都整理在这篇里。如果你也在做设备分期付…

📅 2026/10/22 12:00:57
.NET构建发布链路革新:Native AOT、裁剪与单文件容器化实践

.NET构建发布链路革新:Native AOT、裁剪与单文件容器化实践

1. 这轮革新,动的不是语法而是交付链路 前两篇我们分别聊了为什么要持续折腾 .NET 的构建发布方式,以及整体方案是怎么一步步演进的。这篇直接落到实操:把我手里一个给内部同事用的订单查询服务,再加一个命令行小工具,…

📅 2026/10/22 12:00:57
基于Wi-Fi CSI的细粒度跌倒检测:从原理到部署实践

基于Wi-Fi CSI的细粒度跌倒检测:从原理到部署实践

先说一个让我印象很深的实验细节。某天晚上在实验室做数据采集,我模拟了一位老人从椅子上站起来没站稳、后仰跌倒的动作,防摔垫上那声响在空旷的房间里特别刺耳。做完之后我愣了一下——如果这不是实验,而是在真实的独居家庭里,身…

📅 2026/10/22 12:00:57
MORE NEWS

更多资讯

📰

上下文工程实战:从72%到91%准确率的智能体优化复盘

从一次客服机器人改版说起:我把一个"看起来什么都懂"的智能体,从准确率72%调到了91%,单次调用成本还降了一半以上。这个过程中最深刻的体会是——模型本身没换,prompt也没有玄学式的反复修改,真正的杠杆在上…

📰

数字串123222工程实战:进制转换、质因数分解与分布式分片

我看到“123222”这种数字串,第一反应不是“这有什么可挖的”,而是“这能当多少种样本用”。六位数字,字符组成简单,却同时具备统计特征、数学性质、进制转换后的多重身份,甚至可以直接扔进测试用例、分布式分片和校验…

📰

RESTful接口设计:资源、路径、方法的对应关系全拆解

RESTful 接口这块,很多团队聊起来能聊一下午,但真到写代码的时候,最容易出问题的反而是最基础的三个词:资源、路径、方法。我见过不少前后端联调翻车的现场,根因几乎都不是框架不会用,而是这三者的对应关系…

📰

仿微信登录注册界面:Android Studio线性布局实现与避坑指南

简介:面向 Android 初学者的仿微信登录注册界面完整工程包,采用 Java 与 Android Studio 实现,重点演示线性布局 LinearLayout 的垂直/水平排列、layout_weight 权重分配、EditText 与 Button 样式定制,并涵盖登录与注册页间的 In…

📰

提示工程持续部署:从版本化到灰度回滚的完整策略

最近在搭提示工程持续部署机制的时候,我被问得最多的一个问题特别直接:Prompt也要做持续部署?不都是改完直接上线看效果吗?这个问题的背后,其实是很多团队仍然在用“改代码、跑测试、发版”那套传统CD思路来管理人机交…

📰

AI办公提效实战:提示词与校验如何改造周报、对账和会议纪要

到了2026年,AI办公早就不是什么遥远概念,而是我每天打开电脑就要面对的事。身边有人对各种新功能如数家珍,也有人还在用最原始的方式手工填表、逐句改汇报,最后被大量低水平重复劳动拖到晚上九十点。说白了,AI工具已经…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬