全景:C# 与 .NET 的定位与现状
钻进语法之前先回答三个问题:C# 和 .NET 各自是什么、今天它跑在哪些平台上、和邻近技术栈怎么选。后面每一章都是这张地图的放大。
C# 是语言,.NET 是运行时:语法与编译器归 C#,CLR 虚拟机、GC、BCL 类库、dotnet 命令行归 .NET。你写的是 C#,跑的是 .NET。
今天它跑在哪
- 服务端与容器是主流形态,Windows / Linux / macOS 都是一等公民;
- 另外三块:Unity 拿它当脚本语言、MAUI / Avalonia 做客户端、Blazor 让它跑进浏览器;
- 能跨平台只因一件事:2020 年的 .NET 5 合并了三条分裂的线(.NET Framework、.NET Core、Mono)。
和邻居怎么选
- vs Java:定位高度重合,技术上难分高下,现实里由团队和历史决定;
- vs Go:小而多的网络服务选 Go,领域模型厚重的系统选 C#;
- 别选它:要极致冷启动、要写无 GC 的底层、要做数据科学。
// 一个文件、一个 Program.cs,就是完整的 .NET 10 程序
// 顶级语句:没有 class、没有 Main,第一行就是可执行代码
using System.Net.Http.Json;
record Repo(string Name, int Stars); // 一行定义不可变数据类型(含相等性/解构/ToString)
var http = new HttpClient();
var repos = await http.GetFromJsonAsync<Repo[]>(url) ?? []; // async/await + JSON 反序列化都在 BCL 里
foreach (var r in repos.Where(r => r.Stars > 1000)
.OrderByDescending(r => r.Stars)
.Take(3)) // LINQ:查询即代码
Console.WriteLine($"{r.Name,-20} {r.Stars:N0}"); // 插值字符串 + 对齐 + 格式化<TargetFramework>:net10.0 是新线,net48 是旧线——老教程里的 Web Forms、Remoting 在新线上不存在。上手:跑通第一个跨平台程序
这一章不讲语言,只做一件事:把 SDK 装上、把程序跑起来、把报错看懂。三条命令(new / run / publish)撑起整个 .NET 日常,先让它们在你机器上真的动起来,后面每一章才有落脚点。
.NET 的安装只有一个决定:装 SDK 还是只装运行时——要写代码就装 SDK,它自带运行时。装完 .NET 10 有两种起步方式,先各跑一遍,你会立刻理解「项目」多出来了什么。
dotnet --info 的三段读法
Host: Version: 10.0.10 ... // ① 宿主:dotnet 这个可执行文件本身 .NET SDKs installed: // ② 能不能编译,看这一段 10.0.302 .NET runtimes installed: // ③ 能不能运行,看这一段 Microsoft.NETCore.App 10.0.10
SDK 与运行时是两件事:运行时只跑已编译好的程序,SDK 才含编译器与 dotnet build/new/publish。多版本可以并存,要钉死版本就在仓库根放一个 global.json;SDK 版本号与运行时版本号不是一回事也不需要相等。
两条路,各一条命令
- 单文件(.NET 10 新增):写一句
Console.WriteLine("hi");存成app.cs,然后dotnet run app.cs——没有项目文件,没有obj/; - 项目:
dotnet new console -o hello再dotnet run --project hello。生成的Program.cs只有一行,多出来的hello.csproj才是项目本体(18 章); - 怎么选:学语法、写一次性小工具用单文件;要写测试、引依赖、发布就用项目。两者语言语义完全一致,用单文件学到的东西一行都不浪费。
# 三条命令验证装好了:版本、清单、能不能编译
$ dotnet --version # 只打印当前生效的 SDK 版本,如 10.0.302
$ dotnet --list-sdks # 装了哪些 SDK(空 = 只有运行时,编译不了)
$ dotnet --list-runtimes # 装了哪些运行时
# 无管理员权限的装法(CI / 受限机器)
$ curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir ~/dotnet
$ export DOTNET_ROOT=~/dotnet && export PATH=$DOTNET_ROOT:$PATHdotnet --info 里「No SDKs were found.」这行必须当场读到:很多软件会顺手装上 .NET 运行时,而运行时不含编译器。dotnet run 后给程序传参数必须用 -- 隔开,否则参数会被 CLI 吃掉。开发期用 dotnet watch:改文件自动重编重跑。模板生成的 Program.cs 只有一行,但这一行背后有三个 C# 特性在发力。把它们拆开,你就知道老教程里那一大坨 namespace/class/static void Main 去哪了。
三层糖,逐层剥开
- ① 顶级语句(C# 9+):一个项目里只能有一个文件写顶级语句,编译器把它包成隐藏的
Program类和Main方法; - ② 隐式 using:SDK 自动加上
System、System.Linq等一批命名空间——这就是不写using也能用Console的原因; - ③ 文件作用域命名空间:
namespace X;一行代替一整层花括号。
顶级语句里还有什么是白送的
args凭空可用;可以直接await——编译器会把Main生成为async Task Main,脚本级代码做异步不需要任何仪式;return一个整数就是退出码;要定义类型就写在文件末尾。
// Program.cs —— 顶级语句能做的全在这儿了
if (args.Length == 0)
{
Console.Error.WriteLine("用法: greet <名字>");
return 2; // 顶级语句里 return = 进程退出码
}
await Task.Delay(50); // 直接 await,编译器生成 async Main
Console.WriteLine(Greeter.Hi(args[0]));
return 0;
// 类型声明必须写在顶级语句之后
static class Greeter
{
public static string Hi(string name) => $"你好,{name}!";
}class 写在中间会直接编译失败。另外一个项目里出现两个含顶级语句的文件也会报错——把示例代码到处复制粘贴时最容易撞上这一条。真需要多个可执行入口,就拆成多个项目。C# 的编译诊断全都带一个 CSxxxx 编号,这个编号就是最好的搜索关键词——微软官方文档每一条都有专页。下面是本机 .NET 10.0.302 的报错原文。
warning 不拦你,但它往往才是真 bug
$ cat e5.cs string? s = null; Console.WriteLine(s.Length); warning CS8602: 解引用可能出现空引用。 // 只是警告,照样编译通过 Unhandled exception. System.NullReferenceException: at Program.<Main>$(String[] args) in e5.cs:line 2
- 这就是可空引用类型(07 章)的全部真相:编译器警告你,但不阻止你;运行时该炸还是炸。所以真实项目几乎都会在 csproj 里加
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>,把这类警告变成硬拦截; - 运行期异常的输出固定三段:异常类型 + 消息 + 堆栈,堆栈里带文件名和行号(Debug 构建下),从上往下第一行就是出事地点。
报错是中文的,怎么办
诊断文案跟随系统语言本地化,中文报错去搜英文资料会搜不到。设 DOTNET_CLI_UI_LANGUAGE=en 即可切回英文。但编号永远不变:不管界面什么语言,CS0029 就是 CS0029——搜索时用编号,不要用报错文案。
# 报错太长看不清?把诊断按等级过滤
$ dotnet build 2>&1 | grep -E "error|warning"
# 只想看错误、不看警告刷屏
$ dotnet build -warnaserror:nullable # 只把可空相关的警告升级为错误
$ dotnet build -v quiet # 少打点 MSBuild 的日志
# 临时切回英文报错(跨平台通用)
$ DOTNET_CLI_UI_LANGUAGE=en dotnet buildDOTNET_CLI_UI_LANGUAGE=en 并非处处生效:它对 dotnet build 有效,但走单文件应用路径时诊断仍是本地化文案。撞上这种不一致时,直接拿 CS 编号去搜——编号是跨语言不变的。类型系统:值类型与引用类型
C# 的一切都从「这是值类型还是引用类型」开始。赋值时复制什么、相等性怎么算、放在栈上还是堆上、要不要担心 null——全都由这一个二分决定。这一章把它讲透,后面的类、record、struct、泛型才不会乱。
C# 的类型分两大阵营:值类型(struct、enum,以及所有内置数字类型)赋值时复制内容;引用类型(class、record、string、数组、委托、接口)赋值时复制引用——两个变量指向同一个对象。
一段代码把区别钉死
struct PointS { public int X, Y; } class PointC { public int X, Y; } var p1 = new PointS(1, 2); var p2 = p1; p2.X = 99; var c1 = new PointC { X = 1 }; var c2 = c1; c2.X = 99; // 输出: struct: p1.X=1 p2.X=99 // 改副本不影响原件 class : c1.X=99 c2.X=99 // 两个名字,同一个对象
- 这是 C# 最重要的一条规则,没有之一:把对象传进方法、放进集合、从属性读出来,全都遵循同一套复制语义。「改了却没生效」和「没改却变了」两类 bug 的根源都在这。
- 判断某个类型属于哪一阵营,运行时可直接问:
typeof(T).IsValueType(PointS→ True、PointC→ False)。
两张表:谁是谁
| 值类型 | 引用类型 | |
|---|---|---|
| 典型成员 | int double bool char decimal DateTime Guid struct enum 元组 | class record string 数组 List<T> 委托 接口 object |
| 赋值语义 | 复制全部字段 | 复制引用(指向同一对象) |
| 默认值 | 各字段的零值(default(int) = 0) | null |
| 能不能为 null | 不能,除非写成 int? | 能(这正是 07 章要治的病) |
| 通常分配在 | 栈上或所属对象内部 | 托管堆(受 GC 管理) |
| 相等性默认行为 | 逐字段比较 | 比较引用(是不是同一个对象) |
「值类型在栈上」是个方便的谎言
- 准确的说法是:值类型没有独立身份,它存在于它所在的位置。局部变量在栈上,但作为字段的值类型就在所属对象里(跟着对象在堆上),装进
List<int>的 int 也在堆上的数组里。 - 所以别用「栈/堆」来记忆,用「复制内容还是复制引用」来记忆——后者永远正确,前者只在一半场景下成立。
// 传参也遵循同一套规则
void Bump(PointS s, PointC c)
{
s.X++; // 改的是副本,调用方看不见
c.X++; // 改的是同一个对象,调用方看得见
c = new PointC(); // 但把参数变量重新指向新对象,调用方仍看不见!
} //(引用是按值传的——除非用 ref,见 05 章)
var s = new PointS { X = 1 };
var c = new PointC { X = 1 };
Bump(s, c);
Console.WriteLine($"{s.X} {c.X}"); // 1 2list[0].X = 5 在 List<PointS> 上直接编译不过(索引器返回的是副本),而在数组上却能编译并生效——同一份代码换个容器行为就变。要写 struct 就把它写成不可变的(字段 readonly、或直接用 readonly record struct)。class;只有同时满足「逻辑上表示单一值」「实例很小(约 16 字节以内)」「不可变」「不会被大量装箱」时才考虑 struct。坐标、颜色、金额、时间点是好例子;有集合字段、有继承需求、会被到处传递的实体全部用 class。数值类型的选择只有两个真正的决策点:要不要小数、能不能容忍二进制浮点误差。其余都是容量问题。
常用类型一览
| 类型 | 别名 / 全名 | 大小 | 用途 |
|---|---|---|---|
int | System.Int32 | 4 字节 | 整数的默认选择,约 ±21 亿 |
long | System.Int64 | 8 字节 | 时间戳、大计数、雪花 ID |
double | System.Double | 8 字节 | 浮点的默认选择,科学计算、几何 |
decimal | System.Decimal | 16 字节 | 钱——十进制精确,慢但不会出误差 |
bool / char | Boolean / Char | 1 / 2 字节 | char 是 UTF-16 码元,不是「一个字」(03 章) |
byte / sbyte / short / uint… | — | 1–8 字节 | 只在二进制协议、互操作、大数组省内存时用 |
- 关键字
int和类型名System.Int32完全等价,是同一个类型的两个写法。日常一律用小写别名。 - 字面量后缀决定类型:
1.5是 double、1.5f是 float、1.5m是 decimal、1L是 long。算钱时忘了m后缀是高频事故。
三条算术规则
0.1 + 0.2 == 0.3 // False —— 实际是 0.30000000000000004 0.1m + 0.2m == 0.3m // True —— decimal 是十进制的 int.MaxValue + 1 // -2147483648(默认 unchecked,静默环绕!) checked(int.MaxValue+1) // 抛 OverflowException: // Arithmetic operation resulted in an overflow. 5 / 2 = 2 // 整数除法直接截断,不是四舍五入 5.0 / 2 = 2.5 // 只要有一边是浮点,结果就是浮点 -7 / 2 = -3 、 -7 % 2 = -1 // C# 的整除向零取整、余数随被除数符号
- 整数溢出默认是静默环绕的,这是 C# 沿袭 C 的设计。要全项目开启检查,在 csproj 加
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>;局部检查用checked(...)。 - 浮点比较永远别用
==,用「差的绝对值小于容差」;钱一律decimal。
// 金额:decimal,且别忘了 m 后缀
decimal price = 19.99m;
decimal total = price * 3; // 59.97 —— 精确
// 浮点比较用容差
static bool NearlyEqual(double a, double b, double eps = 1e-9)
=> Math.Abs(a - b) < eps;
// 数字分隔符与其它进制字面量(可读性)
int million = 1_000_000;
int mask = 0b1010_1010;
int color = 0xFF_20_A0;
// 泛型数学(.NET 7+):一个方法吃所有数值类型
static T Sum<T>(IEnumerable<T> xs) where T : INumber<T>
{
T acc = T.Zero;
foreach (var x in xs) acc += x;
return acc;
}double 存钱是最经典的生产事故:0.1 + 0.2 != 0.3 在单条记录上误差微不足道,但在累加与比较上会暴雷——一万笔求和之后对不上账,或者 if (balance == 0) 永远不成立。同样地,数据库列也要用 decimal/numeric,C# 端再精确,落库时转成 float 也白搭。decimal 慢是真的(软件实现、无硬件加速),但「慢」是相对的:单次运算差几十倍,放到一次数据库往返(毫秒级)面前根本看不见。金额场景不要为性能牺牲精度——这类优化的收益永远小于一次对账出错的代价。var 是 C# 里最容易被误解的关键字:它不是动态类型。编译器在编译期就把类型定死了,只是让你少写一遍而已——运行时与写出全名的代码完全一样。
三种「少写一遍」的写法
var list = new List<string>(); // ① var:右边推左边 List<string> list2 = new(); // ② 目标类型 new(C# 9+):左边推右边 var nums = new[] { 1, 2, 3 }; // 隐式类型数组 → int[] int[] nums2 = [1, 2, 3]; // ③ 集合表达式(C# 12+),也能推 List/Span
- 什么时候用
var:右边已经把类型写清楚了(new Foo()、强制转换、明显的字面量),再写一遍就是噪音。团队约定不同,跟着仓库的.editorconfig走即可。 - 什么时候别用:右边是方法调用且返回类型不明显(
var x = Parse(s);—— 读代码的人得跳转去看签名)。 var有三条硬限制:必须初始化、不能是null字面量(推不出类型)、不能用作字段类型。
const 与 readonly:编译期 vs 运行期
const | readonly | |
|---|---|---|
| 何时定值 | 编译期,必须是字面量常量 | 运行期,构造函数里也能赋 |
| 能用于 | 数字、bool、string、enum、null | 任何类型 |
| 隐含 | 自动是 static | 实例字段(可加 static) |
| 坑 | 值会被内联进调用方程序集 | 无 |
- 那个「内联」的坑很实在:类库里
public const int Version = 1;,调用方编译后拿到的是字面量1;你把类库改成 2 并单独替换 dll,调用方仍然是 1——必须重新编译调用方。所以公开 API 上的常量优先用static readonly,const 留给真正永不变的东西(Math.PI那种)。
// 目标类型推断让长泛型类型不用写两遍
Dictionary<string, List<int>> index = new(); // 比 var 更适合字段声明
// 集合表达式(C# 12+):同一个语法,多种目标类型
int[] a = [1, 2, 3];
List<int> b = [1, 2, 3];
Span<int> c = [1, 2, 3];
int[] merged = [.. a, 99, .. b]; // 展开运算符 ..
// 常量与只读
public const string Scheme = "https"; // 编译期常量(会被内联)
public static readonly TimeSpan Timeout // 运行期只读(不会内联)
= TimeSpan.FromSeconds(30); // const 写不了:不是编译期常量var 配合数值字面量容易推出意外类型:var x = 1; 是 int,于是 x / 2 得到 0 而不是 0.5;var ratio = 1 / 3; 同理是 0。想要浮点就写 1.0 或显式声明 double x = 1;。这类 bug 不报错、不警告,只是结果悄悄错了。dynamic 才是真正的动态类型(绕过编译期检查,运行时解析成员)。它在 .NET 里几乎只用于 COM 互操作和少数反射场景——看到教程用 dynamic 处理 JSON,直接换成 System.Text.Json 的强类型反序列化或 JsonNode,性能与安全都好几个档次,而且 dynamic 在 NativeAOT 下根本不能用(16 章)。值类型天生不能为 null,但现实里「没有值」是常态(数据库 NULL、可选参数、解析失败)。int? 就是为这个准备的——它是 Nullable<int> 的语法糖,本身仍是一个值类型(一个 int 加一个 bool 标记)。
四个空值运算符,用熟了代码能短一半
int? age = null; age ?? 18 // 空合并:左边是 null 就用右边 → 18 age ??= 20; // 空合并赋值:只在为 null 时赋值 person?.Address?.City // 空条件:链上任何一环是 null,整个表达式就是 null list?[0] // 索引器也有空条件写法 age!.Value // null 宽容:「我保证不是 null」,仅关掉警告(07 章)
?.会短路:a?.B.C中若a为 null,.B.C整段都不会求值,结果直接是 null——不会抛异常。- 取值三种方式:
x.Value(为 null 时抛InvalidOperationException)、x ?? 默认值、x.GetValueOrDefault()。日常优先第二种。 x.HasValue与x is not null等价,后者可读性更好,也和引用类型的写法统一。
可空值的算术与比较:三值逻辑
int? a = null; a + 1 // null(不是 1!任何一边是 null,结果就是 null) a > 0 // false a <= 0 // 也是 false ← 两个都是 false,这不是普通的布尔取反 a == null // true(相等比较是例外,正常工作)
- 这套语义和 SQL 的三值逻辑一模一样:「不知道」既不大于也不小于。写
if (a > 0)与if (a <= 0)覆盖不了全部情况,null 会两边都漏掉。 - 所以对可空值做区间判断时,先处理 null 分支,别指望
else兜住它。
// TryParse 模式:解析失败不抛异常,返回 bool + out
if (int.TryParse(input, out var n))
Console.WriteLine(n * 2);
else
Console.WriteLine("不是数字");
// 用可空值表达「解析失败」,能参与后续链式处理
static int? ParseOrNull(string? s)
=> int.TryParse(s, out var v) ? v : null;
int port = ParseOrNull(Environment.GetEnvironmentVariable("PORT")) ?? 8080;
// 模式匹配处理可空值最干净
string msg = ParseOrNull(input) switch
{
null => "没填",
< 0 => "不能是负数",
0 => "零",
var v => $"收到 {v}",
};null 的 int? 得到的是 真正的 null 引用,不是「装了个空盒子」:object o = (int?)null; 之后 o is null 为 true、o.GetType() 直接抛 NullReferenceException。这也意味着 int? 装箱后再拆回来会丢失「它原本是 Nullable」这个信息——反射与序列化代码里踩这条的不少。int?(可空值类型)和 string?(可空引用类型,07 章)语法长得一样,机制完全不同:前者是真实存在的 Nullable<int> 结构体、运行时看得见;后者只是给编译器看的静态标注、运行时什么都不剩。搞清这一点,很多「为什么这里能为 null 那里不行」的困惑就解开了。C# 有四套完全不同的「转换」,用错的代价从「多抛一个异常」到「悄悄丢精度」不等。按「转不成时希望发生什么」来选,一次就能选对。
四套转换,四种失败行为
| 写法 | 用于 | 转不成时 |
|---|---|---|
long x = intValue; | 隐式转换(不丢信息才允许) | 不会发生,编译期就保证了 |
(int)longValue | 显式转换 / 强制类型转换 | 数值:静默截断;引用:抛 InvalidCastException |
obj as Foo | 引用/可空类型的试探转换 | 返回 null,不抛 |
obj is Foo f | 类型判断 + 同时取值 | 返回 false,最推荐 |
- 今天的首选是
is模式:一步完成「判断 + 转换 + 声明变量」,没有二次转换、没有 null 检查遗漏。as加 null 判断是它出现之前的老写法。 (int)3.9得到 3(向零截断,不是四舍五入);要四舍五入用Math.Round,注意它默认是「银行家舍入」:Math.Round(2.5)得到 2,不是 3。要传统四舍五入得写Math.Round(2.5, MidpointRounding.AwayFromZero)。
字符串转数字:三个 API,别用错
int.Parse("abc") // 抛 FormatException: // The input string 'abc' was not in a correct format.(报错原文) int.TryParse("abc", out var n) // false,n = 0 —— 不抛异常 Convert.ToInt32(null) // 0(把 null 当 0,很少是你想要的)
- 处理外部输入一律用
TryParse:用户输入、配置文件、HTTP 参数都可能是垃圾,异常不该用来做流程控制。 Parse留给「这里绝不可能出错,出错就是 bug」的场合——那时抛异常正是你要的。- 解析要显式指定区域:
double.TryParse("1,5")在中文区域下成功返回 15(逗号被当千位分隔符),换 de-DE 区域则返回 1.5。同一份代码在不同机器上得到不同结果——13 章会把这条讲透。
// 推荐:is 模式一步到位
if (value is string s && s.Length > 0)
Console.WriteLine(s.ToUpperInvariant());
// 老写法(仍常见于旧代码)
var s2 = value as string;
if (s2 != null) { /* ... */ }
// 解析外部输入:TryParse + 显式区域
using System.Globalization;
if (decimal.TryParse(raw, NumberStyles.Number,
CultureInfo.InvariantCulture, out var amount))
Console.WriteLine(amount);
// 自定义类型也能定义转换(谨慎使用)
public readonly record struct Celsius(double Degrees)
{
public static implicit operator double(Celsius c) => c.Degrees;
public static explicit operator Celsius(double d) => new(d);
}(int) 强制转换数值时不检查溢出:long big = 3_000_000_000; 之后 (int)big 静默得到 -1294967296。(有意思的是,直接写常量 (int)3_000_000_000L 反而编译不过——报 CS0221;编译器只在常量能算出来时帮你挡一下,运行期的值就管不着了。)要安全转换用 checked((int)x),或者干脆用 Convert.ToInt32(x)——它内部是 checked 的,超范围会抛 OverflowException。这也是 Convert 系列少数比强转更合适的场景。ToString() 也是转换,同样受区域影响。写日志、拼 URL、存文件、做 key 时一律用 ToString(CultureInfo.InvariantCulture) 或 ToString("O")(时间的往返格式);只有直接给人看的 UI 文本才用当前区域。这条规矩能省掉一整类「本地跑得好、上线就乱码/解析失败」的问题。把一个值类型赋给 object(或它实现的接口)时,运行时会在堆上分配一个盒子、把值复制进去——这叫装箱。它是 C# 里最隐蔽的一类性能开销,因为语法上什么都看不出来。
装箱到底花多少
object boxed = 0; for (int i = 0; i < 100_000; i++) boxed = i; // 每次循环装一次箱 // GC.GetTotalAllocatedBytes 分配 2,400,024 字节 // = 每个 int 24 字节(4 字节的 int,被包成了 24 字节的堆对象)
- 24 字节这个数是确定的、由 64 位运行时的对象布局决定:对象头 8 字节 + 方法表指针 8 字节 + 数据 8 字节(对齐后)。谁跑都一样,不是本机特例。
- 代价不只是这 24 字节:这些盒子全都进 gen0,制造 GC 压力;每次读值还要解引用一次,缓存局部性也没了。
装箱在哪些地方悄悄发生
- 赋给
object或接口:object o = 42;、IComparable c = 42;。 - 非泛型集合:
ArrayList、Hashtable——这就是它们被泛型集合取代的根本原因。 - 字符串拼接与格式化的老写法:
string.Format("{0}", 42)里 42 要装箱(插值字符串在 .NET 6+ 已通过插值处理器大幅避免)。 - struct 调用未重写的
object方法:没重写Equals/GetHashCode的 struct 参与字典 key,每次比较都装箱。 - LINQ 对值类型序列的某些路径、以及
foreach遍历以接口形式暴露的集合。
怎么避免
- 用泛型:
List<int>在运行时会为 int 生成专门的代码(值类型泛型特化),全程零装箱——这是 .NET 泛型相比 Java 擦除式泛型的一个实打实的优势。 - 给 struct 重写
Equals(T)与GetHashCode(),并实现IEquatable<T>;record struct会自动帮你做完。 - 热路径上想确认有没有装箱,看 IL 里有没有
box指令(sharplab.io 直接可见),或用 BenchmarkDotNet 的[MemoryDiagnoser]看每次操作的分配字节数。
// 装箱看不出来,但 IL 里有 box 指令
object o = 42; // box —— 堆分配 24 字节
int back = (int)o; // unbox.any —— 类型必须完全一致
// long wrong = (long)o; // 运行期 InvalidCastException!拆箱不做数值转换
// 非泛型 vs 泛型
var old = new ArrayList(); old.Add(1); // 装箱
var now = new List<int>(); now.Add(1); // 不装箱
// 让 struct 免于装箱比较
public readonly struct Money(decimal v) : IEquatable<Money>
{
public readonly decimal Value = v;
public bool Equals(Money other) => Value == other.Value; // 强类型重载,无装箱
public override bool Equals(object? o) => o is Money m && Equals(m);
public override int GetHashCode() => Value.GetHashCode();
}object o = 42; 之后 (long)o 在运行期抛 InvalidCastException,尽管 int 到 long 本是隐式转换。正确写法是先拆回原类型再转:(long)(int)o。从 object/JSON/反射里取值时这条踩得极多。字符串与文本
字符串是每个程序里出现频率最高的类型,也是跨平台差异最集中的地方——编码、区域、大小写、比较规则,每一项都能让「本地跑得好」的代码在另一台机器上给出不同答案。这一章从不可变性讲到 Unicode,把这些坑一次挖开。
string 是引用类型,但它被设计得处处像值类型:== 比较内容而不是引用、任何「修改」都返回新对象、可以安全地在线程间共享。理解这一点,字符串的性能特征就全部推得出来。
不可变意味着什么
string s = "hello"; s.ToUpper(); // 返回新字符串,s 自己没变!——新手最常见的一行废代码 s = s.ToUpper(); // 这样才行 s[0] = 'H'; // 编译错误:字符串索引器是只读的
Replace、Trim、Substring、Insert、PadLeft——所有看起来在改字符串的方法,实际都是「返回一个新的」。忘了接返回值等于什么都没做。- 好处是巨大的:字符串可以随便共享、可以做字典 key 而不怕被人改掉、多线程读无需加锁。代价是每次修改都要分配。
三条相等性规则
string a = "he", b = "llo"; (a + b) == "hello" // True —— == 比较内容 ReferenceEquals(a + b, "hello") // False —— 运行期拼出来的是新对象 ReferenceEquals("hello", "hello") // True —— 字面量被驻留(intern)到同一个实例 (object)(a + b) == (object)"hello" // False —— 转成 object 后 == 变回引用比较!
- 字面量驻留:编译期出现的相同字符串字面量在整个进程内共享一个实例,省内存也让引用比较偶尔「碰巧成立」——千万别依赖它。
- 最后一行是真正的坑:一旦把字符串当作
object(比如放进object参数、非泛型集合),==的语义就悄悄从「比内容」变成「比引用」。比较字符串永远用string.Equals或保持静态类型为 string。
// 判空:三个方法,语义不同
string.IsNullOrEmpty(s) // null 或 ""
string.IsNullOrWhiteSpace(s) // null、"" 或全是空白 —— 校验用户输入用这个
s is { Length: > 0 } // 模式匹配写法:非 null 且非空
// 常用操作全是「返回新的」
var t = " Hello, World ";
t.Trim() // "Hello, World"
t.Replace("World", "C#") // " Hello, C# "
t.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
string.Join(" | ", ["a", "b", "c"]) // "a | b | c"
t.Contains("World", StringComparison.OrdinalIgnoreCase)null——而 "" 不是 null。null.Length 抛 NullReferenceException,"".Length 是 0,两者行为天差地别。从数据库、JSON、环境变量里取到的字符串都可能是 null,判空一律用 string.IsNullOrEmpty 而不是 s == ""。07 章的可空引用类型就是为了让编译器帮你盯住这件事。Split 的 StringSplitOptions.TrimEntries | RemoveEmptyEntries 组合(.NET 5+)能一次搞定「切完去空白、丢掉空项」,比先 Split 再 .Select(x => x.Trim()).Where(...) 干净得多,而且不产生中间集合。解析 CSV 风格的配置串时几乎每次都用得上。C# 的字符串字面量有四种写法,各解决一类痛点。今天写新代码基本只需要插值字符串和原始字符串字面量两种。
四种写法
"普通\n换行" // 支持转义 @"C:\不转义\的路径" // 逐字字符串:反斜杠不再是转义符 $"你好 {name},共 {count} 条" // 插值:把表达式直接嵌进去 """ { "json": "里面可以随便写引号和反斜杠" } """ // 原始字符串(C# 11+):所见即所得
- 原始字符串是写 JSON / SQL / 正则 / HTML 的最佳选择:不用转义引号、保留换行、结束引号所在列决定缩进基线(每行会自动去掉这么多前导空白),嵌进代码里排版还很整齐。
- 内容里如果本来就有三个连续引号,就用四个引号开头结尾——引号数量可以按需增加。
- 原始字符串也能插值:写成
$"""…""";如果内容里有{,用$$"""…""",此时占位符要写成{{expr}}——写 JSON 模板时靠这招免于花括号打架。
格式说明符:{值,对齐:格式}
$"{name,-20}" // 左对齐、宽 20(负数=左对齐)
$"{price,10:N2}" // 右对齐宽 10,千分位 + 两位小数 → 1,234.50
$"{ratio:P1}" // 百分比 → 42.0%
$"{n:X}" // 十六进制 → FF
$"{dt:yyyy-MM-dd}" // 自定义日期格式
$"{dt:O}" // 往返格式(ISO 8601)—— 存储与传输一律用它- 插值字符串在 .NET 6+ 被编译成插值字符串处理器(interpolated string handler),拼接过程走
DefaultInterpolatedStringHandler,避免了老式string.Format的装箱与中间字符串。日常写法即是高性能写法。 - 日志框架(Serilog / ILogger)是例外:那里应该写 结构化模板
logger.LogInformation("用户 {UserId} 下单", id),而不是插值——插值会在日志被过滤掉时也照样拼接,且丢失结构化字段。
// 原始字符串写 JSON:不用转义任何引号
var json = """
{
"name": "Alice",
"tags": ["a", "b"]
}
""";
// 原始字符串 + 插值(双 $ 让占位符变成 {{}},花括号本身就不用转义了)
var tpl = $$"""
{ "user": "{{name}}", "level": {{level}} }
""";
// 对齐做表格输出
foreach (var (n, v) in rows)
Console.WriteLine($"{n,-16}{v,12:N0}");
// 结构化日志:不要用插值!
logger.LogInformation("订单 {OrderId} 金额 {Amount}", id, amount); // ✓
logger.LogInformation($"订单 {id} 金额 {amount}"); // ✗ 丢字段@"..." 里要表示一个双引号得写两个(@"他说""好"""),这是很多人放弃它的原因。今天写路径也不必再用它——用正斜杠:Path.GetFullPath("a/b") 在 Windows 上正常工作并返回反斜杠形式的绝对路径。跨平台代码里,正斜杠 + Path.Combine 永远比手写平台分隔符安全(13 章)。""" 所在列为基准,每一行都会去掉这么多前导空白。所以把结束引号和内容对齐写,生成的字符串就没有多余缩进;把结束引号顶格写,内容的缩进就会原样保留。写单元测试的期望值时,这条能省掉一堆 Trim。因为字符串不可变,循环里 s += x 每一轮都要分配一个新字符串并复制全部旧内容——这是 O(n²) 的复杂度。这不是理论担忧,量级大到肉眼可见。
拼 1 万个字符
var sb = new StringBuilder(); for (int i = 0; i < 10_000; i++) sb.Append('x'); // 分配 33,176 字节 string s = ""; for (int i = 0; i < 10_000; i++) s += 'x'; // 分配 100,261,224 字节 // 差约 3000 倍 —— 用 GC.GetTotalAllocatedBytes
- 那 1 亿字节是可以手算出来的:第 i 轮要复制 i 个字符,总量约 n²/2 × 2 字节 = 10000²/2 × 2 = 1 亿。这个数只取决于算法,不取决于机器——你在任何机器上跑都是这个量级。
StringBuilder用可增长的内部缓冲区,均摊下来是 O(n)。差距随 n 增长而拉大:拼 100 个几乎无所谓,拼 10 万个就是几秒钟的卡顿。
什么时候不需要 StringBuilder
- 固定次数的拼接:
a + b + c + d会被编译器合成一次string.Concat调用,只分配一次,比 StringBuilder 还快。 - 已有集合:
string.Join(", ", items)内部就是优化过的,比自己拼快也更短。 - 格式化少量值:插值字符串(上一卡)已经用处理器优化过了。
- 判据很简单:拼接次数在编译期确定 → 直接拼;次数由循环/数据量决定 → StringBuilder。
// 循环拼接的正确姿势
var sb = new StringBuilder(capacity: 1024); // 预估容量可再省几次扩容
foreach (var row in rows)
sb.Append(row.Name).Append('\t').Append(row.Score).AppendLine();
string result = sb.ToString();
// 但这些场景别用 StringBuilder
var a = first + " " + last; // 编译成一次 Concat
var b = string.Join(", ", names); // 内部已优化
var c = $"{name} 共 {count} 条"; // 插值处理器
// 高性能场景:不产生中间字符串地构造(.NET 6+)
string masked = string.Create(card.Length, card, (span, src) =>
{
src.AsSpan().CopyTo(span);
span[..^4].Fill('*'); // 只保留后四位
});StringBuilder 当万能药:它自己也是要分配的,为拼两三个字符串专门 new 一个反而更慢、代码也更长。另外 StringBuilder 不是线程安全的,多个线程往同一个实例 Append 会产生错乱甚至异常——并发场景每个线程各用各的,最后再合并。StringBuilder 可以链式调用(每个 Append 都返回自己),也有 AppendJoin、AppendFormat、Insert、Replace。它还支持插值直接 Append:sb.Append($"x={x}") 在 .NET 6+ 走的是 AppendInterpolatedStringHandler,不会先拼出一个中间字符串——写起来顺手,性能也不亏。.NET 的 string 内部是 UTF-16,char 是一个 16 位码元——不是「一个字符」。这个区别在中文上基本看不出来,在 emoji 和少数生僻字上会立刻暴雷。
三个字符的字符串
"👍".Length // 2 —— 一个 emoji 占两个 char(代理对) "中".Length // 1 "a👍中" 的三种数法: .Length = 4 // UTF-16 码元数 .EnumerateRunes().Count() = 3 // Unicode 码点数(真正的"字符"数) Encoding.UTF8.GetByteCount(..) = 8 // UTF-8 字节数(a=1, 👍=4, 中=3)
- 三个数各有各的用途:
Length用于索引与切片;Rune 数用于「这段文本有几个字符」的人类语义;UTF-8 字节数用于网络传输与存储配额。混用是 bug 的来源。 - 超出基本平面(U+FFFF 以上)的字符——emoji、部分生僻汉字、古文字——在 UTF-16 里用代理对(两个 char)表示。
s[0]取到的只是半个字符,打印出来是乱码。 - 还有更麻烦的一层:「字位簇」(grapheme cluster)。带肤色的 emoji、家庭 emoji、印度语系的组合字,一个视觉字符可能由好几个码点组成。要按「用户看到的字符」处理,得用
StringInfo.GetTextElementEnumerator。
三条实用规矩
- 别按
char反转字符串:new string(s.Reverse().ToArray())会把代理对拆散,emoji 变乱码。要反转就按 Rune 或文本元素反转。 - 截断字符串要小心:
s.Substring(0, 10)可能正好切在代理对中间。给用户看的截断用StringInfo,给存储用的截断按 UTF-8 字节数算。 - 遍历字符用
EnumerateRunes()(.NET Core 3.0+),它返回Rune——一个完整的 Unicode 码点,且是值类型不分配。
using System.Text;
using System.Globalization;
var s = "a👍中";
// ✗ 按 char 遍历:emoji 会被拆成两个乱码
foreach (char c in s) Console.Write(c + "|");
// ✓ 按 Rune 遍历:每次拿到一个完整码点
foreach (Rune r in s.EnumerateRunes())
Console.WriteLine($"{r} U+{r.Value:X4}");
// ✓ 按"用户看到的字符"遍历(处理组合字、肤色 emoji)
var e = StringInfo.GetTextElementEnumerator(s);
while (e.MoveNext()) Console.WriteLine(e.Current);
// 编码转换:.NET 的默认编码是 UTF-8(无 BOM)
byte[] utf8 = Encoding.UTF8.GetBytes(s);
string back = Encoding.UTF8.GetString(utf8);
byte[] utf16 = Encoding.Unicode.GetBytes(s); // Unicode = UTF-16 LE,别被名字骗了Encoding.GetEncoding("GBK") 会抛 ArgumentException: 'GBK' is not a supported encoding name.(报错原文)。解法是引 System.Text.Encoding.CodePages 包并在启动时注册一次:Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);。从 .NET Framework 迁移过来的代码,这是必踩的一条。Encoding.Unicode 在 .NET 里指的是 UTF-16 小端,不是「Unicode 编码」这个泛称。要 UTF-8 就明确写 Encoding.UTF8。另外 new UTF8Encoding(false) 与 Encoding.UTF8 的区别在于后者写入时会带 BOM——生成给 Linux 工具链消费的文件时,BOM 经常是「文件开头多了三个怪字节」的元凶。「两个字符串相等吗」「哪个排前面」在计算机里没有唯一答案——取决于你是按字节比,还是按某种语言的习惯比。选错了,代码会在另一个区域设置的机器上给出不同结果。
同样的输入,不同的答案
"i".ToUpper(InvariantCulture) = "I" "i".ToUpper(tr-TR) = "İ" // 土耳其语的大写 i 带点! "a".CompareTo("B") = -1 // 按中文区域:a 排在 B 前(字母序) string.CompareOrdinal("a","B") = 31 // 按码位:'a'(97) > 'B'(66),正数 排序 ["赵","钱","孙","李"]: Ordinal 序: 孙 李 赵 钱 // 按 Unicode 码位 zh-CN 序: 李 钱 孙 赵 // 按拼音
- 土耳其 I 问题是这一类 bug 的代名词:
"FILE".ToLower()在土耳其区域下得到"fıle"(无点的 ı),于是== "file"为假,文件扩展名判断、协议头解析全线崩溃。 - 中文排序同理:Ordinal 与拼音序完全是两个顺序。给用户看的列表要按区域排,做去重/查找的 key 要按 Ordinal 排。
选择表:只有两类场景
| 场景 | 用什么 | 为什么 |
|---|---|---|
| 标识符、URL、文件名、协议头、枚举名、字典 key | StringComparison.OrdinalOrdinalIgnoreCase | 要的是「字节一样」,与语言无关;还更快 |
| 给人看的排序、搜索、UI 里的相等判断 | StringComparison.CurrentCulture | 要符合用户语言直觉 |
| 持久化的排序(存进数据库/文件) | InvariantCulture 或 Ordinal | 不能随机器区域变 |
- 默认值是个陷阱:
a == b与a.Equals(b)是 Ordinal,但a.CompareTo(b)、string.Compare(a,b)、Array.Sort默认是 CurrentCulture。同一个类上两套默认——所以永远显式传StringComparison。 StartsWith/EndsWith/IndexOf默认也是 CurrentCulture,Contains(string)却是 Ordinal。记不住就靠一条规矩解决:调用时把比较方式写出来,编译器的 CA1307/CA1310 分析器也会提醒你。
using System.Globalization;
// ✓ 判断扩展名、协议头这类"标识符":Ordinal
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { }
if (header.Equals("content-type", StringComparison.OrdinalIgnoreCase)) { }
// ✗ 危险写法:ToLower 之后比较
if (path.ToLower().EndsWith(".json")) { } // 土耳其区域会出错,还多分配一个字符串
// ✓ 给用户看的排序:显式区域
var sorted = names.OrderBy(n => n, StringComparer.Create(
CultureInfo.GetCultureInfo("zh-CN"), ignoreCase: false));
// ✓ 字典 key 忽略大小写:把比较器交给字典,别自己 ToLower
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
// 规范化大小写用 Invariant 版本,绝不用 ToLower()/ToUpper() 无参重载
var key = raw.ToUpperInvariant();InvariantGlobalization 打开会静默改变行为。为了缩小容器镜像,不少项目在 csproj 里加 <InvariantGlobalization>true</InvariantGlobalization>——这会去掉 ICU,所有区域都退化成不变区域:中文排序变成码位序、区域相关的日期数字格式全部按不变区域走、CultureInfo.GetCultureInfo("zh-CN") 也不再有真实数据。跨平台部署时这个开关必须和产品需求对齐,13 章会展开。ToUpperInvariant() 而不是 ToLowerInvariant()——这是微软官方建议:某些字符(如德语 ß)在转小写时会丢失往返性,转大写更能保证「不同的字符不会撞成同一个」。"straße".ToUpper() 得到 "STRAßE"(ß 保持不变),可见大小写映射本身就不是双射,能少一层不确定就少一层。控制流与模式匹配
分支和循环是所有语言的公共部分,C# 真正值得单讲的是模式匹配——它把「判断类型 + 取值 + 分支」压缩成一个表达式,是这门语言近十年最大的一次表达力升级。这一章顺带把元组、解构和迭代器一并讲掉。
这部分和大多数 C 系语言一致,只挑三处 C# 特有的、写起来更短的地方讲。
循环四种,各有各的场合
foreach (var x in items) { } // 首选:只读遍历,不用管索引和边界 for (int i = 0; i < n; i++) { } // 需要索引、或要跳步/倒序 while (cond) { } // 次数不确定 do { } while (cond); // 至少执行一次(很少用)
foreach是绝大多数场景的正确答案:它对数组、List<T>、Span<T>都有专门优化(不走接口、无装箱),性能与手写 for 基本一致,还免掉了越界。- 要索引又想用 foreach,.NET 9 起有
Index():foreach (var (i, item) in items.Index())——输出0:a 1:b。 foreach遍历过程中不能修改集合,会抛InvalidOperationException: Collection was modified; enumeration operation may not execute.(报错原文)。要边遍历边删就倒序 for,或者先ToList()拍一份快照。
索引与范围运算符(C# 8+)
var s = "0123456789"; s[2] // '2' s[^1] // '9' —— ^ 表示"从末尾数",^1 是最后一个 s[2..5] // "234" —— 左闭右开 s[^3..] // "789" —— 最后三个 s[..^1] // "012345678" —— 去掉最后一个
- 同一套语法对数组、
Span<T>、List<T>(.NET 8+ 支持部分场景)通用。对Span切片是零分配的,对 string 和数组会复制。 ^0是「末尾之后」,等于长度,用作区间上界合法但不能用作索引。
几个小语法糖
?:三元与??空合并可以串联:a ?? b ?? c。- 模式匹配的
is可以直接当条件:if (obj is string { Length: > 3 } s)——判断、约束、取值一步完成。 goto存在但基本不用;唯一还偶尔见到的是goto case(switch 语句里跨分支跳转)。
// 边遍历边删:倒序 for,或先快照
for (int i = list.Count - 1; i >= 0; i--)
if (list[i].IsExpired) list.RemoveAt(i);
list.RemoveAll(x => x.IsExpired); // List<T> 自带,更短更快
// 范围切片:解析固定宽度的文本
ReadOnlySpan<char> line = raw.AsSpan();
var code = line[..3]; // 零分配
var name = line[3..20].Trim();
var tail = line[^8..];
// 带索引的 foreach(.NET 9+)
foreach (var (i, item) in items.Index())
Console.WriteLine($"{i}: {item}");^ 索引对空集合会抛 IndexOutOfRangeException:arr[^1] 在空数组上和 arr[-1] 一样炸。要安全地取最后一个用 LINQ 的 LastOrDefault(),或先判 Length > 0。范围 arr[^3..] 在元素不足 3 个时同样抛异常(不像 Python 会自动截断)——C# 的切片不容忍越界。foreach 里想跳过一批元素,别写 if (cond) continue; 套一层——直接在集合上过滤:foreach (var x in items.Where(x => x.IsActive))。可读性更好,也顺手把「筛选」和「处理」两件事分开了。这是把 08 章的 LINQ 思维带进日常循环的第一步。C# 有两个 switch:老的 switch 语句(执行动作)和新的 switch 表达式(返回值)。写新代码时,只要目的是「根据输入算出一个值」,就用后者。
同一件事,两种写法
// switch 语句:啰嗦,还必须每个分支 break string label; switch (code) { case 200: label = "成功"; break; case 404: label = "未找到"; break; default: label = "其它"; break; } // switch 表达式:整体是一个值,可以直接赋给变量/return string label = code switch { 200 => "成功", 404 => "未找到", _ => "其它", // _ 是"其余情况" };
- 形式上的差别:被判断的表达式写在
switch前面、用=>而不是:、用_而不是default、分支之间用逗号、整体末尾有分号。 - 没有
break,因为不存在贯穿(fall-through)——这本身就消灭了一类 bug。
穷尽性检查:编译器帮你数清楚
- switch 表达式要求覆盖所有可能的输入。漏了会给警告 CS8509;运行时真的落到未覆盖的分支,会抛
SwitchExpressionException。 - 对
enum尤其有用:加了新枚举值之后,所有没写_兜底的 switch 表达式都会亮起警告,把「有哪些地方要跟着改」直接指给你。 - 这也意味着要不要写
_是个设计决定:写了,将来加枚举值时静默走兜底;不写,将来加枚举值时编译器提醒你。处理已知封闭集合时,倾向于不写。
switch 语句仍有它的位置
- 分支里要执行多条语句/副作用(写日志、调用方法、抛异常)而不是产出一个值时,switch 语句更自然。
- 需要多个 case 共用一段代码时,switch 语句可以连写
case 1: case 2:;switch 表达式里对应的写法是逻辑模式1 or 2 =>。
// 典型用法:把状态映射成展示文本
string desc = status switch
{
OrderStatus.Pending => "待付款",
OrderStatus.Paid => "已付款",
OrderStatus.Shipped => "已发货",
OrderStatus.Cancelled => "已取消",
// 故意不写 _:以后加了新状态,这里会亮警告
};
// 关系模式 + 逻辑模式:区间判断不用写一串 if
string grade = score switch
{
< 0 or > 100 => throw new ArgumentOutOfRangeException(nameof(score)),
>= 90 => "A",
>= 80 => "B",
>= 60 => "C",
_ => "F",
};
// 带守卫(when):模式之外再加任意布尔条件
var fee = (kind, weight) switch
{
(Kind.Express, var w) when w > 10 => w * 3.0m,
(Kind.Express, _) => 25m,
(_, var w) => w * 1.5m,
};_ => HandleOther(x)),要么改回 switch 语句。把复杂逻辑硬塞进 switch 表达式(层层嵌套三元、逗号表达式)会让可读性急剧下降——这时候老老实实用 switch 语句才是对的。>= 90 写在 >= 60 前面。顺序写反了不会报错,只会让所有及格分都变成 C——编译器只在「某分支完全被前面覆盖」时才给出 CS8510「模式已被处理」的错误。模式匹配是 C# 从 7.0 开始逐版加码的核心能力。它能用在 is、switch 语句、switch 表达式三个地方,语法完全一致——学一次,三处通用。
七种模式,一段代码代码全用上
object[] samples = [42, "hi", 3.14, new PointR(1,2), null, new[]{1,2,3}]; foreach (var s in samples) Console.WriteLine(s switch { null => "null", // ① 常量模式 int n when n > 40 => $"大 int {n}", // ② 类型模式 + 守卫 int n => $"int {n}", string { Length: > 5 } => "长字符串", // ③ 属性模式 string str => $"字符串 {str}", PointR(var x, var y) => $"解构 record ({x},{y})", // ④ 位置模式 int[] a when a is [1, .. var rest] => $"以 1 开头,其余 {rest.Length} 个", // ⑤ 列表模式 _ => $"其它 {s.GetType().Name}", }); // 输出: 大 int 42 / 字符串 hi / 其它 Double / 解构 record (1,2) / null / 以 1 开头,其余 2 个
速查
| 模式 | 写法 | 说明 |
|---|---|---|
| 常量 | 42 "x" null Status.Ok | 相等判断 |
| 类型 | string s | 判断类型并声明变量 |
| 属性 | { Length: > 5, Name: "a" } | 可任意嵌套:{ Addr: { City: "北京" } } |
| 位置 | Point(var x, var y) | 需要类型有 Deconstruct(record 自动有) |
| 关系 | > 90 <= 0 | 只能和常量比 |
| 逻辑 | and or not | >= 1 and <= 9、not null |
| 列表 | [1, 2, ..] [_, var second] | C# 11+,.. 匹配任意个 |
| var / 丢弃 | var x / _ | 永远匹配;var 顺便取值 |
最常用的三个日常写法
x is not null取代x != null——不会被自定义operator !=劫持,语义更硬。if (obj is Foo f)取代「先as再判 null」,少一行少一个变量。{ Prop: var v }一次完成「非 null 且取出属性」:if (order is { Customer.Name: var name })(C# 10 起支持这种点号链)。
// 属性模式嵌套:从深层结构里一次取出需要的字段
if (evt is { Type: "order.paid", Data.Amount: > 1000 and < 100000, Data.Currency: "CNY" })
HandleBigOrder(evt);
// 列表模式解析命令行
string result = args switch
{
[] => "用法: tool <命令> [参数]",
["help"] => Help(),
["add", var file] => Add(file),
["add", .. var files] => AddMany(files),
[var unknown, ..] => $"未知命令 {unknown}",
};
// 元组模式:多个条件的组合表
var action = (isLoggedIn, isAdmin) switch
{
(false, _) => "跳转登录",
(true, true) => "进入后台",
(true, false) => "进入首页",
};is 的形式。int[] [1, .. var rest] => ... 直接编译报 CS1003: 语法错误;正确写法是 int[] a when a is [1, .. var rest],或者在已知是数组的上下文里直接 [1, .. var rest]。另外列表模式需要类型可索引且有 Length/Count——对纯 IEnumerable<T> 用不了,得先 ToArray()。if-else if 改写成 switch 表达式,往往当场就能发现原来漏了某个情况。元组让你在不定义类型的前提下把几个值打成一包——用于方法返回多个值、临时分组、模式匹配。它是值类型(ValueTuple),轻量、无堆分配。
基本用法
(int, string) t = (1, "a"); // 位置元素:t.Item1 / t.Item2 var named = (Id: 1, Name: "a"); // 命名元素:named.Id / named.Name(推荐) var (id, name) = named; // 解构成两个变量 (a, b) = (b, a); // 一行交换,不需要临时变量 // 方法返回多值:不用为了两个字段专门建个类 static (bool ok, string? error) Validate(string s) => s.Length > 0 ? (true, null) : (false, "不能为空"); var (ok, error) = Validate(input);
- 元组的相等性是逐元素比较的:
(1,"a") == (1,"a")为 true,而且元素名不参与比较((Id:1) == (Code:1)也成立)。 - 元组名字只存在于编译期,运行时看到的仍是
Item1/Item2。所以反射、序列化里拿不到你起的名字——这是元组不适合做公开 API 返回类型的原因之一。
解构不止于元组
- record 自动可解构:
var (name, age) = person;(输出A/20)。 - 任何类型都能解构,只要提供一个
Deconstruct方法(可以是扩展方法)。KeyValuePair<K,V>就自带,所以能写foreach (var (k, v) in dict)。 - 解构时可以丢弃不需要的部分:
var (_, y) = point;。
元组 vs record:什么时候该定义类型
| 元组 | record | |
|---|---|---|
| 适用范围 | 方法内部、私有方法返回值 | 公开 API、领域模型、跨层传递 |
| 成员名 | 编译期存在,运行时消失 | 真实属性,反射/序列化可见 |
| 能加行为 | 不能 | 能(方法、校验、计算属性) |
| 可读性 | 字段一多就看不懂 | 有名字、有文档 |
- 判据:超过三个元素,或者要跨越模块边界,就定义 record。元组是「懒得起名」时的便利,不是「不需要起名」的许可。
// 字典遍历:KeyValuePair 自带解构
foreach (var (key, value) in dict)
Console.WriteLine($"{key} = {value}");
// 自定义类型支持解构
public class Rect(double w, double h)
{
public double W { get; } = w;
public double H { get; } = h;
public void Deconstruct(out double w, out double h) => (w, h) = (W, H);
}
var (w, h) = new Rect(3, 4);
// 元组做临时分组 + LINQ
var stats = orders
.GroupBy(o => o.CustomerId)
.Select(g => (Customer: g.Key, Count: g.Count(), Total: g.Sum(o => o.Amount)))
.OrderByDescending(x => x.Total)
.ToList();
foreach (var (customer, count, total) in stats)
Console.WriteLine($"{customer}: {count} 单 / {total:C}");System.Tuple(大写 T、引用类型)和 ValueTuple(就是 (int, string) 这个语法)是两回事。老代码里的 Tuple.Create(1, "a") 是引用类型、要堆分配、成员名固定是 Item1 且不可解构。新代码一律用语法糖形式的值元组,别把两者搞混——看到 new Tuple<...> 基本可以判定是该现代化的旧代码。(a, b) = (b, a) 的交换写法背后是先算右边再赋左边,所以三个变量的轮转也能一行写完:(a, b, c) = (c, a, b)。斐波那契那种迭代写起来特别干净:(a, b) = (b, a + b);——输出 0,1,1,2,3,5,8,13。yield return 让你像写普通循环一样写出一个惰性序列——调用方每要一个元素,方法才往下执行一步。编译器把方法体改写成一个状态机,这是 LINQ 整套延迟执行的底层机制。
惰性到什么程度
static IEnumerable<int> Fib() { int a = 0, b = 1; while (true) // 无限循环! { yield return a; (a, b) = (b, a + b); } } Fib().Take(8) // 0,1,1,2,3,5,8,13 —— 不会卡死
- 方法体在第一次
MoveNext()之前一行都不执行。调用Fib()只是造了个状态机对象,Take(8)拿够 8 个就不再要了,无限循环自然停下。 - 每次
yield return就「暂停」在那一行,局部变量的值被状态机保存下来,下次从这里继续。
什么时候写迭代器
- 数据量大或来源慢:逐行读文件、逐页拉接口——不用先把全部结果攒进内存。
- 调用方可能只要前几个:搜索、分页、
FirstOrDefault。 - 要做流式转换:把一个序列变成另一个序列,中间不落地。
- 反过来说,结果本来就小、要被多次遍历时,直接返回
List<T>更省事——否则每次遍历都要重跑一遍(08 章会用数据说明这一点)。
三条容易踩的规则
- 参数校验会被延后:迭代器方法里的
throw new ArgumentNullException要等到第一次遍历才抛,而不是调用时。正确做法是把校验放在一个普通方法里,再由它调用真正的迭代器方法(BCL 内部到处这么写)。 yield不能出现在try...catch的 try 块里(可以在try...finally里)。这是状态机改写的限制。- 迭代器方法不能有
ref/out参数,也不能是unsafe。
// 逐行读大文件:内存占用与文件大小无关
static IEnumerable<string> ReadLines(string path)
{
using var reader = new StreamReader(path); // finally 里会被正确释放
string? line;
while ((line = reader.ReadLine()) != null)
yield return line;
}
// 正确的参数校验:外层普通方法 + 内层迭代器
public static IEnumerable<T> TakeEvery<T>(this IEnumerable<T> src, int n)
{
ArgumentNullException.ThrowIfNull(src); // 立即校验
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(n);
return Iterate(src, n); // 惰性部分单独放
static IEnumerable<T> Iterate(IEnumerable<T> s, int k)
{
int i = 0;
foreach (var x in s) if (i++ % k == 0) yield return x;
}
}
// 异步迭代器(C# 8+):await 和 yield 可以共存
static async IAsyncEnumerable<string> FetchPagesAsync(string url)
{
string? next = url;
while (next is not null)
{
var page = await http.GetFromJsonAsync<Page>(next);
yield return page!.Body;
next = page.Next;
}
}
// 消费:await foreach (var body in FetchPagesAsync(url)) { ... }var q = ReadLines(path); q.Count(); q.First(); 会把文件读两遍。这在文件上只是慢,在「每次枚举都发一次 HTTP 请求」的迭代器上就是重复副作用。判据:要遍历超过一次,先 ToList()。08 章有一段数据把这个代价量化了。using(或 try/finally)会在枚举结束或提前中断时正确执行——foreach 退出时会调用枚举器的 Dispose(),状态机借此跑完 finally。所以上面 ReadLines 里的文件句柄,即使调用方只 Take(1) 也会被关掉。但前提是调用方用了 foreach 或 using——手工调 MoveNext() 却不 Dispose,就会漏。方法、参数与委托
方法是代码复用的最小单位,委托是把方法当值传递的能力——LINQ、事件、回调、依赖注入全都建在委托之上。这一章从参数传递的四种修饰讲到闭包捕获,把「函数是一等公民」这件事在 C# 里的形态说清楚。
C# 的方法定义有一套让签名保持简洁的工具:重载、默认值、命名实参、表达式体。用对了,同一个功能不需要五个名字相近的方法。
四种写法
// ① 重载:同名不同参数列表(返回类型不参与区分!) void Log(string msg) { } void Log(string msg, Exception ex) { } // ② 可选参数:调用方可以不传 void Send(string to, int retries = 3, bool urgent = false) { } // ③ 命名实参:跳过中间的可选参数,顺便自解释 Send("a@b.c", urgent: true); // retries 用默认值 3 // ④ 表达式体:一行方法不用写大括号和 return int Square(int x) => x * x; string Name => $"{First} {Last}"; // 属性也能用
- 重载不看返回类型:只有参数个数、类型、修饰符(
ref/out/in)参与区分。只改返回类型会直接编译失败。 - 命名实参能大幅提升调用点的可读性:
CreateUser("张三", true, false, true)谁看得懂?改成CreateUser("张三", isAdmin: true, sendEmail: false, active: true)就不用跳转去看签名了。布尔参数尤其推荐这么写。
局部函数:只在这个方法里用的方法
- 直接定义在方法体内部,可以访问外层的局部变量。比私有方法更内聚——「这段逻辑只有这里用」这件事写在代码结构里。
- 加
static修饰(static int Helper(...))表示「不捕获任何外层变量」,编译器会为你检查,也避免生成闭包对象。能加就加。 - 局部函数可以写在使用它的代码之后——不像局部变量必须先声明。上一卡的
TakeEvery就是这个模式。
可选参数的一个隐患
- 默认值是在调用方编译进去的,和
const一样的坑:类库把retries = 3改成5,已编译的调用方仍然传 3,必须重新编译。 - 所以公开 API 上的可选参数要慎用,改默认值等于破坏性变更。内部代码随意。
- 可选参数还会让重载解析变复杂:同时存在
Log(string)和Log(string, int = 0)时,Log("x")会选无参数版本(编译器优先选不需要填默认值的),这种歧义最好从设计上避免。
public class Report
{
// 表达式体成员:构造、属性、方法、索引器都能用
public Report(string title) => Title = title;
public string Title { get; }
public string Slug => Title.ToLowerInvariant().Replace(' ', '-');
// nameof:把标识符变成字符串,重命名时跟着变
public void Validate()
{
if (string.IsNullOrWhiteSpace(Title))
throw new ArgumentException("标题不能为空", nameof(Title));
}
// 局部函数:static 表示不捕获外层变量
public int Score(IEnumerable<int> xs)
{
return xs.Select(Weight).Sum();
static int Weight(int x) => x > 0 ? x * 2 : 0;
}
}nameof 是被低估的小工具:它在编译期把标识符变成字符串,重构改名时会跟着改,而字面量 "Title" 不会。异常的参数名、INotifyPropertyChanged、日志字段、配置绑定的 key——凡是需要「某个成员的名字」的地方都该用它。C# 的参数默认全部按值传递——包括引用类型:传过去的是「引用的副本」。这句话不绕清楚,就会一直有「为什么方法里改了外面没变」的困惑。
先把默认行为钉死
void Bump(PointS s, PointC c) { s.X++; // 值类型副本:外面看不见 c.X++; // 通过引用副本改对象:外面看得见 ✓ c = new PointC(); // 把引用副本指向新对象:外面看不见 ✗ } // s.X 仍是 1,c.X 变成 2
- 「改对象的内容」看得见,「换一个对象」看不见——因为换的只是那份引用副本。
- 要连「换对象」也让调用方看见,就得用
ref。
四个修饰符
| 修饰 | 方向 | 调用前必须赋值 | 方法内必须赋值 | 典型用途 |
|---|---|---|---|---|
ref | 进 + 出 | 是 | 否 | 就地修改、交换 |
out | 只出 | 否 | 是 | TryParse 模式 |
in | 只进(只读引用) | 是 | 不能改 | 传大 struct 避免复制 |
params | 可变个数 | — | — | string.Format 那样的接口 |
- 调用时也要写修饰符:
Swap(ref a, ref b)、int.TryParse(s, out var n)。这是 C# 刻意的设计——调用点就能看出这个变量会被改。 out支持内联声明:out var n,不用提前声明变量。不关心值时用out _丢弃。params在 C# 13 起不限于数组,可以是ReadOnlySpan<T>、List<T>等——params ReadOnlySpan<T>版本调用时零分配,BCL 的很多方法已经这么改了。
in 的隐藏成本
in传只读引用,本意是「大 struct 别复制了」。但如果那个 struct 不是readonly struct,编译器为了保证「调用方法不会改到它」,会在每次调用其成员前偷偷做一次防御性复制——反而更慢。- 所以规矩是:要用
in,就把 struct 声明成readonly struct(或readonly record struct)。两者要配对使用才有意义。
// out:TryXxx 模式,C# 里最常见的 out 用法
public static bool TryGetConfig(string key, out string? value)
{
value = Environment.GetEnvironmentVariable(key);
return value is not null;
}
if (TryGetConfig("PORT", out var port)) { /* 用 port */ }
// ref:就地修改
static void Double(ref int x) => x *= 2;
int n = 5; Double(ref n); // n == 10
// in + readonly struct:避免大结构体复制
public readonly struct Matrix4 { /* 64 字节 */ }
static double Trace(in Matrix4 m) => m.M11 + m.M22 + m.M33 + m.M44;
// params:可变参数
static int SumAll(params ReadOnlySpan<int> xs) // C# 13+:调用时零分配
{
int s = 0;
foreach (var x in xs) s += x;
return s;
}
SumAll(1, 2, 3);out 参数不能用在 async 方法和迭代器上(状态机改写做不到)。想在异步方法里返回「成功与否 + 值」,标准做法是返回一个元组 Task<(bool ok, T? value)>,或者返回可空值 / 自定义 Result 类型。看到有人为此把方法改成同步,那是本末倒置。ref 家族还有几个高级成员:ref 返回值与 ref 局部变量(让方法返回「对某个元素的引用」,能就地改数组元素)、ref readonly、ref struct(只能在栈上,Span<T> 就是)。日常业务代码几乎用不到,但读高性能库的源码时会大量遇到——14 章会在 Span 那里再碰面。委托是「方法的类型」——它让方法能被赋值、传参、返回。LINQ 的每一个 x => x.Age > 18 都是一个委托实例,事件、回调、依赖注入的工厂也全都是委托。
三种写法,一个东西
delegate bool Predicate(int x); // ① 自定义委托类型(今天很少写了) Func<int, bool> isPos = x => x > 0; // ② Func:有返回值,最后一个类型参数是返回类型 Action<string> log = s => Console.WriteLine(s); // ③ Action:无返回值 Predicate<int> pred = x => x > 0; // BCL 自带的 Predicate<T>,等价于 Func<T,bool> isPos(5); log("hi"); // 像方法一样调用 list.Where(isPos); // 也可以当值传
- 日常只用
Func和Action:Func<A, B, R>表示「吃 A 和 B,返回 R」;Action<A>表示「吃 A,不返回」。自定义 delegate 类型只在需要ref/out参数或想给类型起个有意义的名字时才写。 - Lambda 的参数类型能推就推:
(x, y) => x + y;一个参数时括号可省:x => x * 2;不需要参数时写() => ...。 - 方法组转换:已有方法可以直接当委托用,
list.Select(int.Parse)比list.Select(s => int.Parse(s))更短也更快(少一层间接)。
闭包捕获:foreach 与 for 的关键差异
var a = new List<Func<int>>(); foreach (var i in Enumerable.Range(0,3)) a.Add(() => i); // 输出:0,1,2 —— 每轮一个新变量 var b = new List<Func<int>>(); for (int i = 0; i < 3; i++) b.Add(() => i); // 输出:3,3,3 —— 三个 lambda 共享同一个 i
- Lambda 捕获的是变量本身,不是变量当时的值。
for的循环变量整个循环只有一个,所以三个 lambda 看到的是循环结束后的i == 3。 foreach的迭代变量每轮都是新的(C# 5 起特意改的,就是为了修这个坑),所以捕获到的是各自的值。for里要正确捕获,在循环体内复制一份:int copy = i; b.Add(() => copy);。
闭包是有成本的
- 捕获了外层变量的 lambda,编译器会生成一个闭包类并在堆上分配一个实例,被捕获的变量变成它的字段。热路径上这就是每次调用一次分配。
- 没捕获任何东西的 lambda 会被缓存成静态单例,零分配。加
static修饰(static x => x * 2,C# 9+)可以让编译器帮你确认「我确实没捕获」。 - 只捕获了
this的 lambda 也不会额外分配闭包类(直接用实例方法),但会延长this的生命周期——事件订阅里的内存泄漏就是这么来的(下一卡)。
// 委托作为策略参数:把"怎么做"交给调用方
public static T Retry<T>(Func<T> action, int times = 3,
Action<Exception, int>? onError = null)
{
for (int i = 1; ; i++)
{
try { return action(); }
catch (Exception e) when (i < times)
{
onError?.Invoke(e, i); // ?.Invoke:委托可能是 null
}
}
}
var html = Retry(() => http.GetStringAsync(url).Result,
onError: (e, n) => logger.LogWarning("第 {N} 次失败", n));
// 方法组转换与 static lambda
var nums = lines.Select(int.Parse); // 方法组,不写 lambda
var big = nums.Where(static x => x > 100); // static:编译器确认无捕获
// 多播委托:一个委托可以挂多个方法(事件的基础)
Action pipeline = () => Console.WriteLine("A");
pipeline += () => Console.WriteLine("B");
pipeline(); // 依次输出 A、BFunc 有返回值这件事会悄悄改变重载解析。list.ForEach(x => Console.WriteLine(x)) 里 lambda 是 Action;但 x => list.Add(x) 因为 Add 返回 void 也是 Action,而 x => sb.Append(x) 返回 StringBuilder,在同时存在 Func/Action 重载的 API 上会选中 Func 版本——返回值被静默丢弃。表达式体 lambda 的「隐式返回」是这类困惑的根源,写成带大括号的语句体就能明确。+= 实际是「造一个新的多播委托再赋回去」。所以在多线程下增删订阅要用 Interlocked.CompareExchange 或加锁——事件(下一卡)的 +=/-= 由编译器生成的默认实现已经是线程安全的,自己手写委托字段时才需要操心。event 是加了访问限制的委托字段:外部只能 += 订阅和 -= 退订,不能直接赋值、不能清空、不能替发布者触发。这三条限制正是事件存在的理由。
标准写法
public class Downloader { // BCL 约定:EventHandler<T>,参数是 (object? sender, T e) public event EventHandler<ProgressEventArgs>? Progress; protected virtual void OnProgress(int percent) => Progress?.Invoke(this, new ProgressEventArgs(percent)); // ?.Invoke 是必须的:没人订阅时委托是 null } // 订阅方 d.Progress += (sender, e) => Console.WriteLine($"{e.Percent}%");
?.Invoke不能省:没有订阅者时事件字段是null,直接Progress(...)会抛NullReferenceException。?.顺带解决了「读出来之后正好被别的线程退订」的竞态。- 命名约定:事件用动词的现在时或过去时(
Closing/Closed),触发方法叫OnXxx且为protected virtual(让子类能拦截)。 - 今天在异步、响应式场景里,事件常被
IObservable、IAsyncEnumerable、Channel 或者直接的回调委托取代。事件在 UI 框架和长生命周期的组件通知里仍是主流。
事件是 .NET 里最经典的内存泄漏来源
- 机制:发布者持有订阅者的引用。只要发布者活着,所有订阅者就都不会被 GC 回收——哪怕订阅者本身已经「没人用了」。
- 典型场景:长生命周期的服务(单例、静态、应用级对象)暴露事件,短生命周期的对象(页面、视图模型、请求作用域的服务)订阅了却没退订。页面关了一百次,内存里躺着一百个页面对象。
- 解法三选一:① 显式
-=退订(在Dispose里);② 订阅方与发布方生命周期本来就一致,那就不用管;③ 用弱事件模式(WPF 的WeakEventManager)。 -=时必须用同一个委托实例:用 lambda 订阅的话,-= (s,e) => ...是另一个委托,退订静默失败。要能退订就把 lambda 存进变量或用具名方法。
public class FileWatcher : IDisposable
{
private readonly Downloader _source;
private readonly EventHandler<ProgressEventArgs> _handler;
public FileWatcher(Downloader source)
{
_source = source;
_handler = OnProgress; // 存下来,退订时要用同一个实例
_source.Progress += _handler;
}
private void OnProgress(object? sender, ProgressEventArgs e)
=> Console.WriteLine($"{e.Percent}%");
public void Dispose() => _source.Progress -= _handler; // 必须退订
}
// 事件参数:轻量 record 就够,不必非要继承 EventArgs(那是老约定)
public sealed record ProgressEventArgs(int Percent);d.Progress += (s, e) => Log(e); 之后没有任何句柄可以用来 -=——每次写 -= 都会生成一个新委托实例,与订阅时的那个不相等,退订静默失败、不报任何错。要么用具名方法/存起来的委托变量订阅,要么确认这个订阅的生命周期就是到进程结束为止。async void——除非它就是最外层的事件处理器(那是 async void 唯一正当的用途,见 11 章)。原因是 async void 抛出的异常无法被调用方捕获,会直接掀翻进程。UI 事件处理器属于这个例外,但即便如此,里面也要用 try/catch 兜住。扩展方法让你「给一个你无权修改的类型加方法」——语法上像实例方法,实质是静态方法调用的语法糖。整个 LINQ 就是一组扩展方法,这是理解 08 章的前提。
怎么写
public static class StringExtensions // 必须是静态类 { public static string Shout(this string s) // 第一个参数加 this => s.ToUpperInvariant() + "!"; } "hello".Shout() // 输出 HELLO! —— 看起来是 string 的方法 StringExtensions.Shout("hello") // 编译后其实是这个
- 三个硬性要求:静态类、静态方法、第一个参数加
this。 - 要用得先把命名空间
using进来——这就是为什么忘了using System.Linq时Where会「不存在」(不过隐式 using 已经帮你加上了)。 - 实例方法优先级高于扩展方法:如果类型本身有同名同签名的方法,扩展方法永远不会被调用。所以 BCL 后来给某个类型加了正式方法时,你的同名扩展方法会静默失效——升级 .NET 后行为变了,多半是撞上了这条。
一个反直觉的结果
((string?)null).IsBlank() // 返回 True,不抛 NullReferenceException! public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(s);
- 因为它本质是静态方法调用,
this参数只是普通参数——传 null 进去完全合法。这与实例方法(在 null 上调用必炸)截然不同。 - 这个特性很有用(可以写
s.IsBlank()而不用先判 null),但也很容易让人误判:看到x.Foo()不抛异常,别急着断定 x 不是 null。
什么时候该写、什么时候别写
- 该写:给
string/DateTime/接口 加项目内通用的小工具;给第三方类型补一个顺手的转换;给接口提供默认行为(在接口默认实现出现之前这是唯一办法)。 - 别写:自己能改的类型——直接加实例方法;需要访问私有状态的逻辑——扩展方法只能看到公开成员;把一堆无关的方法塞进一个
Extensions静态类当垃圾桶。 - C# 14 新增了「扩展成员」(
extension块),可以扩展属性、索引器、静态方法,而不只是实例方法。这是这门语言这一代最值得关注的新特性之一,但用之前先确认团队的 TFM 已经到net10.0。
// 常见的实用扩展
public static class EnumerableExtensions
{
// 给 IEnumerable<T> 补一个"分批"(.NET 6+ 已内置 Chunk,这里作示例)
public static IEnumerable<List<T>> Batch<T>(this IEnumerable<T> src, int size)
{
var buf = new List<T>(size);
foreach (var x in src)
{
buf.Add(x);
if (buf.Count == size) { yield return buf; buf = new(size); }
}
if (buf.Count > 0) yield return buf;
}
}
// C# 14 的扩展成员:能扩展属性和静态成员了
public static class StringExt
{
extension(string s)
{
public bool IsBlank => string.IsNullOrWhiteSpace(s); // 扩展属性
public string Truncate(int n) => s.Length <= n ? s : s[..n] + "…";
}
}
// 用起来:if (name.IsBlank) ...IAnimal 写的扩展方法,即使实际对象是 Dog 且 Dog 上也有个同名扩展方法,通过 IAnimal 变量调用时走的仍是 IAnimal 那个。想要多态行为,用虚方法或接口默认实现,别用扩展方法。using——后者更礼貌,也让代码审查时一眼看得出「这里用了我们自己的扩展」。面向对象:class、record、struct、interface
C# 是一门面向对象的语言,但今天写 C# 的姿势已经和二十年前很不一样:record 处理数据、interface 定义契约、继承用得越来越少。这一章把四种类型各自的定位讲清楚,重点是「什么时候该用哪一个」。
写一个类要决定的事其实只有三件:状态放哪(字段还是属性)、怎么初始化(构造函数还是对象初始化器)、什么时候允许改(set / init / 只读)。C# 近几版一直在给这三件事减少样板代码。
从最啰嗦到最简洁
// 老写法:私有字段 + 手写属性 + 构造函数 public class User { private readonly string _name; public string Name { get { return _name; } } public User(string name) { _name = name; } } // 自动属性 + 主构造函数(C# 12):同样的语义,三行 public class User(string name) { public string Name { get; } = name; } // 只做数据容器?直接 record(下一卡) public record User(string Name);
- 属性不是字段:属性是「一对方法」(getter/setter),字段是真实的存储。公开成员一律用属性——它能加校验、能改成计算值、能被接口约束、能被数据绑定和序列化识别,而把公开字段改成属性是二进制破坏性变更。
{ get; }只读、{ get; set; }可读写、{ get; init; }只能在构造或对象初始化器里赋值(C# 9+)——这是做不可变对象最方便的写法。required(C# 11+):强制调用方必须在对象初始化器里给这个成员赋值,否则编译失败。它让「用对象初始化器」也能有构造函数那样的强制性。new Box { Name = "x" }通过,漏掉Name则编译不过。
field 关键字(C# 14):属性写一半的场景
public string Name { get; set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value)); } // 以前必须为此手写一个私有字段 _name;现在 field 直接指代编译器生成的那个
- 这是 C# 14 最实用的小特性:需要在 setter 里加一点逻辑,就不必再退回「手写私有字段」。注意它会让名为
field的现有变量产生歧义,旧代码升级时留意编译器警告。
public class Order
{
// 只读:构造后不可变
public required string Id { get; init; }
public required decimal Amount { get; init; }
// 带默认值的自动属性
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
// 计算属性:没有存储,每次算
public bool IsLarge => Amount > 10_000m;
// 可变但有校验
public string? Note
{
get;
set => field = value?.Trim(); // C# 14 的 field 关键字
}
// 私有状态用字段
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines; // 对外只读视图
}
// 用对象初始化器构造,required 保证不漏字段
var o = new Order { Id = "A-1", Amount = 99.5m };public class User(string name) { public string Name => name; } 会把参数捕获成一个隐藏字段——看起来是计算属性,实际持有状态。更坑的是主构造参数不是只读的,方法里可以给它赋值。想要真正的只读,还是显式写 public string Name { get; } = name;。IReadOnlyList<T> 而不是 List<T>。返回 List 等于把内部状态的写权限交出去了——调用方一个 order.Lines.Clear() 就能破坏你的不变量,而且不留痕迹。这是 C# 里最容易被忽略的封装漏洞之一。C# 是单继承 + 多接口。继承本身不难,难的是分清 virtual/override(多态,运行时决定)和 new(遮蔽,编译期决定)——后者的行为足够反直觉,值得一段。
同一个对象,两种结果
class Base { public virtual string Virt() => "Base.Virt"; public string Shadow() => "Base.Shadow"; } class Derived : Base { public override string Virt() => "Derived.Virt"; public new string Shadow() => "Derived.Shadow"; } Base b = new Derived(); b.Virt() // "Derived.Virt" ← 运行时类型说了算(多态) b.Shadow() // "Base.Shadow" ← 编译期类型说了算(遮蔽) ((Derived)b).Shadow() // "Derived.Shadow"
new遮蔽不是多态,只是「借用同一个名字」。同一个对象,通过不同静态类型调用得到不同结果——这几乎总是 bug 的温床。- C# 强制你在遮蔽时显式写
new,不写会给警告 CS0108。看到new修饰方法,先怀疑是不是本该写override。
四个修饰符
| 修饰 | 含义 | 子类 |
|---|---|---|
virtual | 可以被覆写,本身有实现 | 可选 override |
abstract | 没有实现,所在类也必须 abstract | 必须 override |
override | 覆写基类的虚/抽象成员 | 还能被再覆写,除非加 sealed |
sealed | 类:不能被继承;方法:不能被再覆写 | — |
- C# 的方法默认不是虚的(与 Java 相反)。这是个刻意的设计:可覆写性是要主动设计的能力,不是默认赠品。
sealed class除了表达设计意图,还能让 JIT 做去虚化优化。不打算被继承的类就加sealed——BCL 里大量类型是 sealed 的。
继承在今天用得越来越少
- 优先组合而不是继承:把「有一个」(持有一个字段并委托)用在「是一个」不成立的地方。继承把父子类紧紧绑在一起,父类改动会波及所有子类(脆弱基类问题)。
- 共享契约用接口,共享实现优先用组合或扩展方法,实在需要共享一段模板流程时才用抽象基类。
- 一个实用判据:如果基类里有
protected状态被子类随意读写,这个继承多半设计错了。
// 抽象基类:定义模板流程,把变化点留给子类
public abstract class Exporter
{
// 模板方法:流程固定
public async Task RunAsync(IEnumerable<Row> rows, Stream output)
{
await using var w = new StreamWriter(output);
await w.WriteLineAsync(Header());
foreach (var r in rows) await w.WriteLineAsync(Format(r));
}
protected abstract string Header(); // 子类必须实现
protected abstract string Format(Row r);
}
public sealed class CsvExporter : Exporter
{
protected override string Header() => "id,name,amount";
protected override string Format(Row r) => $"{r.Id},{r.Name},{r.Amount}";
}
// 组合替代继承:把"怎么格式化"作为依赖注入进来
public sealed class Exporter2(IRowFormatter formatter)
{
public async Task RunAsync(IEnumerable<Row> rows, Stream output) { /* ... */ }
}protected 字段是继承里最常见的设计错误。它让子类可以绕过基类的一切校验直接改状态,等于把基类的不变量交给每一个(包括将来才写出来的)子类去维护。要给子类访问权,用 protected 属性(只读)或 protected 方法,把「怎么改」的规则留在基类手里。null/0)。这个坑不会有任何编译警告,出问题时表现为「明明赋了值却是 null」。分析器规则 CA2214 会提醒你。接口定义「能做什么」,不关心「是什么」。C# 的接口在近几版扩权不少:能有默认实现、能有静态抽象成员、能有静态字段——但它的核心用途始终是解耦。
三种成员形态
interface IShape { double Area(); // ① 纯契约 string Describe() => $"面积 {Area():F1} 的图形"; // ② 默认实现(C# 8+) static abstract IShape Parse(string s); // ③ 静态抽象(C# 11+) } record Circle(double R) : IShape { public double Area() => Math.PI * R * R; } IShape s = new Circle(2); s.Area() // 12.57 s.Describe() // "面积 12.6 的图形" ——,接口默认实现生效
- 默认实现的用途很窄:主要是让已发布的接口能加新成员而不破坏所有实现者。不要用它来做代码复用——那是抽象基类或扩展方法的活。
- 默认实现的方法只能通过接口类型调用:
new Circle(2).Describe()编译不过,必须先转成IShape。 - 静态抽象成员(C# 11+)是泛型数学的基础:它让你在泛型代码里写
T.Zero、T.Parse(s)。where T : INumber<T>的Sum对int和double都能用(返回 6 与 4)。
显式实现:把方法藏起来
class Doc : IPrintable { string IPrintable.Print() => "只能通过接口调用"; // 没有访问修饰符! } new Doc().Print() // 编译错误:Doc 上没有 Print ((IPrintable)new Doc()).Print() // 正常,输出"只能通过接口调用"
- 两个用途:① 解决两个接口有同名成员的冲突;② 实现了某接口但不想让它污染类型的公开 API(
List<T>就把一堆IList的非泛型成员显式实现了)。
接口该怎么设计
- 小而专:
IDisposable一个方法、IComparable<T>一个方法。接口越大,实现者越痛苦,也越难被复用。 - 不要为每个类都配一个接口。「
IUserService+UserService且永远只有一个实现」是常见的过度抽象——加了一层间接却没换来任何解耦。判据:有第二个实现(包括测试替身,且必须是手写的替身而非 mock 库)时才抽接口。 - 命名约定:接口以
I开头。这是 .NET 生态的铁律,别标新立异。
// 小接口 + 组合:典型的可测试设计
public interface IClock { DateTimeOffset UtcNow(); }
public interface IRateLimiter { Task<bool> TryAcquireAsync(string key); }
public sealed class CouponService(IClock clock, IRateLimiter limiter)
{
public async Task<bool> RedeemAsync(Coupon c)
=> c.ExpiresAt > clock.UtcNow()
&& await limiter.TryAcquireAsync(c.Code);
}
// 测试里塞一个假的时钟,就能测"过期"分支——不用等到明天
sealed class FakeClock(DateTimeOffset t) : IClock
{
public DateTimeOffset UtcNow() => t;
}
// 泛型 + 静态抽象:一个方法吃所有数值类型(.NET 7+)
static T Sum<T>(IEnumerable<T> xs) where T : INumber<T>
{
T acc = T.Zero; // 静态抽象成员
foreach (var x in xs) acc = acc + x;
return acc;
}new 遮蔽是同一类陷阱。TimeProvider(.NET 8+)就是官方版的 IClock——需要「可测试的时间」时直接用它,别自己造。它同时抽象了 UtcNow、Timer 和 Task.Delay,测试里换成 FakeTimeProvider(在 Microsoft.Extensions.TimeProvider.Testing 包里)就能瞬间「快进」几小时。record 是「以数据为中心」的类型:一行声明就自动获得值相等性、ToString、解构、with 复制。今天写 DTO(只装数据的传输对象)、事件、值对象、配置模型,默认答案就是 record。
一行换来多少东西
record PointR(int X, int Y); new PointR(1,2) == new PointR(1,2) // True —— 逐属性比较(class 是 False) new PointR(1,2).ToString() // "PointR { X = 1, Y = 2 }" var r2 = r1 with { Y = 9 }; // "PointR { X = 1, Y = 9 }",r1 原封不动 var (x, y) = r1; // 自动解构
- 位置参数会生成
init属性:record PointR(int X, int Y)里的 X、Y 是只读的(只能在构造与对象初始化器里赋值)。要可变就写record Foo { public int X { get; set; } }——但那基本上说明你不该用 record。 with表达式做「改一个字段的副本」,是不可变数据模型的日常操作。它调用的是编译器生成的拷贝构造函数,浅拷贝。- record 默认是引用类型(class)。要值类型语义就写
record struct,要不可变的值类型写readonly record struct——record struct的==同样是逐字段比较。
继承时的相等性:一个容易被忽略的结果
record Person(string Name, int Age); record Student(string Name, int Age, string Major) : Person(Name, Age); Person p1 = new Student("A", 20, "CS"); Person p2 = new Student("A", 20, "CS"); p1 == p2 // True Person p3 = new Person("A", 20); p3 == (Person)p1 // False!—— 尽管 Name 和 Age 都一样
- 原因是 record 生成了一个隐藏的
EqualityContract属性(返回运行时类型),相等性判断里先比它。所以「基类实例」永远不等于「子类实例」,即使公共字段全部相同。 - 这正是你想要的语义(一个 Person 不该等于一个 Student),但它和普通 class 的
Equals覆写惯例不同,手写Equals时很少有人记得处理这一点——这也是「能用 record 就别手写相等性」的一个理由。
record vs class vs struct:选型表
| 需求 | 选 |
|---|---|
| DTO、API 请求/响应、领域事件、配置 | record |
| 有行为、有生命周期、有可变状态的对象(服务、实体) | class |
| 小的不可变值(坐标、金额、ID 包装) | readonly record struct |
| 需要极致性能、无堆分配的临时数据 | readonly struct / ref struct |
// DTO / API 契约:record + required + init
public sealed record CreateOrderRequest
{
public required string CustomerId { get; init; }
public required IReadOnlyList<Line> Lines { get; init; }
public string? Coupon { get; init; }
}
// 值对象:用 readonly record struct 包装原始类型,避免"参数传反了"
public readonly record struct UserId(Guid Value)
{
public override string ToString() => Value.ToString("N");
}
// void Transfer(UserId from, UserId to) —— 传反了编译不过;
// 如果两个参数都是 Guid,传反了只有线上才知道
// 领域事件:record 的相等性和 ToString 让日志与测试都省事
public abstract record DomainEvent(DateTimeOffset OccurredAt);
public sealed record OrderPaid(string OrderId, decimal Amount, DateTimeOffset OccurredAt)
: DomainEvent(OccurredAt);
// with 做状态推进
var shipped = order with { Status = OrderStatus.Shipped, ShippedAt = clock.UtcNow() };record Basket(List<string> Items) 里两个不同的 List 实例即使内容一样,== 也是 False(比的是 List 的引用)。with 同理是浅拷贝——新对象和旧对象共享同一个 List,改一个另一个也变。record 里放集合,就要么用不可变集合(ImmutableArray<T>),要么自己处理相等性与拷贝。ToString() 会把所有公开属性打印出来——写日志时很方便,但也意味着密码、令牌、身份证号会被一起打出去。敏感字段要么不放进 record,要么覆写 ToString(),要么用 [JsonIgnore] 之类的标记配合自定义打印。这条在生产事故里出现的频率比想象中高。写 struct 的动机只有一个:避免堆分配。它不是「更轻量的 class」——用错了反而更慢(到处复制)、更容易出 bug(可变副本)。
官方设计准则:四条同时满足才用 struct
- ① 逻辑上表示单一值(像数字那样);② 实例很小(经验值 16 字节以内);③ 不可变;④ 不会被频繁装箱。
- 反过来,只要有一条不满足就该用 class。「我觉得它是小对象」不是理由——BCL 里
DateTime、Guid、decimal、TimeSpan是 struct,string、List<T>、Uri是 class。
四种 struct 形态
| 写法 | 特点 | 用途 |
|---|---|---|
struct | 可变,最容易出坑 | 基本不该用 |
readonly struct | 所有字段只读,编译器保证无防御性复制 | 值对象、互操作结构 |
record struct | 自动相等性 + with + ToString | 多字段的值 |
ref struct | 只能在栈上,不能装箱、不能进集合、不能被 async 捕获 | Span<T>、高性能解析 |
- 默认写
readonly record struct:它一次性满足了「不可变」「有相等性」「无防御性复制」三条。 ref struct的限制看着苛刻,但正是它让Span<T>能安全地指向栈内存(14 章展开)。
struct 的三个性能反直觉
- 大 struct 到处传是负优化:每次传参、赋值、从集合读取都要复制全部字段。32 字节以上就要考虑
in传递(05 章)或干脆改 class。 - 没重写
Equals的 struct 比较非常慢:默认的ValueType.Equals走反射逐字段比对(含字段有引用类型时)。放进Dictionary当 key,性能会掉一个数量级。record struct自动生成强类型的Equals,一劳永逸。 - 装箱会把「避免堆分配」的收益全部吃掉:赋给
object、赋给接口变量、进非泛型集合都会装箱(02 章每个 24 字节)。
// ✓ 典型的好 struct:小、不可变、像一个值
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money operator +(Money a, Money b) =>
a.Currency == b.Currency
? a with { Amount = a.Amount + b.Amount }
: throw new InvalidOperationException("币种不同不能相加");
}
// ✗ 典型的坏 struct:可变 + 有集合字段
public struct BadCart // 别这么写
{
public List<string> Items; // 引用字段:复制 struct 时两份共享同一个 List
public int Count; // 可变字段:改副本改不到原件
}
// ref struct:只活在栈上,用于零分配解析
public ref struct LineReader(ReadOnlySpan<char> text)
{
private ReadOnlySpan<char> _rest = text;
public bool TryReadLine(out ReadOnlySpan<char> line)
{
int i = _rest.IndexOf('\n');
if (i < 0) { line = _rest; _rest = default; return line.Length > 0; }
line = _rest[..i]; _rest = _rest[(i + 1)..];
return true;
}
}struct 不能有无参构造函数做「非默认」的初始化。C# 10 起虽然允许写无参构造,但 default(T)、new T[10]、未初始化的字段都会绕过它,直接给你一个全零的实例。所以 struct 必须保证「全零状态是合法状态」——这也是为什么带校验的值对象经常还是得用 class,或者接受「零值 = 未设置」这个约定。[MemoryDiagnoser] 十分钟就能给你答案。最后把三个「不属于实例」的东西收尾:静态成员、资源释放、以及为什么 C# 里几乎不需要写析构函数。
静态:全局状态的正确用法
static class不能实例化、不能继承,成员全是静态的——扩展方法类、纯函数工具类(Math、Path)用它。- 静态构造函数(
static Foo() { })在类型第一次被使用前由运行时调用一次,线程安全由运行时保证。这是实现懒加载单例最省事的办法。 - 静态字段是全局可变状态:多线程访问要自己加锁,单元测试之间会互相污染,在 Web 应用里所有请求共享同一份。能用依赖注入就别用静态——这是从 .NET Framework 时代过来的代码里最需要现代化的一类。
IDisposable:C# 的确定性释放
using (var f = File.OpenRead(path)) { ... } // 块结束时 Dispose using var f = File.OpenRead(path); // C# 8+:作用域结束时 Dispose await using var conn = new SqlConnection(cs); // IAsyncDisposable // 的释放顺序:嵌套 using 是"后进先出" using (var outer = new Noisy("外")) using (var inner = new Noisy("内")) { } // 输出:Dispose 内 → Dispose 外
- GC 管内存,
Dispose管其它一切:文件句柄、socket、数据库连接、非托管内存、锁。GC 不知道这些资源有多稀缺,也不保证何时回收——所以必须显式释放。 using保证即使抛异常也会释放(编译成try/finally)。凡是实现了IDisposable的对象,几乎总该用using。- 例外:由 DI 容器管理生命周期的对象(容器会释放)、
HttpClient这类设计为长期复用的(15 章)。
终结器(析构函数):几乎永远不要写
- 语法是
~Foo() { },由 GC 在回收前调用。但它不保证何时执行、不保证一定执行、会让对象多活一代、拖慢 GC。 - 唯一正当用途:直接持有非托管资源(原始句柄、
Marshal.AllocHGlobal的内存)时作为「忘了 Dispose」的最后保险。而即使这种场景,正确做法也是用SafeHandle——它已经把这套逻辑做对了。 - 结论:普通业务代码里写终结器 100% 是错的。要释放资源就实现
IDisposable并让调用方using。
// 标准的 IDisposable 实现(持有其它 IDisposable 时)
public sealed class Repository : IDisposable, IAsyncDisposable
{
private readonly DbConnection _conn;
private bool _disposed;
public Repository(string cs) => _conn = new SqlConnection(cs);
public void Dispose()
{
if (_disposed) return; // Dispose 必须可以被调用多次
_conn.Dispose();
_disposed = true;
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
await _conn.DisposeAsync();
_disposed = true;
}
// 注意:没有终结器 —— 因为这里没有直接持有非托管资源
}
// 懒加载单例:静态构造函数版本(线程安全由运行时保证)
public static class Config
{
public static readonly IReadOnlyDictionary<string, string> Values;
static Config() => Values = Load();
}
// 更现代的做法:交给 DI 容器(17 章)
// builder.Services.AddSingleton<IConfigStore, ConfigStore>();using 的后果不是「稍后被回收」,而是可能永远不释放。数据库连接不还池,几百个请求之后连接池耗尽、整个服务卡死在等待连接上;文件句柄不关,Linux 上撞 ulimit -n 报 "Too many open files"。这类故障的现场离根因很远,极难排查。分析器规则 CA2000 会提醒你,值得在项目里开成错误。sealed + IDisposable 是个好组合:类被密封后,那套复杂的 Dispose(bool disposing) 保护模式(为了让子类正确参与释放)就完全不需要了,直接写一个简单的 Dispose() 即可。BCL 文档里那个又长又绕的 dispose 模式,只在「类可以被继承且直接持有非托管资源」时才需要——这种类你这辈子可能都不用写一个。可空引用类型与空安全
NullReferenceException 是 .NET 上出现最多的运行时异常。可空引用类型(NRT)是 C# 8 给出的答案:用类型标注 + 编译期流分析把「这里可能是 null」变成看得见的信息。这一章讲清它能做什么、不能做什么,以及怎么在真实项目里落地。
开启 <Nullable>enable</Nullable> 之后,string 表示「不该为 null」,string? 表示「可能为 null」。但这全部是给编译器看的——运行时两者是同一个类型,没有任何检查。
三条证据
① typeof(string) == typeof(string?) // True —— 就是同一个类型 ② static string Greet(string name) => $"hi {name.Length}"; Greet(null!) // 编译通过,运行时抛 NullReferenceException ③ 元数据里只是多了个特性: Box.Name(required string)→ RequiredMemberAttribute Box.Tag (string?) → NullableAttribute
- 所以 NRT 不是「空安全的类型系统」(不像 Kotlin 的可空类型有运行时保障,也不像 Rust 的
Option是真实的不同类型)。它是一套静态分析,产出的是警告。 - 这也意味着 NRT 拦不住来自外部的 null:反射、JSON 反序列化、没开 NRT 的第三方库、
default(T)——这些路径都能把 null 塞进一个声明为非空的字段,而编译器毫不知情。
警告不是错误:一条
string? s = null; Console.WriteLine(s.Length); warning CS8602: 解引用可能出现空引用。 // 只是警告 // 程序照常编译、照常运行、照常在第 2 行抛 NullReferenceException
- 所以第一件事就是把这类警告变成错误:csproj 里加
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>,或者只针对可空<WarningsAsErrors>nullable</WarningsAsErrors>。 - 不这么做的话,NRT 的实际收益接近于零——一屏警告没人看,等于没开。
常见的四条警告
| 编号 | 含义 | 典型触发 |
|---|---|---|
CS8600 | 把可能为 null 的值赋给非空变量 | string s = null; |
CS8602 | 解引用可能为 null 的引用 | s.Length 而 s 是 string? |
CS8618 | 非空字段/属性在构造后仍未赋值 | 忘了在构造函数里赋值 |
CS8625 | 不能把 null 字面量转成非空类型 | 传 null 给 string 参数 |
// csproj:新项目模板默认已经开了 Nullable,但没把警告变成错误
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors> <!-- 关键的一行 -->
</PropertyGroup>
// 四种可空上下文的粒度
#nullable enable // 标注 + 警告都开(默认)
#nullable disable // 全关(迁移旧文件时临时用)
#nullable enable annotations // 只认标注,不报警告
#nullable restore // 回到 csproj 的设定
// 典型的边界处理:外部数据一律当作可空
string? raw = Environment.GetEnvironmentVariable("API_KEY");
if (raw is null)
throw new InvalidOperationException("缺少 API_KEY");
// 从这一行往后,编译器知道 raw 不是 null 了
Console.WriteLine(raw.Length);default(T)——每一条都能绕过它。NRT 是把风险从「到处都有」收窄到「几个明确的边界」,边界上的检查还得你自己写。.csproj 或者直接试:如果调用它的方法返回 string 而实际可能返回 null,编译器不会警告你——没标注的库,在开了 NRT 的项目里会被当成「什么都不为空」(更准确说是「无信息」,编译器保持沉默)。跨越这类库的边界时要自己判空,别信编译器的沉默。NRT 的实际体验来自流分析:编译器沿着代码路径追踪每个变量「此刻可能不可能是 null」。判过一次 null 之后,后面就不再警告——这套推断相当聪明,但也有明确的边界。
能被正确收窄的写法
string? s = GetName(); if (s is null) return; Console.WriteLine(s.Length); // ✓ 不警告:这条路径上 s 一定非空 if (s is not null && s.Length > 0) { } // ✓ 短路后的收窄 if (string.IsNullOrEmpty(s)) return; Console.WriteLine(s.Length); // ✓ BCL 方法带了 [NotNullWhen] 标注 var len = s?.Length ?? 0; // ✓ 空条件 + 空合并 if (s is { Length: > 0 }) { } // ✓ 属性模式隐含非空
四种会让流分析失效的情况
- 把判空逻辑抽进自己的方法:
if (IsValid(s)) Console.WriteLine(s.Length);——编译器不知道IsValid保证了什么。解法是给方法加标注:static bool IsValid([NotNullWhen(true)] string? s)。 - 属性和字段跨语句的推断有限:
if (obj.Name is not null) Use(obj.Name);通常可以;但中间夹了方法调用,编译器就得假设属性可能已经变了(属性可以有任意副作用)。解法:先存进局部变量。 - 循环与闭包:lambda 里捕获的变量,编译器无法确定执行时机,往往保留警告。
- 异步与多线程:
await前后编译器同样会重新考虑字段的可空性。
标注特性:把你的知识告诉编译器
| 特性 | 含义 |
|---|---|
[NotNullWhen(true)] | 方法返回 true 时,这个参数一定非空(TryParse 的 out 参数) |
[MaybeNullWhen(false)] | 返回 false 时该参数可能为空 |
[NotNullIfNotNull(nameof(x))] | 入参非空则返回值非空(Path.GetFileName 那类) |
[MemberNotNull(nameof(_field))] | 本方法执行后该字段一定非空(初始化方法) |
[DoesNotReturn] | 本方法永不返回(抛异常的辅助方法) |
using System.Diagnostics.CodeAnalysis;
// 让自定义的判空方法参与流分析
public static bool TryGetName(User? u, [NotNullWhen(true)] out string? name)
{
name = u?.Name;
return name is not null;
}
if (TryGetName(user, out var name))
Console.WriteLine(name.Length); // ✓ 不警告
// 抛异常的辅助方法:标注后调用它之后的代码会被视为"一定非空"
[DoesNotReturn]
static void Fail(string msg) => throw new InvalidOperationException(msg);
// 延迟初始化的字段:告诉编译器 Init 之后 _conn 一定有值
private DbConnection? _conn;
[MemberNotNull(nameof(_conn))]
private void EnsureConnected()
{
_conn ??= new SqlConnection(_cs);
}
public void Query()
{
EnsureConnected();
_conn.Open(); // ✓ 不警告,因为上面的 MemberNotNull
}
// BCL 里已经标好的:ThrowIfNull 之后不再警告
ArgumentNullException.ThrowIfNull(input);
Console.WriteLine(input.Length); // ✓if (Guard.NotEmpty(s)) s.Length; 之后编译器照样警告,很多人由此得出「NRT 不智能」的结论并开始滥用 !。正确反应是给那个方法加 [NotNullWhen(true)]——一次标注,全项目受益。ArgumentNullException.ThrowIfNull(x)(.NET 6+)比手写 if (x is null) throw new ArgumentNullException(nameof(x)) 短得多,而且参数名是编译器用 CallerArgumentExpression 自动填的——不会因为改名而对不上。同系列还有 ArgumentException.ThrowIfNullOrWhiteSpace、ArgumentOutOfRangeException.ThrowIfNegative。公开 API 的参数校验一律用它们。!(null 宽容运算符)的意思是「编译器你别管,我保证这里不是 null」。它不生成任何运行时代码——用错了就是把警告换成了运行时崩溃。
! 的三种正当用法
- ① 编译器推不出来但你确定的场景:
dict.TryGetValue(k, out var v); Use(v!);(如果那个 API 没标注[NotNullWhen])。 - ② 测试代码里故意传 null:
Assert.Throws<ArgumentNullException>(() => Foo(null!))——这正是让Greet(null!)抛NullReferenceException的写法。 - ③ 与未标注的旧库交互时的边界处理。
- 除此之外,写
!之前先问一句「我能不能改成真正的判空」。仓库里!越多,NRT 的价值越低。
初始化难题:required 是正确答案
// ✗ 老办法一:用 ! 骗过编译器(把问题推到运行时) public string Name { get; set; } = null!; // ✗ 老办法二:改成可空,然后到处判空(污染了整个下游) public string? Name { get; set; } // ✓ 正确答案:required(C# 11+) public required string Name { get; init; } // new Box { Name = "x" } ✓ 通过 // new Box { } ✗ 编译错误:必须设置 Name
required把「必须赋值」的约束移到了编译期,同时保留了对象初始化器的写法自由——这是= null!;这个丑陋习惯的真正解药。- 配合
init使用:构造后不可变,构造时必须给,这是 DTO 的理想形态。 - ORM 和序列化器需要无参构造 + 反射赋值时,
required可能报错——EF Core 与System.Text.Json都已支持(后者需要[SetsRequiredMembers]或使用带参构造),但第三方库不一定。
泛型里的可空:T? 的含义是「看情况」
- 在
T?中,如果T是引用类型就是可空引用,是值类型就是Nullable<T>——而当 T 无约束时,T?只是一个「可能为 default」的标记,语义相当微妙。 - 约束能让它明确:
where T : class→T?是可空引用;where T : struct→T?是Nullable<T>;where T : notnull→ 禁止传可空类型。 static T? Def<T>() => default;对int返回 0、对string返回 null——同一行代码两种语义,这正是泛型 + 可空最容易绕晕的地方。
// ✓ required + init:DTO 的标准形态
public sealed record Customer
{
public required string Id { get; init; }
public required string Name { get; init; }
public string? Nickname { get; init; } // 明确表示"可以没有"
}
// 让 System.Text.Json 能构造带 required 的类型
public sealed class Dto
{
public required string Name { get; init; }
[SetsRequiredMembers]
public Dto(string name) => Name = name;
}
// 泛型约束让 T? 的含义变明确
static T? FirstOrNull<T>(IEnumerable<T> xs) where T : class
=> xs.FirstOrDefault();
static T? FirstOrNull<T>(IEnumerable<T> xs) where T : struct
=> xs.Cast<T?>().FirstOrDefault();
// 字典 key 不许为空
static void Add<TKey, TValue>(Dictionary<TKey, TValue> d, TKey k, TValue v)
where TKey : notnull { d[k] = v; }! 会「传染」并掩盖真问题。典型链条:某处报 CS8602 → 加 ! 消掉 → 上线后在那一行抛 NullReferenceException → 加个 ?. → 变成静默的 null 传播 → 最终在完全不相干的地方以奇怪的方式失败。看到警告的第一反应应该是「这里真的可能为 null 吗」,而不是「怎么把警告关掉」。= null!; 当成一个技术债标记:它在代码里的每一次出现都意味着「这里有个字段,编译器认为它非空,但实际上可能没被赋值」。定期 grep 一遍 null!,能用 required 的换掉,剩下的加注释说明为什么安全。这比追求「零警告」实在得多。新项目开 NRT 毫无争议(模板默认就开)。难的是把一个几十万行的老项目迁过来——一次性打开会得到几千条警告,团队第一反应就是关掉它。有一套可行的渐进路径。
迁移四步走
- ① 全局开启标注、暂不报警:csproj 里设
<Nullable>annotations</Nullable>。此时你可以开始写string?标注,但不会有任何警告刷屏。 - ② 从叶子往根,逐文件打开:在文件顶部写
#nullable enable,修完这个文件的警告再开下一个。优先从没有依赖的工具类、模型类开始——它们的标注会让上游文件的分析更准。 - ③ 新代码强制开启:CI 里检查新增文件必须带
#nullable enable,或者干脆按目录切分(新模块目录整体开启)。 - ④ 全部迁完后翻转默认值:改成
<Nullable>enable</Nullable>+<WarningsAsErrors>nullable</WarningsAsErrors>,删掉所有#nullable指令。
三个边界要单独处理
| 边界 | 风险 | 做法 |
|---|---|---|
| JSON / 配置反序列化 | 缺字段时非空属性变成 null | 用 required;或在 System.Text.Json 上开 RespectNullableAnnotations(.NET 9+) |
| EF Core / 数据库 | 列可空但属性非空 | EF 会按 NRT 推断列的可空性——标注错了会直接改变建表结果 |
| HTTP 入参 / 表单 | 客户端不传就是 null | 模型上用 required + 校验特性,让框架在绑定期拦住 |
- EF Core 那一条尤其要注意:它把
string Name映射成NOT NULL列、把string? Name映射成可空列。迁移老项目时随手改一个标注,可能生成一条 ALTER TABLE。
一个务实的心态
- NRT 的目标不是消灭所有 NullReferenceException,而是把「哪里可能为 null」这件事从口口相传变成类型签名的一部分。达到「新代码不再引入空引用 bug」就已经赚回成本了。
- 不要为了消灭警告而把类型改成可空——那是把编译期的问题转嫁给所有调用方。正确顺序是:先想清楚这个值业务上能不能为空,再决定标注。
- 团队约定比工具更重要:「public API 的返回值尽量非空」「集合返回空集合而不是 null」「参数校验在入口处一次做完」——这三条比任何标注都管用。
// 逐文件迁移:文件顶部一行
#nullable enable
// 边界防御:所有外部输入在入口处收敛
public sealed class OrderController
{
public IResult Create(CreateOrderRequest? req)
{
if (req is null) return Results.BadRequest("请求体不能为空");
// 从这里往下,整个方法体内 req 都是非空的
return Results.Ok(_service.Create(req));
}
}
// 集合永远不返回 null —— 这条比任何标注都省事
public IReadOnlyList<Order> FindByCustomer(string id)
=> _db.Orders.Where(o => o.CustomerId == id).ToList(); // 没有就是空列表
// System.Text.Json 尊重可空标注(.NET 9+)
var options = new JsonSerializerOptions
{
RespectNullableAnnotations = true, // 缺字段 / 显式 null → 抛异常而不是静默塞 null
RespectRequiredConstructorParameters = true,
};public string Email { get; set; }(映射为 NOT NULL)随手改成 string? 来消警告,下一次 dotnet ef migrations add 就会生成一条把列改成可空的迁移——数据库约束就这么被一个「消警告」的动作悄悄放松了。在有 ORM 的项目里改标注,一定要跟着看一眼生成的迁移脚本。集合与 LINQ
LINQ 是 C# 最有辨识度的能力:一套统一的查询语法,作用在内存集合、数据库、XML、任何实现了接口的数据源上。这一章先把集合选型讲清楚,再把 LINQ 的延迟执行机制和它带来的三类陷阱说透。
选集合只需要回答一个问题:你主要拿它干什么——按位置取、按 key 取、判断存不存在、还是排队。选对了,代码不用优化就已经快了。
常用集合与复杂度
| 类型 | 特点 | 查找 | 插入 | 用于 |
|---|---|---|---|---|
List<T> | 可变长数组,有序 | 按索引 O(1) 按值 O(n) | 末尾均摊 O(1) | 默认选择 |
Dictionary<K,V> | 哈希表,无序 | O(1) | O(1) | 按 key 取值 |
HashSet<T> | 哈希集合,去重 | O(1) | O(1) | 判存在、去重、集合运算 |
Queue<T> / Stack<T> | 先进先出 / 后进先出 | — | O(1) | 任务队列、回溯 |
SortedDictionary / SortedSet | 按 key 有序(红黑树) | O(log n) | O(log n) | 需要有序遍历或范围查询 |
T[](数组) | 定长 | O(1) | — | 长度固定、性能敏感 |
ImmutableArray<T> / FrozenSet<T> | 不可变 / 只读优化 | O(1) | 每次复制 / 不可变 | 共享配置、只读查表 |
List.Contains 和 HashSet.Contains 的鸿沟
20 万元素的集合,做 2 万次查找(Release 构建): List<int>.Contains 218 ms // O(n) × 2 万次 HashSet<int>.Contains 0 ms // O(1) × 2 万次,快到测不出 Dictionary.ContainsKey 0 ms
- 这不是「优化」,是选错了数据结构。差距随数据量线性拉大:一千个元素时你察觉不到,二十万时就是几百毫秒的卡顿。
- 最常见的现场:在循环里对一个 List 反复
Contains——这就是 O(n²)。改法只有一行:var set = list.ToHashSet();,之后查 set。 - 数字会因机器而异,但方向和量级是确定的,你自己跑一遍会看到同样的结论。
几个实用细节
List<T>容量按倍增长:新建后依次是 4 → 8 → 16 → 32 → 64。已知大概有多少元素时,new List<T>(capacity)能省掉全部扩容与复制。- 字典取值用
TryGetValue:d["missing"]抛KeyNotFoundException: The given key 'b' was not present in the dictionary.(报错原文),而TryGetValue返回 false。要「没有就给默认值」用CollectionsMarshal.GetValueRefOrAddDefault或d.TryAdd。 - 接口暴露原则:参数用
IEnumerable<T>(最宽松),返回值用IReadOnlyList<T>或具体类型(信息最多)。
// 把 O(n²) 改成 O(n):一行的事
var blocked = blockedList.ToHashSet(); // 建一次索引
var allowed = users.Where(u => !blocked.Contains(u.Id));
// 分组统计的惯用法:字典 + 默认值
var counts = new Dictionary<string, int>();
foreach (var w in words)
counts[w] = counts.GetValueOrDefault(w) + 1;
// 集合运算:HashSet 自带
var a = new HashSet<int>([1, 2, 3]);
a.UnionWith([3, 4]); // 并集 {1,2,3,4}
a.IntersectWith([2, 3]); // 交集 {2,3}
a.ExceptWith([2]); // 差集 {3}
// 只读查表:FrozenSet / FrozenDictionary(.NET 8+)
// 构建慢一点,之后的查找比 HashSet/Dictionary 还快 —— 适合启动时建好、全程只读
private static readonly FrozenSet<string> Reserved =
new[] { "admin", "root", "system" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);object[] objs = new string[2]; 编译通过(string[] 可以当 object[] 用),但 objs[0] = 42; 在运行时抛 ArrayTypeMismatchException。这是 .NET 1.0 为了在没有泛型时能写通用方法留下的历史包袱。泛型集合 List<T> 不协变,正是为了避免这个问题——又一个「能用泛型集合就别用数组」的理由。Dictionary 和 HashSet 都能接受自定义比较器,这比「把 key 先 ToLower 一遍」优雅得多也快得多:new Dictionary<string,int>(StringComparer.OrdinalIgnoreCase)。同理,需要按业务规则去重时传一个 IEqualityComparer<T>,别去改类型本身的 Equals。LINQ 没有任何魔法:Where、Select 就是 IEnumerable<T> 上的扩展方法(05 章),参数是委托,返回的是一个还没开始执行的迭代器(04 章)。三个已学过的机制拼起来,就是 LINQ。
查询到底什么时候才跑
int calls = 0; IEnumerable<int> Src() { for (int i=1; i<=3; i++) { calls++; yield return i; } } var q = Src().Where(x => x % 2 == 1).Select(x => x * 10); calls == 0 // 定义查询后:一次都没执行! string.Join(",", q) → "10,30"; calls == 3 // 第一次枚举,跑了 3 个元素 string.Join(",", q) → "10,30"; calls == 6 // 第二次枚举:又跑了一遍! var cached = q.ToList(); calls == 9 // ToList 触发第三遍 foreach (var _ in cached) { } calls == 9 // 之后随便遍历,不再重跑
- 这段计数是确定的(0/3/6/9),任何机器上都一样——它把「延迟执行」这件事变成了可以数出来的数字。
- 查询变量保存的是「怎么算」,不是「算出来的结果」。每次遍历都会从数据源重新走一遍整条链。
两种触发执行的操作
| 类别 | 行为 | 代表 |
|---|---|---|
延迟执行(返回 IEnumerable) | 只是搭链子,不执行 | Where Select OrderBy Take Skip GroupBy Distinct Concat |
| 立即执行(返回具体值/集合) | 当场遍历数据源 | ToList ToArray ToDictionary Count Sum First Any Max |
- 判据很简单:返回类型还是
IEnumerable<T>就是延迟的,返回别的就是立即的。 OrderBy是个特殊的延迟操作:它一旦开始执行,必须把整个序列读完才能给出第一个元素(要排序嘛)。所以.OrderBy(...).First()并不便宜——用MinBy/MaxBy代替。
两种写法,同一件事
// 方法语法(method syntax)—— 90% 的人只用这个 var r = people.Where(p => p.Age > 18).OrderBy(p => p.Name).Select(p => p.Name); // 查询语法(query syntax)—— 像 SQL,多表 join / 多重 from 时更清楚 var r2 = from p in people where p.Age > 18 orderby p.Name select p.Name;
- 查询语法在编译期被翻译成方法语法,两者完全等价、可以混用(
(from ... select ...).Count())。 - 查询语法只覆盖一部分操作符(没有
Count/Any/Skip等),所以现实中方法语法是主流。
// 一条典型的 LINQ 链:读起来就是需求本身
var top = orders
.Where(o => o.Status == OrderStatus.Paid) // 筛选
.Where(o => o.CreatedAt >= start) // 多个 Where 会被合并成一次遍历
.GroupBy(o => o.CustomerId) // 分组
.Select(g => new // 投影成匿名类型
{
Customer = g.Key,
Count = g.Count(),
Total = g.Sum(o => o.Amount),
})
.Where(x => x.Total > 10_000m)
.OrderByDescending(x => x.Total)
.Take(10)
.ToList(); // 到这里才真正执行
// 惰性的价值:只要前 3 个,就只算 3 个
var firstThree = HugeSequence()
.Where(ExpensiveCheck)
.Take(3)
.ToList(); // ExpensiveCheck 只会被调用到凑够 3 个为止IEnumerable<T> 的方法,把执行时机交给了调用方。如果方法内部用了 using(打开文件/连接)而返回的是延迟查询,调用方真正枚举时资源可能已经被释放了——典型报错是 ObjectDisposedException 或「连接已关闭」。方法内部涉及被释放资源时,返回前先 ToList()。Where 不会多遍历几次数据源——LINQ 把它们串成了一条迭代器链,一次遍历里逐个通过。所以为了可读性把条件拆成三个 Where 是完全免费的,不必挤进一个大 lambda。同理 Select 之后接 Where 也只走一遍。LINQ 有一百多个操作符,日常真正用得多的就下面这张表。按「我想干什么」反查,比按字母表背有用得多。
反查表
| 我想… | 用 | 备注 |
|---|---|---|
| 筛选 | Where | 带索引的重载:Where((x,i) => ...) |
| 变形 / 取字段 | Select | 投影成匿名类型或 record |
| 展平嵌套集合 | SelectMany | 「每个订单的每一行」 |
| 排序 | OrderBy / OrderByDescending / ThenBy | 稳定排序(同 key 保持原序) |
| 分组 | GroupBy | 返回 IGrouping<K,T>,Key + 组内元素 |
| 去重 | Distinct / DistinctBy | DistinctBy 是 .NET 6+ |
| 取前 / 跳过 | Take / Skip / TakeWhile / SkipWhile | Take(2..5) 支持范围(.NET 6+) |
| 求和 / 均值 / 极值 | Sum Average Min Max MinBy MaxBy | MaxBy 返回元素,Max 返回值 |
| 计数 | Count / LongCount / TryGetNonEnumeratedCount | 最后一个不触发枚举(.NET 6+) |
| 取一个 | First Single Last ElementAt(各带 OrDefault 版) | 见下方陷阱 |
| 判断 | Any All Contains | 判空用 Any() 而不是 Count() > 0 |
| 合并两个序列 | Concat Union Intersect Except Zip | Zip 以短的为准(3 个配 2 个得 2 个) |
| 关联 | Join GroupJoin ToLookup | 内存里做 join,见下一卡 |
| 分批 / 带索引 | Chunk(.NET 6+) Index(.NET 9+) | Chunk(3) 把 7 个分成 3/3/1 |
| 累积 | Aggregate | 能表达一切,但可读性差,慎用 |
| 物化 | ToList ToArray ToDictionary ToHashSet ToFrozenSet | 触发执行 |
取单个元素:四个方法,四种失败方式
空序列.First() → InvalidOperationException: Sequence contains no elements 空序列.FirstOrDefault() → 0 // int 的 default 是 0,不是 null! 空序列.FirstOrDefault(-1) → -1 // .NET 6+ 可以自己给默认值 两个元素.Single() → InvalidOperationException: Sequence contains more than one element
Single表达的是「我断言这里有且只有一个」——多了要报错正是它的价值。查主键、查唯一约束的记录时用它,比First更能暴露数据问题。FirstOrDefault对值类型序列是个陷阱:返回 0 时你分不清「没找到」还是「找到了一个 0」。这种场景用Cast<int?>().FirstOrDefault(),或者干脆改用Any+First。
// SelectMany:展平嵌套
var allLines = orders.SelectMany(o => o.Lines);
var withOwner = orders.SelectMany(o => o.Lines, (o, l) => (o.Id, l.Sku, l.Qty));
// 多级排序
var sorted = people
.OrderBy(p => p.Department)
.ThenByDescending(p => p.Salary)
.ThenBy(p => p.Name, StringComparer.Ordinal); // 可指定比较器(03 章)
// MinBy / MaxBy:要的是"哪个元素"而不是"最大值是多少"
var richest = people.MaxBy(p => p.Salary); // 返回 Person
var maxSalary = people.Max(p => p.Salary); // 返回 decimal
// 判空:Any 比 Count 好——Any 找到一个就返回
if (orders.Any(o => o.IsOverdue)) { } // ✓
if (orders.Count(o => o.IsOverdue) > 0) { } // ✗ 要数完整个序列
// Chunk:批量处理(每 500 条提交一次)
foreach (var batch in items.Chunk(500))
await _db.BulkInsertAsync(batch);OrderBy 是稳定排序,但 Array.Sort/List.Sort 不是。LINQ 的 OrderBy(p => p.Age) 对 张30 李25 王30 赵25 得到 李25 赵25 张30 王30——同龄的保持了原始顺序。而 List<T>.Sort 用的是不稳定的内省排序,同 key 元素的相对顺序未定义。需要「先按 A 排再按 B 排」的两次排序时,这个区别会直接改变结果。Aggregate 能写出所有其它操作符,但基本上不该用——xs.Aggregate((a,b) => a * b) 比 xs.Sum() 难读一个数量级,而且空序列会抛 InvalidOperationException: Sequence contains no elements。要用就给种子值:xs.Aggregate(1L, (a,b) => a * b),空序列时返回种子,不抛异常。分组和连接是把「一堆平铺的数据」变成「有结构的结果」的两把主要工具。它们在内存里的行为和 SQL 里同名的操作很像,但有几处关键差异值得先说清。
GroupBy:返回的是「一组组」
people.GroupBy(p => p.Age) // IEnumerable<IGrouping<int, Person>> // (输入 张30 李25 王30 赵25): 年龄 25: 李,赵 年龄 30: 张,王
IGrouping<K,T>本身就是一个IEnumerable<T>,同时带一个Key。所以能直接g.Count()、g.Sum(...)、foreach (var x in g)。- 组内保持原始顺序,组的顺序按「每组第一个元素出现的先后」。要按 key 排序得自己加
.OrderBy(g => g.Key)。 - 带结果选择器的重载能少一层嵌套:
GroupBy(p => p.Age, (key, items) => new { key, n = items.Count() })。
ToLookup:一次建好索引,之后随便查
var lk = items.ToLookup(x => x % 3); lk[0] // 0,3,6,9 lk[99] // 返回空序列,不抛异常 ← 与 Dictionary 的关键差别
ToLookup立即执行(GroupBy是延迟的),返回一个可以反复按 key 查的只读多值字典。- 不存在的 key 返回空序列而不是抛异常——这一点比
Dictionary<K, List<V>>好用得多,省掉了所有TryGetValue样板。 - 需要在循环里反复按 key 找一批元素时,先
ToLookup建索引,别在循环里Where——这是把 O(n²) 变成 O(n) 的另一个常见改法。
Join:内存里的关联
Join做内连接:只保留两边都匹配上的。它内部就是把内层序列做成 Lookup,所以是 O(n+m) 而不是 O(n×m)。- 左连接要用
GroupJoin+SelectMany+DefaultIfEmpty,写起来相当啰嗦。更实用的写法是自己建 Lookup 再 Select(见代码)。 - 但先问一句:这个 join 是不是本该在数据库里做?把两张表全查进内存再 join,通常意味着数据传输量大了几个数量级。内存 join 适合「一边是小的查找表」的场景。
// 分组聚合:一次算出每个客户的单数与总额
var stats = orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
CustomerId = g.Key,
Count = g.Count(),
Total = g.Sum(o => o.Amount),
Latest = g.Max(o => o.CreatedAt),
})
.OrderByDescending(x => x.Total);
// 多键分组:用元组当 key(元组的相等性是逐元素的,02 章)
var byMonth = orders.GroupBy(o => (o.CreatedAt.Year, o.CreatedAt.Month));
// 左连接的实用写法:Lookup + 空合并
var byCustomer = orders.ToLookup(o => o.CustomerId);
var report = customers.Select(c => new
{
c.Name,
Orders = byCustomer[c.Id].ToList(), // 没有订单就是空列表,不用判空
});
// 内连接:标准 Join
var joined = orders.Join(
customers,
o => o.CustomerId, // 外层的 key
c => c.Id, // 内层的 key
(o, c) => new { o.Id, c.Name, o.Amount });GroupBy 在内存里必须把整个序列读完并全部缓存——它做不到流式(要等最后一个元素才能确定分组完整)。对上千万行的数据源做 GroupBy,内存会直接顶上去。大数据量的分组该交给数据库(或者先按 key 排序再顺序聚合)。同样的道理适用于 OrderBy、Distinct、Reverse、ToLookup——它们都是「缓冲型」操作符。GroupBy(...).Select(g => g.First())——用 DistinctBy(.NET 6+):orders.DistinctBy(o => o.CustomerId),语义更清楚,也不用把每组的全部元素都攒起来。延迟执行是 LINQ 最强大也最容易伤到自己的特性。三类问题,都源于同一件事:你以为拿到的是结果,其实拿到的是一份「怎么算」的说明书。
陷阱一:重复枚举
var q = db.Orders.Where(o => o.IsPaid); // 只是说明书 if (q.Any()) // 第 1 次执行 Console.WriteLine(q.Count()); // 第 2 次 foreach (var o in q) { } // 第 3 次
- 内存集合上是「慢三倍」,数据库上就是发了三次查询,HTTP 数据源上就是请求了三次。
- 那个计数器(上上卡)把这件事量化得很清楚:三次枚举 =
calls从 3 涨到 9。 - 解法:需要用超过一次,先
ToList()。代价是一次性把结果装进内存——通常远小于重复查询的代价。
陷阱二:闭包捕获的变量变了
var threshold = 10; var q = items.Where(x => x > threshold); threshold = 100; // 改了变量 q.ToList(); // 用的是 100,不是 10!
- lambda 捕获的是变量不是值(05 章验证过 for/foreach 的差异)。查询在枚举时才求值,那时读到的是变量的当前值。
- 在循环里构建查询然后收集起来稍后执行,是这个坑的高发地。解法:循环体内复制一份局部变量,或者当场
ToList()。
陷阱三:带副作用的查询
Select(x => { Log(x); return x.Name; })—— 枚举几次就写几次日志,不枚举就一次都不写。- LINQ 的心智模型是「纯函数式的查询」:不要在
Where/Select里做任何有副作用的事(写日志、发请求、改状态、抛业务异常)。要做副作用,先物化再foreach。 List<T>.ForEach与 LINQ 无关(它是List的方法,立即执行),可以用;但普通foreach循环可读性更好、还能break。
什么时候该 ToList():一张判断表
| 情况 | 做法 |
|---|---|
| 要枚举超过一次 | 必须 ToList |
方法要返回结果给外部,且内部用了 using | 必须 ToList |
结果要跨越 await 或线程边界 | 必须 ToList |
| 只枚举一次,且马上就用 | 不要 ToList(白白多一次分配) |
| 数据量可能很大,只要前几个 | 不要 ToList(会全部物化) |
// ✗ 常见错误:以为 q 是结果
var q = source.Where(Expensive);
Console.WriteLine($"共 {q.Count()} 条"); // 跑一遍
foreach (var x in q) Handle(x); // 又跑一遍
// ✓ 物化一次
var list = source.Where(Expensive).ToList();
Console.WriteLine($"共 {list.Count} 条"); // 用 Count 属性,不是 Count()
foreach (var x in list) Handle(x);
// ✗ 方法返回延迟查询 + 内部有 using:调用方枚举时流已关闭
public IEnumerable<Row> Load(string path)
{
using var r = new StreamReader(path);
return Parse(r).Where(x => x.IsValid); // 返回时 r 已被释放!
}
// ✓ 要么物化,要么把 using 放进迭代器里(04 章的 yield 写法)
public IReadOnlyList<Row> Load(string path)
{
using var r = new StreamReader(path);
return Parse(r).Where(x => x.IsValid).ToList();
}list.Count(属性)和 list.Count()(LINQ 方法)差一对括号,行为差很远。对 List<T> 两者都是 O(1)(LINQ 的 Count() 会检测到 ICollection 并走属性),但对一个延迟查询,Count() 会把整条链完整跑一遍。看到 .Count() 就该想一想「这个序列到底是什么」。IEnumerable<T> 意味着「可能是延迟的,你自己决定什么时候算」,返回 IReadOnlyList<T> 意味着「已经算好了,随便枚举几次」。公开 API 上把这件事表达清楚,能替调用方省掉一整类问题。同样一行 .Where(o => o.Amount > 100),作用在 List 上是「在内存里逐个判断」,作用在 EF Core 的 DbSet 上是「翻译成 SQL 的 WHERE 子句」。区别全在静态类型是 IEnumerable 还是 IQueryable。
两套 LINQ,一套语法
IEnumerable<T> | IQueryable<T> | |
|---|---|---|
| lambda 编译成 | 委托(可执行的代码) | 表达式树(可分析的数据结构) |
| 在哪执行 | 本进程内存里 | 由 provider 翻译后交给数据库/远端 |
| 能用什么 | 任意 C# 代码 | 只能用 provider 认识的表达式 |
| 典型来源 | List 数组 yield | EF Core 的 DbSet、OData |
- 表达式树是关键:
Expression<Func<Order,bool>>不是一段可执行代码,而是这段代码的语法树。EF Core 遍历这棵树,生成对应的 SQL。 - 所以
IQueryable上能写什么,取决于 provider 翻译得动什么。o => MyHelper(o)这种调用自定义方法的 lambda,EF Core 翻译不了。
最危险的一步:无意中从 Queryable 掉回 Enumerable
// ✗ AsEnumerable / ToList 之后,后面的条件全部在内存里做 var r = db.Orders.ToList() // ← 把整张表拉进内存! .Where(o => o.Amount > 100) .Take(10); // ✓ 让数据库做筛选和分页 var r2 = await db.Orders .Where(o => o.Amount > 100) .OrderByDescending(o => o.CreatedAt) .Take(10) .ToListAsync();
- 触发点:
ToList()、ToArray()、AsEnumerable()、foreach、以及任何把结果赋给IEnumerable<T>变量的地方。 - 这类 bug 在开发环境(几十条数据)完全看不出来,到生产环境(几百万条)直接把内存打满。写 EF Core 查询时,眼睛要盯着「哪一步之后就不再是 IQueryable 了」。
- EF Core 会在无法翻译时抛异常(早期版本是静默切回内存,这个行为在 EF Core 3.0 被改成抛异常,正是因为坑太深)。
什么时候该主动切回内存
- 需要用 provider 翻译不了的逻辑(复杂的字符串处理、调用本地方法)——先把数据量筛小,再
AsEnumerable()。 - 已经
Select成小对象、行数可控之后。 - 顺序很重要:
db.Orders.Where(数据库能翻译的条件).AsEnumerable().Where(本地逻辑)。
using System.Linq.Expressions;
// 委托 vs 表达式树:只差一层 Expression<>
Func<Order, bool> f = o => o.Amount > 100; // 可执行
Expression<Func<Order, bool>> e = o => o.Amount > 100; // 可分析
Console.WriteLine(e.Body); // (o.Amount > 100) —— 能把自己打印出来
var compiled = e.Compile(); // 也能编译成委托再执行
// 正确的分页查询:所有条件都在数据库侧
public async Task<IReadOnlyList<OrderDto>> SearchAsync(
string? keyword, int page, int size, CancellationToken ct)
{
IQueryable<Order> q = _db.Orders.AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword)) // 条件式组装:仍是 IQueryable
q = q.Where(o => o.CustomerName.Contains(keyword));
return await q
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * size).Take(size) // 分页交给 SQL
.Select(o => new OrderDto(o.Id, o.CustomerName, o.Amount)) // 只查需要的列
.ToListAsync(ct);
}Select 的位置决定了传输多少数据。db.Orders.ToList().Select(o => o.Name) 会把每一行的所有列拉过来再挑一个字段;把 Select 放进 IQueryable 链里,EF Core 会生成 SELECT Name FROM ...。表宽的时候这是几十倍的差距,而两种写法的返回值一模一样——看不出问题,只是慢。query.ToQueryString()(.NET 5+ / EF Core 5+)——它返回翻译后的 SQL 字符串,不执行。开发期把它打进日志,比事后翻数据库慢查询日志高效得多。LINQ 的可读性收益是巨大的,但它不是零成本:每个操作符是一个迭代器对象,每个 lambda 可能是一次闭包分配,每次 MoveNext 是一次虚调用。在热路径上,这些会累加成可测量的差距。
1000 万个 int 的筛选求和(Release 构建)
手写 foreach + if: 4 ms LINQ Where+Select+Sum: 239 ms
- 差距的来源不是「LINQ 慢」,而是抽象层数:三个迭代器串联、每个元素三次委托调用、外加装箱/接口派发。
- 但请把这个数字放回语境:1000 万个元素才差 235 毫秒。业务代码里对一个几百条的列表做 LINQ,开销在微秒级——为此放弃可读性是彻头彻尾的负优化。
- 具体倍数因机器与运行时版本而异(.NET 9 起 LINQ 内部做了大量特化优化),但方向是确定的:数据量越大、循环越热,差距越明显。自己跑一遍看看你的环境。
该考虑改写的三个信号
- ① 在每秒执行几万次以上的路径里(请求处理的内层循环、游戏帧循环、解析器)。
- ② 元素数量在百万级且要做多步转换。
- ③ profiler 明确指出这里是热点——这一条最重要。没量过就改写,八成改错了地方。
不改写也能省的几件事
- 别在循环里建查询:把
ToHashSet()、ToLookup()提到循环外面。这类改动的收益(O(n²) → O(n))比「不用 LINQ」大得多。 - 用
staticlambda(05 章)避免闭包分配:.Where(static x => x > 0)。 - 对数组和
List优先用Span/CollectionsMarshal.AsSpan做只读遍历——foreach 一个 Span 是纯索引访问,没有任何接口派发。 - 避免中间物化:链条中间的
ToList()是最常见的无谓分配。
// 热路径上的等价改写(只在 profiler 指认后再做)
// LINQ 版:可读性最好
long sum = arr.Where(x => x % 2 == 0).Select(x => (long)x).Sum();
// 手写版:最快
long sum2 = 0;
foreach (var x in arr)
if (x % 2 == 0) sum2 += x;
// 折中:用 Span 遍历,保留结构
long sum3 = 0;
foreach (var x in CollectionsMarshal.AsSpan(list)) // List<T> 的底层数组
if (x % 2 == 0) sum3 += x;
// 量一量再说:BenchmarkDotNet 是 .NET 生态的标准答案
// [MemoryDiagnoser]
// public class Bench {
// [Benchmark(Baseline = true)] public long Linq() => ...;
// [Benchmark] public long Loop() => ...;
// }Stopwatch 做微基准测试。JIT 预热、分层编译、GC 时机、CPU 频率调节都会让手写计时严重失真——本页这些数字只用来说明量级方向,不是精确测量。要出可信数据就用 BenchmarkDotNet:它会自动预热、多轮采样、报告分配字节数和统计误差,是 .NET 生态里没有争议的标准工具。泛型
泛型让同一段代码安全地作用于多种类型。C# 的泛型和 Java 的擦除式泛型有本质区别——它在运行时是真实存在的,值类型还会被特化成专门的机器码。这一章讲清约束、协变逆变,以及这个「真实泛型」带来的性能优势。
泛型解决的问题很朴素:逻辑一样,只是类型不同。没有泛型就只能为每种类型复制一遍代码,或者退回 object(丢类型安全 + 装箱)。
三种形态
class Cache<T> { } // 泛型类 interface IRepository<T> { } // 泛型接口 static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b; // 泛型方法 Max(3, 5); // 类型推断:不用写 Max<int>(3, 5) Max("a", "b"); // 同一个方法,另一个类型
- 泛型方法的类型参数能从实参推断出来,泛型类的不能(必须写
new Cache<string>())。这就是为什么很多库会提供一个静态工厂方法。 - 类型参数命名约定:单个用
T,多个用有意义的名字并加T前缀(TKey、TValue、TResult)。 - 同名不同参数个数的泛型类型是不同类型:
Foo、Foo<T>、Foo<T,U>可以共存(Task和Task<T>就是)。
泛型换来的三样东西
- 类型安全:
List<int>里放不进 string,编译期就拦住了。 - 无装箱:
List<int>内部是真的int[],不是object[](02 章:装箱每个 24 字节)。 - 代码复用:一份实现服务所有类型。
默认值:default 的两副面孔
static T? Def<T>() => default; Def<int>() // 0 ← 值类型的零值 Def<string>() // null ← 引用类型的空引用
- 无约束的
T里,default(T)的含义随 T 而变。这是泛型代码里最容易出错的一处:写「找不到就返回 default」时,调用方拿到 0 分不清是「没找到」还是「找到了 0」。 - 解法:加约束(
where T : class),或者返回bool + out(TryXxx 模式),或者返回Option风格的自定义类型。
// 泛型类:一份实现,所有类型可用
public sealed class LruCache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, LinkedListNode<(TKey, TValue)>> _map = [];
private readonly LinkedList<(TKey, TValue)> _order = new();
private readonly int _capacity;
public LruCache(int capacity) => _capacity = capacity;
public bool TryGet(TKey key, out TValue? value) // TryXxx 而不是返回 default
{
if (!_map.TryGetValue(key, out var node)) { value = default; return false; }
_order.Remove(node); _order.AddFirst(node);
value = node.Value.Item2;
return true;
}
}
// 泛型方法 + 推断:调用处很干净
public static IReadOnlyList<TResult> MapNotNull<TSource, TResult>(
this IEnumerable<TSource> src, Func<TSource, TResult?> selector)
where TResult : class
=> src.Select(selector).OfType<TResult>().ToList();
var names = users.MapNotNull(u => u.Nickname); // 两个类型参数都推断出来了switch 的类型模式做「按 T 分派」——或者说,能写但几乎总是设计错误:if (typeof(T) == typeof(int)) ... 这种写法说明泛型用错了地方,应该改成重载、约束或者策略对象。泛型的价值在于「对所有 T 一视同仁」,一旦开始按具体类型分支,抽象就漏了。typeof(List<>)(不带类型参数)表示开放泛型类型,用于反射与 DI 注册:services.AddSingleton(typeof(IRepository<>), typeof(EfRepository<>)) 一行注册所有 IRepository<T>。typeof(List<>).MakeGenericType(typeof(int)) == typeof(List<int>) 为 True——运行时构造出来的和写死的是同一个类型。没有约束时,编译器对 T 一无所知——只能当 object 用(调 ToString、Equals)。约束是你和编译器之间的契约:我保证 T 满足这些条件,你允许我用对应的能力。
六类约束
| 写法 | 含义 | 解锁的能力 |
|---|---|---|
where T : class | 必须是引用类型 | 可以和 null 比较、T? 是可空引用 |
where T : struct | 必须是值类型(不含可空) | T? 变成 Nullable<T> |
where T : notnull | 不能是可空类型 | 字典 key 的标准约束 |
where T : SomeBase | 是某类的子类 | 可以调该类的成员 |
where T : IFoo | 实现某接口 | 可以调接口成员 |
where T : new() | 有公开无参构造 | 可以 new T() |
- 可以叠加:
where T : class, IEntity, new()。顺序有要求:class/struct在最前,new()在最后。 - 多个类型参数各写一条:
where TKey : notnull where TValue : class。
静态抽象成员:泛型数学的钥匙(.NET 7+)
static T Sum<T>(IEnumerable<T> xs) where T : INumber<T> { T acc = T.Zero; // 通过类型参数调静态成员! foreach (var x in xs) acc += x; return acc; } Sum([1, 2, 3]) // 6 Sum([1.5, 2.5]) // 4
- 在此之前,「写一个能对所有数值类型求和的方法」在 C# 里是做不到的——只能为每种类型写一遍重载。
INumber<T>之外还有IParsable<T>(统一T.Parse)、ISpanFormattable、IAdditionOperators等一整族接口。
约束不参与重载解析
- 两个方法只有约束不同(
where T : classvswhere T : struct)不构成合法的重载——签名相同就是重复定义。 - 要按值/引用类型分派,得改方法名,或者让参数类型不同(比如一个吃
T、一个吃T?)。上一章FirstOrNull的两个版本就是靠T?展开后签名不同才能共存。
// 典型的仓储约束
public interface IEntity { Guid Id { get; } }
public class Repository<T> where T : class, IEntity, new()
{
public T Create() => new T(); // new() 约束
public T? Find(Guid id) => _items
.FirstOrDefault(x => x.Id == id); // IEntity 约束
}
// 泛型数学:一份实现服务 int / long / double / decimal / BigInteger
using System.Numerics;
public static T Average<T>(ReadOnlySpan<T> xs) where T : INumber<T>
{
if (xs.IsEmpty) throw new ArgumentException("空序列", nameof(xs));
T sum = T.Zero;
foreach (var x in xs) sum += x;
return sum / T.CreateChecked(xs.Length);
}
// 统一解析:IParsable<T>(.NET 7+)
public static T GetConfig<T>(string key, T fallback) where T : IParsable<T>
=> T.TryParse(Environment.GetEnvironmentVariable(key),
CultureInfo.InvariantCulture, out var v) ? v : fallback;
int port = GetConfig("PORT", 8080);
TimeSpan ttl = GetConfig("TTL", TimeSpan.FromMinutes(5));new() 约束比看起来贵。它在运行时通过 Activator.CreateInstance(或编译器优化后的等价路径)创建实例,比直接 new Foo() 慢,而且在 NativeAOT 下可能因为裁剪而失败(16 章)。需要工厂能力时,传一个 Func<T> 委托往往更好:显式、更快、也不受裁剪影响。where T : notnull 是被低估的约束:它禁止 T 被推断成 string? 或 int?,把「这里不接受可空类型」写进签名。所有以 T 作为字典 key、集合元素、缓存 key 的泛型 API 都该加上它——Dictionary<TKey,TValue> 自己就是这么声明的。「Dog 是 Animal,那 List<Dog> 是不是 List<Animal>?」答案是不是。而 IEnumerable<Dog> 可以当 IEnumerable<Animal> 用。区别就在于 out/in 两个修饰符。
为什么 List 不行
List<Animal> animals = new List<Dog>(); // 假设允许…… animals.Add(new Cat()); // 就能往 List<Dog> 里塞猫!
- 可写的容器天然不能协变——允许了就破坏类型安全。数组恰恰允许了(历史包袱),于是只能在运行时抛
ArrayTypeMismatchException(08 章)。 - 泛型的设计吸取了这个教训:默认不变(invariant),要协变/逆变必须显式声明。
out = 协变(只出不进)
interface IEnumerable<out T> { } // T 只作为返回值出现 IEnumerable<Animal> a = new List<Dog>(); // ✓ 合法
- 类型参数只出现在返回值位置(输出),就可以协变——「能给你狗的地方,一定能给你动物」。
- 常见协变接口:
IEnumerable<out T>、IReadOnlyList<out T>、IQueryable<out T>、Func<out TResult>、Task<out T>(严格说 Task 不是接口,但行为类似)。
in = 逆变(只进不出)
interface IComparer<in T> { int Compare(T a, T b); } IComparer<Dog> c = someAnimalComparer; // ✓ 能比较动物的,当然能比较狗
- 类型参数只出现在参数位置(输入),就可以逆变——「能处理动物的,一定能处理狗」。
- 常见逆变接口:
IComparer<in T>、IEqualityComparer<in T>、Action<in T>。 - 记忆法:
out是「往外给」,in是「往里收」;给的可以给更具体的,收的可以收更宽泛的。
三条限制
- 只对接口和委托有效,类不行。所以
List<T>永远不变,但它实现的IEnumerable<T>是协变的。 - 只对引用类型有效:
IEnumerable<int>不能当IEnumerable<object>用(装箱会改变表示形式)。 - 同一个类型参数不能同时协变和逆变——所以
IList<T>(既能读又能写)只能是不变的。
// 协变让 API 更好用:参数收 IEnumerable<基类>,调用方传 List<派生类>
void Feed(IEnumerable<Animal> animals) { }
Feed(new List<Dog>()); // ✓ 因为 IEnumerable<out T>
// 逆变让比较器可复用
class ByName : IComparer<Animal>
{
public int Compare(Animal? a, Animal? b)
=> string.CompareOrdinal(a?.Name, b?.Name);
}
List<Dog> dogs = [];
dogs.Sort(new ByName()); // ✓ 因为 IComparer<in T>
// 自己定义协变接口:T 只能出现在返回位置
public interface IReadOnlyRepo<out T>
{
T? Find(Guid id); // ✓ 返回位置
IEnumerable<T> All(); // ✓ 也算返回位置
// void Save(T item); ✗ 编译错误:T 是 out,不能作参数
}IEnumerable<int> 赋给 IEnumerable<object> 会报 CS1503「无法转换」,让人一头雾水——明明 int 也是 object。原因是 int 到 object 需要装箱(改变内存表示),而协变要求的是「引用可以直接复用」。要转就显式 .Cast<object>(),代价是逐个装箱。out;只进,加 in。这不只是为了兼容性,也是一次有价值的设计审视:一个既读又写的接口,往往该拆成读接口和写接口两个(CQRS 的思路在类型层面的体现)。.NET 的泛型是运行时特性,不是编译期的语法糖。List<int> 在运行时是一个真实存在、独一无二的类型,JIT 会为它生成专门的机器码。这一点决定了它的性能特征。
两种策略,按 T 是值还是引用分
- 值类型 → 各自特化:
List<int>、List<double>、List<MyStruct>各自生成一份机器码,字段就是真正的int[]、double[]。零装箱、内存紧凑、可内联——这是 .NET 泛型最大的性能优势。 - 引用类型 → 共享一份代码:
List<string>和List<object>共用同一份机器码(所有引用都是同样大小的指针),只是各自的类型信息不同。不会代码膨胀。 - 代价是特化会增加 JIT 时间与代码体积——泛型类型参数组合极多时(深度泛型的库)启动会变慢,NativeAOT 下还会遇到「无法预先生成」的问题(16 章)。
和 Java 擦除式泛型的对比
| .NET | Java | |
|---|---|---|
| 运行时是否存在 | 存在:typeof(List<int>) 有意义 | 擦除:运行时只有 List |
| 值类型 | 特化,无装箱 | 必须装箱(List<Integer>) |
能否 new T() | 能(配 new() 约束) | 不能 |
| 能否反射拿到 T | 能 | 不能(除非用 TypeToken 之类的技巧) |
| 数组 | T[] 可以创建 | 受限 |
- 印证:
typeof(List<int>)打印出System.Collections.Generic.List`1[System.Int32]——类型参数就在名字里。
实践含义
- 放心用
List<int>:它和手写int[]的性能几乎一致,没有隐藏的装箱。 - 泛型代码里的接口约束调用可能被去虚化:
where T : struct, IComparable<T>时,JIT 知道具体类型,能直接内联比较——所以给 struct 实现IEquatable<T>收益很大(02 章)。 - 反射构造泛型类型是可行的:
typeof(Repository<>).MakeGenericType(entityType),DI 容器和 ORM 大量依赖这一点。代价是 NativeAOT 下需要额外的元数据保留。
// 泛型 + struct 约束:JIT 特化后可以完全内联,等价于手写
public static T MaxOf<T>(ReadOnlySpan<T> xs) where T : struct, IComparable<T>
{
T best = xs[0];
for (int i = 1; i < xs.Length; i++)
if (xs[i].CompareTo(best) > 0) best = xs[i]; // 无装箱、可内联
return best;
}
// 开放泛型 + 反射:DI 容器的核心技巧
var closed = typeof(Repository<>).MakeGenericType(entityType);
var repo = Activator.CreateInstance(closed)!;
// 一行注册所有 IRepository<T> 的实现(17 章的 DI)
// services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
// 静态字段是"每个封闭类型一份"——常被用作类型级缓存
public static class TypeCache<T>
{
public static readonly string Name = typeof(T).Name; // 每个 T 各算一次
}
// TypeCache<int>.Name 和 TypeCache<string>.Name 是两个不同的静态字段MakeGenericType)如果编译期推不出来,运行时会抛异常。用了 MakeGenericType、Activator.CreateInstance 的代码在 NativeAOT 下要额外验证——这也是 16 章会强调「AOT 与反射天然对立」的原因之一。static class Cache<T> { public static readonly Func<T> Factory = Build(); }——每种 T 只构造一次,之后的访问是直接读静态字段,比 Dictionary<Type, ...> 查表快得多。BCL 和主流序列化库内部大量使用这个技巧。异常与错误处理
C# 用异常表达「不该发生的事发生了」。用好它的关键不在语法,而在两个判断:什么时候该抛、什么时候该接。这一章把 BCL 的异常族、堆栈保留的差异、以及「异常 vs 返回值」的取舍讲清楚。
异常处理的三个块各司其职:try 圈定范围,catch 按类型接住,finally 无论如何都会执行。C# 额外提供了 when 过滤器,让「接不接」可以带条件。
finally 和 return 谁先
static int F() { try { return 1; } finally { Console.WriteLine("finally"); } } // 输出顺序: finally // ← 先打印 返回值 = 1 // ← 后返回
- 准确的顺序是:先算出返回值 → 执行 finally → 真正返回。所以 finally 里改局部变量影响不了已经算好的返回值。
finally在异常向上传播时也会执行——这正是using能保证释放的原因(using编译成try/finally,06 章)。
异常过滤器 when:比在 catch 里判断更好
try { Call(); } catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound) { return null; // 只接 404 } catch (HttpRequestException e) when (IsTransient(e)) { await RetryAsync(); // 只接可重试的 }
- 关键区别:
when为假时异常不会被捕获,栈也不会展开——异常继续往上找处理者,而原始的调用栈完整保留。 - 如果写成「先 catch 再判断,不符合就
throw;」,栈虽然靠throw;保住了,但栈已经展开过一次,调试器捕获的第一现场没了。能用when就别用「catch 完再重抛」。 - 过滤器不匹配时,异常确实落到了后面的通用
catch——过滤器只影响这一个 catch 子句。
三条不该做的事
- 不要写空的
catch { }:吞掉异常等于让 bug 静默发生,排查时毫无线索。真要忽略也要写日志并注明原因。 - 不要
catch (Exception)之后继续跑:OutOfMemoryException、StackOverflowException(其实根本捕获不到)这类是进程级问题,接住它继续跑只会让状态更糟。 - 不要用异常做流程控制:「用
try { int.Parse(s) } catch判断是不是数字」比TryParse慢几个数量级——异常的开销在于捕获栈帧信息,不是在于try块本身(没抛异常时 try 几乎零成本)。
// 典型的分层处理:具体的在前,通用的在后
try
{
using var stream = File.OpenRead(path);
return JsonSerializer.Deserialize<Config>(stream);
}
catch (FileNotFoundException)
{
return Config.Default; // 可恢复:用默认值
}
catch (JsonException e)
{
throw new ConfigException($"配置文件 {path} 格式错误", e); // 包装后上抛
}
// 其它异常(权限、IO 错误)不接 —— 让上层决定
// 过滤器做重试判断,栈不展开
catch (SqlException e) when (e.Number is 1205 or -2) // 死锁 / 超时
{
await Task.Delay(backoff);
goto retry;
}
// when 还能用来做"只记日志不处理"的窃听器(永远返回 false)
catch (Exception e) when (LogAndContinue(e)) { } // LogAndContinue 恒返回 falsefinally 里不要抛异常。如果 try 块已经在传播一个异常,finally 里再抛一个,原始异常会被完全丢弃——真正的错因就此消失,只剩下 finally 里那个(通常是次要的)异常。Dispose() 实现里尤其要小心,这也是「Dispose 应当永不抛异常」这条准则的由来。catch 里返回 false 的过滤器」是个真实可用的技巧:catch (Exception e) when (Log(e)),让 Log 写完日志后返回 false——异常不被捕获、继续传播,但你在栈未展开的第一现场拿到了日志。ASP.NET Core 内部就用这招记录未处理异常。重抛异常有三种写法,效果差别巨大。写错一个字符,排查现场就没了。
三种写法的堆栈
static void Deep() => throw new InvalidOperationException("原始"); static void Rethrow() { try { Deep(); } catch { throw; } } static void RethrowEx() { try { Deep(); } catch (Exception e) { throw e; } } // 结果: throw; 堆栈 3 帧,首帧 = Deep() // ✓ 原始抛出点还在 throw e; 堆栈 2 帧,首帧 = RethrowEx() // ✗ 抛出点被改写成了这里!
throw;(不带对象)保留原始堆栈,是重抛的唯一正确写法。throw e;会重置堆栈起点——真正出错的Deep()从堆栈里消失了。日志上看只知道「RethrowEx 里出错了」,而那里其实什么都没做。- 编译器会给出警告 CA2200: 再次引发捕获到的异常会更改堆栈信息(触发)。这条警告一定要修,不要抑制。
第三种:包装成新异常
catch (Exception e) { throw new ApplicationException("包一层", e); } // e.Message = "包一层",e.InnerException.Message = "原始"
- 包装的价值是加上下文:「解析配置文件 /etc/app.json 失败」比裸的
JsonException有用得多。 - 但必须把原异常作为
innerException传进去——漏了这一步就等于销毁证据。所有 BCL 异常类型都有(string message, Exception inner)的构造重载。 - 看日志时记得一路看到最里层:
e.GetBaseException()直接拿最内层,或者e.ToString()会把整条链连同各自的堆栈全部打出来。
跨越异步/线程边界重抛:ExceptionDispatchInfo
- 把异常存起来稍后在另一个上下文重抛时,
throw storedException;同样会破坏堆栈。正确做法是ExceptionDispatchInfo.Capture(e).Throw();——它保留原始堆栈并追加当前位置。 await内部就是用这个机制把 Task 里的异常「原样」抛到 await 处的:这就是为什么await抛出的是原始异常,而.Wait()抛出的是AggregateException(11 章)。
// ✓ 重抛:保留堆栈
try { Process(); }
catch (Exception e)
{
_logger.LogError(e, "处理 {Id} 失败", id);
throw; // 注意:没有 e
}
// ✓ 包装:加上下文,但保留内部异常
catch (JsonException e)
{
throw new ConfigException($"配置文件 {path} 第 {e.LineNumber} 行解析失败", e);
}
// ✓ 跨上下文重抛
using System.Runtime.ExceptionServices;
ExceptionDispatchInfo? captured = null;
try { Work(); } catch (Exception e) { captured = ExceptionDispatchInfo.Capture(e); }
// ……稍后,在别处……
captured?.Throw(); // 原始堆栈完整保留
// 自定义异常:三个标准构造 + 有用的属性
public sealed class ConfigException : Exception
{
public string? FilePath { get; init; }
public ConfigException() { }
public ConfigException(string message) : base(message) { }
public ConfigException(string message, Exception inner) : base(message, inner) { }
}catch → log → throw;,日志里就有五条一模一样的错误,还都带着不同深度的堆栈。约定好在哪一层记录(通常是最外层的全局处理器或请求边界),中间层只做「加上下文并包装」或者干脆不接。e.Message:_logger.LogError(e, "失败") 会把类型、消息、完整堆栈、以及整条 InnerException 链全部记下来;_logger.LogError($"失败: {e.Message}") 只留下一句话,排查时几乎没用。这是代码审查里最值得抓的一条。抛异常前先问:调用方能对这个情况做点什么吗?能——那可能该用返回值表达;不能,或者这是「不该发生的事」——抛异常。
常用 BCL 异常与它们的真实消息
| 类型 | 什么时候抛 | 默认消息 |
|---|---|---|
ArgumentNullException | 参数是 null | Value cannot be null. (Parameter 'userName') |
ArgumentOutOfRangeException | 参数超出范围 | count ('-5') must be a non-negative value. |
ArgumentException | 参数不合法(其它情况) | — |
InvalidOperationException | 对象当前状态不允许这个操作 | — |
NotSupportedException | 这个类型永远不支持该操作 | — |
NotImplementedException | 还没写(不该出现在发布代码里) | The method or operation is not implemented. |
FormatException | 字符串格式不对 | The input string 'x' was not in a correct format. |
NullReferenceException | 永远不该由你手动抛——它是 bug 的症状 | Object reference not set to an instance of an object. |
- 参数校验一律用
ThrowIfXxx助手(.NET 6/8+):ArgumentNullException.ThrowIfNull(x)、ArgumentException.ThrowIfNullOrWhiteSpace(s)、ArgumentOutOfRangeException.ThrowIfNegative(n)。参数名由编译器自动填(07 章)。 ArgumentException系列表示「调用方传错了」,InvalidOperationException表示「现在不能这么做」——这个区分决定了看日志的人第一时间去查哪边。
异常 vs 返回值:一张判断表
| 情况 | 用 | 例子 |
|---|---|---|
| 预期内、调用方能处理 | 返回值(TryXxx / 可空 / Result) | 用户名已存在、解析失败、找不到 |
| 违反了调用契约 | 异常(Argument 系列) | 参数为 null、数量为负 |
| 外部环境出问题 | 异常 | 网络断、磁盘满、数据库宕 |
| 不变量被破坏(bug) | 异常(快速失败) | 状态机进入不可能的状态 |
- 「找不到」的处理是最容易纠结的:
GetUser(id)找不到该抛还是返回 null?判据是调用方的预期——如果 id 来自另一条查询(理应存在),找不到就是数据问题,抛;如果 id 来自用户输入,找不到是常态,返回null或TryGet。BCL 提供两套 API 正是这个原因(FirstvsFirstOrDefault、d[k]vsTryGetValue)。
不要在异常里做这三件事
- 不要用异常返回业务数据(把结果塞进自定义异常的属性里再上抛)——这是把异常当
goto用。 - 不要在高频路径上抛异常:抛出+捕获一次的代价在微秒级,比正常返回慢几个数量级。循环里每次都抛的代码会慢到不可用。
- 不要吞掉
OperationCanceledException:它表示「有人主动取消了」,不是错误。通用的catch (Exception)会把取消也接住并当成失败上报——正确写法是先单独catch (OperationCanceledException) { throw; }(11 章)。
// 公开 API 的参数校验:一行一条,放在方法最前面
public Order Create(string customerId, decimal amount, IReadOnlyList<Line> lines)
{
ArgumentException.ThrowIfNullOrWhiteSpace(customerId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
ArgumentNullException.ThrowIfNull(lines);
if (lines.Count == 0)
throw new ArgumentException("订单至少要有一行", nameof(lines));
if (_state != State.Ready) // 状态不对 → InvalidOperation
throw new InvalidOperationException($"当前状态 {_state} 不能下单");
// ...
}
// 预期内的失败用返回值,不用异常
public Result<User> Register(string email)
=> _repo.Exists(email)
? Result<User>.Fail("邮箱已注册") // 业务规则,不是异常
: Result<User>.Ok(_repo.Add(email));
// 一个够用的 Result 类型(社区有 FluentResults、ErrorOr 等成熟实现)
public readonly record struct Result<T>(bool Success, T? Value, string? Error)
{
public static Result<T> Ok(T v) => new(true, v, null);
public static Result<T> Fail(string e) => new(false, default, e);
}ISerializable、[Serializable])在现代 .NET 上已被废弃并默认禁用(BinaryFormatter 因安全问题在 .NET 9 被移除)。跨服务传递错误要用结构化的错误响应(HTTP 的 ProblemDetails,17 章),不是把异常对象扔过去。老代码里那些 protected Exception(SerializationInfo, StreamingContext) 构造函数,今天写新异常类不需要。PaymentDeclinedException);不会,只是想换个消息——直接用 BCL 的类型加详细 message 就够了。一个项目里几十个自定义异常类型而没人分别捕获,是典型的过度设计。再好的局部处理也会有漏网的。每个应用都需要一个「最外层」:把没人接的异常记录下来,让进程以可诊断的方式结束,而不是静默消失。
未处理异常会怎样
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object. at Program.<Main>$(String[] args) in e5.cs:line 2
- 格式固定三段:异常类型 + 消息 + 堆栈,写到
stderr,进程随后终止。 - 进程退出码不是 1,各平台不同(Windows 上是异常代码,Linux 上通常是
134/SIGABRT)。CI 脚本别指望用具体退出码区分「异常崩溃」和「正常失败」——只判断「非 0」就好。 - Debug 构建的堆栈带文件名和行号(靠
.pdb);Release 发布时要把 pdb 一起带上或上传到符号服务器,否则线上堆栈只有方法名。
三个全局钩子
| 事件 | 捕获什么 | 能否阻止崩溃 |
|---|---|---|
AppDomain.CurrentDomain.UnhandledException | 任何线程上未处理的异常 | 不能,只能记录 |
TaskScheduler.UnobservedTaskException | Task 里抛了但从没被 await 的异常 | 能(e.SetObserved()) |
AppDomain.CurrentDomain.ProcessExit | 正常退出时做收尾 | — |
- ASP.NET Core 已经内置了请求级的兜底(异常中间件),一个请求崩了不会拖垮进程——所以 Web 应用里这几个钩子主要用于「后台任务」的异常(17 章)。
UnobservedTaskException值得开:它专门抓「fire and forget 的 Task 里悄悄失败了」,这类问题不开钩子就完全看不见。
日志里该有什么
- 异常对象本身(不是
e.Message)——带完整堆栈和 InnerException 链。 - 关联标识:请求 ID / TraceId / 用户 ID / 订单号。没有它,线上日志海里根本定位不到这一次。
- 输入的关键参数——但要脱敏(06 章那条 record ToString 的坑同样适用)。
- 现代做法是接 OpenTelemetry:异常自动记到当前 Activity(Span)上,和分布式追踪串起来。.NET 内置
System.Diagnostics.Activity,不需要额外的 APM SDK。
// 控制台/后台服务的全局兜底
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = e.ExceptionObject as Exception;
Log.Fatal(ex, "未处理异常,进程即将终止 (IsTerminating={T})", e.IsTerminating);
Log.CloseAndFlush(); // 关键:确保日志真的写出去了
};
// 抓"没人 await 的 Task 里的异常"
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Log.Error(e.Exception, "有 Task 抛了异常却没人观察");
e.SetObserved(); // 标记为已处理,避免影响进程
};
// 顶层 try:给 CLI 工具一个体面的退出
try
{
return await RunAsync(args);
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("已取消");
return 130; // 约定:128 + SIGINT(2)
}
catch (Exception e)
{
Console.Error.WriteLine(e.ToString()); // ToString 含整条 Inner 链
return 1;
}StackOverflowException 捕获不到、也无法兜底。从 .NET 2.0 起,栈溢出会立即终止进程,不触发任何 catch、不触发 UnhandledException 钩子、finally 也不执行。所以无限递归的 bug 在日志里往往什么都留不下——进程就那么没了。遇到「服务毫无征兆消失且日志无异常」,递归和栈溢出值得优先排查。e.ToString() 和 e.Message 差别很大:前者输出「类型 + 消息 + 堆栈 + 所有内部异常各自的这三样」,后者只有一句话。控制台输出、日志、错误上报全部用 ToString()(或把异常对象交给日志框架);Message 只适合给最终用户看的提示语——而给用户看的提示通常本来就该另写一句人话。异步:async / await 与 Task
async/await 是 C# 影响力最大的语言特性——今天的 JS、Python、Rust、Kotlin 都借鉴了它。但它也是被误解最多的:它不是多线程,也不会让代码变快。这一章从「它到底做了什么」讲起,把死锁、取消、并发限流这几件真正会出事的东西说透。
最重要的一句话:async/await 解决的是「等待时不占着线程」,不是「让事情并行发生」。一个 await Task.Delay(1000) 期间,没有任何线程在为你等待——线程被还回线程池去干别的了。
await 前后发生了什么
Console.WriteLine($"await 前 线程 {Environment.CurrentManagedThreadId}"); // 6 await Task.Delay(10); Console.WriteLine($"await 后 线程 {Environment.CurrentManagedThreadId}"); // 6 SynchronizationContext.Current // null(控制台程序没有同步上下文) Thread.CurrentThread.IsThreadPoolThread // True
- await 不创建线程。它把方法从这里「切开」:前半段执行完就返回给调用方,后半段注册成回调,等 Task 完成时由某个线程池线程继续跑。
- await 之后可能是同一个线程,也可能不是——控制台/服务端没有同步上下文,续体(continuation)就由线程池随便挑一个线程执行。这次恰好还是 6 号线程,但这不是保证。
- UI 程序(WPF/WinForms/MAUI)有同步上下文,await 后会回到 UI 线程——这既是它好用的原因,也是死锁的根源(第 5 卡)。
IO 密集 vs CPU 密集:两种完全不同的场景
| IO 密集 | CPU 密集 | |
|---|---|---|
| 例子 | HTTP 请求、数据库、读文件 | 图像处理、加密、大量计算 |
| 瓶颈 | 等外部系统 | CPU 算不过来 |
| 正确做法 | async/await(等待时不占线程) | Task.Run / 并行(真的用多核) |
| 收益 | 同样的线程数扛更多并发 | 缩短单次耗时 |
- 服务端的收益全在第一列:一个 ASP.NET Core 服务用几十个线程扛住上万并发连接,就是因为等数据库时线程都还回去了。
- 不要用
Task.Run包装 IO 操作:await Task.Run(() => File.ReadAllText(p))是把一个线程换成另一个线程去阻塞,纯浪费。有ReadAllTextAsync就用它。
async 方法的三种返回类型
Task:无返回值的异步方法。Task<T>:有返回值。void:只允许用于事件处理器(05 章)。它的异常无法被调用方捕获,会直接以未处理异常的形式掀翻进程。- 另有
ValueTask/ValueTask<T>(下一卡)和IAsyncEnumerable<T>(第 6 卡)。
// ✓ IO 密集:全程 async,一个线程都不占
public async Task<Report> BuildAsync(string id, CancellationToken ct)
{
var user = await _db.FindUserAsync(id, ct);
var stats = await _http.GetFromJsonAsync<Stats>($"/stats/{id}", ct);
return new Report(user, stats);
}
// ✓ CPU 密集:用 Task.Run 挪到线程池,别堵住调用方(尤其 UI 线程)
var thumbnail = await Task.Run(() => ResizeImage(bytes, 200, 200));
// ✗ 反模式:用 Task.Run 包装本来就有异步版本的 IO
var text = await Task.Run(() => File.ReadAllText(path)); // 浪费一个线程
var text2 = await File.ReadAllTextAsync(path); // ✓
// 异步方法的命名约定:Async 后缀(不是硬性规定,但生态一致遵守)
public Task SaveAsync(CancellationToken ct = default);await 就别加 async。如果方法体只是「把另一个 Task 原样返回」,直接返回那个 Task(public Task<X> GetAsync() => _inner.GetAsync();)——省掉一层状态机与一次分配。但有 using 或 try/catch 时必须写 async/await,否则资源会在 Task 完成前被释放、异常也不会被 catch 到。Task 表示「一件将来会完成的事」。它是引用类型、要堆分配——在绝大多数已完成的路径上,这次分配是纯浪费。ValueTask 就是为了这个场景。
常用构造
Task.CompletedTask // 无返回值的已完成 Task —— 是单例(ReferenceEquals 为 True) Task.FromResult(42) // 已完成且带结果;IsCompletedSuccessfully = True Task.FromException(e) / FromCanceled(ct) Task.Delay(ms, ct) // 定时器,不占线程 Task.Run(() => ...) // 丢到线程池执行 new ValueTask<int>(7) // 同步就有结果,零分配
Task.CompletedTask是单例,所以「同步实现一个异步接口」时用它是零分配的:public Task SaveAsync() { _list.Add(x); return Task.CompletedTask; }。Task.FromResult对小整数与bool有缓存,但一般情况下仍会分配一个 Task 对象。
ValueTask:什么时候值得
- 适用条件:这个方法大多数时候能同步完成(缓存命中、缓冲区里已有数据),且它在热路径上被调用。典型代表是
Stream.ReadAsync、Channel.ReadAsync、各种带缓存的读取。 - 使用限制(很严格):一个
ValueTask只能被 await 一次、不能.Result阻塞取值、不能存起来稍后用、不能传给Task.WhenAll。要做这些事就先.AsTask()。 - 默认还是用
Task:ValueTask 的收益只在高频路径上体现,而误用它的后果(多次 await 会产生未定义行为)相当严重。写库时考虑,写业务代码时别想。
永远不要碰的两个成员
.Result和.Wait():它们把异步变回同步阻塞,是死锁的头号来源(第 5 卡),而且抛出的是AggregateException而不是原始异常。Task.Factory.StartNew:老 API,默认参数有坑(对 async lambda 会返回Task<Task>)。一律用Task.Run。- 唯一可以接受
.Result的地方:确定 Task 已经完成时(if (task.IsCompletedSuccessfully) use(task.Result);)——这是高性能代码里的常见优化。
// 同步实现异步接口:零分配
public Task FlushAsync()
{
_buffer.Clear();
return Task.CompletedTask; // 不要写 async 然后什么都不 await
}
// 缓存命中时同步返回:ValueTask 的教科书场景
public ValueTask<User> GetUserAsync(string id, CancellationToken ct)
{
if (_cache.TryGetValue(id, out var cached))
return new ValueTask<User>(cached); // 同步路径:零分配
return new ValueTask<User>(LoadAsync(id, ct)); // 异步路径:包一个 Task
}
// ✗ ValueTask 的误用(未定义行为)
var vt = GetUserAsync(id);
var a = await vt;
var b = await vt; // ✗ 只能 await 一次!
// ✓ 需要多次使用就转成 Task
Task<User> t = GetUserAsync(id).AsTask();
// 超时:给任何 Task 加超时的标准写法(.NET 6+)
await someTask.WaitAsync(TimeSpan.FromSeconds(5), ct); // 超时抛 TimeoutExceptionasync Task 方法里的异常,要等到 await 时才抛出。调用 var t = FooAsync(); 时即使参数非法也不会当场抛——异常被存进了 Task。所以参数校验应该放在一个同步的外层方法里(和 04 章迭代器的参数校验是同一个模式)。忘了 await 的 Task 更糟:异常永远不会浮现,只有 TaskScheduler.UnobservedTaskException 钩子能看见(10 章)。Task.WaitAsync(TimeSpan)(.NET 6+)是给「没有超时参数的第三方异步 API」加超时的标准做法,比老式的 Task.WhenAny(task, Task.Delay(t)) 干净得多——后者还会留下一个永远不完成的 Delay 任务。注意它不会真的取消底层操作,只是不再等待;能传 CancellationToken 的话优先传。异步的价值在能同时等很多件事。但「全都同时开始」往往会打爆下游——所以真实代码里,组合和限流总是成对出现。
三种组合方式
// 串行:一个接一个(三个 200ms 任务耗时 625 ms) await A(); await B(); await C(); // 并发:一起开始,等全部完成(同样三个任务 212 ms) await Task.WhenAll(A(), B(), C()); // 竞速:谁先完成用谁 var winner = await Task.WhenAny(primary, fallback);
- 没有相互依赖的异步操作要用
WhenAll——这是 async 代码里收益最大、也最常被漏掉的一处优化。3 倍差距。 WhenAll要求任务已经启动:Task.WhenAll(list.Select(x => FooAsync(x)))里FooAsync在Select枚举时就开始跑了。WhenAny之后另一个任务仍在跑,别忘了取消或观察它的异常。
限流:两种有效的写法
// ① Parallel.ForEachAsync(.NET 6+)—— 首选 await Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (item, ct) => await ProcessAsync(item, ct)); // 20 个 100ms 任务、并发 5 → 444 ms(理论下限 400 ms) // ② SemaphoreSlim —— 更灵活,可以跨方法共享 var sem = new SemaphoreSlim(3); await Task.WhenAll(items.Select(async x => { await sem.WaitAsync(ct); try { await ProcessAsync(x, ct); } finally { sem.Release(); } // finally 必须有,否则一次异常就永久少一个名额 })); // 12 个 100ms 任务、并发 3 → 432 ms(理论下限 400 ms)
- 两次都非常接近理论下限,说明限流本身的开销可以忽略——真正的成本是你自己设的并发度。
- 不限流的
WhenAll是生产事故的常见起点:一万条数据一起发 HTTP 请求,下游被打挂、连接池耗尽、本机端口耗尽。
怎么选并发度
- IO 密集:由下游承受能力决定(数据库连接池大小、对方接口的限流),通常几十到几百。
- CPU 密集:
Environment.ProcessorCount附近,再高只是增加上下文切换。 - 不确定就从小开始(5~10),观察下游指标再调。并发度是配置项,不该硬编码。
// 独立操作并发做:从串行到并发只改一行
var userTask = _db.FindUserAsync(id, ct);
var ordersTask = _db.FindOrdersAsync(id, ct);
var statsTask = _http.GetStatsAsync(id, ct);
await Task.WhenAll(userTask, ordersTask, statsTask);
return new Dashboard(userTask.Result, ordersTask.Result, statsTask.Result);
// ↑ WhenAll 之后取 .Result 是安全的:任务已确定完成
// 有返回值的批量并发(注意:这里没有限流!)
var results = await Task.WhenAll(ids.Select(i => FetchAsync(i, ct)));
// 带限流 + 收集结果的完整写法
var bag = new ConcurrentBag<Result>(); // 并发写要用并发集合
await Parallel.ForEachAsync(ids,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (id, token) => bag.Add(await FetchAsync(id, token)));
// 竞速 + 取消落败者
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var t1 = FetchFromPrimaryAsync(cts.Token);
var t2 = FetchFromMirrorAsync(cts.Token);
var done = await Task.WhenAny(t1, t2);
await cts.CancelAsync(); // 让另一个尽快收工
return await done;SemaphoreSlim 的 Release() 必须放在 finally 里。放在 try 块末尾的话,一次异常就永久少掉一个名额;几次之后信号量归零,所有任务永远卡在 WaitAsync——表现为「服务莫名其妙不响应了,CPU 却是 0」。这类死锁没有堆栈可看,极难排查。同理,任何「拿了要还」的资源都该 try/finally 配对。Parallel.ForEachAsync 或多个并发 Task 里往同一个 List<T> 里 Add,会得到丢数据、错乱甚至异常。用 ConcurrentBag/ConcurrentDictionary,或者让每个任务返回结果、最后由 WhenAll 统一收集(后者更简单,也更容易推理)。异步代码里的异常和取消有几处和同步世界不同的语义,不知道就会写出「异常被吞掉」或者「取消不生效」的代码。
WhenAll 的异常只抛出一个
var t1 = Task.Run(() => throw new InvalidOperationException("A")); var t2 = Task.Run(() => throw new ArgumentException("B")); var all = Task.WhenAll(t1, t2); try { await all; } catch (Exception e) { } // 只抓到 InvalidOperationException: A all.Exception!.InnerExceptions.Count // 2 —— 另一个在这里
await只重抛第一个异常(准确说是AggregateException里的第一个)。要拿到全部,得从task.Exception.InnerExceptions取。- 对比:
.Wait()/.Result抛的是AggregateException,要.InnerException才拿到原始异常——这是「await 比 Wait 好」的又一个理由。 - 需要「知道每个任务各自的结果」时,别用 WhenAll 的异常,而是让每个任务自己捕获并返回一个 Result 对象(10 章)。
CancellationToken:一路传下去
using var cts = new CancellationTokenSource(100); // 100ms 后自动取消 try { await Task.Delay(5000, cts.Token); } catch (OperationCanceledException e) { // TaskCanceledException: A task was canceled. }
TaskCanceledException继承自OperationCanceledException——catch 后者,两种都能接住。- token 要一路传到最底层:只在最外层判断一次是没用的,中间任何一个不接受 token 的异步调用都会让取消失效。这就是为什么几乎每个 BCL 异步方法最后一个参数都是
CancellationToken。 - 自己写的循环要主动检查:
ct.ThrowIfCancellationRequested()。把它放进循环,CancelAfter(50)能让取消准确传播到最外层。 - 取消不是错误:不要把
OperationCanceledException当失败上报。ASP.NET Core 里客户端断开连接就会触发它,日志里全是它会淹没真正的错误。
链接令牌:合并多个取消源
CancellationTokenSource.CreateLinkedTokenSource(a, b)生成一个「任一触发即取消」的令牌。典型用法:把「请求被取消」和「我自己的超时」合并。- 链接的 CTS 必须
Dispose(用using),否则会在父令牌上留下回调注册,长生命周期的父令牌下会造成内存泄漏。
// 每个任务自己兜住异常,最后统一看结果
var results = await Task.WhenAll(ids.Select(async id =>
{
try { return Result<Data>.Ok(await FetchAsync(id, ct)); }
catch (OperationCanceledException) { throw; } // 取消要放行
catch (Exception e) { return Result<Data>.Fail(e.Message); }
}));
var failed = results.Where(r => !r.Success).ToList(); // 每一个失败都看得见
// 取消令牌一路传递 + 主动检查
public async Task SyncAllAsync(CancellationToken ct)
{
foreach (var batch in batches)
{
ct.ThrowIfCancellationRequested(); // 循环里主动检查
await ProcessAsync(batch, ct); // 继续往下传
}
}
// 合并"外部取消"与"自己的超时"
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(TimeSpan.FromSeconds(30));
await LongRunningAsync(linked.Token);
// ASP.NET Core:取消令牌直接作为 handler 参数注入(17 章)
// app.MapGet("/x", async (CancellationToken ct) => await SvcAsync(ct));catch (Exception) 会把取消也吃掉。用户按了取消/客户端断开连接 → 抛 OperationCanceledException → 被当成业务失败记进错误日志、触发告警、甚至写了一条「处理失败」的记录。标准写法是先单独接住取消并原样重抛:catch (OperationCanceledException) { throw; } 放在通用 catch 之前。CancellationToken 的第三方异步 API」加取消,用 task.WaitAsync(ct)(.NET 6+)。它让你的等待可以被取消,但底层操作仍在跑——所以它是「不再等待」而不是「真的停止」。真要停止就只能靠库本身支持取消,或者把操作放进独立进程。异步代码最经典的事故是死锁:程序卡住、CPU 为零、没有异常、没有日志。它的成因只有一个——在有同步上下文的线程上阻塞等待一个需要回到该线程才能完成的 Task。
死锁是怎么形成的
// 在 UI 线程(或老 ASP.NET 的请求线程)上: var result = GetDataAsync().Result; // ① UI 线程阻塞在这里 async Task<string> GetDataAsync() { var r = await _http.GetStringAsync(url); // ② 完成后要回到 UI 线程执行续体 return r.Trim(); // ③ 但 UI 线程正卡在 ① 上 → 互等 }
- 关键是同步上下文:UI 框架的
SynchronizationContext要求续体回到 UI 线程。而 UI 线程被.Result阻塞着,永远不会去处理这个续体。 - 控制台程序不会死锁——
SynchronizationContext.Current是null,续体由线程池随便一个线程执行,.Result正常拿到值(108 ms 拿到 42)。这正是「本地控制台测试没问题,放进 UI/老 ASP.NET 就卡死」的原因。 - 现代 ASP.NET Core 没有同步上下文,所以不会因此死锁——但
.Result仍然会白白占用线程,高并发下造成线程池饥饿(下一卡)。
四条铁律
- ① async 一路到底(async all the way):调用异步方法的方法也应该是异步的。中途用
.Result/.Wait()变回同步,就是在制造问题。 - ② 永远不要
.Result/.Wait()/GetAwaiter().GetResult()——除非你在Main的最外层,或者确定 Task 已完成。 - ③ 类库里的每个
await都加.ConfigureAwait(false):告诉运行时「续体不必回到原上下文」,从根上避免死锁,也少一次上下文切换。应用代码(ASP.NET Core / 控制台)不需要加,因为本来就没有上下文。 - ④
async void只用于事件处理器,且内部必须 try/catch 兜住所有异常。
ConfigureAwait 的现状
- 在 ASP.NET Core、控制台、后台服务里,加不加行为上没区别(没有同步上下文)。所以现代应用代码里普遍不加了。
- 但类库不知道自己会被谁调用——可能被 WPF 应用引用。所以公开发布的库仍应全程
ConfigureAwait(false)。可以用<ConfigureAwaitAnalyzer>之类的分析器强制。 - .NET 8 起有
ConfigureAwaitOptions枚举,能表达更细的意图(如SuppressThrowing)。
// ✗ 死锁的典型现场(WPF / WinForms / 老 ASP.NET)
private void Button_Click(object s, EventArgs e)
{
var data = LoadAsync().Result; // 卡死
}
// ✓ 事件处理器是 async void 的唯一正当用途
private async void Button_Click(object s, EventArgs e)
{
try
{
var data = await LoadAsync(); // 不阻塞 UI 线程
_label.Text = data;
}
catch (Exception ex) // 必须自己兜住:async void 的异常没人接得住
{
ShowError(ex);
}
}
// ✓ 类库代码:全程 ConfigureAwait(false)
public async Task<string> FetchAsync(string url, CancellationToken ct)
{
using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
// 唯一可以阻塞的地方:程序入口(而顶级语句里可以直接 await,连这个都不需要)
static int Main(string[] args) => RunAsync(args).GetAwaiter().GetResult();async void 方法抛出的异常无法被任何 try/catch 接住——它不像 async Task 会把异常存进 Task,而是直接在原始同步上下文上重新抛出,通常等于进程崩溃。更隐蔽的是:把一个 async lambda 传给接受 Action 的 API(比如 list.ForEach(async x => await Foo(x)))会静默变成 async void——不但异常没人接,连「什么时候跑完」都不知道。看到 ForEach(async ...) 一律改写。.Result 一切正常。要复现就得在真实的 UI 应用里,或者手动装一个同步上下文。这也解释了为什么这类 bug 常常「本地怎么都测不出来,一到客户端就卡死」。当数据是一段一段异步到来的(分页接口、消息队列、大文件流),Task<List<T>> 表达不了「边拿边处理」。IAsyncEnumerable<T>(C# 8+)就是 04 章的迭代器加上 async。
写法:async + yield return
static async IAsyncEnumerable<int> Gen() { for (int i = 0; i < 5; i++) { await Task.Delay(10); yield return i; } } await foreach (var x in Gen()) Console.Write(x + " "); // 输出:0 1 2 3 4 —— 每个之间隔 10ms,边产生边消费
- 消费端用
await foreach。整条链是惰性的:不消费就不产生,和同步迭代器一样。 - 取消要用
[EnumeratorCancellation]标注参数,配合WithCancellation(ct),否则 token 传不进去。 ConfigureAwait(false)对应的是.ConfigureAwait(false)用在await foreach的表达式上。
典型场景:分页拉取
- 调用方写
await foreach (var item in FetchAllAsync()),完全看不到分页逻辑;而底层每翻一页才发一次请求,内存里永远只有一页。 - 配合 LINQ 需要
System.Linq.Async包(IAsyncEnumerable的Where/Select不在 BCL 里);简单场景直接在await foreach里写逻辑更省事。 - EF Core 的
AsAsyncEnumerable()、ASP.NET Core 的流式响应都是它。
生产者/消费者:Channel<T>
System.Threading.Channels是 .NET 内置的高性能异步队列,比BlockingCollection更适合异步场景(不阻塞线程)。Channel.CreateBounded<T>(capacity)提供背压:队列满时生产者的WriteAsync会异步等待,天然防止内存被打爆。- 消费端就是
await foreach (var item in channel.Reader.ReadAllAsync(ct))——同样是IAsyncEnumerable。
using System.Runtime.CompilerServices;
using System.Threading.Channels;
// 分页拉取:调用方看不到分页
public async IAsyncEnumerable<Item> FetchAllAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
string? cursor = null;
do
{
var page = await _http.GetFromJsonAsync<Page>(
$"/items?cursor={cursor}", ct).ConfigureAwait(false);
foreach (var item in page!.Items)
yield return item;
cursor = page.NextCursor;
} while (cursor is not null);
}
// 消费:内存里只有一页
await foreach (var item in FetchAllAsync().WithCancellation(ct))
await HandleAsync(item, ct);
// Channel:带背压的生产者/消费者
var channel = Channel.CreateBounded<Job>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait, // 满了就让生产者等
});
// 生产者
_ = Task.Run(async () =>
{
foreach (var job in Source()) await channel.Writer.WriteAsync(job, ct);
channel.Writer.Complete();
}, ct);
// 消费者(可以起多个)
await foreach (var job in channel.Reader.ReadAllAsync(ct))
await ProcessAsync(job, ct);[EnumeratorCancellation],取消令牌会静默失效。写成 FetchAllAsync(CancellationToken ct) 而不加标注时,调用方的 WithCancellation(ct) 传进来的令牌到不了方法体内——参数 ct 保持为 default,取消完全不起作用。编译器不会提醒(有分析器规则可以开),只有运行时「取消不了」才会发现。IAsyncEnumerable 在 ASP.NET Core 里可以直接作为返回类型,框架会流式写出 JSON 数组——导出大报表、推实时数据时是一行改动换来的巨大差别,细节见 17 章。异步代码的性能问题几乎都指向同一个根因:某个地方在线程池线程上做了阻塞等待。理解线程池的行为,才能看懂那些「CPU 很闲但响应变慢」的现场。
线程池的默认配置
ThreadPool.GetMinThreads → worker=16, IOCP=1 ThreadPool.GetMaxThreads → worker=32767, IOCP=1000 本机 Environment.ProcessorCount = 16
- 最小线程数默认等于逻辑处理器数。这是「不用等待就能立刻拿到」的线程数量。
- 超过最小值之后,线程池按需缓慢扩容——历史上大约每 500ms 增加一个(具体节奏随版本变化)。这个「缓慢」正是线程池饥饿的痛点:需求突然涨到 100 个线程时,要等相当长的时间才补得上。
线程池饥饿:症状与成因
- 症状:响应时间从毫秒级涨到秒级甚至更久,CPU 使用率却很低,重启后好一阵子又复发。
- 成因:线程池线程被阻塞在同步等待上(
.Result、.Wait()、lock里做 IO、同步的数据库调用)。线程没被释放,新请求只能等线程池扩容。 - 恶性循环:请求堆积 → 需要更多线程 → 扩容跟不上 → 排队更久 → 更多请求堆积。
- 排查:
dotnet-counters monitor --counters System.Runtime看ThreadPool Thread Count与ThreadPool Queue Length;后者持续大于 0 就是明确信号(18 章)。
几条实用规则
- 不要调大
SetMinThreads来「解决」饥饿——那是止痛药。它能缓解突发,但根因(有人在阻塞)没解决。先找到那个.Result。 - 同步和异步不要混用同一个方法的两个版本:既提供
Foo()又提供FooAsync(),且其中一个是另一个的包装,几乎必然出问题(「sync over async」或「async over sync」)。选一种,做对。 lock块里不能await(编译器直接禁止),因为 Monitor 是线程相关的。异步场景要互斥就用SemaphoreSlim(1),或者 .NET 9 起的System.Threading.Lock配合无 await 的临界区。- 短小的 CPU 工作不要
Task.Run:调度开销可能比工作本身还大。判据是工作量至少在几十微秒以上。
// ✗ 线程池饥饿的制造机
public IActionResult Get(int id)
{
var data = _service.GetAsync(id).Result; // 每个请求占死一个线程池线程
return Ok(data);
}
// ✓ 一路 async
public async Task<IActionResult> Get(int id, CancellationToken ct)
=> Ok(await _service.GetAsync(id, ct));
// 异步互斥:不能用 lock,用 SemaphoreSlim(1)
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task<Config> GetConfigAsync(CancellationToken ct)
{
if (_cached is not null) return _cached; // 快路径:不进锁
await _gate.WaitAsync(ct);
try
{
return _cached ??= await LoadAsync(ct); // 双检
}
finally { _gate.Release(); } // 必须 finally
}
// 同步互斥:.NET 9 起有专门的 Lock 类型(比 lock(object) 更快更明确)
private readonly System.Threading.Lock _sync = new();
public void Bump() { lock (_sync) { _counter++; } }Task.Run 里再 .Result 不能解决死锁,只是把问题挪了个地方。网上流传的「解法」Task.Run(() => FooAsync()).Result 确实能绕过 UI 死锁(因为续体不再需要 UI 线程),但它同时占用了调用线程和一个线程池线程,在服务端高并发下反而更容易造成饥饿。唯一正确的解法始终是 async 一路到底。dotnet-counters 是排查这类问题最快的工具,不需要改代码、不需要重启:dotnet-counters monitor -p <pid> --counters System.Runtime 就能实时看到线程池线程数、队列长度、GC 次数、异常速率。发现「队列长度持续大于 0 而 CPU 不高」,基本可以直接去代码里搜 .Result 和 .Wait()。标准库:深水区
这一章讲的是「不知道就会错」的契约:相等性和哈希的约定、JSON 序列化的默认值、资源释放的责任边界、HttpClient 的生命周期。它们不难,但每一条都在生产环境里咬过人,而且出问题时的表现往往离根因很远。
只要一个类型会被放进 Dictionary、HashSet,或者被 Distinct/GroupBy 处理,它就必须遵守相等性契约。违约不会报错,只会给出错误答案。
三条契约
- ① 相等的对象必须有相同的哈希码。反之不要求(哈希冲突是允许的)。
- ② 哈希码在对象「作为 key 期间」不能变。这条最常被违反。
- ③
Equals要满足自反、对称、传递,且对null返回 false、不抛异常。
违反第②条会发生什么
class BadKey { public int Id; /* Equals/GetHashCode 基于 Id */ } var d = new Dictionary<BadKey, int>(); var k = new BadKey { Id = 1 }; d[k] = 1; k.Id = 2; // 改了参与哈希的字段 d.ContainsKey(k) // False —— 用同一个对象都找不到自己了
- 原因:对象被放进桶时用的是旧哈希,改了字段之后算出的新哈希指向另一个桶。这个条目从此既查不到也删不掉,只能靠遍历找出来。
- 所以字典的 key 应当是不可变的:
string、int、Guid、readonly record struct。要用自定义类型当 key,就把参与相等性的字段设成只读。
怎么正确实现
- 首选:用
record或record struct(06 章),编译器生成的实现完全正确、且是强类型无装箱的。 - 必须手写时用
HashCode.Combine(a, b, c)——它处理了混合与分布,比手写a * 31 + b好。同时实现IEquatable<T>提供强类型重载,避免装箱(02 章)。 - 不要把可变字段放进
GetHashCode。如果类型天生可变,就只用不可变的标识字段(比如实体的 Id)参与相等性。
string.GetHashCode() 每次运行都不一样
- 同一个字符串在不同进程里哈希码不同——这是 .NET Core 起默认开启的哈希随机化,用于防御哈希碰撞 DoS 攻击。
- 推论:绝对不能把
GetHashCode()的结果持久化(存数据库、写文件、放缓存 key、做分片路由)。要稳定的哈希请用SHA256、xxHash或System.IO.Hashing里的算法。 HashCode.Combine同样是进程内稳定、跨进程不保证。
// ✓ 首选:让编译器生成
public readonly record struct CacheKey(string Region, string Id);
// ✓ 手写版:IEquatable + HashCode.Combine
public sealed class Customer : IEquatable<Customer>
{
public required Guid Id { get; init; } // 只读的标识字段
public string? Name { get; set; } // 可变,不参与相等性
public bool Equals(Customer? other) => other is not null && Id == other.Id;
public override bool Equals(object? o) => Equals(o as Customer);
public override int GetHashCode() => Id.GetHashCode();
}
// ✓ 不改类型也能自定义相等:传比较器
var unique = items.Distinct(EqualityComparer<Item>.Create(
(a, b) => a?.Sku == b?.Sku,
x => x.Sku.GetHashCode())); // .NET 8+ 的工厂方法
// ✓ 需要跨进程稳定的哈希:用真正的哈希算法
using System.IO.Hashing;
ulong stable = XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(key)); // 分片、缓存 key== 和 Equals 不是一回事,重写一个不影响另一个。给类重写了 Equals 但没重载 operator ==,那么 a == b 仍然是引用比较,而 a.Equals(b) 是值比较——同一对对象两种答案。LINQ 内部用的是 EqualityComparer<T>.Default(走 Equals),你自己的代码可能用 ==,于是「Distinct 去重了但我的 if 判断说不相等」。record 会把两者一起生成,这也是推荐它的理由之一。Equals 却忘了 GetHashCode,编译器会给警告 CS0659;反过来则没有警告。两个永远要一起改。更省事的判断:凡是「按值比较」的类型,一律用 record——手写这两个方法在今天已经没什么正当理由了。System.Text.Json(STJ)是 .NET 内置的 JSON 库,设计上比 Newtonsoft.Json 严格且快。它的几个默认值和 Newtonsoft 不同,迁移时几乎必踩。
五个默认值
record Person(string Name, int Age, string? Nick); JsonSerializer.Serialize(new Person("张三", 30, null)) → {"Name":"\u5F20\u4E09","Age":30,"Nick":null} ① 非 ASCII 默认被转义 ② 属性名保持 PascalCase ③ null 照常输出 JsonSerializer.Deserialize<Person>("""{"name":"李","age":1}""") → Name 为 null ④ 反序列化默认区分大小写 JsonSerializer.Serialize(new WithField()) → {"PublicProp":2} ⑤ 公开字段默认不序列化,只有属性会 DateTime → "2026-01-02T03:04:05Z" (ISO 8601 往返格式)
- ①「中文变成一串 \uXXXX」是最常被问的问题——它是合法 JSON、解析回来完全正确,只是不好看。要原样输出就换
Encoder(见代码)。 - ④ 区分大小写是和 Newtonsoft 最大的差异(后者默认不区分)。ASP.NET Core 的默认配置已经帮你设成 camelCase + 不区分大小写,所以只有自己直接调
JsonSerializer时才会撞上。 - ⑤ 字段不序列化:从 Newtonsoft 迁过来时,用公开字段的 DTO 会静默丢字段。加
IncludeFields = true,或者(更好)把字段改成属性。
推荐的一套默认配置
- 把
JsonSerializerOptions做成静态只读单例并复用——每次 new 一个都会重新构建元数据缓存,是常见的性能杀手。 - .NET 9 起有预置的
JsonSerializerOptions.Web(camelCase + 不区分大小写 + 允许数字用字符串表示),和 ASP.NET Core 的默认一致。 - 需要严格校验时开
RespectNullableAnnotations与RespectRequiredConstructorParameters(07 章)——让缺字段直接报错而不是静默塞 null。
源生成器:AOT 与性能的必选项
- 默认的
JsonSerializer.Serialize<T>走反射,在裁剪/NativeAOT 下会失效。编译器为此专门给了 IL2026 / IL3050 两条警告。 - 解法是源生成器:定义一个
JsonSerializerContext,编译期生成序列化代码。既能 AOT,又更快、启动更省。 - 一个直接后果:.NET 10 的单文件应用(
dotnet run app.cs)默认按 AOT 友好方式构建,反射式序列化被禁用,直接抛InvalidOperationException: Reflection-based serialization has been disabled for this application.——要在单文件应用里用反射式 JSON,得加#:property PublishAot=false。
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Encodings.Web;
// 一份复用的配置(务必 static readonly,别每次 new)
public static class Json
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // 中文原样输出
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
}
// 属性级控制
public sealed record UserDto
{
[JsonPropertyName("user_id")] public required string Id { get; init; }
[JsonIgnore] public string? PasswordHash { get; init; }
[JsonConverter(typeof(JsonStringEnumConverter<Status>))]
public Status Status { get; init; } // 枚举序列化成名字而不是数字
}
// 源生成器:AOT 必需,非 AOT 也更快
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(UserDto))]
[JsonSerializable(typeof(List<UserDto>))]
internal partial class AppJsonContext : JsonSerializerContext;
// 用生成的元数据(无反射、无警告)
var json = JsonSerializer.Serialize(dto, AppJsonContext.Default.UserDto);
var back = JsonSerializer.Deserialize(json, AppJsonContext.Default.UserDto);new JsonSerializerOptions() 会让性能掉一个数量级。STJ 把类型的元数据缓存挂在 options 实例上,新实例意味着每次都重新反射整棵类型树。这个坑很隐蔽——功能完全正常,只是慢,而且在压测里才看得出来。options 一律做成 static readonly 并复用。JsonNode / JsonDocument 而不是 dynamic:JsonNode.Parse(s)!["items"]![0]!["name"]。JsonDocument 是 IDisposable(它持有池化的内存),用完要释放;JsonNode 是可变的对象模型,不需要释放但更占内存。只读解析用前者,需要改再写回用后者。HttpClient 是 .NET 里最容易用错的类,因为它的两种错误用法各自都有一半道理,而正确答案是第三种。
两个都错的用法
- ❌ 每次请求
new HttpClient()并Dispose:连接不复用,TCP 连接进入TIME_WAIT大量堆积,高并发下耗尽本机端口,表现为SocketException: 通常每个套接字地址只允许使用一次。这是最经典的 .NET 生产事故。 - ❌ 全局一个
static HttpClient永不释放:解决了端口问题,但底层连接不会感知 DNS 变化——服务端换了 IP(蓝绿部署、故障转移、K8s 重新调度),客户端还在往旧地址发请求。
✓ 正确做法
- 用
IHttpClientFactory(Microsoft.Extensions.Http包,ASP.NET Core 里默认可用):它把「HttpClient实例」和「底层连接处理器」分开管理——handler 被池化复用(解决端口耗尽),且默认每 2 分钟轮换一次(解决 DNS 过期)。 - 不用 DI 的场景(控制台工具、类库):用一个静态
HttpClient,但显式配置SocketsHttpHandler.PooledConnectionLifetime,效果等价。 - 无论哪种,
HttpClient本身是线程安全的,可以被任意多个线程同时使用。
几个容易忽略的默认值
Timeout默认 100 秒,而且是整个请求(含读完响应体)的总超时,超时抛的是TaskCanceledException——很容易被误当成「用户取消了」。- 非 2xx 不会抛异常:要么手动判
IsSuccessStatusCode,要么调EnsureSuccessStatusCode()。 GetStringAsync会把整个响应读进内存。大文件要用GetStreamAsync或HttpCompletionOption.ResponseHeadersRead。- 默认不跟随跨协议重定向(http→https 会跟,https→http 不会)。
// ✓ ASP.NET Core / 有 DI 的场景:命名或类型化客户端
builder.Services.AddHttpClient<GitHubClient>(c =>
{
c.BaseAddress = new Uri("https://api.github.com/");
c.Timeout = TimeSpan.FromSeconds(10);
c.DefaultRequestHeaders.Add("User-Agent", "my-app");
})
.AddStandardResilienceHandler(); // 重试/熔断/超时(Microsoft.Extensions.Http.Resilience)
public sealed class GitHubClient(HttpClient http) // 注入进来,不要自己 new
{
public async Task<Repo[]> ReposAsync(string user, CancellationToken ct)
{
using var resp = await http.GetAsync($"users/{user}/repos", ct);
resp.EnsureSuccessStatusCode(); // 非 2xx 要自己检查
return await resp.Content.ReadFromJsonAsync<Repo[]>(ct) ?? [];
}
}
// ✓ 没有 DI 的场景(CLI 工具 / 类库):静态 + 连接轮换
private static readonly HttpClient Http = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // 关键:让连接定期重建
MaxConnectionsPerServer = 20,
})
{
Timeout = TimeSpan.FromSeconds(30),
};
// 大响应:不要一次读进内存
using var resp = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
await using var file = File.Create(path);
await stream.CopyToAsync(file, ct);using var client = new HttpClient() 是文档级的反面教材,但它到处都是。因为 HttpClient 实现了 IDisposable,IDE 和分析器(CA2000)都会建议你 using 它——而这恰恰是错的。这是 BCL 里少数「实现了 IDisposable 但不该逐次释放」的类型。记住这个例外,并在代码审查时留意。HttpClient.Timeout 到期抛的是 TaskCanceledException,和「用户主动取消」是同一个类型——区分方法是看 ex.CancellationToken.IsCancellationRequested:为 false 说明是超时而不是取消(.NET 6+ 也可以看 ex.InnerException is TimeoutException)。把超时误报成取消,会让监控上完全看不见下游变慢。时间是最容易写错的领域,而 .NET 的 DateTime 设计得尤其容易误用——它可以表示一个「没有时区信息」的时刻,而这几乎总是 bug 的开始。
三个 Kind
DateTime.Now.Kind → Local // 本机时区的当前时间 DateTime.UtcNow.Kind → Utc // UTC 时间 default(DateTime).Kind → Unspecified // ← 危险:不知道是哪个时区 DateTimeOffset.Now → 2026-07-29T00:00:38.79+08:00 // 带偏移,无歧义
Unspecified是默认值:从数据库读出来的、从字符串解析出来的、new DateTime(2026,1,1)造出来的,全都是 Unspecified。此后任何时区转换都是在猜。- 更糟的是运算不检查 Kind:一个 Local 减一个 Utc 会得到一个数,编译器和运行时都不吭声,结果差了整整一个时区。
选型:三个类型各管一段
| 场景 | 用 | 理由 |
|---|---|---|
| 某个具体时刻(下单时间、日志时间戳) | DateTimeOffset | 自带偏移,跨时区无歧义 |
| 只有日期(生日、结算日) | DateOnly(.NET 6+) | 没有时间部分就不会被时区搞乱 |
| 只有时间(营业时间 09:00) | TimeOnly | 同上 |
| 时间段 | TimeSpan | — |
| 「北京时间明天上午 9 点」这类未来的本地时间 | 本地时间 + 时区 ID 分开存 | 时区规则将来可能变(夏令时调整) |
- 存储与传输一律用 UTC 或带偏移的 ISO 8601(
ToString("O")),只在展示时转成用户时区。 - 数据库列:PostgreSQL 用
timestamptz、SQL Server 用datetimeoffset。用不带时区的列存「本地时间」是最常见的时间事故来源。
可测试的时间:TimeProvider(.NET 8+)
- 代码里直接写
DateTime.Now就没法测试「跨月结算」「过期逻辑」——总不能改系统时钟。 TimeProvider是官方抽象:注入TimeProvider.System,测试里换成FakeTimeProvider(Microsoft.Extensions.TimeProvider.Testing包),一行Advance(TimeSpan.FromDays(31))就能快进一个月。- 它同时抽象了
GetUtcNow()、CreateTimer()和Task.Delay——连「等 5 分钟后重试」的逻辑都能瞬间测完。
// ✓ 记录事件时刻
public sealed record OrderPaid(string OrderId, DateTimeOffset PaidAt);
// ✓ 依赖注入时间,而不是直接读时钟
public sealed class CouponService(TimeProvider time)
{
public bool IsValid(Coupon c) => c.ExpiresAt > time.GetUtcNow();
}
// 注册:builder.Services.AddSingleton(TimeProvider.System);
// 测试:var fake = new FakeTimeProvider(); fake.Advance(TimeSpan.FromDays(31));
// 时区转换:拿到用户时区再转,不要用服务器时区
var tz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
var local = TimeZoneInfo.ConvertTime(order.PaidAt, tz);
// 格式化:往返用 O,展示用区域格式
order.PaidAt.ToString("O"); // 2026-07-29T00:00:38.7945151+08:00
order.PaidAt.ToString("f", CultureInfo.GetCultureInfo("zh-CN"));
// 只有日期的场景
DateOnly birthday = new(1990, 5, 20);
DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow);
int age = today.Year - birthday.Year - (today < birthday.AddYears(today.Year - birthday.Year) ? 1 : 0);
// BCL 没有现成的"算年龄"方法,这一行是标准写法:先按年差,再看今年生日过没过
// 测耗时用 Stopwatch,不要用两次 DateTime.Now 相减(受时钟调整影响)
var ts = Stopwatch.GetTimestamp();
// ...
var elapsed = Stopwatch.GetElapsedTime(ts); // .NET 7+,零分配DateTime.Now 在服务器上几乎总是错的选择。它返回服务器所在时区的时间——而服务器时区是运维配的,容器里默认还是 UTC。同一份代码在开发机(东八区)和生产容器(UTC)里行为不同,且不会报错,只是所有时间差了 8 小时。服务端代码里看到 DateTime.Now 就该警觉,正确的是 DateTimeOffset.UtcNow 或注入的 TimeProvider。Stopwatch,不要用 DateTime.Now 相减。系统时钟会被 NTP 校正、被用户修改、会因夏令时跳变——实际发生过「耗时算出负数」的事故。Stopwatch 用的是单调递增的高精度计数器,不受这些影响。Stopwatch.GetElapsedTime(timestamp)(.NET 7+)连 Stopwatch 对象都不用分配。「生成一个 ID」和「生成一个随机数」看起来简单,但选错 API 会导致可预测的令牌或糟糕的数据库性能——两者都是要修很久的问题。
随机数:三个 API,用途完全不同
| API | 特点 | 用于 |
|---|---|---|
Random.Shared(.NET 6+) | 线程安全的共享实例 | 洗牌、抖动、模拟 |
new Random(seed) | 可复现 | 测试、可重放的模拟 |
RandomNumberGenerator | 加密安全 | 令牌、密码盐、会话 ID、验证码 |
- 凡是「猜中了会出事」的地方,一律用
RandomNumberGenerator:RandomNumberGenerator.GetBytes(32)、GetInt32(min, max)、GetHexString(.NET 9+)。 new Random()在 .NET Core 起已经不再是「同一毫秒创建多个会得到相同序列」了(种子来自线程本地熵),但它仍然不是线程安全的——多线程共用一个实例会得到全 0 之类的怪结果。用Random.Shared。
Guid:v4 还是 v7
Guid.NewGuid() → 33fcf7a1-38f5-4511-8c41-... // v4:122 位随机 Guid.CreateVersion7() → 019fa998-3acb-7b6e-b71d-... // v7(.NET 9+):前 48 位是时间戳
- v4 完全随机 → 做数据库主键时是灾难:聚簇索引上的插入位置随机分布,导致页分裂、索引碎片、缓存命中率暴跌。这是「用 Guid 当主键性能差」这个说法的真实来源。
- v7 前 48 位是毫秒时间戳 → 天然按时间递增,插入接近顺序追加,同时保留了「客户端可生成、全局唯一」的优点。今天新项目要用 Guid 主键,就用 v7。
- 解析与格式化:
Guid.Parse接受多种形式;ToString("N")得到无连字符的 32 位十六进制,做 URL 片段时更短。
不要自己造 ID
- 不要用
DateTime.Now.Ticks+ 随机数拼 ID:并发下会撞、时钟回拨会撞、跨机器会撞。 - 不要用
GetHashCode()做 ID(本章第 1 卡:跨进程不稳定)。 - 需要短 ID 就把随机字节做 Base64Url 编码;需要有序就用 Guid v7 或雪花算法的成熟实现。
using System.Security.Cryptography;
// ✓ 令牌 / 会话 ID / 密码重置码:加密安全
string token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace('+', '-').Replace('/', '_').TrimEnd('='); // URL 安全
string code = RandomNumberGenerator.GetString("0123456789", 6); // .NET 9+:验证码
// ✓ 重试退避的抖动:普通随机就够
var delay = baseDelay * Math.Pow(2, attempt)
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 200));
// ✓ 数据库主键:Guid v7(.NET 9+)
public sealed record Order
{
public Guid Id { get; init; } = Guid.CreateVersion7(); // 时间有序
}
// ✓ 密码:绝不自己实现哈希
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(password, salt,
iterations: 600_000, HashAlgorithmName.SHA256, outputLength: 32);
// 更好的选择:ASP.NET Core Identity 的 PasswordHasher(已处理好参数与升级)
// ✓ 比较敏感数据要用定时安全比较,避免时序攻击
bool ok = CryptographicOperations.FixedTimeEquals(expected, actual);Random 生成安全令牌是真实发生过的漏洞。Random 是伪随机,内部状态可以被少量输出反推——攻击者拿到几个已知令牌就能预测后续的。判据很简单:这个值如果被人猜到会不会出事?会——用 RandomNumberGenerator。密码重置链接、API key、会话 ID、验证码,全都属于「会出事」。Guid.CreateVersion7() 的有序性只在字节序上成立,而 Guid.ToString() 的显示顺序和 SQL Server 的 uniqueidentifier 排序规则都不是简单的字节序。用 Guid v7 做聚簇主键时,要确认数据库的排序规则确实能利用这个有序性(PostgreSQL 的 uuid 按字节序,没问题;SQL Server 需要留意)。Stream 是所有 IO 的公共抽象。用它时最容易出错的不是读写本身,而是「谁拥有这个流、谁负责关它」这条隐含契约。
所有权的三条规则
- ① 包装流默认会释放被包装的流:
new StreamReader(fs)被 Dispose 时会连fs一起关掉。要保留就用带leaveOpen: true的重载。 - ② 方法参数里的流,通常不该由被调方释放——调用方给你的,调用方负责。除非文档明说。
- ③ 方法返回的流,由调用方释放。返回流的方法(
File.OpenRead、GetStreamAsync)都要求调用方using。 - 违反这些规则的典型症状是
ObjectDisposedException: Cannot access a closed Stream——某人提前关了不属于自己的流。
缓冲:为什么写完不 Flush 会丢数据
StreamWriter、BufferedStream、FileStream都有内部缓冲区。数据先进缓冲,攒够了才真正写出去。Dispose/DisposeAsync会自动 Flush——所以「忘了 using」不只是资源泄漏,还会丢数据。进程被 kill 时缓冲区里的内容直接消失。- 要立即落盘用
FlushAsync();要确保写进物理磁盘(不只是操作系统缓存)用FileStream.Flush(true)——代价很大,只在真正需要持久性保证时用。
异步 IO:什么时候真的异步
- 文件 IO 想真异步,要显式开启:
new FileStream(path, ..., useAsync: true)或FileOptions.Asynchronous。否则ReadAsync内部可能只是「在线程池上做同步读」——不报错,但没有省下线程。 File.ReadAllTextAsync这类便捷方法内部已经处理好了。- 网络流天生是异步的,直接用
ReadAsync/WriteAsync即可。 - 大文件搬运用
CopyToAsync——它内部用池化缓冲区,比自己写循环更快也更省内存。
// ✓ 所有权清晰:谁 open 谁 using
await using var fs = File.Create(path);
await using var writer = new StreamWriter(fs); // 释放时会连 fs 一起关
await writer.WriteLineAsync("hello");
// 出作用域:writer 先 Dispose(Flush + 关 fs),fs 的 Dispose 再跑一次(幂等)
// ✓ 不想让包装器关掉底层流
using (var w = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true))
{
await w.WriteAsync(json);
}
stream.Position = 0; // stream 还活着,可以继续用
// ✓ 真正的异步文件 IO
await using var src = new FileStream(from, FileMode.Open, FileAccess.Read,
FileShare.Read, bufferSize: 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var dst = new FileStream(to, FileMode.Create, FileAccess.Write,
FileShare.None, 81920, FileOptions.Asynchronous);
await src.CopyToAsync(dst, ct);
// ✓ 逐行读大文件:一行一行来,内存与文件大小无关
await foreach (var line in File.ReadLinesAsync(path, ct)) // .NET 7+
Process(line);
// ✓ 内存里的流:MemoryStream 之外还有更省的选择
var ms = new MemoryStream(); // 会不断扩容复制
var ro = new MemoryStream(bytes, writable: false); // 只读包装,零复制using 的 StreamWriter 会静默丢掉最后一部分数据。文件被创建了、前面的内容也在、就是结尾缺一截——因为缓冲区里的内容从没被 Flush。这个 bug 在小文件上可能完全看不出来(缓冲区没满过也没写出去,或者恰好进程退出时终结器救了一把),在大文件上表现为「偶尔损坏」。凡是写文件,检查 using 是第一件事。Dispose() 必须是幂等的(调多次不出错),这是 BCL 的明确契约。所以「嵌套 using 导致底层流被关两次」不会出问题——嵌套 using 的释放顺序是由内到外,内层关掉底层流后,外层再调一次 Dispose 是安全的空操作。自己实现 IDisposable 时也要保证这一点(06 章的 _disposed 标志)。跨平台的现实:路径、区域、时区、编码
「.NET 跨平台」不等于「同一份代码在哪都表现一致」。路径分隔符、文件名大小写、换行符、默认编码、区域格式、时区标识——这六件事在 Windows 和 Linux 上真的不一样。这一章把它们逐条摊开,并给出可以直接照抄的写法。
路径是跨平台差异最密集的地方。好消息是:只要不手写分隔符、不假设大小写,绝大多数问题就不存在了。
本机(Windows)的路径常量
Path.DirectorySeparatorChar = '\' // Linux/macOS 是 '/' Path.AltDirectorySeparatorChar = '/' // Windows 上正斜杠也被接受 Path.PathSeparator = ';' // PATH 环境变量的分隔符;Unix 是 ':' Path.Combine("a","b","c.txt") = a\b\c.txt Path.Combine("a","/abs") = /abs // ← 第二段是绝对路径就丢掉前面! Path.GetFullPath("a/b") = ...\a\b // 正斜杠在 Windows 上正常工作 Path.GetInvalidFileNameChars().Length = 41 // Linux 上只有 2 个
- 写路径字面量时用正斜杠:
"config/app.json"在三个平台上都能用。Windows 的 API 全面接受正斜杠,反过来 Linux 不接受反斜杠。 Path.Combine的「绝对路径吃掉前缀」行为要记住:拼接用户提供的片段时,如果那一段以/开头,前面的基准目录会被整个丢弃——这既是 bug 也是路径穿越漏洞的入口。- 安全拼接的正确姿势:拼完用
Path.GetFullPath规范化,再检查结果是否仍在允许的根目录下。
大小写:最容易漏的一条
写入 CaseTest.txt 之后,File.Exists("casetest.txt")
Windows / macOS(默认) → True // 不区分大小写
Linux → False // 区分大小写- 「本地开发好好的,部署到 Linux 容器就找不到文件」几乎全是这个原因:
appsettings.Production.json写成了appSettings.production.json。 - 同理适用于嵌入资源名、程序集名、视图路径、静态文件 URL。
- 防御办法:在 CI 里跑一次 Linux 构建与测试(哪怕只是
docker run),比人工检查可靠得多。
Windows 独有的三个坑
- 保留文件名:
CON、PRN、AUX、NUL、COM1~COM9、LPT1~LPT9——连带扩展名也不行(con.txt)。用户上传的文件名要过滤。 - 路径长度限制:传统上限 260 字符,虽然新版 Windows 可以放开,但仍会遇到工具链不支持的情况。
- 文件名不能含
< > : " | ? *(非法字符共 41 个),而 Linux 上只有/和\0不行。
// ✓ 拼路径:永远用 Path.Combine / Path.Join
var config = Path.Combine(AppContext.BaseDirectory, "config", "app.json");
// Path.Join 和 Combine 的区别:Join 不做"绝对路径吃前缀"的处理,只是拼
Path.Join("a", "/abs"); // "a/abs" —— 更适合拼接不可信的片段
// ✓ 防路径穿越:规范化后校验根目录
static string? SafeResolve(string root, string userPath)
{
var full = Path.GetFullPath(Path.Join(root, userPath));
var rootFull = Path.GetFullPath(root);
return full.StartsWith(rootFull, StringComparison.Ordinal) ? full : null;
}
// ✓ 常用的路径来源(各有各的语义,别混)
AppContext.BaseDirectory // 程序集所在目录 —— 找随程序发布的文件用这个
Directory.GetCurrentDirectory() // 进程工作目录 —— 由启动方式决定,会变!
Environment.ProcessPath // 可执行文件完整路径(.NET 6+)
Path.GetTempPath() // 临时目录(跨平台)
Environment.GetFolderPath( // 用户数据目录(跨平台映射)
Environment.SpecialFolder.ApplicationData);
// ✓ 文件名清洗
static string Sanitize(string name)
=> string.Join('_', name.Split(Path.GetInvalidFileNameChars()));Path.Combine(root, userInput) 是一个路径穿越漏洞。用户传 "/etc/passwd" 或 "../../secrets" 时,Combine 会(对绝对路径)直接丢弃 root,或者(对 ..)拼出跳出根目录的路径。凡是把用户输入拼进路径,必须 GetFullPath 规范化后校验前缀——这是 Web 文件下载/上传接口的必检项。AppContext.BaseDirectory 和 Directory.GetCurrentDirectory() 不是一回事,而且经常不同——前者是程序集所在目录,后者是启动进程时的工作目录(从哪个目录敲的命令就是哪里)。读「随程序一起发布的配置文件、资源文件」要用前者;用后者会在 systemd、Docker、IDE、双击运行等不同启动方式下得到不同结果。.NET Core 起在编码上做了一次彻底的简化:默认编码在所有平台上都是 UTF-8,而且只内置了少数几个编码。这解决了老 .NET 上的一堆乱码问题,也带来了一个迁移必踩的坑。
中文老编码要额外注册
Encoding.GetEncoding("GBK") → ArgumentException: 'GBK' is not a supported encoding name. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method. (Parameter 'name') // 装 System.Text.Encoding.CodePages 包,启动时注册一次: Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.GetEncoding("GBK") → Chinese Simplified (GB2312) // 可用 gbk.GetBytes("中") → D6D0 // 两字节,正确
- .NET Core 精简掉了几百个代码页编码。读老系统导出的 GBK/Big5 文件、对接老接口时,这行注册是必需的。
Encoding.Default在 .NET Core 起恒为 UTF-8(utf-8),不再跟随系统 ANSI 代码页——这是从 .NET Framework 迁移时行为变化最大的一处。
BOM:那三个看不见的字节
Encoding.UTF8.GetPreamble().Length → 3 // EF BB BF,会被写进文件! new UTF8Encoding(false).GetPreamble() → 0 // 无 BOM
Encoding.UTF8这个内置实例是「带 BOM」的。用它写文件,开头会多出三个字节——Linux 上的 shell 脚本、YAML、CSV 解析器经常因此报错,而肉眼完全看不出来。- 写文件给非 Windows 工具消费时,用
new UTF8Encoding(false)。File.WriteAllText的默认行为是不写 BOM(用的是内部的无 BOM UTF-8),只有显式传Encoding.UTF8才会带上。 - 读取时 .NET 会自动识别并跳过 BOM,所以读一般没问题。
换行符
Environment.NewLine:Windows 是\r\n(长度 2,),Linux/macOS 是\n(长度 1)。- 写文件时想固定用
\n,就显式写"\n",别用Environment.NewLine或WriteLine。 - 读取时用
ReadLine/File.ReadLines——它们同时识别\n和\r\n,不需要你操心。自己Split('\n')会在 Windows 文件上留下一堆\r结尾。 - Git 的
core.autocrlf会在检出时改写换行——这就是「同事的文件 diff 显示整个文件都变了」的原因。仓库里放一个.gitattributes统一规则。
using System.Text;
// 启动时注册一次(Program.cs 顶部)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// 读 GBK 文件
var gbk = Encoding.GetEncoding("GBK");
var text = await File.ReadAllTextAsync(path, gbk);
// ✓ 写无 BOM 的 UTF-8(给 Linux 工具链消费)
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
await File.WriteAllTextAsync(path, content, utf8NoBom);
// ✓ 固定用 \n 换行(生成脚本、CSV、配置文件时)
var sb = new StringBuilder();
foreach (var row in rows) sb.Append(row).Append('\n'); // 不用 AppendLine
// ✓ 读取时不必操心换行差异
await foreach (var line in File.ReadLinesAsync(path, ct))
Process(line); // \n 与 \r\n 都被正确处理
// 控制台输出中文乱码时(Windows 终端)
Console.OutputEncoding = Encoding.UTF8;JSON.parse 报 Unexpected token,但文件用编辑器打开一切正常;或者生成的 .sh 脚本在 Linux 上报 #!/bin/bash: 未找到命令。怀疑 BOM 时用 hexdump -C file | head -1 看头三个字节是不是 ef bb bf。Encoding.GetEncoding(name, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback) 能让「解码失败」真的抛异常而不是产出一堆问号。.NET Core 起在所有平台上都用 ICU(International Components for Unicode)做区域相关的处理。这带来了跨平台一致性,但也意味着「容器里没装 ICU 就跑不起来」这类新问题。
回顾:区域影响哪些行为(03 章)
- 字符串比较与排序:中文按拼音 vs 按码位,两个完全不同的顺序。
- 大小写转换:土耳其语的
i大写成İ。 - 数字与日期格式:
1234.5在 de-DE 下是1234,5;double.TryParse("1,5")在中文区域下得到 15,在 de-DE 下得到 1.5——同一份代码不同结果。 - 规则:给机器看的(协议、文件名、key、存储)用
Ordinal/Invariant;给人看的用当前区域。
容器里的两个经典故障
- ① 基础镜像不含 ICU:Alpine 或精简版镜像里跑 .NET,启动直接抛
Couldn't find a valid ICU package installed on the system。解法二选一:镜像里装icu-libs,或者开InvariantGlobalization。 - ② 开了
InvariantGlobalization=true之后行为静默改变:所有区域都退化成不变区域——中文排序变成码位序、区域相关的日期数字格式全部按不变区域走、CultureInfo.GetCultureInfo("zh-CN")拿到的也不再是真实数据。不报错,只是结果不一样了。 - 判断该不该开:纯 API 服务、不做本地化展示、不依赖区域排序 → 开,省几十 MB 镜像;要按用户语言排序、格式化金额日期 → 不能开。
显式设置区域
- 进程级:
CultureInfo.DefaultThreadCurrentCulture(数字/日期格式)与DefaultThreadCurrentUICulture(资源文件/翻译)。两者是分开的:可以「界面英文但金额按中文格式」。 - ASP.NET Core 里用本地化中间件按请求设置(依据
Accept-Language头或 URL 参数),因为每个请求的用户语言可能不同。 - 后台任务、日志、序列化一律用
InvariantCulture,别跟着当前请求的区域走。
using System.Globalization;
// 进程级默认区域(控制台/后台服务)
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo("zh-CN");
// ✓ 给机器看的:显式 Invariant
var key = amount.ToString(CultureInfo.InvariantCulture);
var stamp = time.ToString("O", CultureInfo.InvariantCulture);
var ok = decimal.TryParse(raw, NumberStyles.Number,
CultureInfo.InvariantCulture, out var v);
// ✓ 给人看的:显式区域
var display = amount.ToString("C", CultureInfo.GetCultureInfo("zh-CN")); // ¥1,234.50
// csproj:容器精简(确认不需要区域功能再开)
// <InvariantGlobalization>true</InvariantGlobalization>
// Dockerfile:需要 ICU 的 Alpine 镜像
// RUN apk add --no-cache icu-libs
// ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
// 运行时探测当前有没有真的区域数据
Console.WriteLine(CultureInfo.CurrentCulture.Name); // 不变模式下是空字符串
Console.WriteLine(CultureInfo.GetCultures(CultureTypes.AllCultures).Length);InvariantGlobalization 是个「静默改变行为」的开关。它不会让任何代码报错,只是让排序、大小写、格式化的结果换了一套规则。有人为了缩小镜像随手加上,几个月后才发现「用户列表的排序不对了」「金额格式变了」。加这个开关必须和产品需求一起确认,并在代码里留下注释说明为什么可以开。<AnalysisMode>Recommended</AnalysisMode>,CA1304(指定 CultureInfo)、CA1305(指定 IFormatProvider)、CA1307/CA1310(指定 StringComparison)会逐个点出来。新项目一开始就开,比事后排查便宜太多。时区标识历史上有两套:Windows 用 "China Standard Time",其余系统用 IANA 的 "Asia/Shanghai"。这曾经是跨平台代码里最繁琐的一处 if-else。
.NET 6 起两种 ID 都认
// 在 Windows 上 TimeZoneInfo.Local.Id → "China Standard Time" // 本机返回的是 Windows ID TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai") → 成功,DisplayName = "(UTC+08:00) 北京,重庆,香港特别行政区,乌鲁木齐" TimeZoneInfo.FindSystemTimeZoneById("China Standard Time") → 成功,Id = "China Standard Time"
- .NET 6 起在两个方向上都做了自动转换:Windows 上能用 IANA ID,Linux 上能用 Windows ID。跨平台代码不再需要分支。
- 但
TimeZoneInfo.Local.Id返回的仍是「本平台原生的那种」——在 Windows 上拿到 Windows ID。所以不要把Local.Id存进数据库,那份数据换个平台读出来会显得很奇怪(虽然还能用)。 - 持久化一律用 IANA ID(
Asia/Shanghai):它是国际标准、被所有语言和数据库支持、含义明确。
容器里的时区数据
- 精简镜像可能不含时区数据库,此时
FindSystemTimeZoneById会抛TimeZoneNotFoundException,而且只有用到时才炸——本地测试完全正常。 - 解法:镜像里装
tzdata(Alpine:apk add tzdata;Debian 系:apt-get install tzdata),或者引TimeZoneConverter之类的包自带数据。 - 容器默认时区是 UTC——这通常是好事(服务端本就该用 UTC),但要意识到它与开发机不同。
三条实践规则
- ① 存 UTC,显示时转。数据库里存
DateTimeOffset或 UTC 时间,用户时区作为用户配置单独存。 - ② 「未来的本地时间」不能存成 UTC:「每周一上午 9 点(北京时间)执行」如果换算成 UTC 存起来,遇到夏令时规则调整就错了。存「本地时间 + 时区 ID」,每次计算时再换算。
- ③ 处理夏令时的边界:
TimeZoneInfo.IsInvalidTime(春季跳过的那一小时里的时间根本不存在)、IsAmbiguousTime(秋季重复的那一小时有两个对应的 UTC 时刻)。做日程/闹钟功能必须处理这两种情况。
// ✓ 跨平台安全的时区获取(.NET 6+ 两种 ID 都能用)
var tz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai"); // 一律用 IANA
// UTC → 用户时区
DateTimeOffset utc = order.CreatedAt; // 存的是 UTC
DateTimeOffset local = TimeZoneInfo.ConvertTime(utc, tz);
Console.WriteLine(local.ToString("yyyy-MM-dd HH:mm"));
// ✓ 未来的本地时间:存"本地时间 + 时区",而不是提前算好的 UTC
public sealed record Schedule(
DayOfWeek Day,
TimeOnly At, // 09:00
string TimeZoneId); // "Asia/Shanghai"
// ✓ 夏令时边界处理
var naive = new DateTime(2026, 3, 8, 2, 30, 0, DateTimeKind.Unspecified);
if (tz.IsInvalidTime(naive))
throw new ArgumentException("这个本地时间不存在(夏令时跳过)");
if (tz.IsAmbiguousTime(naive))
{
var offsets = tz.GetAmbiguousTimeOffsets(naive); // 两个候选偏移
// 业务上决定取哪一个(通常取较早的那个)
}
// 列出系统支持的所有时区(排查容器缺 tzdata)
Console.WriteLine(TimeZoneInfo.GetSystemTimeZones().Count);TimeZoneInfo.Local 在容器里几乎总是 UTC,而这经常不是你以为的。本地开发时它是东八区,代码里 ConvertTime(utc, TimeZoneInfo.Local) 看起来工作正常;部署到容器后 Local 变成 UTC,转换成了空操作,所有显示时间少 8 小时。服务端代码里不要依赖 TimeZoneInfo.Local——用户时区必须显式传入。写跨平台代码时,大部分差异应该靠 BCL 抽象掉。但总有需要「这里只在 Linux 上做」的时候——.NET 提供了三层不同粒度的判断方式。
三种判断,从粗到细
| 方式 | 何时求值 | 用于 |
|---|---|---|
OperatingSystem.IsLinux() | 运行时 | 首选;分析器认得,能配合平台特性做检查 |
RuntimeInformation.IsOSPlatform(OSPlatform.Linux) | 运行时 | 老写法,仍然有效 |
#if WINDOWS | 编译期 | 只在多目标框架(net10.0-windows)时用 |
- 优先用
OperatingSystem.IsXxx()(.NET 5+):它能被[SupportedOSPlatform]分析器识别,调用平台专属 API 时不会报 CA1416 警告。还有带版本的重载IsWindowsVersionAtLeast(10, 0, 17763)。 - 条件编译只在真的多目标时才有意义:单个
net10.0目标里#if WINDOWS永远不成立(那个符号只有net10.0-windows才定义)。这是很常见的误用。
本机上的环境探针
RuntimeInformation.OSDescription = Microsoft Windows 10.0.26200 RuntimeInformation.OSArchitecture = X64 RuntimeInformation.RuntimeIdentifier = win-x64 // RID,16 章要用 RuntimeInformation.FrameworkDescription = .NET 10.0.10 Environment.Is64BitProcess = True Environment.ProcessorCount = 16 Environment.ProcessPath = ...\g2.exe // 可执行文件路径(.NET 6+) Environment.OSVersion = Microsoft Windows NT 10.0.26200.0
环境变量:跨平台的差异
- Windows 的环境变量名不区分大小写,Linux 区分。
Environment.GetEnvironmentVariable("path")在 Windows 上能拿到 PATH,Linux 上拿不到。一律用规范的大小写。 - .NET 配置系统把
__(双下划线)映射成配置层级的::Logging__LogLevel__Default对应Logging:LogLevel:Default。这是因为:在某些 shell 里不合法。容器里配 .NET 应用全靠这条。 - 用户级/机器级环境变量(
EnvironmentVariableTarget.User/Machine)只有 Windows 支持,Linux 上传这个参数会被忽略。
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
// ✓ 运行时平台判断(首选写法)
if (OperatingSystem.IsWindows())
UseWindowsCredentialStore();
else if (OperatingSystem.IsLinux())
UseSecretService();
else if (OperatingSystem.IsMacOS())
UseKeychain();
// ✓ 标注平台专属 API,让分析器帮你检查调用点
[SupportedOSPlatform("windows")]
static void UseWindowsCredentialStore() { /* 调用 Windows 专属 API */ }
// ✓ 环境变量:注意大小写与层级映射
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
// 容器里配置 .NET 应用:双下划线 = 配置层级
// ConnectionStrings__Default=Host=db;...
// Logging__LogLevel__Default=Warning
// ASPNETCORE_URLS=http://+:8080
// ✓ 调用外部命令(跨平台要注意 shell 的差异)
var psi = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh",
ArgumentList = { OperatingSystem.IsWindows() ? "/c" : "-c", command },
RedirectStandardOutput = true,
UseShellExecute = false,
};
using var proc = Process.Start(psi)!;
var output = await proc.StandardOutput.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);#if WINDOWS 在纯 net10.0 项目里永远为假,而且不会有任何警告。那个预处理符号只有目标框架写成 net10.0-windows 时才由 SDK 定义。于是「Windows 专属逻辑」被整段编译掉了,测试也测不出来(因为那段代码根本不存在)。要做平台分支就用运行时判断;确实需要条件编译时,先确认 TFM 带了平台后缀。ArgumentList 而不是 Arguments 字符串启动进程:前者由运行时负责按平台规则转义每个参数,后者要你自己处理引号和空格——这在 Windows 和 Unix 上规则完全不同,是命令注入漏洞的常见来源。同理,UseShellExecute = false 应该是默认选择,只有真要「用系统默认程序打开文件/URL」时才设 true。内存、GC 与性能
托管运行时把内存管理接管了,但没有把它变成免费的。这一章讲清 GC 怎么工作、分配为什么是主要成本、Span 与池化怎么把分配降到零,以及最重要的一件事——怎么用工具量出真实数据,而不是凭感觉优化。
.NET 的 GC 是分代、标记-清除-压缩的。理解它只需要一个核心假设:大多数对象生命周期极短。整套设计都建立在这个观察上。
三代 + 两个特殊堆
| 代 | 放什么 | 回收频率 | 代价 |
|---|---|---|---|
| gen0 | 刚分配的对象 | 非常频繁 | 极小(只扫很小一块) |
| gen1 | gen0 回收后活下来的 | 较少 | 小 |
| gen2 | gen1 活下来的(长寿对象) | 很少 | 大:要扫整个堆 |
| LOH(大对象堆) | ≥ 85000 字节的对象 | 随 gen2 一起 | 大,且默认不压缩 |
| POH(固定对象堆) | 被 pin 住的对象 | 随 gen2 | — |
GC.MaxGeneration是 2;new byte[100000]的GC.GetGeneration直接返回 2——85000 字节的 LOH 阈值是硬编码的常量,不随机器变化。- gen0 回收几乎是免费的:存活对象少,复制走就行,死对象连碰都不用碰。所以「大量短命对象」对 GC 并不可怕。
- 真正贵的是 gen2 回收:要扫描整个堆。「对象晋升到 gen2」才是要避免的事——缓存、静态集合、事件订阅(05 章)都会造成不该有的晋升。
两种 GC 模式,选错了差很多
- Workstation GC(默认):一个 GC 线程,为低延迟优化。桌面应用、CLI 工具用它。
- Server GC:每个 CPU 核一个堆 + 一个 GC 线程,为吞吐量优化,代价是更高的内存占用。ASP.NET Core 项目模板默认开启。
- 本机控制台程序
GCSettings.IsServerGC为False,LatencyMode为Interactive。 - 容器里要留意:Server GC 会按可见 CPU 数分配堆,在限制了内存的容器里可能过度占用。.NET 会读 cgroup 限制自动调整,但内存限制很小(< 512 MB)时建议显式关掉 Server GC。
关于 GC 的三个误解
- ❌「调用
GC.Collect()能优化性能」——恰恰相反,手动触发会打乱 GC 自己的调度、强制做一次全代回收。正常代码里永远不要调它(例外:基准测试的准备阶段、明确知道刚释放了大量内存的一次性时机)。 - ❌「有 GC 就不会内存泄漏」——GC 只回收不可达的对象。静态集合越攒越多、事件没退订、缓存无上限、
Timer没释放,这些对象一直可达,GC 不会碰它们。托管内存泄漏在 .NET 里非常常见。 - ❌「
Dispose会释放内存」——Dispose管的是非托管资源(06 章),内存仍由 GC 决定何时回收。
// 观察 GC 行为的几个 API
GC.CollectionCount(0) // 各代回收次数
GC.GetTotalMemory(forceFullCollection: false) // 当前托管堆大小
GC.GetTotalAllocatedBytes(precise: true) // 累计分配量 —— 最有用的一个
GC.GetGeneration(obj) // 对象在第几代
GC.GetGCMemoryInfo() // 上次 GC 的详细信息
// 测一段代码的分配量:前后差值(14 章所有数字的做法)
long before = GC.GetTotalAllocatedBytes(true);
DoWork();
long after = GC.GetTotalAllocatedBytes(true);
Console.WriteLine($"分配了 {after - before:N0} 字节");
// csproj:GC 模式配置
// <ServerGarbageCollection>true</ServerGarbageCollection> 服务端吞吐优先
// <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> 后台并发回收(默认开)
// 环境变量同样可配(容器里更常用):
// DOTNET_gcServer=1 DOTNET_GCHeapHardLimit=...static Dictionary<string, Data> 做缓存,没有淘汰策略——随着 key 的种类增长,它会一直涨到进程 OOM。所有缓存都必须有上限或过期:用 MemoryCache(带 SizeLimit 和过期时间)而不是裸字典。「反正 GC 会管」在这里完全不成立——那些对象一直可达。GC.GetTotalAllocatedBytes(precise: true) 是评估「这段代码分配了多少」最直接的工具,不需要装任何东西。本页多处数字(StringBuilder vs 字符串拼接、装箱 24 字节)都是这么量出来的。要更严谨的结论再上 BenchmarkDotNet——但先用这个看一眼量级,往往就够决定要不要优化了。在托管运行时里,分配本身很快(指针碰撞,几乎零成本),贵的是之后要回收它。所以性能优化的主线不是「少调用几个方法」,而是少产生垃圾。
三组数据
① 循环拼 1 万个字符(03 章) StringBuilder 33,176 字节 string += 100,261,224 字节 // 差约 3000 倍 ↑ 这 1 亿是可以手算的:n²/2 × 2 字节,只取决于算法 ② 装箱 10 万个 int(02 章) 2,400,024 字节 = 每个 24 字节 // 对象头 8 + 方法表指针 8 + 数据 8 ③ List<T> 容量增长(08 章) 4 → 8 → 16 → 32 → 64 … // 每次翻倍都要重新分配 + 复制整个数组
- 三个例子的共同点:浪费来自「反复分配又立刻丢弃」,而不是某个操作本身慢。
- 第三个的启示:知道大概容量就在构造时传进去——
new List<T>(1000)、new StringBuilder(4096)、new Dictionary<K,V>(capacity)。这是收益最高、改动最小的一类优化。
常见的隐藏分配
| 写法 | 分配了什么 | 改法 |
|---|---|---|
s.Substring(a, b) | 新字符串 | s.AsSpan(a, b) |
s.Split(',') | 数组 + 每段一个字符串 | MemoryExtensions.Split 或手工 IndexOf |
object o = 42 | 装箱 24 字节 | 泛型 |
| 捕获变量的 lambda | 闭包对象 | static lambda(05 章) |
foreach 遍历接口类型的集合 | 枚举器装箱 | 用具体类型或 Span |
params object[] | 数组 + 每个值装箱 | params ReadOnlySpan<T> |
返回 Task<T> 但总是同步完成 | Task 对象 | ValueTask<T>(11 章) |
但先看清语境
- 这些优化在业务代码里通常毫无意义。一次 HTTP 请求处理分配几十 KB,和一次数据库往返(毫秒级)比连零头都算不上。
- 值得优化的是三类地方:每秒执行几万次以上的热路径、处理大数据量的批处理、内存受限的环境(容器、嵌入式)。
- 判据永远是 profiler 的输出,不是直觉。BenchmarkDotNet 的
[MemoryDiagnoser]会直接告诉你「每次操作分配 N 字节、触发 gen0 回收 M 次」。
// ① 预分配容量:改动最小、收益最稳的一类
var list = new List<Item>(expectedCount);
var sb = new StringBuilder(capacity: 4096);
var dict = new Dictionary<string, int>(capacity: rows.Count, StringComparer.Ordinal);
// ② 用 Span 切分,不产生中间字符串
static (ReadOnlySpan<char> Key, ReadOnlySpan<char> Value) SplitKv(ReadOnlySpan<char> line)
{
int i = line.IndexOf('=');
return i < 0 ? (line, default) : (line[..i], line[(i + 1)..]);
}
// "key=value" → key / value,全程零分配
// ③ 泛型代替 object,避免装箱
static void Log<T>(string tag, T value) => ...; // ✓
static void Log(string tag, object value) => ...; // ✗ 值类型每次装箱
// ④ 只在必要时物化:链条中间的 ToList 是最常见的浪费(08 章)
var r = src.Where(A).ToList().Select(B).ToList(); // ✗ 两次物化
var r2 = src.Where(A).Select(B).ToList(); // ✓ 一次
// ⑤ 结构化日志的高性能写法:源生成器,零装箱零拼接
[LoggerMessage(Level = LogLevel.Warning, Message = "订单 {OrderId} 超时 {Ms}ms")]
static partial void LogSlowOrder(ILogger logger, string orderId, long ms);Stopwatch 手工计时会被 JIT 预热、分层编译、GC 时机严重干扰——第一次跑的结果几乎总是包含 JIT 编译时间。本页给出的时间数字只用来说明量级方向;要出可信结论必须用 BenchmarkDotNet(本章最后一卡)。[LoggerMessage] 源生成器(Microsoft.Extensions.Logging)是个性价比极高的改动:它在编译期生成日志方法,参数不装箱、日志级别没开时连字符串都不构造。高频日志路径上换成它,往往比一堆手工优化管用。写法就是上面那样声明一个 partial 方法。Span<T> 是 .NET 近十年最重要的性能特性:它是一段连续内存的类型安全视图,可以指向数组、字符串、栈内存、甚至非托管内存——而切片操作完全不分配。
它能指向什么
ReadOnlySpan<char> a = "hello world".AsSpan(); // 字符串(只读) Span<int> b = new int[10]; // 数组 Span<byte> c = stackalloc byte[64]; // 栈内存,完全不碰堆 Span<T> d = CollectionsMarshal.AsSpan(list); // List<T> 的底层数组 a[6..] // "world" —— 切片不分配,只是换了个起点和长度 a.ToString() // ← 这一步才分配
- 切片是 O(1) 且零分配的:
Span内部就是「引用 + 长度」两个字段,切片只是造一个新的(栈上的)视图。 - 对比
Substring:后者每次都复制出一个新字符串。解析大文本时,这个差别是数量级的。
ref struct 的三条限制
Span<T>是ref struct,只能待在栈上。因此:不能作为类的字段、不能装箱、不能放进集合、不能被async方法跨await使用、不能被 lambda 捕获。- 这些限制不是缺陷,而是安全保证:它保证了 Span 指向的栈内存不会在 Span 还活着时失效。
- 需要在异步代码里持有一段内存时用
Memory<T>:它是普通 struct,可以做字段、可以跨 await,用.Span属性在同步代码块里取出 Span 来操作。「同步用 Span,异步用 Memory」是一条好记的分工。
stackalloc:栈上分配的注意事项
- 语法
Span<byte> buf = stackalloc byte[64];——在Span出现之后,stackalloc不再需要unsafe。 - 大小必须有上限:栈只有 1 MB 左右,
stackalloc一个用户输入决定的大小会直接栈溢出并杀死进程(10 章:这种崩溃捕获不到、日志留不下)。 - 标准做法是「小的走栈、大的走池」:
size <= 256 ? stackalloc byte[size] : ArrayPool.Rent(size)(见代码)。 - 绝不要在循环里
stackalloc——栈帧要到方法返回才释放,循环里分配会累加。
// 典型用法:解析而不分配
static int CountFields(ReadOnlySpan<char> line, char sep)
{
int n = 1;
while (true)
{
int i = line.IndexOf(sep);
if (i < 0) return n;
line = line[(i + 1)..]; // 零分配地"吃掉"前面一段
n++;
}
}
// 小走栈、大走池:处理不可信长度的标准模式
const int StackLimit = 256;
char[]? rented = null;
Span<char> buf = size <= StackLimit
? stackalloc char[StackLimit]
: (rented = ArrayPool<char>.Shared.Rent(size));
try
{
buf = buf[..size];
// ... 用 buf ...
}
finally
{
if (rented is not null) ArrayPool<char>.Shared.Return(rented);
}
// 异步场景用 Memory<T>
public async Task CopyAsync(Stream src, Stream dst, CancellationToken ct)
{
var buffer = ArrayPool<byte>.Shared.Rent(81920);
try
{
Memory<byte> mem = buffer; // Memory 可以跨 await
int n;
while ((n = await src.ReadAsync(mem, ct)) > 0)
await dst.WriteAsync(mem[..n], ct);
}
finally { ArrayPool<byte>.Shared.Return(buffer); }
}
// 构造字符串而不产生中间结果
string masked = string.Create(card.Length, card, static (span, src) =>
{
src.AsSpan().CopyTo(span);
span[..^4].Fill('*');
});Span 不能跨 await,编译器会直接报错——但这个限制经常逼得人把整个方法改成同步。正确做法是把 Span 的使用限制在一个不含 await 的局部方法或代码块里,异步部分用 Memory<T> 承载。硬把方法改回同步(然后在异步上下文里 .Result)会引发 11 章那一整套问题,得不偿失。Span 重载:int.TryParse(ReadOnlySpan<char>)、DateTime.TryParse、Encoding.GetBytes(ReadOnlySpan<char>, Span<byte>)、Stream.Read(Span<byte>)、ToString(Span<char>, out int)(ISpanFormattable)。解析热路径上,把 Substring 换成 AsSpan 加上这些重载,常常一行不改逻辑就消掉全部分配。当同样大小的临时对象被反复创建又丢弃时(缓冲区、StringBuilder、大数组),池化可以把分配次数从「每次一个」降到「几乎为零」。代价是你要自己管理归还。
三个现成的池
| 池 | 用于 | 要点 |
|---|---|---|
ArrayPool<T>.Shared | 临时数组/缓冲区 | Rent 得到的可能比要的大,且内容是脏的 |
ObjectPool<T>(Microsoft.Extensions.ObjectPool) | StringBuilder 等可复用对象 | 需要自定义「归还时怎么重置」 |
RecyclableMemoryStream(微软的 NuGet 包) | 替代 MemoryStream | 避免大 buffer 进 LOH |
Rent(n)返回的数组长度>= n——这是最容易出错的一点。一定要用array.AsSpan(0, n)限定实际范围,否则会读到上一个使用者留下的垃圾数据(也是信息泄露的途径)。- 归还必须在
finally里,理由和 11 章的SemaphoreSlim.Release一样:一次异常就永久少一个。 - 归还后绝不能再用那个数组——它可能立刻被别的线程租走。这类 bug 表现为「数据偶尔莫名其妙变了」,极难排查。
什么时候不该池化
- 对象很小:gen0 回收几乎免费,池化的簿记开销可能更大。
- 生命周期不清晰:说不清什么时候归还,就别池化——忘了归还只是回到「不池化」的状态(还好),归还了却继续用则是数据损坏(很糟)。
- 没有量过:池化是明确的复杂度增加,必须有 profiler 数据支撑。
避免 LOH:一个具体目标
- ≥ 85000 字节的对象直接进 LOH(本章第 1 卡),LOH 默认不压缩,容易碎片化,且只随 gen2 回收。
byte[85000]大约是 85 KB——处理文件、图片、大 JSON 时很容易越线。- 对策:用
ArrayPool租借(池内的大数组被长期复用,不反复进 LOH)、把大缓冲拆成小块流式处理、用RecyclableMemoryStream。
using System.Buffers;
// ✓ ArrayPool 的标准用法
static async Task<string> HashFileAsync(string path, CancellationToken ct)
{
const int Size = 81920;
byte[] buffer = ArrayPool<byte>.Shared.Rent(Size);
try
{
await using var fs = File.OpenRead(path);
using var sha = SHA256.Create();
int n;
// 注意:只用 [0, n),Rent 回来的数组可能更长且内容是脏的
while ((n = await fs.ReadAsync(buffer.AsMemory(0, Size), ct)) > 0)
sha.TransformBlock(buffer, 0, n, null, 0);
sha.TransformFinalBlock([], 0, 0);
return Convert.ToHexString(sha.Hash!);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // 必须 finally
// 内容敏感时:Return(buffer, clearArray: true)
}
}
// ✓ ObjectPool:复用 StringBuilder
private static readonly ObjectPool<StringBuilder> SbPool =
new DefaultObjectPoolProvider().CreateStringBuilderPool();
var sb = SbPool.Get();
try { sb.Append(...); return sb.ToString(); }
finally { SbPool.Return(sb); } // Return 时会自动 Clear
// ✓ 更省事的写法:SearchValues(.NET 8+,预编译的字符集查找)
private static readonly SearchValues<char> Delimiters =
SearchValues.Create(",;|\t");
int idx = line.AsSpan().IndexOfAny(Delimiters); // 比 IndexOfAny(char[]) 快很多buffer = null!;),让后续使用变成明确的 NullReferenceException 而不是静默的数据损坏。ArrayPool<T>.Shared 对 ≤ 1 MB 的数组有效,更大的它会直接分配。需要更大或想隔离池时用 ArrayPool<T>.Create(maxArrayLength, maxArraysPerBucket)。另外归还含敏感数据的缓冲区时传 clearArray: true——否则下一个租用者能读到你的密钥或用户数据。性能工作的第一原则是不要猜。.NET 的诊断工具链相当完整,而且大部分是免费的命令行工具,生产环境也能用。
四个工具,四类问题
| 问题 | 工具 | 怎么用 |
|---|---|---|
| 「A 和 B 哪个快」 | BenchmarkDotNet | 写基准项目,它负责预热、多轮采样、统计 |
| 「线上现在什么情况」 | dotnet-counters | 实时看 GC、线程池、异常、请求速率 |
| 「时间花在哪个方法」 | dotnet-trace / dotnet-monitor | 采样 CPU,产出可在 PerfView/VS 打开的 trace |
| 「内存被什么占了 / 卡死在哪」 | dotnet-dump + dotnet-gcdump | 抓转储,用 dotnet-dump analyze 看堆和线程栈 |
- 四个工具都用
dotnet tool install -g dotnet-xxx安装,能 attach 到正在运行的进程(不用重启、不用改代码)。 - 容器里用
dotnet-monitor作为 sidecar 更方便,它把这些能力包装成 HTTP 接口。
三条最常用的诊断命令
# 实时看运行时指标(GC 次数、线程池队列、异常速率) dotnet-counters monitor -p <pid> --counters System.Runtime,Microsoft.AspNetCore.Hosting # 采样 CPU 20 秒 dotnet-trace collect -p <pid> --duration 00:00:20 --profile cpu-sampling # 抓堆转储看谁占内存(比 dump 小很多,只含 GC 堆) dotnet-gcdump collect -p <pid>
- 「ThreadPool Queue Length 持续大于 0 而 CPU 不高」→ 线程池饥饿(11 章),去搜
.Result/.Wait()。 - 「gen2 回收频繁」→ 有对象在被不必要地长期持有,用 gcdump 看堆上什么最多。
- 「异常速率很高但功能正常」→ 有人用异常做流程控制(10 章),这会显著拖慢吞吐。
BenchmarkDotNet 的几条纪律
- 必须在 Release 下跑——它会自己检查并拒绝 Debug 构建。
- 用
[MemoryDiagnoser]:分配字节数比时间更稳定、更有指导意义。 - 用
Consumer或返回值防止代码被优化掉:空循环会被 JIT 整个删除,这是本站多个页面反复踩过的坑(比如「循环累加有闭式解,编译器一步算完」)。BenchmarkDotNet 的规则是让基准方法有返回值,它会自动处理。 - 用
[Baseline = true]标出参照组,报告里会直接给出倍数。
// BenchmarkDotNet:一个最小可用的基准项目
// dotnet new console -o Bench && dotnet add Bench package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StringBench>();
[MemoryDiagnoser] // 报告里给出每次操作的分配量
public class StringBench
{
private readonly string _line = "key=value;other=thing";
[Benchmark(Baseline = true)]
public string WithSubstring()
{
int i = _line.IndexOf('=');
return _line.Substring(0, i); // 有返回值 → 不会被优化掉
}
[Benchmark]
public ReadOnlySpan<char> WithSpan()
{
var s = _line.AsSpan();
return s[..s.IndexOf('=')];
}
}
// 输出大致长这样(Mean / Allocated 两列最关键):
// | Method | Mean | Ratio | Allocated |
// | WithSubstring | xx.x ns | 1.00 | 32 B |
// | WithSpan | x.x ns | 0.xx | - |dotnet-dump collect 抓完整转储然后就走。抓 dump 会暂停整个进程,几 GB 的堆可能停几秒到几十秒——对在线服务就是一次故障。优先用 dotnet-gcdump(更小、更快)或 dotnet-counters(几乎无开销)。真要抓完整 dump,选低峰期或先把实例摘出负载均衡。标准库速查
BCL(基础类库)是 .NET 最大的资产之一——HTTP、JSON、加密、正则、并发、配置、日志全都内置。这一章按主题排布,用来「知道有这个东西、知道去哪找」,不追求逐条讲透;要理解契约与取舍去看 12 章的深水区。
字符串相关的 API 极多,这里列日常真正高频的那一批。比较与区域相关的部分见 03 章,那里有。
常用格式说明符
| 符 | 含义 | 示例 |
|---|---|---|
N2 | 千分位 + 2 位小数 | 1,234.50 |
F2 | 定点 2 位小数(无千分位) | 1234.50 |
C | 货币(跟区域) | ¥1,234.50 |
P1 | 百分比 | 42.0% |
X / x | 十六进制 | FF / ff |
D5 | 整数补零到 5 位 | 00042 |
O | 日期往返格式(ISO 8601) | 2026-07-29T00:00:38.79+08:00 |
yyyy-MM-dd | 自定义日期 | 2026-07-29 |
,-20 | 左对齐、宽 20(写在冒号前) | $"{name,-20}" |
// 判空(三个语义不同)
string.IsNullOrEmpty(s) // null 或 ""
string.IsNullOrWhiteSpace(s) // 再加"全是空白" —— 校验输入用这个
s is { Length: > 0 } // 模式匹配写法
// 切分与拼接
"a, b ,,c".Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
string.Join(" | ", items)
string.Concat(a, b, c)
s.Replace("x", "y") s.Trim() s.TrimStart('/') s.PadLeft(8, '0')
// 查找(一律显式指定比较方式)
s.Contains("abc", StringComparison.OrdinalIgnoreCase)
s.StartsWith("http", StringComparison.Ordinal)
s.IndexOf('=') s.LastIndexOf('.') s.IndexOfAny([',', ';'])
// 切片(零分配用 AsSpan)
s[2..5] s[^3..] s.AsSpan(2, 3)
// 大小写规范化:一律用 Invariant 版本
s.ToUpperInvariant() s.ToLowerInvariant()
// 构造
new string('-', 40) // "----...."
string.Create(len, state, (span, st) => {}) // 零中间分配
var sb = new StringBuilder(capacity); // 循环拼接
// 编码转换
Encoding.UTF8.GetBytes(s) / GetString(bytes)
Convert.ToBase64String(bytes) / FromBase64String(s)
Convert.ToHexString(bytes) / FromHexString(s) // .NET 5+
Uri.EscapeDataString(s) / UnescapeDataString(s)string.Format 与插值字符串里,花括号要写两个才能表示字面量花括号:$"{{literal}}" 输出 {literal}。写 JSON 模板时这会让代码难以阅读——用原始字符串 + 双 $$(03 章)或者干脆用序列化器,别手拼 JSON。Convert.ToHexString(.NET 5+)比老式的 BitConverter.ToString(b).Replace("-","") 短、快、且不产生中间字符串。同理 Convert.ToBase64String 有接受 Span 的重载,热路径上能完全免分配。按用途选集合的表在 08 章,这里是操作速查。C# 12 的集合表达式 [...] 统一了各种集合的字面量写法,新代码优先用它。
并发集合与它们的用途
| 类型 | 用于 |
|---|---|
ConcurrentDictionary<K,V> | 并发读写的缓存/映射;GetOrAdd 是核心方法 |
ConcurrentQueue / ConcurrentStack | 无锁队列/栈 |
ConcurrentBag<T> | 「不在乎顺序」的并发收集 |
BlockingCollection<T> | 同步的生产者/消费者(异步场景用 Channel,11 章) |
ImmutableArray / ImmutableList / ImmutableDictionary | 共享的只读数据;每次修改返回新实例 |
FrozenSet / FrozenDictionary(.NET 8+) | 建好后只读、查得极快的查表 |
// 创建(C# 12 集合表达式)
int[] a = [1, 2, 3];
List<int> l = [1, 2, 3];
Span<int> sp = [1, 2, 3];
int[] merged = [.. a, 99, .. l]; // 展开
var d = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };
// List 常用
l.Add(x) l.AddRange(xs) l.Insert(0, x)
l.Remove(x) l.RemoveAt(i) l.RemoveAll(p => p.IsOld)
l.Contains(x) l.IndexOf(x) l.Sort() l.Reverse()
l.BinarySearch(x) // 要求已排序
CollectionsMarshal.AsSpan(l) // 底层数组的 Span,零分配遍历
// Dictionary 常用
d.TryGetValue(k, out var v) // 首选
d.GetValueOrDefault(k, fallback)
d.TryAdd(k, v) // 已存在则返回 false,不抛
d.Remove(k, out var removed)
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
// HashSet 集合运算
hs.UnionWith(o) hs.IntersectWith(o) hs.ExceptWith(o)
hs.IsSubsetOf(o) hs.Overlaps(o)
// 并发:GetOrAdd 是 ConcurrentDictionary 的灵魂
var cache = new ConcurrentDictionary<string, Lazy<Data>>();
var data = cache.GetOrAdd(key, k => new Lazy<Data>(() => Load(k))).Value;
// 不可变 / 冻结
ImmutableArray<int> ia = [1, 2, 3];
var frozen = names.ToFrozenSet(StringComparer.OrdinalIgnoreCase);ImmutableList<T> 不是「只读的 List」,它是棵树。索引访问是 O(log n) 而不是 O(1),遍历也比数组慢得多。要「不可变 + 快速索引」用 ImmutableArray<T>(每次修改整体复制,适合读多写极少)。选错的表现是「换成不可变集合之后莫名其妙慢了」。ConcurrentDictionary.GetOrAdd 的工厂委托可能被并发调用多次(只有一个结果会被采用)。如果工厂很贵或有副作用,就像上面那样存 Lazy<T> 而不是 T——这样重复的只是廉价的 Lazy 对象,真正的初始化保证只跑一次。选型(该用 DateTime 还是 DateTimeOffset)和时区细节见 12、13 章,这里是 API 速查。
类型分工
| 类型 | 表示 |
|---|---|
DateTimeOffset | 某个时刻(带 UTC 偏移)——记录事件用它 |
DateTime | 日期+时间,有 Kind(Utc/Local/Unspecified) |
DateOnly / TimeOnly | 只有日期 / 只有时间(.NET 6+) |
TimeSpan | 时间长度 |
TimeProvider | 可注入、可测试的时钟(.NET 8+) |
Stopwatch | 测量耗时(单调时钟,不受系统时间调整影响) |
// 取值
DateTimeOffset.UtcNow // 服务端首选
DateTimeOffset.Now // 带本机偏移
DateOnly.FromDateTime(DateTime.UtcNow)
TimeOnly.FromDateTime(DateTime.UtcNow)
// 构造与运算
new DateTimeOffset(2026, 7, 29, 10, 0, 0, TimeSpan.FromHours(8))
t.AddDays(1) t.AddHours(-2) t.AddMonths(1)
(t2 - t1) // TimeSpan
TimeSpan.FromMinutes(90).TotalHours // 1.5
// 格式化
t.ToString("O") // 往返:存储/传输一律用这个
t.ToString("yyyy-MM-dd HH:mm:ss")
t.ToUnixTimeSeconds() / ToUnixTimeMilliseconds()
DateTimeOffset.FromUnixTimeSeconds(1800000000)
// 解析(一律显式指定区域与格式)
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var dto)
DateTime.TryParseExact(s, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
// 时区
var tz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
TimeZoneInfo.ConvertTime(dto, tz)
// 测耗时(不要用 DateTime 相减)
var ts = Stopwatch.GetTimestamp();
// ...
TimeSpan took = Stopwatch.GetElapsedTime(ts); // .NET 7+,零分配
// 可测试的时间
TimeProvider.System.GetUtcNow()
await Task.Delay(TimeSpan.FromSeconds(1), timeProvider, ct);TimeSpan.FromSeconds(0.5) 和 TimeSpan.FromMilliseconds(500) 相同,但 new TimeSpan(500) 完全不同——后者的参数是 tick(100 纳秒),new TimeSpan(500) 只有 50 微秒。构造 TimeSpan 一律用 From* 静态方法,别用带单个参数的构造函数。TryParseExact 比 TryParse 更适合处理已知格式的输入(接口返回、日志、CSV):它只接受你给的格式,不会因为区域设置或输入的细微变化而给出不同结果。反过来,处理用户手输的日期时 TryParse 更宽容。跨平台的路径陷阱见 13 章。这里是 API 速查——注意凡是有 Async 后缀的版本就用它(服务端尤其)。
三个静态类的分工
Path:纯字符串操作,不碰磁盘。拼接、取扩展名、取目录、规范化。File:单个文件的读写、复制、删除、属性。Directory:目录的创建、枚举、删除。- 另有
FileInfo/DirectoryInfo的实例版本——需要反复访问同一个文件的多个属性时用它们(会缓存一次系统调用的结果),一次性操作用静态版本。
// Path:只操作字符串
Path.Combine(a, b, c) // 绝对路径会吃掉前缀(13 章)
Path.Join(a, b) // 单纯拼接,更适合不可信片段
Path.GetFullPath(p) // 规范化(解析 .. 与相对路径)
Path.GetFileName(p) GetFileNameWithoutExtension(p)
Path.GetExtension(p) GetDirectoryName(p)
Path.ChangeExtension(p, ".bak")
Path.GetTempPath() Path.GetTempFileName() Path.GetRandomFileName()
// File:读写(优先异步版本)
await File.ReadAllTextAsync(p, ct)
await File.WriteAllTextAsync(p, s, ct)
await File.ReadAllBytesAsync(p, ct)
await File.AppendAllTextAsync(p, s, ct)
await foreach (var line in File.ReadLinesAsync(p, ct)) { } // .NET 7+,流式
// File:存在性与元数据
File.Exists(p) File.Delete(p) File.Move(a, b, overwrite: true)
File.Copy(a, b, overwrite: true)
File.GetLastWriteTimeUtc(p) new FileInfo(p).Length
// 流(大文件)
await using var fs = File.OpenRead(p);
await using var outp = File.Create(q);
await fs.CopyToAsync(outp, ct);
// Directory:创建与枚举
Directory.CreateDirectory(d) // 已存在也不报错,幂等
Directory.EnumerateFiles(d, "*.json", SearchOption.AllDirectories)
Directory.EnumerateDirectories(d)
Directory.Delete(d, recursive: true)
// 原子写入:先写临时文件再替换(避免写一半崩了留下损坏文件)
var tmp = p + ".tmp";
await File.WriteAllTextAsync(tmp, content, ct);
File.Move(tmp, p, overwrite: true);File.Exists 之后再打开文件是竞态:检查和打开之间文件可能被删除或被别的进程锁住。正确做法是直接尝试打开并捕获异常(FileNotFoundException、IOException)——这也是所有「先检查再操作」型代码的通病(TOCTOU)。File.Exists 适合做提示性判断,不适合做安全或正确性保证。Directory.EnumerateFiles 是惰性的(边枚举边返回),Directory.GetFiles 会先把全部结果攒进数组。扫描大目录树时前者内存友好得多,还能配合 Take/FirstOrDefault 提前结束。Enumerate 系列一律优先。HttpClient 的生命周期契约见 12 章(那是最容易出事的一处),JSON 的默认值也在 12 章有。这里是调用速查。
System.Net.Http.Json 的便捷扩展
- 它把「发请求 + 序列化 + 反序列化」压缩成一行:
GetFromJsonAsync<T>、PostAsJsonAsync、PutAsJsonAsync、ReadFromJsonAsync<T>。 - 注意
GetFromJsonAsync对非 2xx 会抛HttpRequestException(与裸GetAsync不同,后者不抛)。想自己处理状态码就用GetAsync+ReadFromJsonAsync。 - 返回类型是
T?:响应体是null时会得到null,别忘了处理。
using System.Net.Http.Json;
using System.Text.Json;
// 常用请求(一行完成)
var user = await http.GetFromJsonAsync<User>($"users/{id}", ct);
var resp = await http.PostAsJsonAsync("orders", dto, ct);
resp.EnsureSuccessStatusCode();
var created = await resp.Content.ReadFromJsonAsync<Order>(ct);
// 需要控制请求细节时
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(dto),
Headers = { { "X-Request-Id", id } },
};
using var r = await http.SendAsync(req, ct);
Console.WriteLine(r.StatusCode); // 非 2xx 不会抛
// 大响应:流式,不进内存
using var r2 = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
await using var s = await r2.Content.ReadAsStreamAsync(ct);
// JSON 序列化(options 务必复用,见 12 章)
var json = JsonSerializer.Serialize(obj, Json.Options);
var obj2 = JsonSerializer.Deserialize<Dto>(json, Json.Options);
await JsonSerializer.SerializeAsync(stream, obj, Json.Options, ct);
var obj3 = await JsonSerializer.DeserializeAsync<Dto>(stream, Json.Options, ct);
// 不定结构:JsonNode(可变) / JsonDocument(只读、需 Dispose)
var node = JsonNode.Parse(json)!;
string? name = node["items"]?[0]?["name"]?.GetValue<string>();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("total", out var el))
Console.WriteLine(el.GetInt32());
// URL 构造:别手拼查询串
var uri = new UriBuilder("https://api.example.com/search")
{
Query = string.Join('&', [$"q={Uri.EscapeDataString(q)}", $"page={page}"]),
}.Uri;GetFromJsonAsync 在非 2xx 时抛 HttpRequestException,而 GetAsync 不抛——两个 API 的错误语义相反,混用时很容易漏掉状态码检查。另外 404 也会抛,而「资源不存在」往往是正常业务分支。需要区分 404 和真错误时,用 GetAsync 自己判 StatusCode。System.Net.Http.Json 的扩展方法有接受 JsonSerializerOptions 和 JsonTypeInfo(源生成器)的重载——NativeAOT 项目必须用后者,否则运行时会因为反射被裁剪而失败(16 章)。写库时优先暴露接受 JsonTypeInfo 的 API。.NET 的正则引擎功能完整(回溯式,支持平衡组等高级特性),但用法上有两个今天必须知道的点:源生成器和灾难性回溯的防护。
三种用法,性能差很多
| 写法 | 编译时机 | 适用 |
|---|---|---|
Regex.IsMatch(s, pattern)(静态) | 运行时解析,有内部缓存 | 一次性、脚本 |
new Regex(pattern, RegexOptions.Compiled) | 运行时生成 IL | 热路径,但启动有成本、AOT 下不可用 |
[GeneratedRegex](.NET 7+) | 编译期生成代码 | 首选:最快、AOT 友好、模式错误在编译期就报 |
using System.Text.RegularExpressions;
// ✓ 首选:源生成器(编译期生成匹配代码)
public partial class Validators
{
[GeneratedRegex(@"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
public static partial Regex Email();
}
if (Validators.Email().IsMatch(input)) { }
// 常用操作
Regex re = Validators.Email();
re.IsMatch(s)
re.Match(s).Groups["name"].Value // 命名组 (?<name>...)
re.Matches(s) // 全部匹配(惰性枚举)
re.Replace(s, "$1-$2") // 反向引用
re.Replace(s, m => m.Value.ToUpperInvariant()) // 委托替换
re.Split(s)
re.EnumerateMatches(s.AsSpan()) // .NET 7+:零分配枚举
// 超时保护(防灾难性回溯)
var safe = new Regex(pattern, RegexOptions.None, TimeSpan.FromMilliseconds(200));
// 超时抛 RegexMatchTimeoutException
// 进程级默认超时(Program.cs 里设一次,兜住所有正则)
AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT",
TimeSpan.FromSeconds(2));
// 非回溯引擎:牺牲部分特性换取线性时间保证(.NET 7+)
var linear = new Regex(pattern, RegexOptions.NonBacktracking);
// 不支持反向引用与前后查找,但永远不会指数级爆炸(a+)+$ 的模式在不匹配的长输入上会指数级爆炸,一个请求就能吃满一个 CPU 核。凡是模式来自用户输入、或者要匹配用户提供的长文本,必须设超时或用 NonBacktracking。这条在处理上传文件、搜索关键词、日志解析时尤其重要。StartsWith/EndsWith、切分用 Split、找字符用 IndexOfAny 或 SearchValues(14 章)——都比正则快一个数量级且更好读。正则适合「结构化提取」,不适合「简单判断」。异步的组合与限流在 11 章,这里是共享状态的保护——什么时候用锁、什么时候用原子操作、什么时候干脆不共享。
按代价从低到高
| 手段 | 用于 | 代价 |
|---|---|---|
| 不共享(每线程一份,最后合并) | 能做到就做到 | 零 |
Interlocked | 单个数值的原子增减/交换 | 极低(CPU 指令) |
Lock(.NET 9+)/ lock | 短临界区,不能 await | 低(无竞争时几纳秒) |
SemaphoreSlim(1) | 异步互斥(可以 await) | 中 |
ReaderWriterLockSlim | 读远多于写 | 中高,常不如直接用锁 |
并发集合 / Channel | 队列、映射 | 内部已优化 |
// .NET 9+:专门的 Lock 类型(比 lock(object) 更快、意图更明确)
private readonly Lock _sync = new();
public void Add(int x) { lock (_sync) { _total += x; } }
// 原子操作:单个数值不用加锁
Interlocked.Increment(ref _counter);
Interlocked.Add(ref _total, n);
Interlocked.Exchange(ref _current, newValue);
Interlocked.CompareExchange(ref _state, want, expected); // CAS
// 异步互斥(lock 里不能 await)
private readonly SemaphoreSlim _gate = new(1, 1);
await _gate.WaitAsync(ct);
try { await DoAsync(); } finally { _gate.Release(); } // finally 必须有
// 只初始化一次
private static readonly Lazy<Config> _cfg = new(() => Load()); // 线程安全
private readonly SemaphoreSlim _once = new(1, 1); // 异步版见 11 章
// 生产者/消费者(异步首选 Channel)
var ch = Channel.CreateBounded<Job>(100);
await ch.Writer.WriteAsync(job, ct);
await foreach (var j in ch.Reader.ReadAllAsync(ct)) { }
// 并行计算(CPU 密集)
Parallel.For(0, n, i => Compute(i));
Parallel.ForEach(items, Process);
await Parallel.ForEachAsync(items, opts, async (x, ct) => await Go(x, ct));
var sum = items.AsParallel().Sum(x => x.Value); // PLINQ
// 线程本地
private static readonly ThreadLocal<Random> _rng = new(() => new Random());
// 更简单:直接用 Random.Shared(12 章)lock 块里绝不能做 IO 或调用外部代码。持锁期间发一次 HTTP 请求,就等于让所有等锁的线程一起等这个请求;调用回调/事件更危险——对方可能再去拿别的锁,构成死锁。临界区应该短到「几行内存操作」的程度:把耗时工作放到锁外面,锁里只做状态更新。Task.WhenAll 收集结果就是这个思路)。锁是最后手段,不是第一反应。Microsoft.Extensions.* 这一族包提供了配置、日志、DI、选项、健康检查等基础设施。它们不只属于 ASP.NET Core——控制台程序、后台服务、甚至类库都能用同一套。
配置来源的优先级(后面覆盖前面)
appsettings.json→appsettings.{Environment}.json→ 用户机密(开发期)→ 环境变量 → 命令行参数。- 环境变量里用
__表示层级(13 章):Logging__LogLevel__Default=Warning。 - 密钥不要写进 appsettings.json:开发期用
dotnet user-secrets,生产用环境变量或密钥服务(Azure Key Vault / AWS Secrets Manager 都有官方 provider)。
DI 的三种生命周期
| 生命周期 | 含义 | 用于 |
|---|---|---|
Singleton | 整个应用一个实例 | 无状态服务、缓存、配置 |
Scoped | 每个「作用域」一个(Web 里=每个请求) | DbContext、工作单元 |
Transient | 每次解析一个新的 | 轻量、无状态的小对象 |
- 最经典的 DI 事故:把 Scoped 服务注入进 Singleton——那个 Scoped 实例会被 Singleton 一直持有,变成事实上的 Singleton(DbContext 这么用会立刻出并发问题)。容器在启动时会检测并报错,别关掉这个检查。
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
// 控制台/后台服务也能用完整的主机(不只是 Web)
var builder = Host.CreateApplicationBuilder(args);
// 配置:强类型绑定 + 校验
builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart(); // 配置错就在启动时失败,不要等到用的时候
// DI 注册
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailSender, SmtpSender>();
builder.Services.AddHttpClient<GitHubClient>();
builder.Services.AddHostedService<CleanupWorker>(); // 后台常驻任务
var app = builder.Build();
await app.RunAsync();
// 构造函数注入(主构造函数写起来最短)
public sealed class OrderService(
IClock clock,
ILogger<OrderService> logger,
IOptions<SmtpOptions> options) : IOrderService
{
public void Handle(Order o)
{
logger.LogInformation("处理订单 {OrderId},金额 {Amount}", o.Id, o.Amount);
// ↑ 结构化日志:不要用插值字符串(03 章)
}
}
// 配置读取(弱类型,少用)
var cs = builder.Configuration.GetConnectionString("Default");
var n = builder.Configuration.GetValue<int>("Worker:BatchSize", 100);
// 后台任务
public sealed class CleanupWorker(ILogger<CleanupWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
await CleanAsync(ct);
}
}IOptions<T>、IOptionsSnapshot<T>、IOptionsMonitor<T> 三者不能混用。IOptions 是单例、配置变了也不会更新;IOptionsSnapshot 是 Scoped、每个请求重新读;IOptionsMonitor 是单例但支持变更通知。在 Singleton 服务里注入 IOptionsSnapshot 会直接抛异常(Scoped 进 Singleton),而用 IOptions 期待热更新则是静默失效。PeriodicTimer(.NET 6+)是写周期性后台任务的正确工具:它是异步的(不占线程)、不会重入(上一轮没跑完不会触发下一轮)、且天然支持 CancellationToken。老式的 System.Threading.Timer 回调可能并发执行,需要自己加锁——看到后台服务里用 Timer 就该考虑换掉。跨平台工程化:发布、RID、AOT、容器
这一章是「.NET 跨平台」这句话真正兑现的地方:一台 Windows 机器上能编出 Linux 的可执行文件,一个 12 MB 的单文件能扔到没装运行时的机器上直接跑。本章所有体积与启动时间都是,你自己跑一遍会看到同样的量级。
dotnet publish 有三个正交的开关,组合出常见的几种交付形态。先看真实体积——一个只有一行 Console.WriteLine 的程序,(.NET 10.0.302,win-x64,Release):
同一个程序,五种发布方式
| 方式 | 体积 | 文件数 | 目标机器要装什么 |
|---|---|---|---|
| 框架依赖(默认) | 0.2 MB | 5 | 对应版本的 .NET 运行时 |
| 自包含 win-x64 | 76.6 MB | 192 | 什么都不用装 |
| 自包含 + 单文件 | 70.1 MB | 2 | 什么都不用装 |
| 自包含 + 单文件 + 裁剪 | 12.3 MB | 2 | 什么都不用装 |
| NativeAOT | 1.06 MB | 1(+pdb) | 什么都不用装 |
- 框架依赖的 5 个文件是:
hello.dll(4.5 KB,你的代码)、hello.exe(158 KB,启动壳)、hello.deps.json(依赖清单)、hello.runtimeconfig.json(要哪个运行时)、hello.pdb(调试符号)。真正属于你的只有那 4.5 KB。 - 76 MB 里绝大部分是整个 .NET 运行时与 BCL,与你的代码量无关——写一万行业务代码,这个数字也几乎不变。
怎么选
| 场景 | 选 |
|---|---|
| 容器部署(镜像里已有运行时) | 框架依赖——镜像分层复用,实际传输量最小 |
| 给用户分发的 CLI 工具 | 单文件 + 裁剪,或 NativeAOT |
| 目标机器环境不可控 | 自包含 |
| Serverless / 冷启动敏感 | NativeAOT |
| 需要反射、动态加载插件 | 框架依赖或自包含(不裁剪、不 AOT) |
# 框架依赖(默认):最小,但目标机要有运行时
$ dotnet publish -c Release -o out
# 自包含:带上整个运行时
$ dotnet publish -c Release -r win-x64 --self-contained true -o out
# 单文件:所有 dll 打进一个 exe(启动时解压到临时目录或内存中加载)
$ dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishSingleFile=true -o out
# 单文件 + 裁剪:70.1 MB → 12.3 MB
$ dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishSingleFile=true -p:PublishTrimmed=true -o out
# 也可以把这些写进 csproj,避免每次敲一长串
<PropertyGroup>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<InvariantGlobalization>true</InvariantGlobalization> <!-- 再省几 MB,注意 13 章的坑 -->
</PropertyGroup>
# 不带 pdb(生产分发时常见)
$ dotnet publish -c Release -p:DebugType=none -o outAssembly.Location 返回空字符串——用它定位资源文件的代码会静默失效,要改用 AppContext.BaseDirectory。RID(Runtime Identifier)就是「目标平台」的标识:win-x64、linux-x64、linux-arm64、osx-arm64。指定了它,SDK 就会拉对应平台的运行时包并产出那个平台的产物——在哪台机器上编译无所谓。
在 Windows 上发布 Linux 版本
$ dotnet publish -c Release -r linux-x64 --self-contained true -o out/linux // 成功,产出 78.8 MB / 192 个文件 // 里面是 Linux 的 .so 原生库和一个无扩展名的可执行文件
- 这就是「.NET 跨平台」最直接的体感:不需要 Linux 机器、不需要 Docker、不需要交叉编译工具链,一条命令就能产出另一个平台的完整应用。
- 唯一的例外是 NativeAOT:它要调用目标平台的原生链接器,所以必须在目标平台上编译(或用容器/交叉工具链)。
常见 RID
| RID | 目标 |
|---|---|
win-x64 / win-arm64 | Windows |
linux-x64 | 主流 Linux(glibc) |
linux-musl-x64 | Alpine(musl libc)——用错会启动失败 |
linux-arm64 | ARM 服务器、树莓派 64 位、Apple Silicon 上的 Linux 容器 |
osx-x64 / osx-arm64 | macOS Intel / Apple Silicon |
- .NET 8 起 RID 图被简化了:不再有
ubuntu.22.04-x64这种发行版级 RID,统一用linux-x64。老项目里那些细分 RID 应该清掉。 - 运行时可以自报:
RuntimeInformation.RuntimeIdentifier返回win-x64。
多目标框架(TFM)与 RID 是两回事
- TFM(
net8.0、net10.0)说的是「用哪个版本的 API」;RID 说的是「跑在哪个操作系统和架构上」。 - 带平台的 TFM(
net10.0-windows、net10.0-android)会解锁平台专属 API,也定义WINDOWS之类的编译符号(13 章)。 - 类库通常不指定 RID,保持可移植;只有最终可执行项目才需要。
# 一次构建多个平台(发布 CLI 工具时常见)
$ foreach rid in win-x64 linux-x64 linux-arm64 osx-arm64; do
dotnet publish -c Release -r $rid --self-contained true \
-p:PublishSingleFile=true -p:PublishTrimmed=true \
-o dist/$rid
done
# Alpine 容器必须用 musl 的 RID
$ dotnet publish -c Release -r linux-musl-x64 --self-contained true -o out
# csproj:多 TFM + 平台专属 API
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks> <!-- 类库兼容两个版本 -->
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="某个只有旧版才需要的兼容包" Version="8.0.0" />
</ItemGroup>
# 按 TFM 写条件代码
#if NET10_0_OR_GREATER
var id = Guid.CreateVersion7();
#else
var id = Guid.NewGuid();
#endifRuntimeIdentifier 之后,dotnet build/dotnet run 的输出路径会多一层 RID 目录(bin/Debug/net10.0/win-x64/)。CI 脚本里硬编码路径的地方会突然找不到文件。用 dotnet publish -o <固定目录> 显式指定输出,别去猜默认路径——这条也适用于多 TFM 项目(每个 TFM 一个子目录)。linux-musl-x64,不是 linux-x64。用错的表现是容器启动时报找不到 libstdc++.so 之类的原生库,或者干脆没有任何输出就退出——因为 glibc 编的原生库在 musl 上加载不了。这是切换到 Alpine 基础镜像时最常见的第一个坑。裁剪(trimming)会分析你的代码,把用不到的类型和方法从产物里删掉。把同一个程序从 70.1 MB 削到 12.3 MB——但它的前提假设是「静态可分析」,而反射恰好打破这个假设。
它是怎么工作的
- 从入口点开始做可达性分析:你的
Main调用了什么、那些方法又调用了什么,一路标记。没被标记到的全部删除。 - 所以「BCL 里 99% 的东西你都没用到」这件事就变成了实实在在的体积节省。
- 反射是天敌:
Type.GetType("MyApp.Handler")这种字符串驱动的调用,分析器看不出来,于是Handler类被删了——运行时才抛TypeLoadException或者拿到null。
编译器会警告你:IL2026 / IL3050
warning IL2026: Using member 'JsonSerializer.Serialize<TValue>(...)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. ... Use the overload that takes a JsonTypeInfo or JsonSerializerContext ...
- 这是跑 JSON 序列化时出现的真实警告原文。裁剪相关警告一条都不能忽略——它们是「这里运行时会炸」的准确预报。
- 开
<PublishTrimmed>true</PublishTrimmed>后,SDK 会自动打开这套分析器。建议再加<TrimmerSingleWarn>false</TrimmerSingleWarn>,让每一处都单独报告而不是汇总成一条。
常见的「裁剪不友好」及其替代
| 不友好的做法 | 替代 |
|---|---|
JsonSerializer.Serialize<T>(obj) | 源生成器 JsonSerializerContext(12 章) |
Type.GetType(name) 动态加载 | 显式注册表 / 编译期已知的工厂委托 |
Activator.CreateInstance | Func<T> 委托(09 章) |
| 基于反射的 ORM / 映射器 | 源生成器版本(Dapper.AOT、Mapperly 等) |
dynamic | 强类型 / JsonNode |
| 反射式配置绑定 | Microsoft.Extensions.Configuration.Binder 的源生成器 |
- 确实必须保留某些类型时,用
[DynamicDependency]特性或TrimmerRootDescriptorXML 文件显式告诉裁剪器「别删这个」。但这是补丁,不是设计。
<!-- csproj:开启裁剪并让警告可见 -->
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode> <!-- 默认就是 full;partial 只裁剪标记过的程序集 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn> <!-- 逐条报告,不要汇总 -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
// 标注"这个方法用了反射,裁剪下不安全"(写库时的责任)
[RequiresUnreferencedCode("使用反射扫描程序集,裁剪下可能失效")]
public static void ScanHandlers(Assembly asm) { /* ... */ }
// 显式保留某个类型(补丁手段)
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(MyPlugin))]
static void EnsurePluginKept() { }
// 告诉分析器"这个 Type 参数的哪些成员会被反射用到"
public static object Create(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
Type t) => Activator.CreateInstance(t)!;
# 排查:看裁剪到底删了什么
$ dotnet publish -c Release -r linux-x64 --self-contained \
-p:PublishTrimmed=true -p:TrimmerRemoveSymbols=false \
-p:_TrimmerDumpDependencies=true
# 产出 linker-dependencies.xml.gz,可用 illinkanalyzer 分析保留原因NativeAOT 把 IL 提前编译成目标平台的机器码,产出一个不含运行时、不需要 JIT 的原生可执行文件。它是 .NET 在「冷启动」和「体积」上的终极答案,代价是放弃一整类动态能力。
(win-x64,同一个 hello world)
| 指标 | 框架依赖 | 单文件自包含 | NativeAOT |
|---|---|---|---|
| 体积 | 0.2 MB(需运行时) | 70.1 MB | 1.06 MB |
| 启动(10 次取中位) | 54 ms | 70 ms | 24 ms |
| 需要目标机装 .NET | 是 | 否 | 否 |
- 启动快约一倍,体积只有单文件的六十分之一。启动时间会因机器而异,但方向是确定的——没有 JIT 预热、没有运行时初始化。
- 对 Serverless(每次冷启动都算钱)、CLI 工具(用户等不了半秒)、容器密集部署(内存占用也更低)来说,这个差距是决定性的。
代价清单:这些能力没了
- 没有 JIT:
Reflection.Emit、Expression.Compile()、动态代理(Castle、Moq 的默认模式)、运行时生成的正则(RegexOptions.Compiled)——全部不可用。 - 不能动态加载程序集:
Assembly.LoadFrom、插件体系。 - 反射受限:能用,但只有被静态分析到的成员会保留;
MakeGenericType遇到编译期推不出的组合会失败(09 章)。 - 必须在目标平台上编译:需要调用平台原生链接器,不能像普通发布那样交叉编译。
- 编译慢:一个真实项目的 AOT 编译可能要几分钟。
一个具体的门槛
// Windows 上第一次跑 AOT 发布,失败: error MSB3073: The command ""'vswhere.exe' 不是内部或外部命令… ;…\VC\Tools\MSVC\…\link.exe" @"…link.rsp"" exited with code 123. // 原因:NativeAOT 在 Windows 上需要 MSVC 的链接器与 C++ 生成工具, // 且 SDK 通过 vswhere.exe 定位它。把 VS Installer 目录加进 PATH 后重跑即成功。
- 各平台的原生工具链前置条件:Windows 需要「使用 C++ 的桌面开发」工作负载;Linux 需要
clang与zlib开发包;macOS 需要 Xcode 命令行工具。CI 镜像里要预装。
<!-- csproj:开启 NativeAOT(会自动启用裁剪) -->
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization> <!-- 再省体积,注意 13 章 -->
<StripSymbols>true</StripSymbols> <!-- Linux/macOS 上剥离符号 -->
<OptimizationPreference>Size</OptimizationPreference> <!-- 或 Speed -->
</PropertyGroup>
# 发布(必须在目标平台上)
$ dotnet publish -c Release -r linux-x64 -p:PublishAot=true -o out
# 用容器在 Linux 上编 Linux 的 AOT 产物(Windows 开发机的标准做法)
# Dockerfile 里用带 AOT 工具链的 SDK 镜像:
# FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# RUN apt-get update && apt-get install -y clang zlib1g-dev
// AOT 友好的写法:JSON 用源生成器(12 章)
[JsonSerializable(typeof(WeatherForecast[]))]
internal partial class AppJsonContext : JsonSerializerContext;
var builder = WebApplication.CreateSlimBuilder(args); // Slim:AOT 友好的精简主机
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
// 模板:dotnet new webapiaot 直接生成一个 AOT 就绪的 Web API 项目Reflection.Emit,EF Core 的部分功能、多数反射式对象映射器也是。这不意味着不能用——测试项目本来就不需要 AOT 发布,只是要接受「测试跑在 JIT 下、生产跑在 AOT 下」这个差异,并在 CI 里对 AOT 产物做端到端验证。dotnet new webapiaot 或 dotnet new console + PublishAot 起步,别拿存量大项目直接试。AOT 的兼容性问题会一次性全部涌出来,很难分辨哪个是真障碍。新项目从第一天就开着 PublishAot,每次引依赖时立刻知道兼容不兼容——增量地保持 AOT 就绪,比事后迁移容易一个数量级。.NET 的容器化今天有两条路:写 Dockerfile(可控)和 dotnet publish /t:PublishContainer(零 Dockerfile)。两条都成熟,按团队习惯选。
官方基础镜像怎么选
| 镜像 | 含 | 体积量级 | 用于 |
|---|---|---|---|
dotnet/sdk:10.0 | 编译器 + 运行时 | 大 | 只用于构建阶段 |
dotnet/aspnet:10.0 | ASP.NET Core 运行时 | 中 | Web 应用的运行阶段 |
dotnet/runtime:10.0 | 基础运行时 | 中 | 控制台/后台服务 |
dotnet/runtime-deps:10.0 | 只有原生依赖 | 小 | 自包含/AOT 发布的运行阶段 |
…-alpine | 基于 Alpine(musl) | 更小 | 要用 linux-musl-x64 RID |
…-noble-chiseled | 极简、无 shell 无包管理器 | 最小 | 生产(攻击面最小) |
- chiseled 镜像是今天的推荐默认:只保留运行 .NET 所需的最小文件集,没有 shell 就没法被 exec 进去乱来,CVE 数量也大幅下降。代价是排障时进不去容器(只能靠日志和 sidecar)。
- 镜像默认以非 root 用户运行(.NET 8 起),并监听 8080 端口(不再是 80,因为非 root 不能绑定 1024 以下)。
多阶段构建的关键:分层缓存
- 先只拷贝 csproj/sln 并
restore,再拷贝源码——这样只要依赖没变,restore那一层就命中缓存。改一行代码重建的时间能从几分钟降到十几秒。 - 这是 .NET Dockerfile 里最值钱的一个技巧,模板里都有,但手写时经常被漏掉。
# ---------- 多阶段 Dockerfile(框架依赖,最常用) ----------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# ① 先只拷项目文件,让 restore 层能被缓存
COPY *.sln .
COPY src/Shop.Api/*.csproj src/Shop.Api/
COPY src/Shop.Core/*.csproj src/Shop.Core/
RUN dotnet restore
# ② 再拷源码并发布
COPY . .
RUN dotnet publish src/Shop.Api -c Release -o /app --no-restore
# ---------- 运行阶段:只带运行时 ----------
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "Shop.Api.dll"]
# ---------- AOT / 自包含的运行阶段(不需要运行时) ----------
# FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled
# COPY --from=build /app/Shop.Api .
# ENTRYPOINT ["./Shop.Api"]
# ---------- 完全不写 Dockerfile ----------
$ dotnet publish -c Release /t:PublishContainer \
-p:ContainerRepository=shop-api \
-p:ContainerImageTag=1.2.3 \
-p:ContainerFamily=noble-chiseled
# 配置一律走环境变量(13 章:双下划线表示层级)
$ docker run -e ConnectionStrings__Default="Host=db;..." \
-e Logging__LogLevel__Default=Warning \
-e ASPNETCORE_ENVIRONMENT=Production \
-p 8080:8080 shop-api:1.2.3icu-libs/tzdata,不需要就明确开 InvariantGlobalization 并在代码里写清楚。dotnet publish /t:PublishContainer 不需要 Docker daemon——它直接产出符合 OCI 规范的镜像层并可以推到 registry。这让「在没有 Docker 的 CI runner 上构建镜像」成为可能,也省掉了维护 Dockerfile 的成本。需要精细控制(多阶段、装系统包、改用户)时再写 Dockerfile。项目一多,「每个 csproj 各写各的」就会失控——版本号不一致、警告级别不一致、有的开了可空有的没开。.NET 有几个专门解决这个问题的文件。
四个仓库级文件
| 文件 | 作用 |
|---|---|
Directory.Build.props | 放在仓库根,所有 csproj 自动继承这里的属性 |
Directory.Packages.props | 中央包管理(CPM):版本号统一在这里定,子项目只写包名 |
global.json | 钉死 SDK 版本,保证团队与 CI 用同一个 |
.editorconfig | 代码风格与分析器规则;dotnet format 按它执行 |
Directory.Build.props是最值得先加的一个:把Nullable、TreatWarningsAsErrors、LangVersion、AnalysisMode统一写在这里,新项目自动继承,不会有人漏配。- 中央包管理解决「同一个包在三个项目里是三个版本」的问题——那种情况下 NuGet 会做版本统一,但结果可能出乎意料。
推荐的项目分层
src/ Shop.Api/ // 入口:ASP.NET Core,只做 HTTP 相关 Shop.Application/ // 用例编排 Shop.Domain/ // 领域模型,不依赖任何基础设施 Shop.Infrastructure/ // 数据库、外部服务 tests/ Shop.UnitTests/ Shop.IntegrationTests/
- 依赖方向单向:Api → Application → Domain,Infrastructure 实现 Domain 定义的接口。Domain 项目的 csproj 里应该一个
PackageReference都没有——这是检验分层有没有塌的最快方法。 - 不必一开始就分这么细。单项目起步、按需拆分是更健康的路径;过早分层比不分层更难改。
<!-- Directory.Build.props(仓库根):所有项目继承 -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisMode>Recommended</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
</Project>
<!-- Directory.Packages.props:版本统一在这里 -->
<Project>
<ItemGroup>
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="xunit.v3" Version="3.1.0" />
</ItemGroup>
</Project>
<!-- 子项目:只写包名,不写版本 -->
<PackageReference Include="Serilog.AspNetCore" />
# global.json:钉死 SDK(避免"我这能编、CI 编不过")
$ dotnet new globaljson --sdk-version 10.0.302 --roll-forward latestFeature
# 常用的仓库级命令
$ dotnet build # 编译整个解决方案
$ dotnet test # 跑全部测试
$ dotnet format # 按 .editorconfig 格式化
$ dotnet format --verify-no-changes # CI 里检查格式(不改文件,有差异就失败)
$ dotnet list package --vulnerable --include-transitive # 安全检查Directory.Build.props 的查找是「向上找到第一个就停」——如果子目录里也有一个同名文件,父目录那个不会被自动合并。需要继承时要在子文件里显式 <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />。忘了这一步的表现是「根目录配的规则对某个子项目不生效」,而且完全没有提示。dotnet list package --vulnerable --include-transitive 应该进 CI:它会列出所有已知有漏洞的依赖,包括传递依赖。配合 --outdated 定期看一眼版本落后情况。这两条命令不需要任何第三方服务,是投入产出比最高的供应链安全措施。NuGet 是 .NET 的包管理器。日常使用很简单,值得讲的是版本策略和可复现构建这两件在团队里会出问题的事。
版本号与还原行为
- 写
Version="4.3.0"表示「至少 4.3.0」而不是「恰好 4.3.0」——这是 NuGet 最反直觉的一点。NuGet 会选满足所有约束的最低版本,但如果别的包要求更高,就会被抬上去。 - 要精确锁定写
[4.3.0](方括号表示闭区间)。要允许补丁更新写4.3.*。 - 真正的可复现构建靠锁文件:
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>生成packages.lock.json,CI 里用dotnet restore --locked-mode强制一致。这是团队项目该开的。
打自己的包
dotnet pack -c Release产出.nupkg。元数据全部写在 csproj 里:PackageId、Version、Authors、Description、PackageLicenseExpression、RepositoryUrl。- 务必开
<GenerateDocumentationFile>true</GenerateDocumentationFile>——XML 文档注释会随包发布,使用者在 IDE 里就能看到你的说明。 - 源链接(Source Link):加
<PublishRepositoryUrl>true</PublishRepositoryUrl>与<EmbedUntrackedSources>true</EmbedUntrackedSources>,使用者调试时能直接步进你的源码。今天这是发布公开包的基本礼仪。
私有源与安全
- 私有包和公有包混用时要小心依赖混淆攻击:攻击者在 nuget.org 上传一个和你内部包同名、版本更高的包。防御是用
<packageSourceMapping>把包名前缀绑定到指定源。 - nuget.config 里不要写明文凭据——用环境变量或
dotnet nuget add source --username/--password --store-password-in-clear-text false。
<!-- 一个可发布的类库 csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<PackageId>Acme.Toolkit</PackageId>
<Version>1.2.3</Version>
<Description>一句话说明这个包干什么</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/acme/toolkit</RepositoryUrl>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IsAotCompatible>true</IsAotCompatible> <!-- 声明并让分析器检查 -->
</PropertyGroup>
</Project>
# 打包与发布
$ dotnet pack -c Release -o ./nupkgs
$ dotnet nuget push ./nupkgs/*.nupkg -s nuget.org -k $API_KEY
# 锁定还原(可复现构建)
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
$ dotnet restore --locked-mode # CI 里用;锁文件不匹配直接失败
# nuget.config:包源映射,防依赖混淆
<packageSourceMapping>
<packageSource key="nuget.org"><package pattern="*" /></packageSource>
<packageSource key="internal"><package pattern="Acme.*" /></packageSource>
</packageSourceMapping>
# 本机上的一个环境问题:NuGet 源列表为空时,任何 restore 都会失败
$ dotnet nuget list source # 确认至少有 nuget.org
$ dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.orgdotnet nuget list source 返回「No sources found」时,任何需要还原包的操作都会失败,报的却是一堆 NU1100: Unable to resolve 'xxx'——看起来像网络问题或包不存在,实际是一个源都没配。企业内网机器、被清理过配置的机器上都可能出现。遇到大批 NU1100 先跑一句 dotnet nuget list source。<IsAotCompatible>true</IsAotCompatible> 是给类库作者的一个好开关:它会同时打开裁剪与 AOT 分析器,让你在写库的时候就知道哪些代码对 AOT 不友好,而不是等使用者来报 bug。今天发布公开包时,声明 AOT 兼容性已经是一个明显的加分项。ASP.NET Core:Minimal API 实战
ASP.NET Core 是 .NET 在服务端的主战场,也是「跨平台」最常被兑现的地方。这一章用 Minimal API 从零搭一个真实的 HTTP 服务:路由、绑定、返回值、中间件、依赖注入、错误处理、OpenAPI——每一步的行为都用真实请求验证过。
ASP.NET Core 的入门门槛已经低到不需要任何仪式:dotnet new web 生成的 Program.cs 只有 5 行。
模板原文
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", () => "Hello World!"); app.Run();
csproj里的关键差别只有一处:Sdk="Microsoft.NET.Sdk.Web",它带来 ASP.NET Core 的全部框架引用与发布规则。不需要写<OutputType>Exe</OutputType>。builder阶段配置服务(依赖注入、配置、日志);app阶段配置管道(中间件、路由)。两个阶段以Build()为界,之后不能再加服务。
三种项目模板
| 模板 | 产出 |
|---|---|
dotnet new web | 最小的空 Web 应用(本卡这个) |
dotnet new webapi | Minimal API + OpenAPI + 示例端点 |
dotnet new webapiaot | NativeAOT 就绪(CreateSlimBuilder + JSON 源生成器,16 章) |
中间件管道:顺序就是一切
- 中间件是一层套一层的洋葱:每个
app.UseXxx()按注册顺序处理请求,再按相反顺序处理响应。 - 顺序写错是静默失败:把
UseAuthentication放在UseRouting之前、把UseCors放在错误的位置,都不会报错,只是不生效。 - 推荐顺序(记住这一串就够):
UseExceptionHandler→UseHttpsRedirection→UseStaticFiles→UseRouting→UseCors→UseAuthentication→UseAuthorization→ 端点。 - Minimal API 里
UseRouting/UseEndpoints通常可以省略——框架会自动补上,但一旦你手动调用了其中之一,顺序就得自己管好。
var builder = WebApplication.CreateBuilder(args);
// ① 服务注册(Build 之前)
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi(); // 需要 Microsoft.AspNetCore.OpenApi 包
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddHttpClient<PaymentClient>();
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.WithOrigins("https://app.example.com").AllowAnyHeader().AllowAnyMethod()));
var app = builder.Build();
// ② 中间件管道(顺序敏感!)
app.UseExceptionHandler(); // 最外层:兜住后面所有异常
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// ③ 端点
app.MapOpenApi(); // /openapi/v1.json
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
// 自定义中间件:一个 lambda 就是一层
app.Use(async (ctx, next) =>
{
var ts = Stopwatch.GetTimestamp();
await next(ctx); // 不调 next 就是短路
app.Logger.LogInformation("{Method} {Path} → {Status} 用时 {Ms}ms",
ctx.Request.Method, ctx.Request.Path, ctx.Response.StatusCode,
Stopwatch.GetElapsedTime(ts).TotalMilliseconds);
});
app.Run();launchSettings.json 只在开发期由 dotnet run 读取,发布产物里根本没有它。「本地能起、部署上去端口不对/环境变量没了」几乎都是把配置写进了这个文件。它属于「开发机便利」,生产配置必须走环境变量或 appsettings。调试这类问题时可以加 --no-launch-profile 忽略它,看看真实行为(本章的就是这么跑的)。--urls 命令行参数 > ASPNETCORE_URLS 环境变量 > launchSettings.json(只在开发机生效,不会被发布)> 默认值。容器里一律用环境变量 ASPNETCORE_URLS=http://+:8080(16 章),+ 表示监听所有网卡——只写 localhost 会导致容器外访问不到。Minimal API 的绑定规则很直观:参数名匹配路由模板就取路径值,否则从查询串取,复杂类型从请求体取,已注册的服务从 DI 取。规则简单到不需要任何特性标注。
绑定来源的优先级
| 参数 | 从哪来 |
|---|---|
名字出现在路由模板里(/hello/{name}) | 路径 |
其它简单类型(int/string/Guid/DateTime…) | 查询串 |
| 复杂类型(自定义 record/class) | 请求体 JSON |
| 已在 DI 注册的类型 | 依赖注入 |
HttpContext / CancellationToken / ClaimsPrincipal | 框架特殊处理 |
- 要显式指定就加特性:
[FromQuery]、[FromRoute]、[FromBody]、[FromHeader]、[FromServices]、[AsParameters](把一组参数打包成一个 struct)。 - 可空参数 = 可选:
int? page不传就是null;有默认值的(int times = 1)不传就用默认值。
绑定失败会怎样
GET /hello/ann?times=3 → 200 "hi ann hi ann hi ann" GET /hello/ann?times=x → 400 // 绑定不了 int,直接 400,处理器根本不执行 GET /nope(没有这条路由) → 404 POST /orders body 是 "{" → 400 // JSON 解析失败 POST /orders body 是空的 → 400
- 框架在进入你的处理器之前就把这些挡掉了,所以处理器里不需要写「参数是不是数字」这类判断。
- 开发环境下这些会以异常页的形式展示(日志里是
BadHttpRequestException: Failed to bind parameter "int times" from "x".),生产环境下就是干净的 400。
路由模板
"/users/{id:int}" // 约束:只匹配整数
"/files/{*path}" // 通配:捕获剩余全部路径段
"/items/{id:guid}" // guid / long / bool / datetime / decimal
"/p/{page:int:min(1)}" // 带范围约束
"/s/{slug:regex(^[a-z-]+$)}" // 正则约束(慎用,见 15 章 ReDoS)- 约束不匹配 → 404 而不是 400:因为在路由层就没匹配上这条路由,等于「这个 URL 不存在」。
// 各种绑定来源
app.MapGet("/hello/{name}", (string name, int times = 1) => // 路径 + 查询
string.Join(" ", Enumerable.Repeat($"hi {name}", times)));
app.MapPost("/orders", async (
CreateOrder req, // 复杂类型 → 请求体 JSON
IOrderService svc, // 已注册 → 依赖注入
CancellationToken ct) => // 框架注入:客户端断开时会触发取消
{
var id = await svc.CreateAsync(req, ct);
return TypedResults.Created($"/orders/{id}", new { id });
});
// 把一堆查询参数打包(分页、筛选很好用)
app.MapGet("/search", ([AsParameters] SearchQuery q, ISearch svc) =>
svc.RunAsync(q));
record struct SearchQuery(string? Keyword, int Page = 1, int Size = 20);
// 路由分组:共享前缀、过滤器、鉴权策略
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders");
orders.MapGet("/{id:guid}", GetOrder);
orders.MapPost("/", CreateOrder);
orders.MapDelete("/{id:guid}", DeleteOrder);
// 处理器可以是普通方法,不必都写成 lambda(更好测试、更好读)
static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
Guid id, IOrderService svc, CancellationToken ct)
=> await svc.FindAsync(id, ct) is { } o
? TypedResults.Ok(o)
: TypedResults.NotFound();MapGet 写一个复杂类型参数,框架会当成「从 DI 取」,找不到服务就启动失败。CancellationToken ct 参数,框架会自动注入「客户端断开」的令牌——把它一路传给数据库和 HTTP 调用(11 章),用户关掉页面时后端就能立刻停止干活。这是最省事、收益最直接的一处改动,尤其对慢查询多的服务。处理器的返回值决定了 HTTP 响应。直接返回对象就是 200 + JSON;要控制状态码就返回 Results / TypedResults。
默认的 JSON 序列化行为
app.MapGet("/json", () => new { id = 1, name = "张三", at = DateTimeOffset.UtcNow }); // 响应体: {"id":1,"name":"张三","at":"2026-07-28T17:02:07.8510364+00:00"} ↑ camelCase ↑ 中文没有被转义 ↑ ISO 8601
- 这和裸调
JsonSerializer.Serialize的默认行为不同(12 章那里中文变成了\u5F20\u4E09)——ASP.NET Core 预置了一套更适合 Web 的选项:camelCase、宽松转义、反序列化不区分大小写。 - 要改就在
builder.Services.ConfigureHttpJsonOptions(o => ...)里配,别去改全局的JsonSerializerOptions。 - POST 回显同样是 camelCase:
{"customerId":"C1","amount":10}。
Results vs TypedResults
Results | TypedResults | |
|---|---|---|
| 返回类型 | IResult(擦除了具体类型) | 具体类型(Ok<T>、NotFound…) |
| OpenAPI 文档 | 推断不出响应类型 | 自动推断 |
| 单元测试 | 要转型才能断言 | 直接断言 result.Value |
- 新代码一律用
TypedResults;多个可能的返回用Results<Ok<T>, NotFound, BadRequest<string>>这种联合类型把契约写进签名。
常用返回
TypedResults.Ok(dto) 200
TypedResults.Created($"/orders/{id}", dto) 201 + Location 头
TypedResults.NoContent() 204
TypedResults.BadRequest(new { error }) 400
TypedResults.NotFound() 404
TypedResults.Conflict() 409
TypedResults.ValidationProblem(errors) 400 + ProblemDetails
TypedResults.Problem("出错了", statusCode: 502)
TypedResults.File(stream, "application/pdf", "报表.pdf")
TypedResults.Stream(...) // 大文件流式返回// 联合返回类型:把契约写进签名,OpenAPI 与测试都受益
app.MapGet("/orders/{id:guid}", async Task<Results<Ok<OrderDto>, NotFound>> (
Guid id, IOrderService svc, CancellationToken ct) =>
{
var o = await svc.FindAsync(id, ct);
return o is null ? TypedResults.NotFound() : TypedResults.Ok(o);
});
// 自定义 JSON 行为(作用于所有 Minimal API 端点)
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// 流式返回大结果集(11 章的 IAsyncEnumerable)
app.MapGet("/export", (IOrderService svc, CancellationToken ct) =>
svc.StreamAllAsync(ct)); // 返回 IAsyncEnumerable<T>,边查边写出
// 返回文件
app.MapGet("/report.pdf", async (IReportService svc, CancellationToken ct) =>
{
var stream = await svc.RenderAsync(ct);
return TypedResults.File(stream, "application/pdf", "月报.pdf");
});PasswordHash、内部状态字段、以及 ORM 的导航属性(后者还可能触发循环引用或 N+1 查询)。永远返回专门的 DTO/record,这既是安全边界也是 API 契约的稳定边界。IAsyncEnumerable<T> 时 ASP.NET Core 会流式写出 JSON 数组:数据边查边发,服务端内存里不会攒出完整结果集,客户端也能提前开始解析。导出大列表时这是一行改动换来的巨大差别(对比先 ToListAsync() 再返回)。ASP.NET Core 内置了依赖注入容器,整个框架自己也建立在它之上。用法在 15 章速查过,这里讲 Web 场景特有的部分。
Web 里的「作用域」就是一次请求
AddScoped注册的服务,在一次 HTTP 请求内是同一个实例,请求结束时被释放。DbContext、工作单元、请求级缓存都用它。AddSingleton跨请求共享——必须是线程安全的,因为会被多个请求同时使用。AddTransient每次注入都新建。
三个真实会咬人的问题
- ① Scoped 注入进 Singleton:那个 Scoped 实例会被 Singleton 永久持有,变成事实上的单例。
DbContext这么用会立刻出并发异常。容器在启动时会检测并抛异常(开发环境默认开启作用域校验),不要关掉它。 - ② 在后台任务里用 Scoped 服务:
BackgroundService本身是 Singleton,不能直接注入DbContext。正确做法是注入IServiceScopeFactory,每轮循环自己开一个作用域(见代码)。 - ③
IOptions系列选错(15 章):Singleton 服务里注入IOptionsSnapshot会抛异常;用IOptions却期待配置热更新会静默失效。
配置的分层与环境
- 加载顺序:
appsettings.json→appsettings.{Environment}.json→ 用户机密(开发)→ 环境变量 → 命令行。后者覆盖前者。 ASPNETCORE_ENVIRONMENT决定环境名(Development/Staging/Production),默认是Production——不带 launch profile 启动时确实是 Production。app.Environment.IsDevelopment()用来开关开发专用的东西(详细错误页、Swagger UI、种子数据)。
// 强类型配置 + 启动时校验
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Jwt"))
.ValidateDataAnnotations()
.ValidateOnStart(); // 配置缺失就在启动时失败,不要等到第一个请求
public sealed class JwtOptions
{
[Required] public string Issuer { get; init; } = "";
[Required, MinLength(32)] public string Secret { get; init; } = "";
}
// 后台任务里正确使用 Scoped 服务
public sealed class OutboxWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = scopeFactory.CreateScope(); // 每轮一个作用域
var db = scope.ServiceProvider.GetRequiredService<AppDb>();
try { await DispatchAsync(db, ct); }
catch (OperationCanceledException) { throw; } // 取消要放行(10 章)
catch (Exception e) { logger.LogError(e, "投递失败"); }
}
}
}
// 环境判断
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseDeveloperExceptionPage();
}DbContext 是最常见的 DI 事故。BackgroundService 是单例,注入进来的 Scoped DbContext 会被永久持有——它内部的变更跟踪器越攒越大(内存泄漏),并且多个并发操作会撞上「同一个 DbContext 不支持并发操作」的异常。必须用 IServiceScopeFactory 每次开新作用域。GetRequiredService<T>() 优于 GetService<T>():前者没注册就抛出带类型名的清晰异常,后者返回 null,然后你会在几层之外收到一个莫名其妙的 NullReferenceException。只有在「这个服务可有可无」时才用 GetService。一个 HTTP 服务对外表现好不好,很大程度看它出错时说了什么。ASP.NET Core 提供了标准化的错误响应格式(RFC 9457 ProblemDetails),但需要你主动接上。
各类错误的默认状态码(Production 环境)
路由不存在 → 404
查询参数绑定失败(times=x) → 400
请求体是坏 JSON("{") → 400
请求体为空 → 400
处理器抛未捕获异常 → 500- 好消息是绑定层已经帮你挡住了一大类脏输入,处理器里不需要再写这类防御。
- 坏消息是默认的 400/500 响应体可能是空的——加上
AddProblemDetails()+UseExceptionHandler()才会返回结构化的错误 JSON。
校验:不要假设「加一行就生效」
- .NET 10 给 Minimal API 引入了内置校验(
builder.Services.AddValidation()+ DataAnnotations 特性)。但只加这一行、DTO 用带[Required]/[Range]的 record 时,非法负载(amount=0、缺customerId)仍然返回 201——校验没有生效。 - 它依赖源生成器/拦截器,对项目配置和 DTO 形态有要求。结论不是「这个特性不好用」,而是「别假设它默认就在工作」——自己发一个非法请求验证一遍。
- 更保险的两条路:① 用 FluentValidation(生态成熟,规则表达力强);② 在处理器入口手写校验并返回
TypedResults.ValidationProblem。手写的好处是行为完全可预测。 - 另一个必须知道的细节:给 record 的位置参数加特性时,
[Required] string CustomerId默认作用在参数上而不是生成的属性上,要写成[property: Required]才落到属性。任何基于属性反射的校验器都会受这一条影响。
// ① 打开结构化错误响应
builder.Services.AddProblemDetails(o =>
{
o.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
};
});
var app = builder.Build();
app.UseExceptionHandler(); // 未处理异常 → 500 + ProblemDetails
app.UseStatusCodePages(); // 404 等无正文的状态码 → 也给个 ProblemDetails
// ② 把业务异常映射成合适的状态码
app.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandler = async ctx =>
{
var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
(int status, string title) = ex switch
{
NotFoundException => (404, "资源不存在"),
ConflictException => (409, "状态冲突"),
UnauthorizedAccessException => (403, "无权访问"),
_ => (500, "服务器内部错误"),
};
await Results.Problem(title: title, statusCode: status)
.ExecuteAsync(ctx);
},
});
// ③ 手写校验:行为完全可预测
app.MapPost("/orders", (CreateOrder req) =>
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(req.CustomerId))
errors[nameof(req.CustomerId)] = ["必填"];
if (req.Amount <= 0)
errors[nameof(req.Amount)] = ["必须大于 0"];
return errors.Count > 0
? Results.ValidationProblem(errors) // 400 + 标准错误格式
: Results.Created("/orders/1", req);
});
// record 位置参数上的特性要写 property: 才落到属性上
record CreateOrder([property: Required] string CustomerId,
[property: Range(1, 10000)] decimal Amount);UseDeveloperExceptionPage。它会把完整堆栈、源码片段、环境变量、请求头全部返回给客户端——等于把内部结构和可能的密钥直接公开。默认模板用 if (app.Environment.IsDevelopment()) 包住它,不要为了「线上好排查」把这个判断去掉。要线上可诊断,靠的是日志 + traceId,不是把错误页发给用户。traceId 放进错误响应(上面的 CustomizeProblemDetails):用户或前端报错时把这个 id 给你,日志里一搜就定位到那一次请求的完整上下文。配合 OpenTelemetry 还能直接跳到分布式追踪。这是投入五行代码、回报最大的可观测性改动。.NET 9 起 OpenAPI 文档生成内置到了框架里(Microsoft.AspNetCore.OpenApi),不再依赖 Swashbuckle。它只负责生成文档 JSON,UI 需要另外接。
两行代码,一份文档
builder.Services.AddOpenApi(); // 需要 Microsoft.AspNetCore.OpenApi 包 app.MapOpenApi(); // 暴露 /openapi/v1.json // GET /openapi/v1.json → 200,内容开头: { "openapi": "3.1.1", "info": { "title": "api | v1", "version": "1.0.0" }, "servers": [ { "url": "http://localhost:5199/" } ], "paths": { "/": { "get": { ... } } } }
- 输出的是 OpenAPI 3.1.1,比老 Swashbuckle 默认的 3.0 新一档。
dotnet new web模板不含这个包——直接写AddOpenApi()会报CS1061: 'IServiceCollection' does not contain a definition for 'AddOpenApi',要先dotnet add package Microsoft.AspNetCore.OpenApi。dotnet new webapi模板则已经带上了。
让文档变得有用
- 用
TypedResults与联合返回类型(本章第 3 卡)——框架能自动推断出每个状态码对应的响应体类型,不用手写[ProducesResponseType]。 - 用
.WithName()、.WithSummary()、.WithTags()给端点加元数据;MapGroup上加的会被组内所有端点继承。 - 要 UI:接 Scalar(
Scalar.AspNetCore包,一行app.MapScalarApiReference())或 Swagger UI。UI 只在开发环境开。
文档的真正价值在于生成客户端
- 有了 OpenAPI 文档,前端可以用
openapi-typescript生成 TS 类型,其它 .NET 服务可以用 Kiota(微软官方)或NSwag生成强类型客户端。 - 把「生成客户端」放进 CI:接口一改,客户端编译就失败——这比任何文档约定都有效。
- 构建期就能导出文档:
Microsoft.Extensions.ApiDescription.Server包能在dotnet build时把 JSON 写到磁盘,不需要启动服务。
// 完整的文档配置
builder.Services.AddOpenApi("v1", o =>
{
o.AddDocumentTransformer((doc, ctx, ct) =>
{
doc.Info.Title = "订单服务";
doc.Info.Version = "1.0";
doc.Info.Description = "内部订单 API";
return Task.CompletedTask;
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json
app.MapScalarApiReference(); // /scalar/v1 —— 交互式 UI
}
// 端点元数据:让文档可读
app.MapGet("/orders/{id:guid}", GetOrder)
.WithName("GetOrder")
.WithSummary("按 ID 查询订单")
.WithDescription("找不到时返回 404,不抛异常")
.WithTags("Orders");
// 返回类型写进签名 → 文档自动带上 200/404 两种响应
static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
Guid id, IOrderService svc, CancellationToken ct) => ...;
# 由文档生成强类型客户端
$ dotnet tool install -g Microsoft.OpenApi.Kiota
$ kiota generate -l CSharp -d http://localhost:5199/openapi/v1.json -o ./Client
# 前端生成 TypeScript 类型
$ npx openapi-typescript http://localhost:5199/openapi/v1.json -o api.d.tsIsDevelopment() 包住是对的。确实需要给外部合作方看,就单独部署一份文档站点或者加鉴权,而不是把生产 API 的 /scalar 直接开出去。Microsoft.Extensions.ApiDescription.Server 让这件事不需要启动服务。最后把一个服务「上生产」还需要的几件事集中过一遍。ASP.NET Core 对这些都有内置支持,不需要第三方框架。
认证与授权:两件事
- 认证(Authentication)回答「你是谁」——解析 JWT / Cookie / OIDC,产出一个
ClaimsPrincipal。 - 授权(Authorization)回答「你能不能做这件事」——基于角色、声明或自定义策略。
- 中间件顺序固定:
UseAuthentication()必须在UseAuthorization()之前,两者都要在端点之前。顺序错了不报错,只是鉴权不生效。 - 端点上用
.RequireAuthorization()或.RequireAuthorization("策略名");MapGroup上加一次,组内全部继承。
生产清单
| 项 | 怎么做 |
|---|---|
| 健康检查 | AddHealthChecks() + MapHealthChecks("/health");区分存活探针与就绪探针 |
| 限流 | AddRateLimiter()(.NET 7+ 内置),固定窗口/滑动窗口/令牌桶/并发数四种 |
| 结构化日志 | Serilog 或内置 ILogger + JSON 格式化器;别用插值字符串(03 章) |
| 可观测性 | OpenTelemetry(.NET 内置 Activity,接上导出器即可) |
| 优雅关闭 | 框架自动处理 SIGTERM;确保后台任务响应 stoppingToken |
| 压缩 / 缓存 | AddResponseCompression()、AddOutputCache()(.NET 7+) |
| 转发头 | 反向代理后面必须 UseForwardedHeaders(),否则拿到的是代理的 IP 和 http 协议 |
容器部署要额外确认的三件事(16 章有细节)
- 监听地址:
ASPNETCORE_URLS=http://+:8080,不是localhost。 - 区域与时区:精简镜像可能没有 ICU / tzdata(13 章)。
- 非 root 用户:官方镜像默认非 root,因此不能绑定 1024 以下端口、不能写只读目录。
// JWT 认证
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = builder.Configuration["Jwt:Authority"];
o.TokenValidationParameters = new
{
ValidateIssuer = true, ValidateAudience = true,
ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30),
};
});
// 授权策略
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("AdminOnly", p => p.RequireRole("admin"));
o.AddPolicy("TenantMatch", p => p.RequireAssertion(ctx =>
ctx.User.FindFirst("tenant")?.Value == CurrentTenant()));
});
// 限流:每个 IP 每分钟 100 次
builder.Services.AddRateLimiter(o =>
{
o.AddFixedWindowLimiter("per-ip", w =>
{
w.Window = TimeSpan.FromMinutes(1);
w.PermitLimit = 100;
w.QueueLimit = 0;
});
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// 健康检查:存活 vs 就绪
builder.Services.AddHealthChecks()
.AddCheck<DbHealthCheck>("db", tags: ["ready"]);
var app = builder.Build();
app.UseForwardedHeaders(); // 反向代理后面必须有
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication(); // 顺序:认证在前
app.UseAuthorization(); // 授权在后
app.MapHealthChecks("/health/live", new() { Predicate = _ => false }); // 只看进程活着
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });
app.MapGroup("/api/admin")
.RequireAuthorization("AdminOnly")
.RequireRateLimiting("per-ip")
.MapAdminEndpoints();
app.Run();UseForwardedHeaders(),会让一整类功能静默出错:拿到的客户端 IP 是代理的(限流、风控、审计全废)、协议被认成 http(生成的绝对 URL 是 http、UseHttpsRedirection 陷入重定向循环)。而且它必须放在管道最前面——放晚了,前面的中间件已经读过错误的值了。Predicate = _ => false 表示不跑任何检查),就绪探针才检查数据库等依赖。混为一谈会导致「数据库抖动 → 就绪失败 → K8s 判定不存活 → 重启 Pod → 雪崩」——重启解决不了数据库的问题,只会让情况更糟。测试与工具链
.NET 的测试与诊断工具链是这门生态最扎实的部分之一:建项目、跑测试、测覆盖率、格式化、静态分析全都是一条 dotnet 命令。这一章把它们串起来,并讲清「怎么写出可测试的代码」——那比任何测试框架都重要。
测试在 .NET 里是一个独立的项目,引用被测项目,用测试框架的特性标注方法。dotnet test 负责发现、运行、汇总。
三个框架,选一个就行
| 框架 | 模板 | 特点 |
|---|---|---|
| xUnit | dotnet new xunit | 社区默认;每个测试一个新实例,天然隔离 |
| NUnit | dotnet new nunit | 特性丰富,从 Java/JUnit 过来的人熟悉 |
| MSTest | dotnet new mstest | 微软自家,企业环境里常见 |
dotnet new xunit生成的 csproj 带四个包:xunit、xunit.runner.visualstudio、Microsoft.NET.Test.Sdk、coverlet.collector(覆盖率),并自动<Using Include="Xunit" />——测试文件里不用写 using。- 三者能力差别不大,选团队熟悉的。本页示例用 xUnit。
dotnet test 的输出
[xUnit.net] t1.Calc.会失败的 [FAIL]
[xUnit.net] t1.Calc.跳过的 [SKIP]
Failed t1.Calc.会失败的 [1 ms]
Error Message:
Assert.Equal() Failure: Values differ
Expected: 5
Actual: 4
Stack Trace:
at t1.Calc.会失败的() in …\UnitTest1.cs:line 9
Failed! - Failed: 1, Passed: 5, Skipped: 1, Total: 7, Duration: 65 ms- 失败时进程退出码是 1,成功是 0——CI 靠这个判断。
- 断言失败信息带 Expected/Actual 与源码行号,定位不需要额外工作。
- 方法名可以用中文(正常运行并正确显示)——测试名就是文档,中文项目里用中文命名可读性更好。
- 一个
[Theory]的每组[InlineData]各算一个测试(3 组 → Total 里占 3 个)。
# 建项目并接线
$ dotnet new xunit -o tests/Shop.Tests
$ dotnet add tests/Shop.Tests reference src/Shop.Core
$ dotnet sln add tests/Shop.Tests
# 跑测试
$ dotnet test # 整个解决方案
$ dotnet test tests/Shop.Tests # 单个项目
$ dotnet test --filter "FullyQualifiedName~参数化" # 只跑 3 个
$ dotnet test --filter "Category=Integration" # 按 Trait 过滤
$ dotnet test --logger "trx;LogFileName=results.trx" # CI 用的结构化结果
$ dotnet test --collect:"XPlat Code Coverage" # 覆盖率(coverlet)
$ dotnet test --blame-hang-timeout 60s # 定位卡死的测试
// 一个典型的测试文件
public class CalcTests
{
[Fact]
public void 加法应当相加() => Assert.Equal(4, Calc.Add(2, 2));
[Theory] // 参数化:每组数据算一个测试
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
public void 加法参数化(int a, int b, int expected)
=> Assert.Equal(expected, Calc.Add(a, b));
[Fact]
public async Task 异步也能测()
{
var r = await Svc.LoadAsync();
Assert.NotNull(r);
}
[Fact(Skip = "等接口定稿")] // 跳过并说明原因
public void 暂时跳过() => Assert.Fail("不该跑");
}dotnet test 默认并行运行不同测试类(xUnit 里同一个 collection 内串行、不同 collection 并行)。共享静态状态、共享数据库、共享临时文件的测试会随机失败,且本地单跑不复现。要么消除共享(最好),要么用 [Collection("名字")] 把它们归到同一组强制串行。--collect:"XPlat Code Coverage" 产出 Cobertura 格式的报告,用 dotnet tool install -g dotnet-reportgenerator-globaltool 生成 HTML 后去看没被覆盖的那些行——真正有价值的是「这条错误分支从来没被测过」这类发现,而不是把数字从 78% 刷到 80%。测试难写,几乎总是设计的问题而不是测试框架的问题。三个最常见的「不可测」根源,都有直接的解法。
三个根源与解法
| 不可测的写法 | 为什么难测 | 解法 |
|---|---|---|
方法内部 new HttpClient() / new SqlConnection() | 没法换掉真实依赖 | 构造函数注入接口 |
DateTime.Now / Guid.NewGuid() / Random | 结果不确定,测不了「过期」「跨月」 | 注入 TimeProvider / Func<Guid> |
| 静态类持有状态、单例全局变量 | 测试之间互相污染 | 改成实例 + DI |
- 判据很好用:如果一个方法要测,必须先起数据库/网络/等一分钟,那它的依赖没有被抽象出来。
- 不要为了测试给每个类配一个接口(06 章)。真正需要替换的只有「跨进程边界」的那几个:时间、随机、网络、数据库、文件系统、消息队列。
测试替身:手写 vs mock 库
- 手写假实现(fake)往往更好:几行代码、行为明确、重构时编译器会提醒你。上一章那个
FakeClock就是。 - mock 库(NSubstitute、Moq)适合「只需要断言某个方法被调用过」的场景。但过度使用会让测试变成「验证实现细节」——重构一次全红,而行为其实没变。
- 注意 AOT/裁剪:动态代理型 mock 库依赖
Reflection.Emit,在 16 章说过它们与 NativeAOT 不兼容(测试项目本身不需要 AOT,所以通常不是问题)。
测什么、不测什么
- 值得测:业务规则与分支、边界条件(空、零、负数、极大值)、错误路径、以前出过的 bug(回归测试)。
- 不值得测:纯粹的属性赋值、框架自己的行为(不用测「ASP.NET Core 能不能路由」)、mock 之间的交互而无真实断言。
- 最有价值的一类测试是「这个 bug 修完之后我加一个测试锁住它」——它的价值随时间增长,且永远不会白写。
// ✗ 不可测:依赖在方法内部造出来
public class BadCoupon
{
public bool IsValid(Coupon c) => c.ExpiresAt > DateTime.Now; // 测不了"明天过期"
}
// ✓ 可测:时间从外面来
public sealed class CouponService(TimeProvider time)
{
public bool IsValid(Coupon c) => c.ExpiresAt > time.GetUtcNow();
}
// 测试:用 FakeTimeProvider 瞬间"快进"
// dotnet add package Microsoft.Extensions.TimeProvider.Testing
[Fact]
public void 过期的券应当失效()
{
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
var svc = new CouponService(clock);
var coupon = new Coupon(ExpiresAt: clock.GetUtcNow().AddDays(7));
Assert.True(svc.IsValid(coupon));
clock.Advance(TimeSpan.FromDays(8)); // 快进 8 天,不用真等
Assert.False(svc.IsValid(coupon));
}
// 手写假实现:比 mock 库更清楚
sealed class InMemoryRepo : IOrderRepo
{
private readonly Dictionary<Guid, Order> _items = [];
public Task<Order?> FindAsync(Guid id, CancellationToken ct)
=> Task.FromResult(_items.GetValueOrDefault(id));
public Task SaveAsync(Order o, CancellationToken ct)
{
_items[o.Id] = o;
return Task.CompletedTask;
}
}
// 断言异常(10 章的错误设计在这里得到回报)
[Fact]
public async Task 金额为负应当拒绝()
{
var ex = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => svc.CreateAsync(new("C1", -1m), CancellationToken.None));
Assert.Equal("amount", ex.ParamName);
}B.Bar() 就红了,而行为完全没变。优先断言可观察的结果(返回值、状态变化),只在「副作用本身就是需求」(发了邮件、写了消息)时才断言调用。过期的券应当失效 比 TestCoupon2 有用一百倍——测试失败时你在 CI 日志里只看得到名字。中文命名在中文团队里完全可行(运行正常),别为了「像英文项目」牺牲可读性。单元测试验证逻辑,集成测试验证「接起来能不能工作」——路由、序列化、中间件顺序、SQL 是否正确、事务边界。ASP.NET Core 为此提供了一个内存中的完整宿主。
WebApplicationFactory:不开端口的真实服务
- 它启动你的完整应用(全部中间件、DI、配置),但用内存传输而不是真实 TCP——快、无端口冲突、可以并行。
factory.CreateClient()返回一个HttpClient,发的请求会走完整个管道。这是验证「中间件顺序对不对」「JSON 序列化行为如何」的唯一可靠方式(17 章那些就是这么做的)。- 可以在测试里替换服务:把真实的支付网关换成假的、把数据库换成 SQLite 内存库。
- 需要引
Microsoft.AspNetCore.Mvc.Testing包,并在被测项目里加<InternalsVisibleTo>或把Program类设为 public(顶级语句生成的Program是 internal,测试项目访问不到——加一行public partial class Program;即可)。
数据库:Testcontainers 是今天的答案
- 用真实数据库:
Testcontainers在测试开始时用 Docker 拉起一个真的 PostgreSQL/Redis/Kafka,结束时销毁。SQL 方言、约束、事务行为全都是真的。 - 对比 EF Core 的内存提供程序:它不是关系数据库,不支持外键、不支持原始 SQL、行为与真库有差异——用它测出来的「通过」不能证明生产能跑。今天的共识是尽量别用它做集成测试。
- 代价是需要 Docker 环境(CI 上通常有)和几秒的启动时间。用类级或集合级 fixture 复用容器,别每个测试起一个。
比例:金字塔还是奖杯
- 经典的「测试金字塔」说单元测试要最多。但在 Web 服务里,大量价值集中在集成测试——因为 bug 常常出在「接缝处」而不是单个函数里。
- 务实的分配:核心业务规则用单元测试(快、多),每条 API 路径至少一个集成测试(慢、少而关键),端到端测试只覆盖最关键的几条链路。
- 速度是可以妥协的对象,可靠性不是:一个偶发失败(flaky)的测试比没有测试更糟——它会训练团队忽略红色。发现 flaky 就立刻修或删。
// 被测项目:让 Program 可见(顶级语句生成的类是 internal)
// 在 Program.cs 末尾加一行:
public partial class Program;
// 集成测试:完整管道,内存传输
public class OrdersApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task 查询不存在的订单应当返回404()
{
var client = factory.CreateClient();
var resp = await client.GetAsync($"/api/orders/{Guid.NewGuid()}");
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
[Fact]
public async Task 创建订单应当返回201与Location头()
{
var client = factory.CreateClient();
var resp = await client.PostAsJsonAsync("/api/orders",
new { customerId = "C1", amount = 99.5m });
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
Assert.NotNull(resp.Headers.Location);
}
}
// 替换依赖:把真实外部服务换成假的
var factory2 = factory.WithWebHostBuilder(b =>
b.ConfigureServices(s =>
{
s.RemoveAll<IPaymentGateway>();
s.AddSingleton<IPaymentGateway, AlwaysSucceedsGateway>();
}));
// 真实数据库:Testcontainers
// dotnet add package Testcontainers.PostgreSql
public sealed class DbFixture : IAsyncLifetime
{
public PostgreSqlContainer Db { get; } = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
public Task InitializeAsync() => Db.StartAsync(); // 整个测试类跑一次
public Task DisposeAsync() => Db.DisposeAsync().AsTask();
}Include 和原始查询的行为都与真库不同。用它写的集成测试全绿,上线照样因为「唯一约束冲突」「SQL 翻译不了」而挂。微软官方文档现在也建议不要用它做测试——用 Testcontainers 起真库,或者至少用 SQLite(虽然方言仍有差异)。.NET SDK 内置了一整套Roslyn 分析器——不需要装任何插件,只要在 csproj 里打开。它能在编译期抓到相当一部分真 bug,而不只是风格问题。
打开分析器
<PropertyGroup> <AnalysisMode>Recommended</AnalysisMode> <!-- 或 All / Minimum --> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <!-- 风格规则也参与编译 --> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup>
- 本页多次提到的规则都出自这套分析器:CA1416(平台兼容性,13 章)、CA1304/CA1305/CA1307(忘了指定区域,03/13 章)、CA2000(忘了 Dispose,06 章)、CA2200(
throw ex破坏堆栈,10 章)、CA2214(构造函数里调虚方法,06 章)。 - 裁剪与 AOT 分析器(IL2026/IL3050)在开启
PublishTrimmed/PublishAot时自动激活(16 章)。 - 第三方分析器里值得一提的:Meziantou.Analyzer(大量实用规则)、SonarAnalyzer.CSharp、Roslynator。
.editorconfig 是唯一的风格真相
- 它同时管格式(缩进、换行、空格)和代码风格规则(用不用
var、要不要表达式体、命名约定),IDE 和dotnet format都读它。 dotnet new editorconfig生成一份带全部 .NET 规则的模板,在此基础上改。- 每条规则可以设成
silent/suggestion/warning/error——团队达成共识的写成 error,仍有分歧的写成 suggestion。
把检查放进 CI
dotnet format --verify-no-changes:不改文件,有格式差异就返回非 0——这是格式检查的标准做法。dotnet build -warnaserror:确保没有新警告混进来。dotnet list package --vulnerable --include-transitive:依赖漏洞(16 章)。- 抑制警告要就地写明理由:
#pragma warning disable CA1031 // 这里必须吞掉所有异常,因为…。在 csproj 里全局关掉某条规则,等于永久放弃它。
# 生成风格配置
$ dotnet new editorconfig
# .editorconfig 片段
[*.cs]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
# 语言风格
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_prefer_pattern_matching = true:warning
csharp_prefer_braces = true:warning
# 命名:接口必须 I 开头
dotnet_naming_rule.interfaces_start_with_i.severity = error
# 具体分析器规则的严重性
dotnet_diagnostic.CA1305.severity = error # 必须指定 IFormatProvider
dotnet_diagnostic.CA2007.severity = none # 应用代码不需要 ConfigureAwait(11 章)
# CI 里的三条检查
$ dotnet format --verify-no-changes --severity warn
$ dotnet build -c Release -warnaserror
$ dotnet test -c Release --logger trx
// 就地抑制并写明理由(而不是全局关掉)
#pragma warning disable CA1031 // 顶层兜底必须吞掉所有异常,见 10 章
catch (Exception e) { Log.Fatal(e, "未处理异常"); }
#pragma warning restore CA1031AnalysisMode=All 通常太吵。它会打开包括「文档注释缺失」「标识符含下划线」在内的全部规则,新项目上直接几千条警告,团队第一反应就是整体关掉——结果连有价值的那几十条也一起丢了。从 Recommended 起步,再按需把个别规则提升到 error,比一步到位实际得多。TreatWarningsAsErrors 打开,代价接近零;等到几万行之后再打开,会一次性冒出上千条警告,然后被整体关掉。这条对 <Nullable>enable</Nullable>(07 章)、裁剪分析器(16 章)同样适用——所有「渐进式收紧」的开关都是越早开越便宜。很多人对 .NET 的第一印象是「XML 配置地狱」——那是十年前的 .NET Framework。今天 dotnet new console 生成的整个项目文件只有 8 行,而且就是下面这个样子。
逐行读懂默认模板
<Project Sdk="Microsoft.NET.Sdk"> <!-- ① 用哪套构建规则 --> <PropertyGroup> <OutputType>Exe</OutputType> <!-- ② 可执行程序(类库则整行没有)--> <TargetFramework>net10.0</TargetFramework> <!-- ③ 目标框架,决定 API 与语言版本 --> <ImplicitUsings>enable</ImplicitUsings> <!-- ④ 自动 using 常用命名空间 --> <Nullable>enable</Nullable> <!-- ⑤ 开启可空引用类型检查(07 章)--> </PropertyGroup> </Project>
- 没有文件清单:SDK-style 项目默认「目录下所有 .cs 都参与编译」。新增文件不用改 csproj——这是与老 .NET Framework 项目最大的体感差异。
Sdk="Microsoft.NET.Sdk"是普通项目;Web 项目是Microsoft.NET.Sdk.Web,多带一套 ASP.NET Core 的引用与发布规则(17 章)。TargetFramework决定一切:能用哪些 BCL API、默认的 C# 语言版本、运行需要哪个运行时。要同时支持多个版本就写复数形式<TargetFrameworks>net8.0;net10.0</TargetFrameworks>。
加依赖:命令行加,别手写
$ dotnet add package Serilog # 自动写进 csproj 并还原 $ dotnet add reference ../Core/Core.csproj # 引用同解决方案的另一个项目 $ dotnet list package --outdated # 看哪些包过期了
- 依赖以
<PackageReference Include="Serilog" Version="4.x" />的形式落进 csproj,传递依赖不写进来——只列你直接用的,这点比老式packages.config干净得多。 - 多项目仓库把版本号统一管理:根目录放
Directory.Packages.props开启中央包管理(CPM),子项目的PackageReference就不写版本号了。
构建产物在哪
obj/ 中间产物(NuGet 还原结果、生成的代码、增量编译缓存)
bin/Debug/net10.0/ 默认输出目录:你的 .dll、可执行外壳、依赖的 .dll、
*.deps.json(依赖清单)、*.runtimeconfig.json(要哪个运行时)- 这两个目录都不入版本库(
dotnet new gitignore会生成好现成的 .gitignore)。构建出问题时,删掉obj/和bin/再来一次,能解决相当比例的玄学问题。
<!-- 一个更接近真实工程的 csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<InvariantGlobalization>true</InvariantGlobalization> <!-- 不带 ICU,容器体积更小(13 章有坑)-->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <!-- 让可空警告真的拦住你 -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.3.0" />
</ItemGroup>
</Project><PropertyGroup> 里放开关,<ItemGroup> 里放文件与包引用,放错地方多半是静默失效而不是报错。另外 dotnet add package 会自动挑最新稳定版写进去——团队协作时记得 review 这一行,别让一次随手 add 把主版本号跳了。<LangVersion>。语言版本由 TargetFramework 决定(net10.0 → C# 14),把它单独调高会让你用上「编译器支持但运行时没有」的特性,编译过了运行时炸。真需要旧框架配新语法时才动它,且要清楚自己在干什么。dotnet 是整个 .NET 的总入口,子命令看着多,但日常真正天天用的就下面这些。把这张表过一遍,之后每一章的命令你都认得出属于哪一类。
按用途分四组
| 组 | 命令 | 干什么 | 常用参数 |
|---|---|---|---|
| 创建 | dotnet new | 按模板生成项目/文件 | console classlib webapi xunit gitignore |
| 创建 | dotnet sln | 管理解决方案(多项目) | add list |
| 构建运行 | dotnet build | 编译 | -c Release -warnaserror |
| 构建运行 | dotnet run | 编译并运行 | --project --no-build -- 参数 |
| 构建运行 | dotnet watch | 改文件自动重跑 | 开发期常驻 |
| 依赖 | dotnet add package | 加 NuGet 包 | --version |
| 依赖 | dotnet restore | 还原依赖 | 一般不用手动跑 |
| 依赖 | dotnet list package | 看依赖树 | --outdated --vulnerable |
| 交付 | dotnet test | 跑测试 | --filter(18 章) |
| 交付 | dotnet publish | 产出可部署文件 | -r --self-contained(16 章) |
| 交付 | dotnet format | 按 .editorconfig 格式化 | --verify-no-changes |
两个救命参数
dotnet 任意命令 --help:CLI 的帮助写得相当完整,比搜索快。dotnet new list列出全部模板(console / classlib / webapi / blazor / mstest / xunit / gitignore / editorconfig / globaljson 一应俱全)。-v(verbosity):构建出玄学问题时dotnet build -v detailed会把 MSBuild 的每一步打出来,包括「到底用了哪个 SDK、还原到了哪个包版本」。
# 一个典型项目从零到跑起来的完整流程
$ dotnet new sln -n Shop # 解决方案(多项目容器)
$ dotnet new webapi -o src/Shop.Api # Web API 项目
$ dotnet new classlib -o src/Shop.Core # 领域类库
$ dotnet new xunit -o tests/Shop.Tests # 测试项目
$ dotnet sln add src/Shop.Api src/Shop.Core tests/Shop.Tests
$ dotnet add src/Shop.Api reference src/Shop.Core
$ dotnet add tests/Shop.Tests reference src/Shop.Core
$ dotnet build # 编译整个解决方案
$ dotnet test # 跑所有测试
$ dotnet run --project src/Shop.Api # 起服务dotnet build 和 dotnet publish 的产物不是一回事,这是新手部署失败的头号原因:build 产出的 bin/Debug/net10.0/ 是给开发机跑的,依赖散落且默认 Debug 配置;要拿去部署的永远是 dotnet publish -c Release 的输出。直接把 bin/Debug 拷到服务器上,能不能跑全看运气。dotnet watch 是开发期最值回票价的一条命令:它监视文件改动,能改完即时生效的走热重载(不重启进程、保留内存状态),改不了的才重启。写 Web API 时开着它,改 handler 存盘、刷新浏览器就看到新结果。最后把日常会用到的工具集中列一遍。它们全都是 dotnet tool install -g 一条命令装好的跨平台命令行程序——Windows、Linux、macOS 上用法完全一样。
该装的几个
| 工具 | 用途 |
|---|---|
dotnet-counters | 实时指标:GC、线程池、异常速率(14 章) |
dotnet-trace | CPU 采样、事件追踪 |
dotnet-dump / dotnet-gcdump | 转储与堆分析 |
dotnet-monitor | 把上面几样包成 HTTP 接口,容器里当 sidecar |
dotnet-ef | EF Core 迁移 |
dotnet-outdated-tool | 批量检查/升级依赖版本 |
dotnet-reportgenerator-globaltool | 覆盖率报告转 HTML |
- 本地工具(local tool)比全局工具更适合团队项目:
dotnet new tool-manifest后dotnet tool install(不加-g),版本写进.config/dotnet-tools.json并入库,队友dotnet tool restore就得到完全相同的版本。
排查问题的入口顺序
- ① 服务变慢但 CPU 不高 →
dotnet-counters看线程池队列 → 多半是同步阻塞(11 章)。 - ② 内存持续增长 →
dotnet-gcdump看堆上什么最多 → 多半是缓存无上限或事件没退订(14 章、05 章)。 - ③ CPU 打满 →
dotnet-traceCPU 采样 → 看火焰图找热点。 - ④ 完全卡死无响应 →
dotnet-dump抓转储 →clrstack -all看所有线程栈 → 多半是死锁(11 章)。 - ⑤ 异常率高但功能正常 →
dotnet-counters的异常计数 → 多半是拿异常当流程控制(10 章)。
其它日常命令
dotnet watch # 改文件自动重跑 / 热重载 dotnet watch test # 改代码自动重跑测试 —— TDD 体验极好 dotnet build -v detailed # 构建玄学问题时看 MSBuild 到底干了什么 dotnet --info # SDK / 运行时清单(01 章) dotnet nuget locals all --clear # 清 NuGet 缓存(还原出怪问题时)
# 装工具(全局)
$ dotnet tool install -g dotnet-counters
$ dotnet tool install -g dotnet-trace
$ dotnet tool install -g dotnet-gcdump
$ dotnet tool list -g
# 团队项目更推荐本地工具(版本随仓库走)
$ dotnet new tool-manifest
$ dotnet tool install dotnet-ef
$ git add .config/dotnet-tools.json
# 队友只需要:
$ dotnet tool restore && dotnet tool run dotnet-ef migrations list
# 找到进程再挂上去
$ dotnet-counters ps
$ dotnet-counters monitor -p 12345 --counters System.Runtime
# 容器里(进程 1 通常就是你的应用)
$ kubectl exec -it pod-xxx -- dotnet-counters monitor -p 1
# 一个完整的 CI 片段(GitHub Actions)
# - uses: actions/setup-dotnet@v5
# with: { dotnet-version: '10.0.x' }
# - run: dotnet restore --locked-mode
# - run: dotnet format --verify-no-changes
# - run: dotnet build -c Release --no-restore -warnaserror
# - run: dotnet test -c Release --no-build --collect:"XPlat Code Coverage"
# - run: dotnet list package --vulnerable --include-transitive
# - run: dotnet publish src/App -c Release -o outactions/setup-dotnet 的版本号别写成具体补丁版(如 10.0.302):微软会下架旧的补丁版本,某天 CI 就会突然报「找不到该版本」。写 10.0.x 让它取最新补丁;真需要钉死就靠仓库里的 global.json(16 章),并配 rollForward 策略。另外版本号要现查——生态里的版本更新很快,凭记忆写进 CI 配置常常已经过时。dotnet watch test 是被低估的一条命令:改一行代码、存盘,相关测试立刻重跑并在终端里报结果。配合分屏,它把「写代码 → 跑测试」的反馈循环压到一两秒,是本地做 TDD 或重构时体验最好的方式——不需要任何 IDE 插件。跨平台 UI 与互操作生态
服务端之外,C# 还能写桌面、移动、Web 前端和游戏。这一章把这些方向的选型讲清楚——哪个成熟、哪个适合什么场景、代价分别是什么——再补上与原生代码、与其它语言互操作的那条路。
「用 C# 写界面」今天有四条主流路线,它们的差别不在语法而在渲染方式——是调用系统原生控件,还是自己画。这个选择决定了外观一致性、平台覆盖和问题的种类。
四条路线对比
| 方案 | 渲染 | 平台 | 适合 |
|---|---|---|---|
| .NET MAUI(微软官方) | 原生控件 | iOS / Android / macOS / Windows | 移动为主、要原生观感与系统集成 |
| Avalonia(社区,成熟) | 自绘(Skia) | Windows / macOS / Linux / 移动 / WASM | 桌面为主,尤其需要 Linux |
| Uno Platform | 可选原生或自绘 | 几乎全平台 + WASM | 已有 WinUI/UWP 代码要扩展 |
| Blazor Hybrid | WebView | 随宿主(MAUI/WPF/WinForms) | 已有 Web 前端资产、团队会 HTML/CSS |
- 关键分水岭是 Linux 桌面:MAUI 不支持 Linux(官方明确不在路线图上),需要 Linux 桌面就基本只剩 Avalonia 和 Uno。
- 自绘方案(Avalonia)的优势是「三个平台长得一模一样」,代价是不完全符合各平台的原生交互习惯、无障碍支持要框架自己实现。
- 原生控件方案(MAUI)的优势是「在每个平台上都像本地应用」,代价是要处理平台差异,且受各平台控件能力限制。
务实的判断
- 只做 Windows 桌面:WPF 依然是最成熟稳妥的选择(生态、控件库、招人都最好),WinUI 3 是新的但生态还在追。
- 要 Linux 桌面 / 三端一致:Avalonia。它是这几年社区里最活跃、生产案例最多的跨平台 UI 框架(JetBrains 的部分产品就用它)。
- 移动优先:MAUI,或者认真评估一下 Flutter / React Native——C# 在移动端不是最主流的选择,生态与资料量都不如前两者。
- 团队本来就写 Web:Blazor Hybrid 或者干脆做成 Web 应用 + 桌面壳。
// Avalonia:XAML + MVVM,与 WPF 高度相似(迁移成本低)
// dotnet new install Avalonia.Templates
// dotnet new avalonia.mvvm -o MyApp
<!-- MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
x:DataType="vm:MainViewModel" Title="待办">
<StackPanel Margin="16" Spacing="8">
<TextBox Text="{Binding NewItem}" Watermark="输入待办" />
<Button Content="添加" Command="{Binding AddCommand}" />
<ListBox ItemsSource="{Binding Items}" />
</StackPanel>
</Window>
// ViewModel:CommunityToolkit.Mvvm 的源生成器省掉大量样板
public partial class MainViewModel : ObservableObject
{
[ObservableProperty] // 生成属性 + 变更通知
private string _newItem = "";
public ObservableCollection<string> Items { get; } = [];
[RelayCommand] // 生成 AddCommand
private void Add()
{
if (!string.IsNullOrWhiteSpace(NewItem))
{
Items.Add(NewItem);
NewItem = "";
}
}
}ObservableCollection.Add)在 WPF 上抛异常、在别的框架上可能静默出错或界面错乱。规则:所有 UI 状态变更必须回到 UI 线程——用 Dispatcher.UIThread.Post(Avalonia)/ MainThread.BeginInvokeOnMainThread(MAUI),或者干脆用 await(有同步上下文时会自动回来,11 章)。CommunityToolkit.Mvvm 的源生成器把 [ObservableProperty] 展开成完整的属性 + INotifyPropertyChanged,把 [RelayCommand] 展开成命令对象。WPF、Avalonia、MAUI、Uno 都能用同一套——换 UI 框架时 ViewModel 层几乎不用改。Blazor 让你用 C# 和 Razor 语法写浏览器里跑的组件。它的三种托管模型差异巨大——选错模型比选错框架代价更大。
三种托管模型
| 模型 | 代码在哪跑 | 首屏 | 代价 |
|---|---|---|---|
| Blazor Server | 服务器,UI 差量走 SignalR 推给浏览器 | 快 | 每个用户一条常驻连接;网络抖动直接影响交互;服务器扛不住太多并发 |
| Blazor WebAssembly | 浏览器(.NET 运行时编译成 WASM) | 慢(要下运行时) | 首次加载几 MB;无法访问服务器资源 |
| Blazor Web App(.NET 8+) | 可按组件选择,含服务端预渲染 | 快 | 心智模型复杂,要清楚每个组件跑在哪 |
- .NET 8 起的统一模型是今天的默认:服务端预渲染 + 交互式组件按需选择渲染模式(
@rendermode InteractiveServer/InteractiveWebAssembly/InteractiveAuto)。 - WASM 的体积问题在改善但没消失:裁剪 + AOT 后仍在几 MB 量级,对首屏敏感的公开站点是硬伤;企业内部系统通常可以接受。
该不该用 Blazor
- 值得用:团队全是 C# 开发、内部管理系统、需要与后端共享大量模型与校验逻辑、不想维护两套技术栈。
- 要谨慎:面向公众的高流量站点(首屏与 SEO)、需要大量现成 UI 组件生态(React/Vue 的生态大得多)、招前端更容易的团队。
- 诚实的说法:Blazor 在 .NET 圈内很受欢迎,但在整个 Web 前端生态里仍是小众。技术上完全可行,人员与生态是主要考量。
@* Counter.razor —— 组件就是 C# + HTML *@
@page "/counter"
@rendermode InteractiveServer
<h3>计数器</h3>
<p>当前:@count</p>
<button class="btn" @onclick="Increment">+1</button>
@code {
private int count;
private void Increment() => count++;
}
// 组件参数与生命周期
@code {
[Parameter] public required string Title { get; set; }
[Inject] private IOrderService Svc { get; set; } = default!;
private IReadOnlyList<Order>? orders;
protected override async Task OnInitializedAsync()
=> orders = await Svc.ListAsync(CancellationToken.None);
}
// 与 JS 互操作(需要用现成 JS 库时)
@inject IJSRuntime JS
@code {
private async Task Copy(string text)
=> await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
}
# 建项目
$ dotnet new blazor -o MyApp # 统一模型(推荐)
$ dotnet new blazorwasm -o MyApp # 纯 WebAssembly(静态托管)需要调用操作系统 API 或 C 库时,用 P/Invoke(平台调用)。今天写新代码应该用 [LibraryImport] 源生成器,而不是老的 [DllImport]。
两代写法
[DllImport](老) | [LibraryImport](.NET 7+) | |
|---|---|---|
| 封送代码 | 运行时生成(需要 JIT) | 编译期生成(可读、可调试) |
| AOT / 裁剪 | 受限 | 完全兼容 |
| 字符串封送 | 隐式,容易出错 | 必须显式指定 StringMarshalling |
| 方法 | 普通 static extern | 必须 static partial |
跨平台调用原生库的三个坑
- 库名不一样:Windows 是
foo.dll、Linux 是libfoo.so、macOS 是libfoo.dylib。写"foo"(不带扩展名和 lib 前缀),运行时会按平台规则去找——这是唯一可移植的写法。 - ABI 和类型大小不一样:
long在 Windows 上是 32 位、在 Linux/macOS 上是 64 位。用nint/nuint(原生整数)或明确的int/long,别照抄 C 头文件的long。 - 调用约定与结构体布局:结构体要标
[StructLayout(LayoutKind.Sequential)],字段顺序和对齐必须和 C 端完全一致——错了不会报错,只会读到垃圾数据。
先问一句:真的需要吗
- BCL 已经跨平台封装了绝大多数常见需求(文件、网络、加密、进程、时间)。P/Invoke 应该是最后手段——每一处都是可移植性和安全性的缺口。
- 需要平台专属功能时,先找有没有现成的 NuGet 包;再考虑用
OperatingSystem.IsXxx()(13 章)分平台实现。
using System.Runtime.InteropServices;
// ✓ 现代写法:LibraryImport 源生成器
internal static partial class Native
{
// 库名不带 lib 前缀和扩展名 —— 运行时按平台解析
[LibraryImport("c", EntryPoint = "getpid")]
internal static partial int GetPid();
// 字符串必须显式指定封送方式
[LibraryImport("c", StringMarshalling = StringMarshalling.Utf8)]
internal static partial nint getenv(string name);
// 返回 bool 要标 MarshalAs(C 的 BOOL 是 4 字节)
[LibraryImport("kernel32", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Beep(uint freq, uint duration);
}
// 结构体布局必须和 C 端一致
[StructLayout(LayoutKind.Sequential)]
internal struct TimeSpec
{
public nint Seconds; // C 的 time_t —— 用 nint 而不是 long
public nint Nanoseconds;
}
// 分平台调用
static int CurrentPid() => OperatingSystem.IsWindows()
? Environment.ProcessId // BCL 已有跨平台封装,优先用它
: Native.GetPid();
// 自定义原生库解析(比如库放在非标准路径)
NativeLibrary.SetDllImportResolver(typeof(Native).Assembly, (name, asm, path) =>
name == "mylib"
? NativeLibrary.Load(Path.Combine(AppContext.BaseDirectory, "runtimes", name))
: IntPtr.Zero);fixed 或 GCHandle.Alloc(..., GCHandleType.Pinned) 固定住它。[UnmanagedCallersOnly] 加上 NativeAOT 的 <NativeLib>Shared</NativeLib>,可以把 C# 编译成一个标准的 .so/.dll,供 Python、Rust、C++ 甚至其它语言直接 dlopen。这让「用 C# 写一个跨语言可复用的库」成为现实选项。真实系统很少是纯 .NET 的。这一卡列出 C# 与外部世界打交道的几条主要通道,以及各自今天的成熟度。
服务间通信
- HTTP + JSON:最通用,17 章讲过。跨语言零障碍。
- gRPC:.NET 的一等公民(
Grpc.AspNetCore),.proto文件生成强类型客户端与服务端,性能比 JSON 高不少,跨语言支持极好。适合内部服务间通信;浏览器直连需要 gRPC-Web。 - 消息队列:RabbitMQ、Kafka、Azure Service Bus 都有成熟的 .NET 客户端;
MassTransit提供更高层的抽象(重试、Saga、Outbox)。 - SignalR:.NET 自家的实时通信(WebSocket 优先,自动降级),有 JS/Java/Python 客户端。
与具体生态的关系
| 对方 | 怎么打通 |
|---|---|
| 前端(React/Vue) | HTTP + OpenAPI 生成 TS 类型(17 章) |
| Python(数据/AI) | HTTP 服务边界最省事;Python.NET 可进程内互调但耦合紧 |
| Java | gRPC / 消息队列;共享 protobuf 或 Avro schema |
| C / C++ / Rust | P/Invoke(上一卡);反向可用 [UnmanagedCallersOnly] |
| 数据库 | ADO.NET(Npgsql/SqlClient)、Dapper(轻)、EF Core(全功能 ORM) |
| 浏览器 | Blazor WebAssembly;或 .NET 编译成 WASM 组件 |
两个特殊生态
- Unity:很多人的第一门 C# 就是在这里学的。但要清楚 Unity 的 C# 与本页讲的 .NET 有差异——它长期使用自己的运行时与较旧的语言版本,最近才在向现代 .NET 靠拢。Unity 里的经验不能直接照搬到 ASP.NET Core,反之亦然。
- AI / 机器学习:
ML.NET能做传统机器学习,Microsoft.Extensions.AI与Semantic Kernel提供调用大模型的统一抽象。但训练模型的主场仍然在 Python——现实中的常见分工是 Python 训练、导出 ONNX,C# 用Microsoft.ML.OnnxRuntime做推理。
// gRPC 服务端(.proto 生成基类)
public sealed class OrderGrpcService(IOrderService svc) : Orders.OrdersBase
{
public override async Task<OrderReply> Get(
OrderRequest req, ServerCallContext ctx)
{
var o = await svc.FindAsync(Guid.Parse(req.Id), ctx.CancellationToken)
?? throw new RpcException(new(StatusCode.NotFound, "订单不存在"));
return new OrderReply { Id = o.Id.ToString(), Amount = (double)o.Amount };
}
}
// app.MapGrpcService<OrderGrpcService>();
// 数据访问三选一
// ① Dapper:薄,SQL 你自己写,性能接近手写 ADO.NET
var orders = await conn.QueryAsync<Order>(
"select * from orders where customer_id = @id", new { id });
// ② EF Core:全功能 ORM,LINQ 翻译成 SQL(08 章的 IQueryable)
var orders2 = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.CreatedAt)
.Take(20)
.ToListAsync(ct);
// ③ 原始 ADO.NET:最大控制力
await using var cmd = conn.CreateCommand();
cmd.CommandText = "select 1";
// 调用大模型:统一抽象(Microsoft.Extensions.AI)
IChatClient chat = new SomeProviderClient(apiKey);
var reply = await chat.GetResponseAsync("用一句话解释 async/await", cancellationToken: ct);
// ONNX 推理:Python 训练、C# 上线的常见分工
using var session = new InferenceSession("model.onnx");order.Customer.Name,每一条都会单独发一次 SQL。开发环境几十条数据看不出来,生产上一页 100 条就是 101 次往返。解法是 Include 预加载或 Select 投影;诊断办法是打开 SQL 日志看一眼到底发了几条——这也是 08 章「IQueryable 掉回 IEnumerable」那条陷阱的近亲。从这里到精通:路线图
本页覆盖了 C# 语言、.NET 运行时、跨平台工程化与 ASP.NET Core 的主干。这一章给出学习顺序、每个阶段的可验证目标,以及往下走的几个方向。
按下面的顺序推进,每个阶段都有一个「做出来就说明学会了」的产物。不要按章号顺序通读——整页二十一个章节里,真正需要先啃透的只有前半部分。
阶段一:把语言写顺(01–07 章)
- 目标:能独立写一个有输入输出、有错误处理的命令行工具。
- 重点:值类型与引用类型的复制语义(02 章)、字符串的不可变性与比较规则(03 章)、模式匹配(04 章)、record 与接口的选型(06 章)、可空引用类型(07 章)。
- 里程碑:写一个命令行工具,读文件、解析、输出统计结果,并处理「文件不存在」「格式错误」两类失败。用
dotnet publish发成单文件。
阶段二:吃透两个杀手锏(08、11 章)
- 目标:LINQ 和 async/await 进入直觉。这两样是 C# 与其它语言差异最大、生产力提升最明显的地方。
- 重点:延迟执行的三个陷阱(08 章)、
WhenAll与并发限流(11 章)、取消令牌的一路传递、四条死锁铁律。 - 里程碑:写一个并发抓取多个 URL、限流、可取消、能正确汇总每个任务成败的程序。
阶段三:做一个真服务(15–18 章)
- 目标:从零搭一个能部署的 HTTP 服务。
- 重点:Minimal API 的路由与返回值(17 章)、依赖注入与配置(15 章)、错误处理与 ProblemDetails、发布与容器化(16 章)、测试(18 章)。
- 里程碑:一个带数据库的 CRUD 服务,有集成测试、有 Dockerfile、有健康检查,能在 Linux 容器里跑起来。然后把它发成 NativeAOT 版本,看看差多少(16 章有参照)。
阶段四:深水区(12–14 章)
- 目标:能诊断和优化真实问题。
- 重点:相等性与 JSON 契约(12 章)、跨平台的六类差异(13 章)、GC 与分配、Span 与池化、用 BenchmarkDotNet 和 dotnet-counters 量出结论(14 章)。
- 里程碑:找出自己项目里一处真实热点,用 profiler 定位、优化、并用基准测试证明改进。
往下走的方向
- 后端深入:EF Core 与数据库性能、分布式系统(消息队列、幂等、Saga)、.NET Aspire(微服务编排)、可观测性(OpenTelemetry)。
- 性能工程:读 BCL 源码(
dotnet/runtime仓库公开且注释详尽)、SIMD(System.Numerics.Vector)、无锁数据结构。 - 客户端:Avalonia 或 MAUI(19 章的选型),配合 MVVM 与
CommunityToolkit.Mvvm。 - 游戏:Unity(注意它与本页 .NET 的差异,19 章)。
- 语言本身:跟一遍 C# 的版本变更日志(每年一个大版本),以及
dotnet/csharplang仓库的提案讨论——那里能看到设计取舍的完整推理过程。
三个可靠的信息源
- 官方文档(learn.microsoft.com)——.NET 的文档质量在业界属于第一梯队,尤其 API 参考与「深入了解」系列。
- 源码(github.com/dotnet/runtime、dotnet/aspnetcore)——遇到「它到底怎么实现的」直接搜源码,比任何二手资料准确。
- 每个版本的 What's New 与性能博客——Stephen Toub 每年写的「Performance Improvements in .NET X」是了解运行时演进最好的一篇长文。
// 阶段一的里程碑:一个完整的小工具
if (args is not [var path, ..])
{
Console.Error.WriteLine("用法: wordcount <文件>");
return 2;
}
try
{
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
await foreach (var line in File.ReadLinesAsync(path)) // 15 章
foreach (var w in line.Split(' ', StringSplitOptions.RemoveEmptyEntries))
counts[w] = counts.GetValueOrDefault(w) + 1; // 08 章
foreach (var (word, n) in counts.OrderByDescending(x => x.Value).Take(10))
Console.WriteLine($"{word,-20}{n,6}"); // 03 章
return 0;
}
catch (FileNotFoundException) // 10 章
{
Console.Error.WriteLine($"找不到文件: {path}");
return 1;
}
// 发布成单文件(16 章):
// dotnet publish -c Release -r linux-x64 --self-contained \
// -p:PublishSingleFile=true -p:PublishTrimmed=trueWebClient、Thread、ArrayList、BinaryFormatter、packages.config、ConfigurationManager、Web Forms,看到这些基本可以判定资料已经老了(00 章的 pitfall 讲过判据)。拿不准就去查官方文档的当前版本,或者直接看 dotnet/runtime 源码。.cs 文件,dotnet run x.cs 就能跑——.NET 10 的单文件应用让验证的成本低到几乎为零。看到任何断言心里犯嘀咕,就当场跑一遍;这比多读三遍文档有用得多,也是本页所有数字的产出方式。