Skip to Content
Week 09Day 6 - 错误处理与日志

Day 6 - 错误处理与日志

建议用时:180-210 分钟

你将学会什么

  • 异常是什么
  • 用户提示和日志记录有什么区别
  • INFO、WARNING、ERROR 分别什么时候用
  • 为什么不能只写“出错了”
  • 怎么把日志写入本地文件
  • 怎么在 Avalonia 页面里触发并检查日志

本页不用额外日志包,先手写一个最小 AppLogger。目标是把日志的本质讲清楚:程序运行时把关键线索写到文件里,出问题后可以按时间线追踪。

本页固定顺序

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

学习衔接

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

今天的最低通过线

第一次学习不要求背完整页。完成下面 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.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); } }

ViewModel

文件位置:

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}"; } } }

窗口

文件位置:

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 步:运行后检查日志

运行后按这个顺序检查:

  1. 点击“写 INFO”。
  2. 点击“写 WARNING”。
  3. 点击“制造错误”。
  4. 复制界面上显示的日志路径。
  5. 打开 app.log
  6. 确认里面有 [INFO][WARNING][ERROR]
  7. 确认 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>

验收结果

运行后必须看到这些结果:

  1. 点击“写 INFO”后,日志文件出现 [INFO]
  2. 点击“写 WARNING”后,日志文件出现 [WARNING]
  3. 点击“制造错误”后,界面显示简短失败提示。
  4. 点击“制造错误”后,日志文件出现 [ERROR]
  5. ERROR 日志里能看到 missing.txt 和异常详情。
  6. 日志文件没有记录密码、Token、Secret。

为什么这个答案是对的

这个答案把错误处理分成了清楚的三层:

层级代码作用
用户提示Message = ...让用户知道发生了什么
日志记录logger.InfoAsync / logger.ErrorAsync留下排查线索
异常保护try/catch防止程序直接崩溃

用户提示负责“现在该怎么办”,日志负责“之后怎么排查”。这两件事分开,程序才不会既吓到用户,又没有排查线索。