Day 6 - 错误处理与日志
建议用时:180-210 分钟
你将学会什么
- 异常是什么
- 用户提示和日志记录有什么区别
- INFO、WARNING、ERROR 分别什么时候用
- 为什么不能只写“出错了”
- 怎么把日志写入本地文件
- 怎么在 Avalonia 页面里触发并检查日志
本页不用额外日志包,先手写一个最小 AppLogger。目标是把日志的本质讲清楚:程序运行时把关键线索写到文件里,出问题后可以按时间线追踪。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“打包与发布”,今天继续学习“错误处理与日志”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“错误处理与日志”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。错误处理不是把所有代码都包进 try/catch,而是分清:哪些是可预期情况,哪些是真异常,哪些信息给用户看,哪些信息写日志。
异常是什么
异常是程序运行时发生的失败。
例如:
string text = await File.ReadAllTextAsync("missing.txt");如果 missing.txt 不存在,这行代码就会抛出异常。
抛出异常的意思是:这行代码无法正常继续,程序需要进入失败处理流程。
用户提示和日志不是一回事
用户提示要短、清楚、能行动。
例如:
读取失败,请确认文件是否存在。日志要详细、能排查问题。
例如:
2026-06-12 10:20:30.123 +08:00 [ERROR] 读取 missing.txt 失败
System.IO.FileNotFoundException: Could not find file ...用户不需要看到整段异常堆栈。开发者需要看到异常类型、时间、动作、文件名这些线索。
INFO、WARNING、ERROR 怎么选
| 级别 | 什么时候用 | 本页例子 |
|---|---|---|
| INFO | 正常关键流程 | 开始保存、保存成功 |
| WARNING | 可恢复问题 | 商品名为空,已阻止保存 |
| ERROR | 异常或失败 | 文件不存在、保存失败 |
不要所有日志都写 ERROR。
如果用户输入为空,这是可预期的校验问题,通常是 WARNING。
如果文件读取时真的抛异常,才是 ERROR。
为什么日志要写动作和对象
差的日志:
出错了这条日志没有排查价值,因为看不出哪个动作、哪个对象、什么原因。
更好的日志:
读取 missing.txt 失败这条日志至少说明了动作是“读取”,对象是 missing.txt。
再配合异常详情,就能知道具体失败原因。
为什么不能吞掉异常
吞掉异常就是这样:
try
{
await File.ReadAllTextAsync("missing.txt");
}
catch
{
}这段代码的问题是:失败了也什么都不说,用户不知道,日志也没有线索。
本页的写法是:
catch (Exception ex)
{
await logger.ErrorAsync("读取 missing.txt 失败", ex);
Message = $"读取失败,异常细节已写入日志:{logger.LogPath}";
}这样用户知道失败了,日志也保留了详细原因。
为什么不要记录敏感信息
日志文件可能被复制、发送、上传、长期保存。
不要写入这些内容:
- 密码
- Token
- Secret
- 身份证号
- 银行卡号
- 私人联系方式
可以记录用户 ID、商品 ID、订单号这类排查需要的标识,但也要按业务要求控制。
日志文件为什么放到 ApplicationData
日志不是源码的一部分,不应该写到项目目录。
本页使用:
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)然后放到:
ProductApp/logs/app.log这样日志位置稳定,发布后也能继续写。
错误处理与日志常用写法速查
| 需求 | 写法 | 说明 |
|---|---|---|
| 捕获异常 | try/catch | 防止程序失控 |
| 错误消息 | ex.Message | 给用户看简要原因 |
| 写日志 | File.AppendAllText | 简单日志文件 |
| 时间戳 | DateTime.Now | 记录发生时间 |
| 日志级别 | INFO/WARNING/ERROR | 区分严重程度 |
| finally 收尾 | finally | 恢复状态 |
| 用户提示 | Message / ErrorMessage | 界面反馈 |
日志一行建议包含:
时间 | 级别 | 操作 | 结果或错误原因第二部分:把知识组合成完整例子
今天做一个“日志演示页面”。
页面上有三个按钮:
- 写入正常日志。
- 写入警告日志。
- 故意读取一个不存在的文件,捕获异常并写入错误日志。
日志会保存到当前用户的应用数据目录。
日志类
文件位置:
Services/AppLogger.csusing System;
using System.IO;
using System.Threading.Tasks;
namespace ProductApp.Services;
public sealed class AppLogger
{
private readonly string logPath;
public AppLogger()
{
string folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ProductApp",
"logs");
Directory.CreateDirectory(folder);
logPath = Path.Combine(folder, "app.log");
}
public string LogPath => logPath;
public Task InfoAsync(string message)
{
return WriteAsync("INFO", message);
}
public Task WarningAsync(string message)
{
return WriteAsync("WARNING", message);
}
public Task ErrorAsync(string message, Exception ex)
{
string detail = $"{message}{Environment.NewLine}{ex}";
return WriteAsync("ERROR", detail);
}
private Task WriteAsync(string level, string message)
{
string line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} [{level}] {message}{Environment.NewLine}";
return File.AppendAllTextAsync(logPath, line);
}
}ViewModel
文件位置:
ViewModels/MainWindowViewModel.csusing System;
using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ProductApp.Services;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly AppLogger logger = new();
[ObservableProperty]
private string message = "";
public MainWindowViewModel()
{
Message = $"日志文件:{logger.LogPath}";
}
[RelayCommand]
private async Task WriteInfoAsync()
{
await logger.InfoAsync("开始保存商品 Name=Keyboard");
await Task.Delay(200);
await logger.InfoAsync("商品保存成功 Name=Keyboard");
Message = $"已写入 INFO 日志:{logger.LogPath}";
}
[RelayCommand]
private async Task WriteWarningAsync()
{
await logger.WarningAsync("商品名为空,已阻止保存");
Message = $"已写入 WARNING 日志:{logger.LogPath}";
}
[RelayCommand]
private async Task MakeErrorAsync()
{
try
{
await logger.InfoAsync("开始读取 missing.txt");
string text = await File.ReadAllTextAsync("missing.txt");
Message = text;
}
catch (Exception ex)
{
await logger.ErrorAsync("读取 missing.txt 失败", ex);
Message = $"读取失败,异常细节已写入日志:{logger.LogPath}";
}
}
}窗口
文件位置:
Views/MainWindow.axaml<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ProductApp.ViewModels"
x:Class="ProductApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="760"
Height="420"
Title="Logging Demo">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12">
<TextBlock Text="错误处理与日志"
FontSize="24"
FontWeight="Bold" />
<TextBlock Grid.Row="1"
Text="用户看到简短提示,日志文件记录详细线索。"
Foreground="#555555" />
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
<Button Content="写 INFO"
Command="{Binding WriteInfoCommand}" />
<Button Content="写 WARNING"
Command="{Binding WriteWarningCommand}" />
<Button Content="制造错误"
Command="{Binding MakeErrorCommand}" />
</StackPanel>
<TextBox Grid.Row="3"
Text="{Binding Message}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap" />
</Grid>
</Window>第三部分:跟着敲代码
从这里开始动手。先写日志类,再写 ViewModel,最后写界面。
第 1 步:创建 Services/AppLogger.cs
新建文件:
Services/AppLogger.cs先写构造函数:
private readonly string logPath;
public AppLogger()
{
string folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ProductApp",
"logs");
Directory.CreateDirectory(folder);
logPath = Path.Combine(folder, "app.log");
}
public string LogPath => logPath;解释:
folder是日志文件夹。Directory.CreateDirectory(folder)确保文件夹存在。logPath是完整日志文件路径。
第 2 步:写三个日志入口
public Task InfoAsync(string message)
{
return WriteAsync("INFO", message);
}
public Task WarningAsync(string message)
{
return WriteAsync("WARNING", message);
}
public Task ErrorAsync(string message, Exception ex)
{
string detail = $"{message}{Environment.NewLine}{ex}";
return WriteAsync("ERROR", detail);
}解释:
InfoAsync记录正常流程。WarningAsync记录可恢复问题。ErrorAsync记录异常,并把ex的详细信息写进去。
第 3 步:写入日志文件
private Task WriteAsync(string level, string message)
{
string line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} [{level}] {message}{Environment.NewLine}";
return File.AppendAllTextAsync(logPath, line);
}解释:
DateTimeOffset.Now记录时间。level记录级别。File.AppendAllTextAsync表示追加写入,不覆盖旧日志。
第 4 步:ViewModel 准备 logger 和提示
打开:
ViewModels/MainWindowViewModel.cs写入:
private readonly AppLogger logger = new();
[ObservableProperty]
private string message = "";
public MainWindowViewModel()
{
Message = $"日志文件:{logger.LogPath}";
}解释:
logger负责写日志。Message负责给界面显示提示。- 构造函数里先把日志路径显示出来。
第 5 步:写 INFO 命令
[RelayCommand]
private async Task WriteInfoAsync()
{
await logger.InfoAsync("开始保存商品 Name=Keyboard");
await Task.Delay(200);
await logger.InfoAsync("商品保存成功 Name=Keyboard");
Message = $"已写入 INFO 日志:{logger.LogPath}";
}解释:
- 正常流程开始和结束都可以写 INFO。
Task.Delay(200)模拟保存花了一点时间。[RelayCommand]会生成WriteInfoCommand。
第 6 步:写 WARNING 命令
[RelayCommand]
private async Task WriteWarningAsync()
{
await logger.WarningAsync("商品名为空,已阻止保存");
Message = $"已写入 WARNING 日志:{logger.LogPath}";
}解释:
- 商品名为空不是程序崩溃,是可恢复问题。
- 这类问题适合 WARNING。
第 7 步:写 ERROR 命令
[RelayCommand]
private async Task MakeErrorAsync()
{
try
{
await logger.InfoAsync("开始读取 missing.txt");
string text = await File.ReadAllTextAsync("missing.txt");
Message = text;
}
catch (Exception ex)
{
await logger.ErrorAsync("读取 missing.txt 失败", ex);
Message = $"读取失败,异常细节已写入日志:{logger.LogPath}";
}
}解释:
- 先记录准备读取文件。
- 故意读取不存在的
missing.txt。 - 捕获异常后写 ERROR。
- 界面只显示简短提示,不直接显示整段异常。
第 8 步:写窗口按钮
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
<Button Content="写 INFO"
Command="{Binding WriteInfoCommand}" />
<Button Content="写 WARNING"
Command="{Binding WriteWarningCommand}" />
<Button Content="制造错误"
Command="{Binding MakeErrorCommand}" />
</StackPanel>解释:
WriteInfoCommand来自WriteInfoAsync。WriteWarningCommand来自WriteWarningAsync。MakeErrorCommand来自MakeErrorAsync。
第 9 步:运行后检查日志
运行后按这个顺序检查:
- 点击“写 INFO”。
- 点击“写 WARNING”。
- 点击“制造错误”。
- 复制界面上显示的日志路径。
- 打开
app.log。 - 确认里面有
[INFO]、[WARNING]、[ERROR]。 - 确认 ERROR 下面有异常详情。
小白重复敲写训练
错误处理要同时练用户提示和开发日志。
训练 1:用户友好提示
try
{
string text = await File.ReadAllTextAsync("missing.txt");
StatusMessage = text;
}
catch (FileNotFoundException)
{
StatusMessage = "文件不存在,请重新选择";
}训练 2:记录详细异常
catch (Exception ex)
{
logger.LogError(ex, "读取文件 {Path} 失败", path);
StatusMessage = "读取失败,请查看日志";
}记住:用户提示简短,日志保留详细原因。
训练 3:全流程日志
logger.LogInformation("开始导入 {Path}", path);
try
{
await ImportAsync(path);
logger.LogInformation("导入成功 {Path}", path);
}
catch (Exception ex)
{
logger.LogError(ex, "导入失败 {Path}", path);
}第三遍增加耗时记录。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:错误处理与日志。今天至少要掌握的 3 个点是:
1. 异常是什么
2. 用户提示和日志记录有什么区别
3. INFO、WARNING、ERROR 分别什么时候用答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:异常是什么。
上位机专项练习
桌面程序要统一捕获未处理错误、写入日志,并给用户可继续操作的提示。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:记录全局异常
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
File.AppendAllText("crash.log", $"{DateTime.Now:u} {args.ExceptionObject}
");
};运行结果或界面效果:
崩溃信息写入 crash.log改动任务: 给日志增加程序版本。
专项例子 2:通信错误转界面消息
try
{
await reader.ConnectAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "连接失败");
ErrorMessage = "设备连接失败,请检查网线和地址";
}运行结果或界面效果:
日志保留细节,界面给出可操作提示改动任务: 增加“重试连接”命令。
专项例子 3:日志文件按日期命名
string logPath = Path.Combine("logs", $"hmi-{DateTime.Today:yyyyMMdd}.log");
Directory.CreateDirectory("logs");
await File.AppendAllTextAsync(logPath, "程序启动
");运行结果或界面效果:
每天生成独立日志文件改动任务: 每条日志增加精确时间。
第四部分:作业完整答案
作业要求:做一个日志页面,能写入 INFO、WARNING、ERROR,并把错误详情保存到日志文件。
答案文件 1:Services/AppLogger.cs
using System;
using System.IO;
using System.Threading.Tasks;
namespace ProductApp.Services;
public sealed class AppLogger
{
private readonly string logPath;
public AppLogger()
{
string folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ProductApp",
"logs");
Directory.CreateDirectory(folder);
logPath = Path.Combine(folder, "app.log");
}
public string LogPath => logPath;
public Task InfoAsync(string message)
{
return WriteAsync("INFO", message);
}
public Task WarningAsync(string message)
{
return WriteAsync("WARNING", message);
}
public Task ErrorAsync(string message, Exception ex)
{
string detail = $"{message}{Environment.NewLine}{ex}";
return WriteAsync("ERROR", detail);
}
private Task WriteAsync(string level, string message)
{
string line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} [{level}] {message}{Environment.NewLine}";
return File.AppendAllTextAsync(logPath, line);
}
}答案文件 2:ViewModels/MainWindowViewModel.cs
using System;
using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ProductApp.Services;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly AppLogger logger = new();
[ObservableProperty]
private string message = "";
public MainWindowViewModel()
{
Message = $"日志文件:{logger.LogPath}";
}
[RelayCommand]
private async Task WriteInfoAsync()
{
await logger.InfoAsync("开始保存商品 Name=Keyboard");
await Task.Delay(200);
await logger.InfoAsync("商品保存成功 Name=Keyboard");
Message = $"已写入 INFO 日志:{logger.LogPath}";
}
[RelayCommand]
private async Task WriteWarningAsync()
{
await logger.WarningAsync("商品名为空,已阻止保存");
Message = $"已写入 WARNING 日志:{logger.LogPath}";
}
[RelayCommand]
private async Task MakeErrorAsync()
{
try
{
await logger.InfoAsync("开始读取 missing.txt");
string text = await File.ReadAllTextAsync("missing.txt");
Message = text;
}
catch (Exception ex)
{
await logger.ErrorAsync("读取 missing.txt 失败", ex);
Message = $"读取失败,异常细节已写入日志:{logger.LogPath}";
}
}
}答案文件 3:Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ProductApp.ViewModels"
x:Class="ProductApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="760"
Height="420"
Title="Logging Demo">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12">
<TextBlock Text="错误处理与日志"
FontSize="24"
FontWeight="Bold" />
<TextBlock Grid.Row="1"
Text="用户看到简短提示,日志文件记录详细线索。"
Foreground="#555555" />
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
<Button Content="写 INFO"
Command="{Binding WriteInfoCommand}" />
<Button Content="写 WARNING"
Command="{Binding WriteWarningCommand}" />
<Button Content="制造错误"
Command="{Binding MakeErrorCommand}" />
</StackPanel>
<TextBox Grid.Row="3"
Text="{Binding Message}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap" />
</Grid>
</Window>验收结果
运行后必须看到这些结果:
- 点击“写 INFO”后,日志文件出现
[INFO]。 - 点击“写 WARNING”后,日志文件出现
[WARNING]。 - 点击“制造错误”后,界面显示简短失败提示。
- 点击“制造错误”后,日志文件出现
[ERROR]。 - ERROR 日志里能看到
missing.txt和异常详情。 - 日志文件没有记录密码、Token、Secret。
为什么这个答案是对的
这个答案把错误处理分成了清楚的三层:
| 层级 | 代码 | 作用 |
|---|---|---|
| 用户提示 | Message = ... | 让用户知道发生了什么 |
| 日志记录 | logger.InfoAsync / logger.ErrorAsync | 留下排查线索 |
| 异常保护 | try/catch | 防止程序直接崩溃 |
用户提示负责“现在该怎么办”,日志负责“之后怎么排查”。这两件事分开,程序才不会既吓到用户,又没有排查线索。