C# 完整实践:可取消的分类账汇总与诊断
输入、输出与范围
前置:起步、LINQ、取消与所有权。本项目读取一个本地文件,按分类累加整数,不需要数据库、账号或HTTP服务。它是一次完整命令行应用,不是站点后端。
文件无表头、无BOM,UTF-8,每行 name|amount。name为1–32个ASCII字母、数字、下划线或短横线,首字符字母;amount为1–999的十进制整数,不接受符号、空格或前导零。最多10000行,空文件成功、空行失败。LF/CRLF都可;金额上限10000×999可装入int。单个超长行会先分配再拒绝,不承诺面对无限长度行固定内存;处理不受信任大文件时还应在字节读取层限制总大小和行长。
分类按Ordinal排序,每行 name=total,最终换行。成功退出0;缺参数、多参数、找不到文件、非法UTF-8、坏行或取消退出2,错误到stderr,stdout为空。库构造完整结果后CLI才输出,因此后半文件坏行不会把前半份报告误当成功;stdout写到磁盘中途失败仍需由调用方检查退出码,不能保证文件系统原子输出。
全部文件与测试
将下列文件保持在 examples/csharp/ledger 的同一目录。项目没有包依赖,NuGet.Config清空包源仅用于标准库练习;未来若添加包需明确恢复配置并锁定依赖。Directory.Build.props分隔三个项目的中间与输出目录。
Ledger.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><EnableDefaultCompileItems>false</EnableDefaultCompileItems><TreatWarningsAsErrors>true</TreatWarningsAsErrors></PropertyGroup>
<ItemGroup><Compile Include="Report.cs"/><Compile Include="Program.cs"/></ItemGroup>
</Project>Directory.Build.props
<Project>
<PropertyGroup><BaseIntermediateOutputPath>obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath><BaseOutputPath>bin/$(MSBuildProjectName)/</BaseOutputPath></PropertyGroup>
</Project>NuGet.Config
<?xml version="1.0" encoding="utf-8"?>
<configuration><packageSources><clear /></packageSources></configuration>Report.cs
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
public sealed record Entry(string Name, int Amount);
public static class Report {
private static readonly Regex Row = new(@"\A([A-Za-z][A-Za-z0-9_-]{0,31})\|([1-9][0-9]{0,2})\z", RegexOptions.CultureInvariant);
public static Entry Parse(string line, int number) {
var match = Row.Match(line);
if (!match.Success) throw new FormatException($"line {number}: expected name|1..999");
return new Entry(match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture));
}
// The caller owns reader. Read one line at a time, keep only distinct-name totals.
public static async Task<string> SummarizeAsync(TextReader reader, CancellationToken token = default) {
var totals = new SortedDictionary<string, int>(StringComparer.Ordinal);
var number = 0;
while (true) {
token.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(token);
if (line is null) break;
if (++number > 10000) throw new FormatException("more than 10000 rows");
var entry = Parse(line, number);
totals[entry.Name] = checked(totals.GetValueOrDefault(entry.Name) + entry.Amount);
}
return string.Concat(totals.Select(pair => $"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}\n"));
}
}Program.cs
using System.Text;
static class Program {
static async Task<int> Main(string[] args) {
using var cancellation = new CancellationTokenSource();
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; cancellation.Cancel(); };
Console.CancelKeyPress += cancel;
try {
if (args.Length != 1) throw new ArgumentException("usage: Ledger FILE");
using var reader = new StreamReader(args[0], new UTF8Encoding(false, true), detectEncodingFromByteOrderMarks: false);
Console.Write(await Report.SummarizeAsync(reader, cancellation.Token));
return 0;
} catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or FormatException or OperationCanceledException) {
Console.Error.WriteLine(e.Message); return 2;
} finally { Console.CancelKeyPress -= cancel; }
}
}Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><EnableDefaultCompileItems>false</EnableDefaultCompileItems></PropertyGroup>
<ItemGroup><Compile Include="Tests.cs"/><ProjectReference Include="Ledger.csproj"/></ItemGroup>
</Project>Tests.cs
using System.Text;
static class Tests {
static void Check(bool value) { if (!value) throw new Exception("assertion failed"); }
static async Task Main() {
using var reader = new StringReader("z|2\na|3\nz|4\n");
Check(await Report.SummarizeAsync(reader) == "a=3\nz=6\n");
Check(await reader.ReadLineAsync() == null); // reader is borrowed, not disposed
foreach (var bad in new[]{"a|0", "a|01", "a|2|3", "a| 2", "a|1000", "x!|1", ""}) {
try { Report.Parse(bad, 1); throw new Exception("accepted invalid"); }
catch (FormatException) { }
}
using var source = new CancellationTokenSource();
source.Cancel();
try { await Report.SummarizeAsync(new StringReader("a|1"), source.Token); throw new Exception("accepted cancellation"); }
catch (OperationCanceledException) { }
using var blocked = new WaitingReader();
using var cancellation = new CancellationTokenSource();
var work = Report.SummarizeAsync(blocked, cancellation.Token);
await blocked.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();
try { await work.WaitAsync(TimeSpan.FromSeconds(5)); throw new Exception("did not cancel"); }
catch (OperationCanceledException) { }
Console.WriteLine("C# module: parse, totals, borrowing, pre/in-flight cancellation passed");
}
sealed class WaitingReader : TextReader {
public readonly TaskCompletionSource Started = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override async ValueTask<string?> ReadLineAsync(CancellationToken token) {
Started.SetResult(); await Task.Delay(Timeout.Infinite, token); return null;
}
}
}构建、运行与失败复现
在项目目录保存 sample.txt:z|2、a|3、z|4 各占一行,无BOM UTF-8。Windows PowerShell 7可用 Set-Content -Encoding utf8NoBOM sample.txt @('z|2','a|3','z|4');Linux Bash可用 printf 'z|2\na|3\nz|4\n' > sample.txt。
dotnet build Ledger.csproj
dotnet run --project Tests.csproj
dotnet run --project Ledger.csproj -- sample.txt测试输出 C# module: parse, totals, borrowing, pre/in-flight cancellation passed。CLI输出 a=3、z=6;把最后一行改为 z|0,应非零、无stdout且stderr说明行号。PowerShell检查 $LASTEXITCODE,Bash检查 $?。正式脚本应运行构建后的DLL,避免把构建日志混入业务stdout:dotnet bin/Ledger/Debug/net10.0/Ledger.dll sample.txt。
仓库独立 verify.py 真实启动进程,核对17种输入及退出码;它与语言内模块测试各司其职。不是只验证“函数没有抛错”。Windows/Linux的实际版本和每项结果保存在 migration/knowledge-rebuild/languages-complete/c,macOS不在本轮验收。
调试与性能诊断
FormatException先看行号和原始分隔符,别用Trim把坏输入悄悄改好。DecoderFallbackException应检查文件编码与编辑器BOM设置;找不到文件先看启动工作目录,路径相对进程而非DLL。编译失败先区分SDK版本、目标框架和语法错误;dotnet --info比反复重装IDE更直接。
调试器可在 Parse 与 catch 上设置断点,观察match.Success、number及entry,再单步确认控制权是否进入finally。若只在Console输出更多信息,先写到stderr以免污染报告。取消测试以受控Reader明确验证状态,而交互Ctrl+C依赖终端信号转发,自动测试结果不能冒称每个IDE终端均已实测。
性能实验运行 Lab 的Release配置,固定相同sum后比较枚举/物化。线程分配量不等于GC暂停;需要解释暂停时用实际进程的EventPipe/trace等诊断工具再分析,不能仅从一次微基准推导生产吞吐。当前项目有10k行上限,没有大规模并行或数据库性能的验证。
综合练习与复习
练习:增加每类记录条数,要求坏行不输出部分结果,预先取消不读一行;先添加模块与CLI期望再实现。解释SortedDictionary与Dictionary加最终排序的取舍。为什么record不会自动让嵌套列表不可变?为什么取消与Dispose不能互相替代?
参考方向
把聚合值从int改为带total/count的不可变组件,或明确拥有的内部累计对象;输出契约改为name=total,count并测空文件、重复类、最大行数和错误路径。预取消在读取前检查。SortedDictionary逐次维护顺序,Dictionary末尾统一排序;比较成本需用实际分类数测试。record的属性可保存可变对象引用;取消是工作协议,Dispose是资源协议,必须等待使用者停止后清理。
已覆盖语言类型、LINQ与枚举、Task取消、资源和完整文件应用;ASP.NET、EF Core、Unity、MAUI各自有额外生命周期与依赖,不把它们的深入课程写作已完成。官方依据同前页,并参照 StreamReader、编码回退。
继续阅读
- 知识专题
- 知识专题
- 知识专题