Skip to Content
Week 05Day 4 - 日志体系

Day 4 - 日志体系

建议用时:240-300 分钟

你将学会什么

  • 日志为什么是排查问题的时间线
  • DebugInformationWarningError 分别什么时候用
  • 日志里应该记录动作、对象、结果和错误原因
  • 为什么不要把密码、Token、密钥写进日志
  • 如何把日志抽成接口并注入到服务里
  • 如何写控制台日志和文件日志

日志不是随手 Console.WriteLine。好的日志要能回答:什么时候、谁、做了什么、结果如何、失败原因是什么。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“配置管理”,今天继续学习“日志体系”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“日志体系”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。日志的价值是给你一条时间线,而不是把控制台刷满文字。

1. 日志是什么

日志是程序运行时留下的记录。

它能回答:

  1. 什么时候开始做某件事。
  2. 处理的是哪个对象。
  3. 结果成功还是失败。
  4. 失败时异常是什么。
  5. 失败前发生过哪些步骤。

异常告诉你哪里坏了,日志告诉你坏之前发生了什么。

2. 日志级别

级别什么时候用
Debug开发排查细节,正式环境通常少开
Information正常关键流程,例如开始、成功
Warning可恢复问题,例如配置缺失使用默认值
Error操作失败、异常、需要处理的问题

不要所有日志都写 Error

如果全是 Error,真正严重的问题会被淹没。

3. 日志上下文

只写:

保存失败

信息不够。

更好的日志要带上下文:

保存商品失败,Name=键盘,Price=199

上下文可以包括:

  1. 商品名。
  2. 订单号。
  3. 用户编号。
  4. 文件路径。
  5. 任务编号。

4. 结构化日志是什么

真实日志框架常用结构化日志:

"保存商品失败,Name={Name}, Price={Price}"

这样日志系统能把 NamePrice 当字段保存,方便搜索和统计。

本页先用字符串拼接理解概念,后面接真实日志框架时再学习模板写法。

5. 不要记录敏感信息

不要写进日志:

  1. 密码。
  2. Token。
  3. 私钥。
  4. 身份证号。
  5. 银行卡号。
  6. 个人联系方式。

日志会被保存、传输、搜索。敏感信息进了日志,很难彻底清理。

6. 日志和异常的关系

异常是错误对象。

日志是记录。

常见做法:

catch (Exception ex) { logger.Error("保存商品失败", ex); throw; }

如果当前层不能真正处理异常,就记录后继续抛出,或者返回明确失败结果。

不要空 catch

7. 为什么日志要注入

如果业务类里直接写 Console.WriteLine

  1. 以后换成文件日志要改业务类。
  2. 以后接真实日志框架要改业务类。
  3. 测试时不好检查日志。

更好的做法是依赖接口:

IAppLogger

具体是控制台日志还是文件日志,由外部传入。

日志常用 API 速查

需求写法说明
记录普通信息logger.LogInformation(...)正常流程
记录警告logger.LogWarning(...)可恢复问题
记录错误logger.LogError(...)操作失败
记录异常logger.LogError(ex, "...")保留堆栈
创建日志工厂LoggerFactory.Create(...)控制台项目常用
添加控制台日志builder.AddConsole()输出到终端
注入日志ILogger<ProductService>类里使用日志

日志级别先这样记:

Information:发生了什么 Warning:有风险但还能继续 Error:失败了,需要处理

服务里常见模板:

public ProductService(ILogger<ProductService> logger) { this.logger = logger; } logger.LogInformation("开始保存商品 {Name}", name); logger.LogError(ex, "保存商品失败 {Name}", name);

第二部分:把知识组合成完整例子

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要能把日志作为依赖传给服务,而不是在业务代码里到处乱写输出。

先看效果:商品保存日志

IAppLogger logger = new ConsoleAppLogger(AppLogLevel.Information); IProductRepository repository = new MemoryProductRepository(); ProductService service = new ProductService(repository, logger); try { service.Create("键盘", 199m); service.Create("", 88m); } catch (ArgumentException ex) { logger.Error("创建商品失败", ex); } interface IAppLogger { void Information(string message); void Warning(string message); void Error(string message, Exception? exception = null); } class ConsoleAppLogger : IAppLogger { private readonly AppLogLevel _minimumLevel; public ConsoleAppLogger(AppLogLevel minimumLevel) { _minimumLevel = minimumLevel; } public void Information(string message) { Write(AppLogLevel.Information, message, null); } public void Warning(string message) { Write(AppLogLevel.Warning, message, null); } public void Error(string message, Exception? exception = null) { Write(AppLogLevel.Error, message, exception); } private void Write(AppLogLevel level, string message, Exception? exception) { if (level < _minimumLevel) { return; } Console.WriteLine($"{DateTime.Now:HH:mm:ss} [{level}] {message}"); if (exception is not null) { Console.WriteLine($"异常类型: {exception.GetType().Name}"); Console.WriteLine($"异常消息: {exception.Message}"); } } } class ProductService { private readonly IProductRepository _repository; private readonly IAppLogger _logger; public ProductService(IProductRepository repository, IAppLogger logger) { _repository = repository; _logger = logger; } public Product Create(string name, decimal price) { _logger.Information($"开始创建商品,名称={name}"); Product product = new Product(name, price); _repository.Save(product); _logger.Information($"商品创建成功,名称={product.Name}"); return product; } } interface IProductRepository { void Save(Product product); } class MemoryProductRepository : IProductRepository { public List<Product> Products { get; } = new List<Product>(); public void Save(Product product) { Products.Add(product); } } class Product { public Product(string name, decimal price) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("商品名称不能为空"); } if (price <= 0) { throw new ArgumentException("商品价格必须大于 0"); } Name = name.Trim(); Price = price; } public string Name { get; } public decimal Price { get; } } enum AppLogLevel { Debug = 1, Information = 2, Warning = 3, Error = 4 }

这个例子体现了日志的核心:

  1. 正常流程写 Information
  2. 失败流程写 Error
  3. 日志里带关键业务上下文。
  4. 日志作为依赖传给服务。

第三部分:跟着敲代码

从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。

动手前先做这 3 件事

  1. 打开一个控制台项目。
  2. 每次只保留一个例子的代码,运行通过后再换下一个。
  3. 每个例子运行后,改一次级别、消息或错误条件。

例子 1:最小日志函数

void Log(string level, string message) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [{level}] {message}"); } Log("INFO", "开始保存商品"); Log("ERROR", "保存失败:文件无权限");

这个版本很简单,但已经包含时间、级别和消息。

例子 2:用枚举表示日志级别

void Log(AppLogLevel level, string message) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [{level}] {message}"); } Log(AppLogLevel.Information, "开始保存商品"); Log(AppLogLevel.Warning, "商品名称有空格,已经自动 Trim"); Log(AppLogLevel.Error, "保存失败"); enum AppLogLevel { Debug, Information, Warning, Error }

枚举比字符串安全,避免写出 INF0WARNNING 这类拼写错误。

例子 3:最小 Logger 类

ConsoleAppLogger logger = new ConsoleAppLogger(); logger.Information("开始保存商品"); logger.Warning("商品库存不足"); logger.Error("保存商品失败", new InvalidOperationException("磁盘空间不足")); class ConsoleAppLogger { public void Information(string message) { Write("INFO", message, null); } public void Warning(string message) { Write("WARN", message, null); } public void Error(string message, Exception? exception) { Write("ERROR", message, exception); } private void Write(string level, string message, Exception? exception) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [{level}] {message}"); if (exception is not null) { Console.WriteLine($"{exception.GetType().Name}: {exception.Message}"); } } }

把日志逻辑放进类,业务代码就不用自己拼时间和级别。

例子 4:日志级别过滤

ConsoleAppLogger logger = new ConsoleAppLogger(AppLogLevel.Warning); logger.Debug("调试细节"); logger.Information("普通流程"); logger.Warning("可恢复问题"); logger.Error("严重失败"); class ConsoleAppLogger { private readonly AppLogLevel _minimumLevel; public ConsoleAppLogger(AppLogLevel minimumLevel) { _minimumLevel = minimumLevel; } public void Debug(string message) => Write(AppLogLevel.Debug, message); public void Information(string message) => Write(AppLogLevel.Information, message); public void Warning(string message) => Write(AppLogLevel.Warning, message); public void Error(string message) => Write(AppLogLevel.Error, message); private void Write(AppLogLevel level, string message) { if (level < _minimumLevel) { return; } Console.WriteLine($"{DateTime.Now:HH:mm:ss} [{level}] {message}"); } } enum AppLogLevel { Debug = 1, Information = 2, Warning = 3, Error = 4 }

最低级别是 Warning 时,DebugInformation 不会输出。

例子 5:不要记录敏感信息

string userId = "U001"; string password = "123456"; string token = "secret-token"; void Log(string action, string context) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [INFO] {action} | {context}"); } Log("用户登录", $"UserId={userId}"); Console.WriteLine("不要这样记录:"); Console.WriteLine($"Password={password}"); Console.WriteLine($"Token={token}");

实际项目里不要输出密码和 Token。这里打印出来只是为了看清哪些内容不能进日志。

例子 6:把日志作为接口注入

IAppLogger logger = new ConsoleAppLogger(); ProductService service = new ProductService(logger); service.Create("键盘"); interface IAppLogger { void Information(string message); } class ConsoleAppLogger : IAppLogger { public void Information(string message) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [INFO] {message}"); } } class ProductService { private readonly IAppLogger _logger; public ProductService(IAppLogger logger) { _logger = logger; } public void Create(string name) { _logger.Information($"开始创建商品: {name}"); _logger.Information($"商品创建成功: {name}"); } }

服务依赖 IAppLogger,以后可以换成文件日志而不改服务。

例子 7:文件日志

IAppLogger logger = new FileAppLogger("app.log"); logger.Information("程序启动"); logger.Error("保存失败", new InvalidOperationException("磁盘空间不足")); Console.WriteLine(await File.ReadAllTextAsync("app.log")); interface IAppLogger { void Information(string message); void Error(string message, Exception? exception = null); } class FileAppLogger : IAppLogger { private readonly string _path; public FileAppLogger(string path) { _path = path; } public void Information(string message) { Write("INFO", message, null); } public void Error(string message, Exception? exception = null) { Write("ERROR", message, exception); } private void Write(string level, string message, Exception? exception) { string line = $"{DateTime.Now:HH:mm:ss} [{level}] {message}"; File.AppendAllText(_path, line + Environment.NewLine); if (exception is not null) { File.AppendAllText(_path, $"{exception.GetType().Name}: {exception.Message}" + Environment.NewLine); } } }

文件日志适合程序结束后继续查看运行记录。

例子 8:保存商品时记录正常和异常流程

IAppLogger logger = new ConsoleAppLogger(); ProductService service = new ProductService(logger); try { service.Create("键盘", 199m); service.Create("", 88m); } catch (ArgumentException ex) { logger.Error("创建商品失败", ex); } interface IAppLogger { void Information(string message); void Error(string message, Exception? exception = null); } class ConsoleAppLogger : IAppLogger { public void Information(string message) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [INFO] {message}"); } public void Error(string message, Exception? exception = null) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [ERROR] {message}"); if (exception is not null) { Console.WriteLine($"{exception.GetType().Name}: {exception.Message}"); } } } class ProductService { private readonly IAppLogger _logger; public ProductService(IAppLogger logger) { _logger = logger; } public void Create(string name, decimal price) { _logger.Information($"开始创建商品,Name={name}"); if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("商品名称不能为空"); } if (price <= 0) { throw new ArgumentException("商品价格必须大于 0"); } _logger.Information($"商品创建成功,Name={name.Trim()}, Price={price}"); } }

日志里既记录了正常路径,也记录了失败原因。

小白重复敲写训练

日志要练“不同级别”和“异常对象”,不能只写字符串。

训练 1:创建控制台日志

using Microsoft.Extensions.Logging; using ILoggerFactory factory = LoggerFactory.Create(builder => { builder.AddConsole(); }); ILogger logger = factory.CreateLogger("Demo"); logger.LogInformation("程序启动"); logger.LogWarning("库存低于 {Count}", 5);

改动任务:增加一条商品编号参数。

训练 2:记录异常

try { throw new InvalidOperationException("模拟保存失败"); } catch (Exception ex) { logger.LogError(ex, "保存商品时发生错误"); }

观察日志里是否包含异常类型和堆栈。

训练 3:方法里使用日志

void Save(string name) { logger.LogInformation("开始保存 {Name}", name); Console.WriteLine("保存完成"); logger.LogInformation("保存 {Name} 成功", name); } Save("Keyboard");

第三遍增加输入为空时的 Warning 日志。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:日志体系。今天至少要掌握的 3 个点是:

1. 日志为什么是排查问题的时间线 2. `Debug`、`Information`、`Warning`、`Error` 分别什么时候用 3. 日志里应该记录动作、对象、结果和错误原因

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:日志为什么是排查问题的时间线。

上位机专项练习

日志要回答何时、哪台设备、做了什么、为什么失败,方便现场排查。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:记录连接日志

ILogger logger = loggerFactory.CreateLogger("Device"); string deviceName = "PLC-01"; logger.LogInformation("开始连接设备 {DeviceName}", deviceName);

运行结果或界面效果:

info: 开始连接设备 PLC-01

改动任务: 再记录连接成功日志。

专项例子 2:记录带参数的错误

try { throw new IOException("连接被拒绝"); } catch (IOException ex) { logger.LogError(ex, "读取设备 {DeviceName} 失败", "PLC-01"); }

运行结果或界面效果:

日志包含设备名、错误消息和堆栈

改动任务: 把设备名换成温控器-01。

专项例子 3:区分日志等级

logger.LogDebug("原始报文: 01 03 00 00"); logger.LogInformation("设备连接成功"); logger.LogWarning("温度接近上限"); logger.LogError("设备通信中断");

运行结果或界面效果:

Debug、Information、Warning、Error 四种等级

改动任务: 为“配置文件不存在”选择合适等级。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。建议先照着敲一遍,再修改日志级别和错误条件验证。

作业 1:最小日志器

要求:

  1. 定义 IAppLogger
  2. ConsoleAppLogger
  3. 支持 InformationWarningError
  4. 输出时间、级别和消息。

完整答案

IAppLogger logger = new ConsoleAppLogger(); logger.Information("程序启动"); logger.Warning("使用默认配置"); logger.Error("保存失败", new InvalidOperationException("磁盘空间不足")); interface IAppLogger { void Information(string message); void Warning(string message); void Error(string message, Exception? exception = null); } class ConsoleAppLogger : IAppLogger { public void Information(string message) { Write("INFO", message, null); } public void Warning(string message) { Write("WARN", message, null); } public void Error(string message, Exception? exception = null) { Write("ERROR", message, exception); } private void Write(string level, string message, Exception? exception) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [{level}] {message}"); if (exception is not null) { Console.WriteLine($"{exception.GetType().Name}: {exception.Message}"); } } }

作业 2:ProductService 注入日志

要求:

  1. ProductService 构造函数接收 IAppLogger
  2. 正常创建商品时写 Information
  3. 商品名为空时抛异常。
  4. 外层捕获异常并写 Error

完整答案

IAppLogger logger = new ConsoleAppLogger(); ProductService service = new ProductService(logger); try { service.Create("键盘", 199m); service.Create("", 88m); } catch (ArgumentException ex) { logger.Error("商品创建失败", ex); } interface IAppLogger { void Information(string message); void Error(string message, Exception? exception = null); } class ConsoleAppLogger : IAppLogger { public void Information(string message) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [INFO] {message}"); } public void Error(string message, Exception? exception = null) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} [ERROR] {message}"); if (exception is not null) { Console.WriteLine($"{exception.GetType().Name}: {exception.Message}"); } } } class ProductService { private readonly IAppLogger _logger; public ProductService(IAppLogger logger) { _logger = logger; } public void Create(string name, decimal price) { _logger.Information($"开始创建商品: {name}"); if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("商品名称不能为空"); } if (price <= 0) { throw new ArgumentException("商品价格必须大于 0"); } _logger.Information($"商品创建成功: {name.Trim()}"); } }

作业 3:文件日志

要求:

  1. FileAppLogger
  2. 日志写入 app.log
  3. 写入后读取文件并输出。

完整答案

IAppLogger logger = new FileAppLogger("app.log"); logger.Information("程序启动"); logger.Error("模拟失败", new InvalidOperationException("磁盘空间不足")); string text = await File.ReadAllTextAsync("app.log"); Console.WriteLine(text); interface IAppLogger { void Information(string message); void Error(string message, Exception? exception = null); } class FileAppLogger : IAppLogger { private readonly string _path; public FileAppLogger(string path) { _path = path; } public void Information(string message) { Write("INFO", message, null); } public void Error(string message, Exception? exception = null) { Write("ERROR", message, exception); } private void Write(string level, string message, Exception? exception) { File.AppendAllText(_path, $"{DateTime.Now:HH:mm:ss} [{level}] {message}" + Environment.NewLine); if (exception is not null) { File.AppendAllText(_path, $"{exception.GetType().Name}: {exception.Message}" + Environment.NewLine); } } }

作业验收

完成后检查:

  1. 正常流程用 Information
  2. 可恢复问题用 Warning
  3. 异常失败用 Error
  4. 日志里有时间、级别、动作和关键上下文。
  5. 日志里没有密码、Token、密钥。
  6. ProductService 依赖 IAppLogger,不直接依赖 Console.WriteLine

本页最后要记住

  1. 日志是排查问题的时间线。
  2. 不同严重程度要用不同日志级别。
  3. 只写“失败了”不够,要带上下文。
  4. 不要把敏感信息写进日志。
  5. 异常日志要包含异常类型和消息。
  6. 日志应该作为依赖注入到服务里。
  7. 控制台日志和文件日志只是不同实现,业务服务不应该被具体实现绑死。