Day 4 - 日志体系
建议用时:240-300 分钟
你将学会什么
- 日志为什么是排查问题的时间线
Debug、Information、Warning、Error分别什么时候用- 日志里应该记录动作、对象、结果和错误原因
- 为什么不要把密码、Token、密钥写进日志
- 如何把日志抽成接口并注入到服务里
- 如何写控制台日志和文件日志
日志不是随手 Console.WriteLine。好的日志要能回答:什么时候、谁、做了什么、结果如何、失败原因是什么。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“配置管理”,今天继续学习“日志体系”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“日志体系”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。日志的价值是给你一条时间线,而不是把控制台刷满文字。
1. 日志是什么
日志是程序运行时留下的记录。
它能回答:
- 什么时候开始做某件事。
- 处理的是哪个对象。
- 结果成功还是失败。
- 失败时异常是什么。
- 失败前发生过哪些步骤。
异常告诉你哪里坏了,日志告诉你坏之前发生了什么。
2. 日志级别
| 级别 | 什么时候用 |
|---|---|
Debug | 开发排查细节,正式环境通常少开 |
Information | 正常关键流程,例如开始、成功 |
Warning | 可恢复问题,例如配置缺失使用默认值 |
Error | 操作失败、异常、需要处理的问题 |
不要所有日志都写 Error。
如果全是 Error,真正严重的问题会被淹没。
3. 日志上下文
只写:
保存失败信息不够。
更好的日志要带上下文:
保存商品失败,Name=键盘,Price=199上下文可以包括:
- 商品名。
- 订单号。
- 用户编号。
- 文件路径。
- 任务编号。
4. 结构化日志是什么
真实日志框架常用结构化日志:
"保存商品失败,Name={Name}, Price={Price}"这样日志系统能把 Name、Price 当字段保存,方便搜索和统计。
本页先用字符串拼接理解概念,后面接真实日志框架时再学习模板写法。
5. 不要记录敏感信息
不要写进日志:
- 密码。
- Token。
- 私钥。
- 身份证号。
- 银行卡号。
- 个人联系方式。
日志会被保存、传输、搜索。敏感信息进了日志,很难彻底清理。
6. 日志和异常的关系
异常是错误对象。
日志是记录。
常见做法:
catch (Exception ex)
{
logger.Error("保存商品失败", ex);
throw;
}如果当前层不能真正处理异常,就记录后继续抛出,或者返回明确失败结果。
不要空 catch。
7. 为什么日志要注入
如果业务类里直接写 Console.WriteLine:
- 以后换成文件日志要改业务类。
- 以后接真实日志框架要改业务类。
- 测试时不好检查日志。
更好的做法是依赖接口:
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
}这个例子体现了日志的核心:
- 正常流程写
Information。 - 失败流程写
Error。 - 日志里带关键业务上下文。
- 日志作为依赖传给服务。
第三部分:跟着敲代码
从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。
动手前先做这 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
}枚举比字符串安全,避免写出 INF0、WARNNING 这类拼写错误。
例子 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 时,Debug 和 Information 不会输出。
例子 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:最小日志器
要求:
- 定义
IAppLogger。 - 写
ConsoleAppLogger。 - 支持
Information、Warning、Error。 - 输出时间、级别和消息。
完整答案
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 注入日志
要求:
ProductService构造函数接收IAppLogger。- 正常创建商品时写
Information。 - 商品名为空时抛异常。
- 外层捕获异常并写
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:文件日志
要求:
- 写
FileAppLogger。 - 日志写入
app.log。 - 写入后读取文件并输出。
完整答案
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);
}
}
}作业验收
完成后检查:
- 正常流程用
Information。 - 可恢复问题用
Warning。 - 异常失败用
Error。 - 日志里有时间、级别、动作和关键上下文。
- 日志里没有密码、Token、密钥。
ProductService依赖IAppLogger,不直接依赖Console.WriteLine。
本页最后要记住
- 日志是排查问题的时间线。
- 不同严重程度要用不同日志级别。
- 只写“失败了”不够,要带上下文。
- 不要把敏感信息写进日志。
- 异常日志要包含异常类型和消息。
- 日志应该作为依赖注入到服务里。
- 控制台日志和文件日志只是不同实现,业务服务不应该被具体实现绑死。