说实话,我在本地养成整理 Demo 仓库的习惯,已经有年头了。刚开始纯属偷懒——有些 API 用法调完就忘,每次都得重新翻文档、找别人的代码仓库。后来干脆把这些零散尝试收拢到一个项目里,取名就叫 BasicDemo,定位也一下子清晰了:一个专注微软基础类库(BCL)核心能力的演示工程项目。基础类库是 .NET 开发者天天都在用的那套底层 API,但正因为太常见,很多人反而没时间静下心来系统地过一遍。这个项目的价值就在这儿:用一个个短平快的实例,把字符串、集合、IO、异步、反射、JSON 这些模块挨个拆开,配上测试和性能对比,让“感觉自己会了”变成“真能跑出个结果”。这篇文章就围绕这个项目的设计思路、模块拆解、代表性实现和踩坑记录展开。不管你是刚入门的 .NET 学习者,还是已经在写业务代码但想补一补底层功底的开发者,应该都能从里面找到能直接抄作业的内容。我尽量少说大话,多给能落地的东西。1. 项目整体设计与思路拆解1.1 为什么把主线放在基础类库上.NET 开发这个圈子,很多人一开始就扑在框架上,比如 Web 项目直接上 MVC,数据访问直接上 ORM,这当然没毛病。但框架本身就是建立在基础类库之上的,框架只是帮我们做了很多调用底层 API 的重复劳动。一旦遇到性能瓶颈、诡异的并发问题、或者框架覆盖不到的场景,最后还是要回到基础类库这一层来找答案。打个比方:框架是精装修的样板间,看着好看,拎包入住;基础类库是埋在墙里的水电管线,平时看不见,但漏水断电的时候,谁懂管线谁就少遭罪。BasicDemo 这个项目的初衷,就是把这些管线一段段挖出来看清楚:字符串为什么不可变,字典为什么查得快,异步到底是谁在帮你等,文件流什么时候该刷缓冲。弄明白这些,写框架层面的代码时心里会踏实很多。我见过不少同事实力不差,但一聊到 Task 底层状态机、Span 为什么零开销这类问题就含糊。这不是他们笨,而是很多人没机会把散落的知识点串起来。BasicDemo 于我就是一根串珠子的线:每个模块都是一个主题,每个主题下有若干最小可运行的示例,跑一遍、改一改、再看结果,比单纯读文档印象深得多。1.2 项目目录与模块划分方式项目结构我一开始也走过弯路,最初把所有示例堆在一个控制台工程里,文件三十多个,Main 函数里全是注释掉的调用。后来痛定思痛,按 API 领域拆工程,一个模块一个项目,互不干扰。当前这个仓库长这样:BasicDemo/ ├── BasicDemo.sln ├── src/ │ ├── BasicDemo.Strings/ // 字符串与文本处理 │ ├── BasicDemo.Collections/ // 数组、List、Dictionary、HashSet │ ├── BasicDemo.IO/ // 文件流、内存流、路径与编码 │ ├── BasicDemo.Async/ // Task、async/await、ValueTask │ ├── BasicDemo.Reflection/ // 反射、特性、动态调用 │ └── BasicDemo.Json/ // System.Text.Json 序列化 ├── test/ │ └── BasicDemo.Tests/ // 核心示例的单元测试 └── docs/ └── 踩坑记录.md拆成多个项目的代价是多了一点创建和维护成本,好处非常明显:每个模块可以单独运行、单独测试,依赖关系也清楚。比如 BasicDemo.Reflection 不需要引入文件流的代码,谁依赖谁一眼可见。另外,每个控制台项目都保持极简, Program 里只保留与该模块相关的演示调用,真正逻辑都写到独立的类中,方便测试项目引用。还有一个细节:测试项目我选的是 xUnit 而不是 MSTest,主要是习惯了 xUnit 的断言语法和并行执行机制,写起来更顺手。而 docs 里的踩坑记录,我会在后面的章节中把重点内容展开分享。1.3 框架版本与运行环境的选型现在这个仓库跑在 .NET 8 上,主要因为 .NET 8 是目前最新的长期支持版本,既有三年的维护窗口,又能覆盖绝大多数生产环境的需求。选 LTS 的理由很朴素:我不希望三年后回来打开这个仓库,发现示例代码里的 API 已经变了,还要额外花时间去分辨哪些是过时的写法。新特性可以观望,但承载“学习地基”功能的项目,稳定压倒一切。运行环境方面,我在 Windows 和 Linux 容器里都跑过这套示例。.NET 的跨平台能力这几年已经非常成熟,控制台程序基本不存在平台差异问题。唯一要注意的是文件路径分隔符,Windows 的反斜杠和 Linux 的斜杠在硬编码时会踩坑,所以所有涉及路径的地方一律用 Path.Combine 或 Path.Join 来拼。这个点在后面实操部分会再展开。2. 核心类库功能拆解与实操要点2.1 字符串处理:从不可变性到零分配切片字符串是 BCL 里最基础也最容易出性能问题的类型。日常写业务代码时,大家容易忽略 string 的不可变性:任何看似“修改”字符串的操作,实际都会生成新的字符串对象。比如在一个循环里做几百次字符串拼接,每次都会在托管堆上留下废弃对象,给 GC 增加压力。BasicDemo 里我用 StringBuilder 和 string.Concat 做了对比示例:// 不推荐:循环内直接拼接 string result = string.Empty; for (int i = 0; i < 10000; i++) { result += $"第{i}行, "; } // 推荐:使用 StringBuilder 增量构建 var builder = new StringBuilder(1024); for (int i = 0; i < 10000; i++) { builder.Append("第").Append(i).Append("行, "); } string finalResult = builder.ToString();这段代码我跑了 BenchmarkDotNet 对比,10000 次拼接时,前者耗时是后者的几十倍,分配的字节数更是多出几个数量级。真正写业务时,很多项目上亿级日志拼接,不优化就等着线上 GC 报警。关于字符串还有一个现代写法值得单独拿出来讲:Span<char> 的切片能力。传统用 Substring 截取子串会分配新字符串,在解析大文本、处理协议报文时很容易造成大量分配。而 Span 是栈上的只读视图,切片只是拿了个指针和长度,不产生新对象:ReadOnlySpan<char> full = "2024-05-12|INFO|请求处理完成".AsSpan(); int firstSeparator = full.IndexOf('|'); ReadOnlySpan<char> datePart = full.Slice(0, firstSeparator);这段代码在处理大量日志时非常有用。注意 Slice 之后拿到的是源数据的视图,适合只读分析;如果确需生成独立字符串,再调用 ToString,那时才发生一次真正分配。项目中我专门写了一个日志解析示例,把每行的日期、级别、消息拆出来,全程除了输出结果外零字符串分配。这个例子对新手理解“零分配”概念帮助很大。补充一个实操中的细节:把字符串存入文件或者在网络上传输时,编码选择要明确。默认用 UTF-8 基本不出问题,但如果读取旧系统产生的 GBK 文件,请务必显式指定编码,否则中文直接乱码。Encoding.GetEncoding 在 .NET Core 里需要额外注册代码页提供程序,这个坑在踩坑记录里被写在了前几页。2.2 集合类型:搞懂复杂度才能选对容器集合 API 是业务代码里使用频率第二高的类库。很多人写代码就是“凡是多个元素就 List,凡是查东西就 Dictionary”,这种惯性思维大部分时候能跑,但遇到性能问题就不知道往哪查了。BasicDemo 里有三个对比 Demo:List、HashSet、Dictionary。我分别给它们塞了十万条整数记录,然后做相同次数的包含判断。List 的 Contains 是 O(n) 线性扫描,HashSet 和 Dictionary 的查找是 O(1),在十万级数据量下,List 的耗时已经是后两者的几百倍。数据结构选错,性能问题不是靠优化代码细节能救回来的,得从根上换容器。更隐蔽的是 Dictionary 的“重复键”问题。直接索引器赋值可以覆盖旧值,而 Add 方法遇到重复键直接抛异常。项目里我演示了三种写入语义的取舍:// 语义1:重复键抛异常,适合严格模式 try { dict.Add(key, value); } catch (ArgumentException) { /* 处理冲突 */ } // 语义2:已有键则不覆盖 if (!dict.TryAdd(key, value)) { /* 说明key已存在 */ } // 语义3:始终覆盖成最新值 dict[key] = value;这个细节能避免不少生产事故。某同学曾在一个配置合并功能里用 Add 写入,结果配置项重复时整个服务启动失败,后来看到这个Demo才恍然大悟。另外 LINQ 对集合的操作很好用,但要记住 LINQ 是惰性求值的,Where、Select 只是构建了查询表达式,真正执行是在遍历那一刻。如果一个查询被遍历多次,实际是执行了多次。项目里专门有一个示例演示了“同一个查询重复枚举导致的意外开销”,提醒大家必要的时候用 ToList 把结果快照下来。2.3 文件与流操作:缓冲、异步化、路径安全文件 IO 是最容易写出“能跑但很慢”代码的地方。最常见的错误就是逐字节读取。很多新手从文档里学了个 StreamReader.Read(),就把它用在二进制文件上,一次读一个字节,几 MB 文件读下来不知道做了多少次系统调用。我推荐的做法是:await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); using var reader = new StreamReader(stream, Encoding.UTF8); string? line; while ((line = await reader.ReadLineAsync()) != null) { // 逐行处理 }这里有两个容易被忽略的点。一是 bufferSize 要按场景调整,日志文件按 4KB~8KB 缓冲通常效率不错;二是 FileStream 构造参数里 useAsync: true 让底层 IO 走异步通道,避免在 await 时用线程池线程去阻塞等待磁盘。桌面程序看不出来,高并发 Web 服务里差距非常大。路径拼接也是一个经典坑。直接拼字符串“文件夹路径 + '\' + 文件名”,在 Windows 上没问题,一旦换到 Linux 容器里就跑不通。而且更隐蔽的是文件名里如果已经带了斜杠或反斜杠,字符串拼接会产生错乱。BasicDemo 里的统一写法是使用 Path.Combine,它会自动处理平台分隔符。还有一个小技巧:处理用户输入的文件名时,可以用 Path.GetFileName 先把路径成分剥掉,防止有人传个“../../etc/passwd”这样的路径穿越攻击。这个安全性问题很多内部测试都没测到,但线上暴露一次就是大事故。流处理上还有一个现代写法我非常推荐:直接用 Stream.CopyToAsync 替代手工循环读缓冲再写缓冲。框架内部已经做了高效搬移,代码量少,性能也稳。项目中我放了一个压缩包解压并落盘的示例,全部采用异步流式写法,全程不引入第三方库。2.4 异步编程模型:不要盲签 asyncasync/await 现在已经是 .NET 开发者的基本功,但仍存在大量错误用法。最常见的一种是“为了异步而异步”:方法里根本没有真正的异步 IO 操作,还是硬加上 async 关键字,让编译器生成一个没必要的状态机。// 不推荐:内部无真正异步操作,白白背上状态机开销 public async Task<int> GetCountAsync() { return _list.Count; } // 推荐:直接返回 Task 或者用同步方法 public int GetCount() => _list.Count;判断标准其实很简单:方法体内有没有一个真的返回 Task 或 ValueTask 的调用?如果没有,就别让状态机参与进来。另一个容易被忽略的坑是 async 方法里使用 Task.Wait() 或 Task.Result 去同步等待。在带有同步上下文的 UI 程序或旧版 ASP.NET 里,这会造成典型死锁:主线程在等子任务完成,子任务却被上下文调度器卡住,两边互相等。社区里管这个叫 Async Deadlock。我的 Demo 里专门写了复现代码和两种解法,要么一路 async/await 到底,要么用 ConfigureAwait(false) 去掉上下文捕获。ValueTask 也是一个值得了解的新类型。大多数时候 Task 就够用,但如果你有一个频繁调用的异步方法,且结果绝大多数时候是同步完成的,那用 ValueTask 能省掉一个堆上分配。比如内存缓存命中时直接返回。项目里我实现了“无锁缓存读取”示例,命中时走 ValueTask 的同步路径,性能比传统 Task 实现好不少。3. 实操过程与关键环节实现3.1 从空白目录搭建整个项目骨架如果你要从零开始建一个类似的仓库,我把自己当时的操作顺序完整列一遍,几乎可以闭眼抄:mkdir BasicDemo && cd BasicDemo dotnet new sln -n BasicDemo dotnet new console -n BasicDemo.Strings -o src/BasicDemo.Strings dotnet new console -n BasicDemo.Collections -o src/BasicDemo.Collections dotnet new console -n BasicDemo.IO -o src/BasicDemo.IO dotnet new console -n BasicDemo.Async -o src/BasicDemo.Async dotnet new console -n BasicDemo.Reflection -o src/BasicDemo.Reflection dotnet new console -n BasicDemo.Json -o src/BasicDemo.Json dotnet new xunit -n BasicDemo.Tests -o test/BasicDemo.Tests dotnet sln add src/***/***.csproj test/BasicDemo.Tests/BasicDemo.Tests.csproj所有示例项目默认都引用同一个目标框架。如果某台机器上只装了更高版本的 SDK,把 csproj 里的 TargetFramework 统一改成对应版本就行。我特别想说一下为什么全部用控制台项目而不是建一个 ASP.NET Core Web API 来演示:控制台项目启动快、依赖少,跑一个示例不需要起服务和端口,非常适合做 API 学习。如果你想把 BasicDemo 升级成可对外展示的“知识库工具”,再套一层 Web API 也不迟,但作为学习仓库,简单就是效率。3.2 代表性示例:日志文件分组统计项目中我挑了一个能串起多个知识点的综合案例:读取一份 10 万行的日志文件,按日志级别分组统计数量,并输出耗时的日志行。这个示例同时使用了文件 IO、字符串切片、字典、LINQ 和异步方法。核心代码如下:public static async Task<Dictionary<string, int>> CountByLevelAsync(string filePath) { var result = new Dictionary<string, int> { ["INFO"] = 0, ["WARN"] = 0, ["ERROR"] = 0, ["DEBUG"] = 0 }; await using var stream = new FileStream( filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); using var reader = new StreamReader(stream, Encoding.UTF8); string? line; while ((line = await reader.ReadLineAsync()) != null) { int sep = line.IndexOf('|'); if (sep <= 0) continue; ReadOnlySpan<char> levelSpan = line.AsSpan(0, sep); string level = levelSpan.ToString(); if (result.ContainsKey(level)) { result[level]++; } } return result; }这个代码里有几个值得注意的决策点。第一,用 StreamReader.ReadLineAsync 按行读取,避免整文件一次性载入内存,10 万行文件在处理过程中内存占用非常稳定。第二,字符串解析我用了“先取 Span、再 ToString”的写法:因为这里确需把 level 作为字典的键存下来,所以必须有真正的字符串对象,但如果只是判断级别而不存结果,就可以直接和 ReadOnlySpan 比较,省掉分配。第三,字典计数时我先用 ContainsKey 再索引器赋值,是刻意为了演示两种操作的语义;实际性能敏感场景可以直接用 CollectionsMarshal.GetValueRefOrAddDefault,这是另一个进阶技巧,我会在文档里标注为“高阶阅读”。跑完这个示例后,再配合测试项目验证三个断言:文件里统计出的各级别数量正确、空行不报错、不存在的级别不会被写入字典。这些断言虽然简单,却让示例从“能跑”变成了“可复验”,别人拉下来运行 dotnet test 一下就知道代码是否正确。3.3 用基准测试把“感觉”变成数据很多优化结论靠“我觉得应该快一些”是立不住的。BasicDemo 里我引入了 BenchmarkDotNet,把几个有争议的优化点量化。这个库本身不是微软基础类库的一部分,但在评估 BCL API 性能时几乎成了社区事实标准。举一个项目里的基准示例:比较三种字符串倒序拼接的写法。[Benchmark] public string UseStringPlus() { string s = string.Empty; for (int i = 0; i < 100; i++) { s += i.ToString(); } return s; } [Benchmark] public string UseStringBuilder() { var sb = new StringBuilder(); for (int i = 0; i < 100; i++) { sb.Append(i); } return sb.ToString(); } [Benchmark] public string UseStringCreate() { return string.Create(100 * 2, state: 0, (span, _) => { for (int i = 0; i < 100; i++) { i.TryFormat(span, out int written, "D2"); span = span[written..]; } }); }跑完基准后结果非常有意思:在 100 次拼接这种小规模循环里,StringBuilder 和 String.Create 优势并不绝对,StringPlus 因为字符串短,编译器也可能做了优化;但当把循环次数拉到 10000 次,差距立刻拉开。这个结论说明,不要张口闭口“不要用字符串加号”,要先看数据。项目里每个基准类都带注释,记录当时跑出来的中位时间和分配字节数,方便后来者对真实差距有直观感知。基准测试环境方面的经验是:务必在 Release 模式下跑,并且关闭其他高 CPU 占用程序;Debug 模式下 JIT 没有完全优化,跑出来的数据会误导人。4. 常见问题与排查技巧实录4.1 高频问题速查表做 Demo 项目的过程中,我把发现的问题直接记进一个文档,现在挑一部分高频问题整理成表,方便对照排查:现象根本原因解决方案备注在循环里拼接字符串,内存暴涨string 不可变,每次+=生成新对象改用 StringBuilder拼接次数超过100次就应该警惕foreach 遍历时修改集合抛异常迭代器检测到版本号变化先收集待改项,遍历结束后统一修改用 List<T>.RemoveAll 也常见异步方法里调 Task.Result 卡死同步上下文死锁全程 async/await;用 ConfigureAwait(false)尽量别用同步等待换 Linux 后文件路径找不到硬编码了反斜杠使用 Path.Combine 或 Path.Join路径成分用 Path.GetFileName 清洗读取文本文件中文乱码编码未显式指定明确指定 Encoding.UTF8 或 GBK依赖系统默认编码非常危险反射调用很慢每次调用都走完整元数据查找缓存 MethodInfo/Delegate;用表达式树编译高频率场景可改用 source generatorJSON 时间格式与前端不一致默认序列化遵循 ISO 8601自定义 JsonConverter 或配置属性System.Text.Json 配置灵活性有限单看表格可能还是太抽象,我挑几个典型展开讲一讲。foreach 修改集合的异常,是我的 Demo 里刻意保留的一个反例。很多人第一次看到 “Collection was modified; enumeration operation may not execute” 这个异常时都是懵的。原理是 List 内部维护了一个版本计数器,每次增删元素都会更新,而 foreach 底层的迭代器在移动时会检查版本号是否一致,不一致就认为“集合边遍历边改”,直接拒绝继续。解决办法不是去 try/catch 吞掉异常,而是先把要删除的元素记下来,循环结束后再统一删除。我在项目里写了对应示例,两步操作的意图注释得很清楚。反射性能这个坑几乎每次做插件系统都会遇到。需要动态创建很多对象时,直接 Activator.CreateInstance + MethodInfo.Invoke 是最省事的写法,但也是性能最差的写法。原因很简单:Invoke 每次都要做参数打包、类型检查、安全检查。我的处理方式是:首次反射拿到 MethodInfo 后,用表达式树编译成一个强类型委托缓存下来,之后每次调用只付委托调用的成本。代码量只多了十几行,性能却可以打平直接调用。4.2 我自己的排查套路遇到莫名其妙的问题时,我有一套固定排查流程,推荐给所有读者参考。第一步永远是“最小化复现”。把项目里不相关的代码全部剥离,只留下能触发问题的最短路径。很多时候,在剥离的过程中问题就水落石出了——比如某同学说“异步方法卡死”,最后发现是构造函数里同步等待了同一个线程池任务,跟异步完全无关。第二步是加日志。不是通常意义上的 log 文件,而是定位用的临时输出,每个关键分支前后打印一条,通过二分法缩小范围。第三步才是用工具。.NET 生态里的 dotnet-counters 可以实时看 CPU、内存和线程池大小,dotnet-dump 可以抓进程级内存快照,处理复杂线上问题时非常有用。我见过太多人在第一步和第三步之间反复横跳,反而忘了最笨但最有效的日志二分。还有一个心态上的建议:排查问题不要凭感觉改代码。每改一处就记录“改了什么、预期解决什么、实测结果是什么”。BasicDemo 文档里用表格记录了整整三个月的踩坑笔记,后来写项目复盘时发现,很多当时看似随机的问题,背后都有共性的根因——比如“总是定时任务偶发卡顿”和“某服务高峰期吞请求”,最后都指向线程池饥饿。这种规律只有靠记录才能看出来。5. 项目扩展与更多玩法仓库从 0.1 版本稳定到现在,我自己最大的体会是“基础类库 Demo”这个概念可以无限延伸。每个人的工作领域不同,遇到的 BCL API 侧重点不一样,但项目的组织方法论是通用的:选一个类库领域,建一个控制台项目,写一个最小示例,配一个测试,跑一次基准,记录一次心得。整套流程走下来,对这个 API 的记忆深度远超读十遍文档。目前我在考虑给仓库添加几个方向,也供大家参考。第一个是并发容器模块,ConcurrentDictionary、Channel、BlockingCollection 这些类型在高并发场景下的取舍非常值得单独立项。而 Channel 更是生产者消费者模式的现代解法,业务代码里频繁用到。第二个是 Source Generator 方向:.NET 编译器平台技术,可以通过编译期生成代码来避免反射开销。这个主题和现有反射模块可以形成对照,一个“动态生成”,一个“运行时读取”。第三个是 AOT 发布与裁剪分析。.这些扩展方向不需要一口气全做完,我个人的经验是每个模块独立推进,完成一个再开下一个,保持仓库随时可编译、可测试。这样即便隔了很久重新打开,也不会出现“上次改到一半编译不过”的尴尬。我的体会这个项目给我最大的收获,不是速查文档本身,而是建立起了一套“遇到不懂就建 Demo 验证”的思维定式。基础类库的 API 多半不会主动出错,只有在你亲手写错、亲手调试、亲手修正之后,它才真正成为你的知识。特别是把 BenchmarkDotNet 引入进来之后,很多以前靠直觉做的技术决策终于有了数据支撑,和别人讨论技术方案时也能拿得出证据。最后分享一个小技巧:给每个 Demo 的代码文件头部写清楚“本示例演示的核心 API”和“运行本示例前需要注意什么”。有时候一句“这里用 ValueTask 是因为绝大多数路径同步返回”比代码本身更有价值。多年以后,那才是这个项目里最保值的东西。
说实话,我在本地养成整理 Demo 仓库的习惯,已经有年头了。刚开始纯属偷懒——有些 API 用法调完就忘,每次都得重新翻文档、找别人的代码仓库。后来干脆把这些零散尝试收拢到一个项目里,取名就叫 BasicDemo,定位也一下子清晰了:一个专注微软基础类库(BCL)核心能力的演示工程项目。基础类库是 .NET 开发者天天都在用的那套底层 API,但正因为太常见,很多人反而没时间静下心来系统地过一遍。这个项目的价值就在这儿:用一个个短平快的实例,把字符串、集合、IO、异步、反射、JSON 这些模块挨个拆开,配上测试和性能对比,让“感觉自己会了”变成“真能跑出个结果”。这篇文章就围绕这个项目的设计思路、模块拆解、代表性实现和踩坑记录展开。不管你是刚入门的 .NET 学习者,还是已经在写业务代码但想补一补底层功底的开发者,应该都能从里面找到能直接抄作业的内容。我尽量少说大话,多给能落地的东西。1. 项目整体设计与思路拆解1.1 为什么把主线放在基础类库上.NET 开发这个圈子,很多人一开始就扑在框架上,比如 Web 项目直接上 MVC,数据访问直接上 ORM,这当然没毛病。但框架本身就是建立在基础类库之上的,框架只是帮我们做了很多调用底层 API 的重复劳动。一旦遇到性能瓶颈、诡异的并发问题、或者框架覆盖不到的场景,最后还是要回到基础类库这一层来找答案。打个比方:框架是精装修的样板间,看着好看,拎包入住;基础类库是埋在墙里的水电管线,平时看不见,但漏水断电的时候,谁懂管线谁就少遭罪。BasicDemo 这个项目的初衷,就是把这些管线一段段挖出来看清楚:字符串为什么不可变,字典为什么查得快,异步到底是谁在帮你等,文件流什么时候该刷缓冲。弄明白这些,写框架层面的代码时心里会踏实很多。我见过不少同事实力不差,但一聊到 Task 底层状态机、Span 为什么零开销这类问题就含糊。这不是他们笨,而是很多人没机会把散落的知识点串起来。BasicDemo 于我就是一根串珠子的线:每个模块都是一个主题,每个主题下有若干最小可运行的示例,跑一遍、改一改、再看结果,比单纯读文档印象深得多。1.2 项目目录与模块划分方式项目结构我一开始也走过弯路,最初把所有示例堆在一个控制台工程里,文件三十多个,Main 函数里全是注释掉的调用。后来痛定思痛,按 API 领域拆工程,一个模块一个项目,互不干扰。当前这个仓库长这样:BasicDemo/ ├── BasicDemo.sln ├── src/ │ ├── BasicDemo.Strings/ // 字符串与文本处理 │ ├── BasicDemo.Collections/ // 数组、List、Dictionary、HashSet │ ├── BasicDemo.IO/ // 文件流、内存流、路径与编码 │ ├── BasicDemo.Async/ // Task、async/await、ValueTask │ ├── BasicDemo.Reflection/ // 反射、特性、动态调用 │ └── BasicDemo.Json/ // System.Text.Json 序列化 ├── test/ │ └── BasicDemo.Tests/ // 核心示例的单元测试 └── docs/ └── 踩坑记录.md拆成多个项目的代价是多了一点创建和维护成本,好处非常明显:每个模块可以单独运行、单独测试,依赖关系也清楚。比如 BasicDemo.Reflection 不需要引入文件流的代码,谁依赖谁一眼可见。另外,每个控制台项目都保持极简, Program 里只保留与该模块相关的演示调用,真正逻辑都写到独立的类中,方便测试项目引用。还有一个细节:测试项目我选的是 xUnit 而不是 MSTest,主要是习惯了 xUnit 的断言语法和并行执行机制,写起来更顺手。而 docs 里的踩坑记录,我会在后面的章节中把重点内容展开分享。1.3 框架版本与运行环境的选型现在这个仓库跑在 .NET 8 上,主要因为 .NET 8 是目前最新的长期支持版本,既有三年的维护窗口,又能覆盖绝大多数生产环境的需求。选 LTS 的理由很朴素:我不希望三年后回来打开这个仓库,发现示例代码里的 API 已经变了,还要额外花时间去分辨哪些是过时的写法。新特性可以观望,但承载“学习地基”功能的项目,稳定压倒一切。运行环境方面,我在 Windows 和 Linux 容器里都跑过这套示例。.NET 的跨平台能力这几年已经非常成熟,控制台程序基本不存在平台差异问题。唯一要注意的是文件路径分隔符,Windows 的反斜杠和 Linux 的斜杠在硬编码时会踩坑,所以所有涉及路径的地方一律用 Path.Combine 或 Path.Join 来拼。这个点在后面实操部分会再展开。2. 核心类库功能拆解与实操要点2.1 字符串处理:从不可变性到零分配切片字符串是 BCL 里最基础也最容易出性能问题的类型。日常写业务代码时,大家容易忽略 string 的不可变性:任何看似“修改”字符串的操作,实际都会生成新的字符串对象。比如在一个循环里做几百次字符串拼接,每次都会在托管堆上留下废弃对象,给 GC 增加压力。BasicDemo 里我用 StringBuilder 和 string.Concat 做了对比示例:// 不推荐:循环内直接拼接 string result = string.Empty; for (int i = 0; i < 10000; i++) { result += $"第{i}行, "; } // 推荐:使用 StringBuilder 增量构建 var builder = new StringBuilder(1024); for (int i = 0; i < 10000; i++) { builder.Append("第").Append(i).Append("行, "); } string finalResult = builder.ToString();这段代码我跑了 BenchmarkDotNet 对比,10000 次拼接时,前者耗时是后者的几十倍,分配的字节数更是多出几个数量级。真正写业务时,很多项目上亿级日志拼接,不优化就等着线上 GC 报警。关于字符串还有一个现代写法值得单独拿出来讲:Span<char> 的切片能力。传统用 Substring 截取子串会分配新字符串,在解析大文本、处理协议报文时很容易造成大量分配。而 Span 是栈上的只读视图,切片只是拿了个指针和长度,不产生新对象:ReadOnlySpan<char> full = "2024-05-12|INFO|请求处理完成".AsSpan(); int firstSeparator = full.IndexOf('|'); ReadOnlySpan<char> datePart = full.Slice(0, firstSeparator);这段代码在处理大量日志时非常有用。注意 Slice 之后拿到的是源数据的视图,适合只读分析;如果确需生成独立字符串,再调用 ToString,那时才发生一次真正分配。项目中我专门写了一个日志解析示例,把每行的日期、级别、消息拆出来,全程除了输出结果外零字符串分配。这个例子对新手理解“零分配”概念帮助很大。补充一个实操中的细节:把字符串存入文件或者在网络上传输时,编码选择要明确。默认用 UTF-8 基本不出问题,但如果读取旧系统产生的 GBK 文件,请务必显式指定编码,否则中文直接乱码。Encoding.GetEncoding 在 .NET Core 里需要额外注册代码页提供程序,这个坑在踩坑记录里被写在了前几页。2.2 集合类型:搞懂复杂度才能选对容器集合 API 是业务代码里使用频率第二高的类库。很多人写代码就是“凡是多个元素就 List,凡是查东西就 Dictionary”,这种惯性思维大部分时候能跑,但遇到性能问题就不知道往哪查了。BasicDemo 里有三个对比 Demo:List、HashSet、Dictionary。我分别给它们塞了十万条整数记录,然后做相同次数的包含判断。List 的 Contains 是 O(n) 线性扫描,HashSet 和 Dictionary 的查找是 O(1),在十万级数据量下,List 的耗时已经是后两者的几百倍。数据结构选错,性能问题不是靠优化代码细节能救回来的,得从根上换容器。更隐蔽的是 Dictionary 的“重复键”问题。直接索引器赋值可以覆盖旧值,而 Add 方法遇到重复键直接抛异常。项目里我演示了三种写入语义的取舍:// 语义1:重复键抛异常,适合严格模式 try { dict.Add(key, value); } catch (ArgumentException) { /* 处理冲突 */ } // 语义2:已有键则不覆盖 if (!dict.TryAdd(key, value)) { /* 说明key已存在 */ } // 语义3:始终覆盖成最新值 dict[key] = value;这个细节能避免不少生产事故。某同学曾在一个配置合并功能里用 Add 写入,结果配置项重复时整个服务启动失败,后来看到这个Demo才恍然大悟。另外 LINQ 对集合的操作很好用,但要记住 LINQ 是惰性求值的,Where、Select 只是构建了查询表达式,真正执行是在遍历那一刻。如果一个查询被遍历多次,实际是执行了多次。项目里专门有一个示例演示了“同一个查询重复枚举导致的意外开销”,提醒大家必要的时候用 ToList 把结果快照下来。2.3 文件与流操作:缓冲、异步化、路径安全文件 IO 是最容易写出“能跑但很慢”代码的地方。最常见的错误就是逐字节读取。很多新手从文档里学了个 StreamReader.Read(),就把它用在二进制文件上,一次读一个字节,几 MB 文件读下来不知道做了多少次系统调用。我推荐的做法是:await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); using var reader = new StreamReader(stream, Encoding.UTF8); string? line; while ((line = await reader.ReadLineAsync()) != null) { // 逐行处理 }这里有两个容易被忽略的点。一是 bufferSize 要按场景调整,日志文件按 4KB~8KB 缓冲通常效率不错;二是 FileStream 构造参数里 useAsync: true 让底层 IO 走异步通道,避免在 await 时用线程池线程去阻塞等待磁盘。桌面程序看不出来,高并发 Web 服务里差距非常大。路径拼接也是一个经典坑。直接拼字符串“文件夹路径 + '\' + 文件名”,在 Windows 上没问题,一旦换到 Linux 容器里就跑不通。而且更隐蔽的是文件名里如果已经带了斜杠或反斜杠,字符串拼接会产生错乱。BasicDemo 里的统一写法是使用 Path.Combine,它会自动处理平台分隔符。还有一个小技巧:处理用户输入的文件名时,可以用 Path.GetFileName 先把路径成分剥掉,防止有人传个“../../etc/passwd”这样的路径穿越攻击。这个安全性问题很多内部测试都没测到,但线上暴露一次就是大事故。流处理上还有一个现代写法我非常推荐:直接用 Stream.CopyToAsync 替代手工循环读缓冲再写缓冲。框架内部已经做了高效搬移,代码量少,性能也稳。项目中我放了一个压缩包解压并落盘的示例,全部采用异步流式写法,全程不引入第三方库。2.4 异步编程模型:不要盲签 asyncasync/await 现在已经是 .NET 开发者的基本功,但仍存在大量错误用法。最常见的一种是“为了异步而异步”:方法里根本没有真正的异步 IO 操作,还是硬加上 async 关键字,让编译器生成一个没必要的状态机。// 不推荐:内部无真正异步操作,白白背上状态机开销 public async Task<int> GetCountAsync() { return _list.Count; } // 推荐:直接返回 Task 或者用同步方法 public int GetCount() => _list.Count;判断标准其实很简单:方法体内有没有一个真的返回 Task 或 ValueTask 的调用?如果没有,就别让状态机参与进来。另一个容易被忽略的坑是 async 方法里使用 Task.Wait() 或 Task.Result 去同步等待。在带有同步上下文的 UI 程序或旧版 ASP.NET 里,这会造成典型死锁:主线程在等子任务完成,子任务却被上下文调度器卡住,两边互相等。社区里管这个叫 Async Deadlock。我的 Demo 里专门写了复现代码和两种解法,要么一路 async/await 到底,要么用 ConfigureAwait(false) 去掉上下文捕获。ValueTask 也是一个值得了解的新类型。大多数时候 Task 就够用,但如果你有一个频繁调用的异步方法,且结果绝大多数时候是同步完成的,那用 ValueTask 能省掉一个堆上分配。比如内存缓存命中时直接返回。项目里我实现了“无锁缓存读取”示例,命中时走 ValueTask 的同步路径,性能比传统 Task 实现好不少。3. 实操过程与关键环节实现3.1 从空白目录搭建整个项目骨架如果你要从零开始建一个类似的仓库,我把自己当时的操作顺序完整列一遍,几乎可以闭眼抄:mkdir BasicDemo && cd BasicDemo dotnet new sln -n BasicDemo dotnet new console -n BasicDemo.Strings -o src/BasicDemo.Strings dotnet new console -n BasicDemo.Collections -o src/BasicDemo.Collections dotnet new console -n BasicDemo.IO -o src/BasicDemo.IO dotnet new console -n BasicDemo.Async -o src/BasicDemo.Async dotnet new console -n BasicDemo.Reflection -o src/BasicDemo.Reflection dotnet new console -n BasicDemo.Json -o src/BasicDemo.Json dotnet new xunit -n BasicDemo.Tests -o test/BasicDemo.Tests dotnet sln add src/***/***.csproj test/BasicDemo.Tests/BasicDemo.Tests.csproj所有示例项目默认都引用同一个目标框架。如果某台机器上只装了更高版本的 SDK,把 csproj 里的 TargetFramework 统一改成对应版本就行。我特别想说一下为什么全部用控制台项目而不是建一个 ASP.NET Core Web API 来演示:控制台项目启动快、依赖少,跑一个示例不需要起服务和端口,非常适合做 API 学习。如果你想把 BasicDemo 升级成可对外展示的“知识库工具”,再套一层 Web API 也不迟,但作为学习仓库,简单就是效率。3.2 代表性示例:日志文件分组统计项目中我挑了一个能串起多个知识点的综合案例:读取一份 10 万行的日志文件,按日志级别分组统计数量,并输出耗时的日志行。这个示例同时使用了文件 IO、字符串切片、字典、LINQ 和异步方法。核心代码如下:public static async Task<Dictionary<string, int>> CountByLevelAsync(string filePath) { var result = new Dictionary<string, int> { ["INFO"] = 0, ["WARN"] = 0, ["ERROR"] = 0, ["DEBUG"] = 0 }; await using var stream = new FileStream( filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); using var reader = new StreamReader(stream, Encoding.UTF8); string? line; while ((line = await reader.ReadLineAsync()) != null) { int sep = line.IndexOf('|'); if (sep <= 0) continue; ReadOnlySpan<char> levelSpan = line.AsSpan(0, sep); string level = levelSpan.ToString(); if (result.ContainsKey(level)) { result[level]++; } } return result; }这个代码里有几个值得注意的决策点。第一,用 StreamReader.ReadLineAsync 按行读取,避免整文件一次性载入内存,10 万行文件在处理过程中内存占用非常稳定。第二,字符串解析我用了“先取 Span、再 ToString”的写法:因为这里确需把 level 作为字典的键存下来,所以必须有真正的字符串对象,但如果只是判断级别而不存结果,就可以直接和 ReadOnlySpan 比较,省掉分配。第三,字典计数时我先用 ContainsKey 再索引器赋值,是刻意为了演示两种操作的语义;实际性能敏感场景可以直接用 CollectionsMarshal.GetValueRefOrAddDefault,这是另一个进阶技巧,我会在文档里标注为“高阶阅读”。跑完这个示例后,再配合测试项目验证三个断言:文件里统计出的各级别数量正确、空行不报错、不存在的级别不会被写入字典。这些断言虽然简单,却让示例从“能跑”变成了“可复验”,别人拉下来运行 dotnet test 一下就知道代码是否正确。3.3 用基准测试把“感觉”变成数据很多优化结论靠“我觉得应该快一些”是立不住的。BasicDemo 里我引入了 BenchmarkDotNet,把几个有争议的优化点量化。这个库本身不是微软基础类库的一部分,但在评估 BCL API 性能时几乎成了社区事实标准。举一个项目里的基准示例:比较三种字符串倒序拼接的写法。[Benchmark] public string UseStringPlus() { string s = string.Empty; for (int i = 0; i < 100; i++) { s += i.ToString(); } return s; } [Benchmark] public string UseStringBuilder() { var sb = new StringBuilder(); for (int i = 0; i < 100; i++) { sb.Append(i); } return sb.ToString(); } [Benchmark] public string UseStringCreate() { return string.Create(100 * 2, state: 0, (span, _) => { for (int i = 0; i < 100; i++) { i.TryFormat(span, out int written, "D2"); span = span[written..]; } }); }跑完基准后结果非常有意思:在 100 次拼接这种小规模循环里,StringBuilder 和 String.Create 优势并不绝对,StringPlus 因为字符串短,编译器也可能做了优化;但当把循环次数拉到 10000 次,差距立刻拉开。这个结论说明,不要张口闭口“不要用字符串加号”,要先看数据。项目里每个基准类都带注释,记录当时跑出来的中位时间和分配字节数,方便后来者对真实差距有直观感知。基准测试环境方面的经验是:务必在 Release 模式下跑,并且关闭其他高 CPU 占用程序;Debug 模式下 JIT 没有完全优化,跑出来的数据会误导人。4. 常见问题与排查技巧实录4.1 高频问题速查表做 Demo 项目的过程中,我把发现的问题直接记进一个文档,现在挑一部分高频问题整理成表,方便对照排查:现象根本原因解决方案备注在循环里拼接字符串,内存暴涨string 不可变,每次+=生成新对象改用 StringBuilder拼接次数超过100次就应该警惕foreach 遍历时修改集合抛异常迭代器检测到版本号变化先收集待改项,遍历结束后统一修改用 List<T>.RemoveAll 也常见异步方法里调 Task.Result 卡死同步上下文死锁全程 async/await;用 ConfigureAwait(false)尽量别用同步等待换 Linux 后文件路径找不到硬编码了反斜杠使用 Path.Combine 或 Path.Join路径成分用 Path.GetFileName 清洗读取文本文件中文乱码编码未显式指定明确指定 Encoding.UTF8 或 GBK依赖系统默认编码非常危险反射调用很慢每次调用都走完整元数据查找缓存 MethodInfo/Delegate;用表达式树编译高频率场景可改用 source generatorJSON 时间格式与前端不一致默认序列化遵循 ISO 8601自定义 JsonConverter 或配置属性System.Text.Json 配置灵活性有限单看表格可能还是太抽象,我挑几个典型展开讲一讲。foreach 修改集合的异常,是我的 Demo 里刻意保留的一个反例。很多人第一次看到 “Collection was modified; enumeration operation may not execute” 这个异常时都是懵的。原理是 List 内部维护了一个版本计数器,每次增删元素都会更新,而 foreach 底层的迭代器在移动时会检查版本号是否一致,不一致就认为“集合边遍历边改”,直接拒绝继续。解决办法不是去 try/catch 吞掉异常,而是先把要删除的元素记下来,循环结束后再统一删除。我在项目里写了对应示例,两步操作的意图注释得很清楚。反射性能这个坑几乎每次做插件系统都会遇到。需要动态创建很多对象时,直接 Activator.CreateInstance + MethodInfo.Invoke 是最省事的写法,但也是性能最差的写法。原因很简单:Invoke 每次都要做参数打包、类型检查、安全检查。我的处理方式是:首次反射拿到 MethodInfo 后,用表达式树编译成一个强类型委托缓存下来,之后每次调用只付委托调用的成本。代码量只多了十几行,性能却可以打平直接调用。4.2 我自己的排查套路遇到莫名其妙的问题时,我有一套固定排查流程,推荐给所有读者参考。第一步永远是“最小化复现”。把项目里不相关的代码全部剥离,只留下能触发问题的最短路径。很多时候,在剥离的过程中问题就水落石出了——比如某同学说“异步方法卡死”,最后发现是构造函数里同步等待了同一个线程池任务,跟异步完全无关。第二步是加日志。不是通常意义上的 log 文件,而是定位用的临时输出,每个关键分支前后打印一条,通过二分法缩小范围。第三步才是用工具。.NET 生态里的 dotnet-counters 可以实时看 CPU、内存和线程池大小,dotnet-dump 可以抓进程级内存快照,处理复杂线上问题时非常有用。我见过太多人在第一步和第三步之间反复横跳,反而忘了最笨但最有效的日志二分。还有一个心态上的建议:排查问题不要凭感觉改代码。每改一处就记录“改了什么、预期解决什么、实测结果是什么”。BasicDemo 文档里用表格记录了整整三个月的踩坑笔记,后来写项目复盘时发现,很多当时看似随机的问题,背后都有共性的根因——比如“总是定时任务偶发卡顿”和“某服务高峰期吞请求”,最后都指向线程池饥饿。这种规律只有靠记录才能看出来。5. 项目扩展与更多玩法仓库从 0.1 版本稳定到现在,我自己最大的体会是“基础类库 Demo”这个概念可以无限延伸。每个人的工作领域不同,遇到的 BCL API 侧重点不一样,但项目的组织方法论是通用的:选一个类库领域,建一个控制台项目,写一个最小示例,配一个测试,跑一次基准,记录一次心得。整套流程走下来,对这个 API 的记忆深度远超读十遍文档。目前我在考虑给仓库添加几个方向,也供大家参考。第一个是并发容器模块,ConcurrentDictionary、Channel、BlockingCollection 这些类型在高并发场景下的取舍非常值得单独立项。而 Channel 更是生产者消费者模式的现代解法,业务代码里频繁用到。第二个是 Source Generator 方向:.NET 编译器平台技术,可以通过编译期生成代码来避免反射开销。这个主题和现有反射模块可以形成对照,一个“动态生成”,一个“运行时读取”。第三个是 AOT 发布与裁剪分析。.这些扩展方向不需要一口气全做完,我个人的经验是每个模块独立推进,完成一个再开下一个,保持仓库随时可编译、可测试。这样即便隔了很久重新打开,也不会出现“上次改到一半编译不过”的尴尬。我的体会这个项目给我最大的收获,不是速查文档本身,而是建立起了一套“遇到不懂就建 Demo 验证”的思维定式。基础类库的 API 多半不会主动出错,只有在你亲手写错、亲手调试、亲手修正之后,它才真正成为你的知识。特别是把 BenchmarkDotNet 引入进来之后,很多以前靠直觉做的技术决策终于有了数据支撑,和别人讨论技术方案时也能拿得出证据。最后分享一个小技巧:给每个 Demo 的代码文件头部写清楚“本示例演示的核心 API”和“运行本示例前需要注意什么”。有时候一句“这里用 ValueTask 是因为绝大多数路径同步返回”比代码本身更有价值。多年以后,那才是这个项目里最保值的东西。