C# 与 .NET 跨平台完整知识体系交互讲解

全景: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}");   // 插值字符串 + 对齐 + 格式化
.NET Framework ≠ .NET,搜资料时最大的坑。4.x 是 Windows 独占的旧线,只修 bug。判据看 csproj 的 <TargetFramework>net10.0 是新线,net48 是旧线——老教程里的 Web Forms、Remoting 在新线上不存在。
本页基准是 .NET 10(LTS)+ C# 14,多数内容在 .NET 8 上同样成立。.NET 每年 11 月发一版,偶数版是 LTS

上手:跑通第一个跨平台程序

这一章不讲语言,只做一件事:把 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 hellodotnet 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:$PATH
dotnet --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 自动加上 SystemSystem.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 写在中间会直接编译失败。另外一个项目里出现两个含顶级语句的文件也会报错——把示例代码到处复制粘贴时最容易撞上这一条。真需要多个可执行入口,就拆成多个项目。
想看编译器把顶级语句展开成了什么,不必猜:把代码扔进 sharplab.io(选 "Results: C#" 看脱糖后的等价代码)。C# 的语法糖几乎全是编译期展开,看一眼脱糖结果,很多「魔法」当场就不神秘了。

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 build
DOTNET_CLI_UI_LANGUAGE=en 并非处处生效:它对 dotnet build 有效,但走单文件应用路径时诊断仍是本地化文案。撞上这种不一致时,直接拿 CS 编号去搜——编号是跨语言不变的。
先修第一条错误,再重新编译。C# 编译器会因为一处语法错误产生连锁误报——一次报 20 条时,往往修掉最上面那条,剩下 19 条自己就消失了。

类型系统:值类型与引用类型

C# 的一切都从「这是值类型还是引用类型」开始。赋值时复制什么、相等性怎么算、放在栈上还是堆上、要不要担心 null——全都由这一个二分决定。这一章把它讲透,后面的类、record、struct、泛型才不会乱。

C# 的类型分两大阵营:值类型structenum,以及所有内置数字类型)赋值时复制内容引用类型classrecordstring、数组、委托、接口)赋值时复制引用——两个变量指向同一个对象。

一段代码把区别钉死

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).IsValueTypePointS → 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 2
可变的 struct 是 C# 的经典陷阱。因为每次赋值、传参、从属性读取、从只读集合取元素都会产生一份副本,你改的往往是那份副本,而编译器一声不吭。真实案例:list[0].X = 5List<PointS> 上直接编译不过(索引器返回的是副本),而在数组上却能编译并生效——同一份代码换个容器行为就变。要写 struct 就把它写成不可变的(字段 readonly、或直接用 readonly record struct)。
选型的经验法则(微软官方设计准则):默认写 class;只有同时满足「逻辑上表示单一值」「实例很小(约 16 字节以内)」「不可变」「不会被大量装箱」时才考虑 struct。坐标、颜色、金额、时间点是好例子;有集合字段、有继承需求、会被到处传递的实体全部用 class。

数值类型的选择只有两个真正的决策点:要不要小数能不能容忍二进制浮点误差。其余都是容量问题。

常用类型一览

类型别名 / 全名大小用途
intSystem.Int324 字节整数的默认选择,约 ±21 亿
longSystem.Int648 字节时间戳、大计数、雪花 ID
doubleSystem.Double8 字节浮点的默认选择,科学计算、几何
decimalSystem.Decimal16 字节——十进制精确,慢但不会出误差
bool / charBoolean / Char1 / 2 字节char 是 UTF-16 码元,不是「一个字」(03 章)
byte / sbyte / short / uint1–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 字面量(推不出类型)、不能用作字段类型。

constreadonly:编译期 vs 运行期

constreadonly
何时定值编译期,必须是字面量常量运行期,构造函数里也能赋
能用于数字、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.HasValuex 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}",
};
装箱一个 nullint? 得到的是 真正的 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;
  • 非泛型集合ArrayListHashtable——这就是它们被泛型集合取代的根本原因。
  • 字符串拼接与格式化的老写法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/反射里取值时这条踩得极多。
不要一上来就为装箱做优化。装箱在每秒执行几百万次的热路径上才值得管;业务代码里一次 HTTP 请求装几百次箱,跟一次数据库查询比连零头都算不上。先用 BenchmarkDotNet 量,再决定改不改——这条对 14 章所有性能话题都适用。

字符串与文本

字符串是每个程序里出现频率最高的类型,也是跨平台差异最集中的地方——编码、区域、大小写、比较规则,每一项都能让「本地跑得好」的代码在另一台机器上给出不同答案。这一章从不可变性讲到 Unicode,把这些坑一次挖开。

string引用类型,但它被设计得处处像值类型:== 比较内容而不是引用、任何「修改」都返回新对象、可以安全地在线程间共享。理解这一点,字符串的性能特征就全部推得出来。

不可变意味着什么

string s = "hello";
s.ToUpper();            // 返回新字符串,s 自己没变!——新手最常见的一行废代码
s = s.ToUpper();        // 这样才行

s[0] = 'H';             // 编译错误:字符串索引器是只读的
  • ReplaceTrimSubstringInsertPadLeft——所有看起来在改字符串的方法,实际都是「返回一个新的」。忘了接返回值等于什么都没做。
  • 好处是巨大的:字符串可以随便共享、可以做字典 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.LengthNullReferenceException"".Length 是 0,两者行为天差地别。从数据库、JSON、环境变量里取到的字符串都可能是 null,判空一律用 string.IsNullOrEmpty 而不是 s == ""。07 章的可空引用类型就是为了让编译器帮你盯住这件事。
SplitStringSplitOptions.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 都返回自己),也有 AppendJoinAppendFormatInsertReplace。它还支持插值直接 Appendsb.Append($"x={x}") 在 .NET 6+ 走的是 AppendInterpolatedStringHandler,不会先拼出一个中间字符串——写起来顺手,性能也不亏。

.NET 的 string 内部是 UTF-16char 是一个 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,别被名字骗了
在跨平台 .NET 上读中文 GBK 文件会直接抛异常。.NET Core 起精简了内置编码,只保留 UTF 系列与 ASCII 等少数几个;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、文件名、协议头、枚举名、字典 keyStringComparison.Ordinal
OrdinalIgnoreCase
要的是「字节一样」,与语言无关;还更快
给人看的排序、搜索、UI 里的相等判断StringComparison.CurrentCulture要符合用户语言直觉
持久化的排序(存进数据库/文件)InvariantCulture 或 Ordinal不能随机器区域变
  • 默认值是个陷阱a == ba.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}");
^ 索引对空集合会抛 IndexOutOfRangeExceptionarr[^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,
};
switch 表达式的分支只能是表达式,不能写语句块。想在分支里干多件事,要么抽成方法(_ => HandleOther(x)),要么改回 switch 语句。把复杂逻辑硬塞进 switch 表达式(层层嵌套三元、逗号表达式)会让可读性急剧下降——这时候老老实实用 switch 语句才是对的。
switch 表达式的分支按书写顺序匹配,第一个命中就返回。所以写区间时要从窄到宽:把 >= 90 写在 >= 60 前面。顺序写反了不会报错,只会让所有及格分都变成 C——编译器只在「某分支完全被前面覆盖」时才给出 CS8510「模式已被处理」的错误。

模式匹配是 C# 从 7.0 开始逐版加码的核心能力。它能用在 isswitch 语句、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 <= 9not 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) 也会被关掉。但前提是调用方用了 foreachusing——手工调 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 readonlyref 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);                      // 也可以当值传
  • 日常只用 FuncActionFunc<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、B
Func 有返回值这件事会悄悄改变重载解析。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(让子类能拦截)。
  • 今天在异步、响应式场景里,事件常被 IObservableIAsyncEnumerable、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);
用 lambda 订阅就基本告别退订了。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.LinqWhere 会「不存在」(不过隐式 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 写的扩展方法,即使实际对象是 DogDog 上也有个同名扩展方法,通过 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 };
主构造函数(C# 12)的参数在整个类体内都可见,包括方法里。这很方便,但也意味着 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.ZeroT.Parse(s)where T : INumber<T>Sumintdouble 都能用(返回 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 遮蔽是同一类陷阱。
BCL 里的 TimeProvider(.NET 8+)就是官方版的 IClock——需要「可测试的时间」时直接用它,别自己造。它同时抽象了 UtcNowTimerTask.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 的值相等性是浅的。record Basket(List<string> Items) 里两个不同的 List 实例即使内容一样,== 也是 False(比的是 List 的引用)。with 同理是浅拷贝——新对象和旧对象共享同一个 List,改一个另一个也变。record 里放集合,就要么用不可变集合(ImmutableArray<T>),要么自己处理相等性与拷贝。
record 的 ToString() 会把所有公开属性打印出来——写日志时很方便,但也意味着密码、令牌、身份证号会被一起打出去。敏感字段要么不放进 record,要么覆写 ToString(),要么用 [JsonIgnore] 之类的标记配合自定义打印。这条在生产事故里出现的频率比想象中高。

struct 的动机只有一个:避免堆分配。它不是「更轻量的 class」——用错了反而更慢(到处复制)、更容易出 bug(可变副本)。

官方设计准则:四条同时满足才用 struct

  • ① 逻辑上表示单一值(像数字那样);② 实例很小(经验值 16 字节以内);③ 不可变;④ 不会被频繁装箱
  • 反过来,只要有一条不满足就该用 class。「我觉得它是小对象」不是理由——BCL 里 DateTimeGuiddecimalTimeSpan 是 struct,stringList<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,或者接受「零值 = 未设置」这个约定。
拿不准该不该用 struct 时,先写 class。把 class 改成 struct 通常只需要改一个关键字(如果它本来就是不可变的),而反过来——发现 struct 到处复制出 bug 之后再改回 class——往往要重新审视每一处用法。性能优化要有数据支撑,BenchmarkDotNet 的 [MemoryDiagnoser] 十分钟就能给你答案。

最后把三个「不属于实例」的东西收尾:静态成员、资源释放、以及为什么 C# 里几乎不需要写析构函数。

静态:全局状态的正确用法

  • static class 不能实例化、不能继承,成员全是静态的——扩展方法类、纯函数工具类(MathPath)用它。
  • 静态构造函数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 字面量转成非空类型nullstring 参数
// 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);
「开了 NRT 就不会有 NullReferenceException 了」是最危险的误解。NRT 只在你自己的、开了 NRT 的、编译期可见的代码路径上有效。JSON 反序列化一个缺字段的对象、EF Core 从数据库读出 NULL、反射构造对象、泛型里的 default(T)——每一条都能绕过它。NRT 是把风险从「到处都有」收窄到「几个明确的边界」,边界上的检查还得你自己写。
判断一个第三方库有没有做 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);          // ✓
把非空检查抽成方法是最常见的「NRT 突然不好使了」的原因。写了 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.ThrowIfNullOrWhiteSpaceArgumentOutOfRangeException.ThrowIfNegative公开 API 的参数校验一律用它们。

!(null 宽容运算符)的意思是「编译器你别管,我保证这里不是 null」。它不生成任何运行时代码——用错了就是把警告换成了运行时崩溃。

! 的三种正当用法

  • ① 编译器推不出来但你确定的场景dict.TryGetValue(k, out var v); Use(v!);(如果那个 API 没标注 [NotNullWhen])。
  • ② 测试代码里故意传 nullAssert.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 : classT? 是可空引用;where T : structT?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 / 配置反序列化缺字段时非空属性变成 nullrequired;或在 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,
};
NRT 标注会真实影响 EF Core 的数据库结构。public string Email { get; set; }(映射为 NOT NULL)随手改成 string? 来消警告,下一次 dotnet ef migrations add 就会生成一条把列改成可空的迁移——数据库约束就这么被一个「消警告」的动作悄悄放松了。在有 ORM 的项目里改标注,一定要跟着看一眼生成的迁移脚本。
迁移时最高效的一招是先给「返回集合的方法」全部改成返回空集合。「返回 null 表示没有结果」是老 .NET 代码里最泛滥的模式,也是空引用异常的头号来源。改完之后,大量下游的判空代码可以直接删掉,警告数量往往当场掉一半。

集合与 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.ContainsHashSet.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) 能省掉全部扩容与复制。
  • 字典取值用 TryGetValued["missing"]KeyNotFoundException: The given key 'b' was not present in the dictionary.(报错原文),而 TryGetValue 返回 false。要「没有就给默认值」用 CollectionsMarshal.GetValueRefOrAddDefaultd.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> 不协变,正是为了避免这个问题——又一个「能用泛型集合就别用数组」的理由。
DictionaryHashSet 都能接受自定义比较器,这比「把 key 先 ToLower 一遍」优雅得多也快得多:new Dictionary<string,int>(StringComparer.OrdinalIgnoreCase)。同理,需要按业务规则去重时传一个 IEqualityComparer<T>,别去改类型本身的 Equals

LINQ 没有任何魔法:WhereSelect 就是 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 / DistinctByDistinctBy 是 .NET 6+
取前 / 跳过Take / Skip / TakeWhile / SkipWhileTake(2..5) 支持范围(.NET 6+)
求和 / 均值 / 极值Sum Average Min Max MinBy MaxByMaxBy 返回元素Max 返回
计数Count / LongCount / TryGetNonEnumeratedCount最后一个不触发枚举(.NET 6+)
取一个First Single Last ElementAt(各带 OrDefault 版)见下方陷阱
判断Any All Contains判空用 Any() 而不是 Count() > 0
合并两个序列Concat Union Intersect Except ZipZip 以短的为准(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 排序再顺序聚合)。同样的道理适用于 OrderByDistinctReverseToLookup——它们都是「缓冲型」操作符。
分组之后如果只需要「每组的第一个」,别写 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 数组 yieldEF 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 ...。表宽的时候这是几十倍的差距,而两种写法的返回值一模一样——看不出问题,只是慢
EF Core 里想确认「这条查询到底生成了什么 SQL」,最直接的办法是 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」大得多。
  • static lambda(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() => ...;
// }
「LINQ 慢所以全都改成 for 循环」是比过度使用 LINQ 更糟的决定。手写循环的 bug 密度远高于 LINQ(差一错误、边界、提前 break 漏处理),而收益在 99% 的业务代码里完全测不出来。正确顺序永远是:先写清楚,profiler 指出热点,再针对那一处改写并用基准测试验证。
不要用 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 前缀(TKeyTValueTResult)。
  • 同名不同参数个数的泛型类型是不同类型FooFoo<T>Foo<T,U> 可以共存(TaskTask<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 用(调 ToStringEquals)。约束是你和编译器之间的契约:我保证 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)、ISpanFormattableIAdditionOperators 等一整族接口。

约束不参与重载解析

  • 两个方法只有约束不同(where T : class vs where 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> 自己就是这么声明的。

DogAnimal,那 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。原因是 intobject 需要装箱(改变内存表示),而协变要求的是「引用可以直接复用」。要转就显式 .Cast<object>(),代价是逐个装箱。
设计泛型接口时,先想清楚 T 是「出」还是「进」——如果只出,加 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 擦除式泛型的对比

.NETJava
运行时是否存在存在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 是两个不同的静态字段
泛型特化在 NativeAOT 下是有边界的。AOT 编译器必须在编译期知道所有会用到的封闭泛型类型;通过反射动态构造的(MakeGenericType)如果编译期推不出来,运行时会抛异常。用了 MakeGenericTypeActivator.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 过滤器,让「接不接」可以带条件。

finallyreturn 谁先

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) 之后继续跑OutOfMemoryExceptionStackOverflowException(其实根本捕获不到)这类是进程级问题,接住它继续跑只会让状态更糟。
  • 不要用异常做流程控制:「用 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 恒返回 false
finally 里不要抛异常。如果 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参数是 nullValue 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 来自用户输入,找不到是常态,返回 nullTryGetBCL 提供两套 API 正是这个原因First vs FirstOrDefaultd[k] vs TryGetValue)。

不要在异常里做这三件事

  • 不要用异常返回业务数据(把结果塞进自定义异常的属性里再上抛)——这是把异常当 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);
}
不要序列化异常跨进程传输。老 .NET 的二进制序列化(ISerializable[Serializable])在现代 .NET 上已被废弃并默认禁用BinaryFormatter 因安全问题在 .NET 9 被移除)。跨服务传递错误要用结构化的错误响应(HTTP 的 ProblemDetails,17 章),不是把异常对象扔过去。老代码里那些 protected Exception(SerializationInfo, StreamingContext) 构造函数,今天写新异常类不需要
自定义异常要不要建,判据是「调用方会不会专门 catch 它」。会——建一个(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.UnobservedTaskExceptionTask 里抛了但从没被 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);
「async 让程序变快」是最普遍的误解。单次操作的耗时不会缩短,反而因为状态机、上下文切换略微增加。它换来的是吞吐量(同样的线程扛更多并发)和响应性(UI 不卡)。在一个只有单用户的桌面工具里把所有方法改成 async,除了增加复杂度什么也得不到。
不需要 await 就别加 async如果方法体只是「把另一个 Task 原样返回」,直接返回那个 Task(public Task<X> GetAsync() => _inner.GetAsync();)——省掉一层状态机与一次分配。但有 usingtry/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.ReadAsyncChannel.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);   // 超时抛 TimeoutException
async 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)))FooAsyncSelect 枚举时就开始跑了。
  • 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;
SemaphoreSlimRelease() 必须放在 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.Currentnull,续体由线程池随便一个线程执行,.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 包(IAsyncEnumerableWhere/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.RuntimeThreadPool Thread CountThreadPool 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 的生命周期。它们不难,但每一条都在生产环境里咬过人,而且出问题时的表现往往离根因很远。

只要一个类型会被放进 DictionaryHashSet,或者被 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 应当是不可变的stringintGuidreadonly record struct。要用自定义类型当 key,就把参与相等性的字段设成只读。

怎么正确实现

  • 首选:用 recordrecord struct(06 章),编译器生成的实现完全正确、且是强类型无装箱的。
  • 必须手写时用 HashCode.Combine(a, b, c)——它处理了混合与分布,比手写 a * 31 + b 好。同时实现 IEquatable<T> 提供强类型重载,避免装箱(02 章)。
  • 不要把可变字段放进 GetHashCode。如果类型天生可变,就只用不可变的标识字段(比如实体的 Id)参与相等性。

string.GetHashCode() 每次运行都不一样

  • 同一个字符串在不同进程里哈希码不同——这是 .NET Core 起默认开启的哈希随机化,用于防御哈希碰撞 DoS 攻击。
  • 推论:绝对不能把 GetHashCode() 的结果持久化(存数据库、写文件、放缓存 key、做分片路由)。要稳定的哈希请用 SHA256xxHashSystem.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 的默认一致。
  • 需要严格校验时开 RespectNullableAnnotationsRespectRequiredConstructorParameters(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 而不是 dynamicJsonNode.Parse(s)!["items"]![0]!["name"]JsonDocumentIDisposable(它持有池化的内存),用完要释放;JsonNode 是可变的对象模型,不需要释放但更占内存。只读解析用前者,需要改再写回用后者。

HttpClient 是 .NET 里最容易用错的类,因为它的两种错误用法各自都有一半道理,而正确答案是第三种。

两个都错的用法

  • ❌ 每次请求 new HttpClient()Dispose:连接不复用,TCP 连接进入 TIME_WAIT 大量堆积,高并发下耗尽本机端口,表现为 SocketException: 通常每个套接字地址只允许使用一次。这是最经典的 .NET 生产事故。
  • ❌ 全局一个 static HttpClient 永不释放:解决了端口问题,但底层连接不会感知 DNS 变化——服务端换了 IP(蓝绿部署、故障转移、K8s 重新调度),客户端还在往旧地址发请求。

✓ 正确做法

  • IHttpClientFactoryMicrosoft.Extensions.Http 包,ASP.NET Core 里默认可用):它把「HttpClient 实例」和「底层连接处理器」分开管理——handler 被池化复用(解决端口耗尽),且默认每 2 分钟轮换一次(解决 DNS 过期)。
  • 不用 DI 的场景(控制台工具、类库):用一个静态 HttpClient,但显式配置 SocketsHttpHandler.PooledConnectionLifetime,效果等价。
  • 无论哪种,HttpClient 本身是线程安全的,可以被任意多个线程同时使用。

几个容易忽略的默认值

  • Timeout 默认 100 秒,而且是整个请求(含读完响应体)的总超时,超时抛的是 TaskCanceledException——很容易被误当成「用户取消了」。
  • 非 2xx 不会抛异常:要么手动判 IsSuccessStatusCode,要么调 EnsureSuccessStatusCode()
  • GetStringAsync 会把整个响应读进内存。大文件要用 GetStreamAsyncHttpCompletionOption.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 8601ToString("O")),只在展示时转成用户时区。
  • 数据库列:PostgreSQL 用 timestamptz、SQL Server 用 datetimeoffset用不带时区的列存「本地时间」是最常见的时间事故来源。

可测试的时间:TimeProvider(.NET 8+)

  • 代码里直接写 DateTime.Now没法测试「跨月结算」「过期逻辑」——总不能改系统时钟。
  • TimeProvider 是官方抽象:注入 TimeProvider.System,测试里换成 FakeTimeProviderMicrosoft.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、验证码
  • 凡是「猜中了会出事」的地方,一律用 RandomNumberGeneratorRandomNumberGenerator.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.OpenReadGetStreamAsync)都要求调用方 using
  • 违反这些规则的典型症状是 ObjectDisposedException: Cannot access a closed Stream——某人提前关了不属于自己的流

缓冲:为什么写完不 Flush 会丢数据

  • StreamWriterBufferedStreamFileStream 都有内部缓冲区。数据先进缓冲,攒够了才真正写出去。
  • 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);   // 只读包装,零复制
忘了 usingStreamWriter 会静默丢掉最后一部分数据。文件被创建了、前面的内容也在、就是结尾缺一截——因为缓冲区里的内容从没被 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 独有的三个坑

  • 保留文件名CONPRNAUXNULCOM1~COM9LPT1~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.BaseDirectoryDirectory.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-8utf-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.NewLineWriteLine
  • 读取时用 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;
BOM 造成的 bug 极难用肉眼发现。典型现场:C# 生成的 JSON 文件,前端 JSON.parseUnexpected token,但文件用编辑器打开一切正常;或者生成的 .sh 脚本在 Linux 上报 #!/bin/bash: 未找到命令怀疑 BOM 时用 hexdump -C file | head -1 看头三个字节是不是 ef bb bf
HTTP 与网络协议里永远用 UTF-8,别去猜对方的编码。真要处理未知编码的文件,可以先读前几百字节做启发式判断(有 BOM 就按 BOM 走,否则先试 UTF-8,解码失败再退回 GBK)——Encoding.GetEncoding(name, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback) 能让「解码失败」真的抛异常而不是产出一堆问号。

.NET Core 起在所有平台上都用 ICU(International Components for Unicode)做区域相关的处理。这带来了跨平台一致性,但也意味着「容器里没装 ICU 就跑不起来」这类新问题。

回顾:区域影响哪些行为(03 章)

  • 字符串比较与排序:中文按拼音 vs 按码位,两个完全不同的顺序。
  • 大小写转换:土耳其语的 i 大写成 İ
  • 数字与日期格式1234.5 在 de-DE 下是 1234,5double.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 是个「静默改变行为」的开关。它不会让任何代码报错,只是让排序、大小写、格式化的结果换了一套规则。有人为了缩小镜像随手加上,几个月后才发现「用户列表的排序不对了」「金额格式变了」。加这个开关必须和产品需求一起确认,并在代码里留下注释说明为什么可以开。
代码分析器能帮你抓住所有「忘了指定区域」的调用:在 csproj 里开 <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 IDAsia/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——用户时区必须显式传入。
需要跨平台且不依赖系统时区数据时,NodaTime 是这个领域公认更严谨的库:它用不同的类型区分「时刻」「本地日期时间」「带时区的日期时间」,从类型系统上就杜绝了本章讲的大部分错误,而且自带 IANA 数据库。做日历、排班、跨国业务时值得引入。

写跨平台代码时,大部分差异应该靠 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刚分配的对象非常频繁极小(只扫很小一块)
gen1gen0 回收后活下来的较少
gen2gen1 活下来的(长寿对象)很少:要扫整个堆
LOH(大对象堆)≥ 85000 字节的对象随 gen2 一起大,且默认不压缩
POH(固定对象堆)被 pin 住的对象随 gen2
  • GC.MaxGeneration 是 2new 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.IsServerGCFalseLatencyModeInteractive
  • 容器里要留意: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=...
缓存是 .NET 内存泄漏的头号来源。一个 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 章那一整套问题,得不偿失。
BCL 里大量方法已经有 Span 重载:int.TryParse(ReadOnlySpan<char>)DateTime.TryParseEncoding.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.ObjectPoolStringBuilder 等可复用对象需要自定义「归还时怎么重置」
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,选低峰期或先把实例摘出负载均衡。
先量再改,改完再量。这句话听起来是废话,但真实项目里绝大多数「性能优化」提交都没有任何前后对比数据。BenchmarkDotNet 跑一次通常只要一两分钟,而它能阻止你把代码改复杂却毫无收益——本站在多个语言页面上都踩过「优化后测出 0 ms,原来整段被编译器删了」的坑,有工具就不会这样。

标准库速查

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* 静态方法,别用带单个参数的构造函数。
TryParseExactTryParse 更适合处理已知格式的输入(接口返回、日志、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 之后再打开文件是竞态:检查和打开之间文件可能被删除或被别的进程锁住。正确做法是直接尝试打开并捕获异常FileNotFoundExceptionIOException)——这也是所有「先检查再操作」型代码的通病(TOCTOU)。File.Exists 适合做提示性判断,不适合做安全或正确性保证。
Directory.EnumerateFiles惰性的(边枚举边返回),Directory.GetFiles先把全部结果攒进数组。扫描大目录树时前者内存友好得多,还能配合 Take/FirstOrDefault 提前结束。Enumerate 系列一律优先。

HttpClient 的生命周期契约见 12 章(那是最容易出事的一处),JSON 的默认值也在 12 章有。这里是调用速查。

System.Net.Http.Json 的便捷扩展

  • 它把「发请求 + 序列化 + 反序列化」压缩成一行:GetFromJsonAsync<T>PostAsJsonAsyncPutAsJsonAsyncReadFromJsonAsync<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 的扩展方法有接受 JsonSerializerOptionsJsonTypeInfo(源生成器)的重载——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);
// 不支持反向引用与前后查找,但永远不会指数级爆炸
灾难性回溯(ReDoS)是真实的拒绝服务漏洞。形如 (a+)+$ 的模式在不匹配的长输入上会指数级爆炸,一个请求就能吃满一个 CPU 核。凡是模式来自用户输入、或者要匹配用户提供的长文本,必须设超时或用 NonBacktracking。这条在处理上传文件、搜索关键词、日志解析时尤其重要。
很多「需要正则」的场景其实不需要:判断前缀后缀用 StartsWith/EndsWith、切分用 Split、找字符用 IndexOfAnySearchValues(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 请求,就等于让所有等锁的线程一起等这个请求;调用回调/事件更危险——对方可能再去拿别的锁,构成死锁。临界区应该短到「几行内存操作」的程度:把耗时工作放到锁外面,锁里只做状态更新。
能不共享就不共享。并发 bug 的根源永远是共享可变状态——把它拆成「每个任务算自己的,最后合并」,往往比任何锁都快也更容易推理(11 章的 Task.WhenAll 收集结果就是这个思路)。锁是最后手段,不是第一反应。

Microsoft.Extensions.* 这一族包提供了配置、日志、DI、选项、健康检查等基础设施。它们不只属于 ASP.NET Core——控制台程序、后台服务、甚至类库都能用同一套。

配置来源的优先级(后面覆盖前面)

  • appsettings.jsonappsettings.{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 MB5对应版本的 .NET 运行时
自包含 win-x6476.6 MB192什么都不用装
自包含 + 单文件70.1 MB2什么都不用装
自包含 + 单文件 + 裁剪12.3 MB2什么都不用装
NativeAOT1.06 MB1(+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 out
单文件不是「解压后直接内存运行」的银弹。在 Linux 上大部分程序集可以直接从文件里映射执行,但原生依赖库(.so/.dll)仍需要解压到磁盘临时目录;只读文件系统或没有可写临时目录的环境会启动失败。另外单文件下 Assembly.Location 返回空字符串——用它定位资源文件的代码会静默失效,要改用 AppContext.BaseDirectory
容器里几乎总该用框架依赖发布,哪怕自包含听起来更省事。原因是 Docker 的分层缓存:运行时在基础镜像层(所有服务共享、只拉一次),你的应用只有几 MB 在最上层。自包含反而让每个镜像都带一份 70 MB 的运行时,推拉都变慢。

RID(Runtime Identifier)就是「目标平台」的标识:win-x64linux-x64linux-arm64osx-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-arm64Windows
linux-x64主流 Linux(glibc)
linux-musl-x64Alpine(musl libc)——用错会启动失败
linux-arm64ARM 服务器、树莓派 64 位、Apple Silicon 上的 Linux 容器
osx-x64 / osx-arm64macOS Intel / Apple Silicon
  • .NET 8 起 RID 图被简化了:不再有 ubuntu.22.04-x64 这种发行版级 RID,统一用 linux-x64。老项目里那些细分 RID 应该清掉。
  • 运行时可以自报:RuntimeInformation.RuntimeIdentifier 返回 win-x64

多目标框架(TFM)与 RID 是两回事

  • TFMnet8.0net10.0)说的是「用哪个版本的 API」;RID 说的是「跑在哪个操作系统和架构上」。
  • 带平台的 TFM(net10.0-windowsnet10.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();
#endif
指定了 RuntimeIdentifier 之后,dotnet build/dotnet run 的输出路径会多一层 RID 目录bin/Debug/net10.0/win-x64/)。CI 脚本里硬编码路径的地方会突然找不到文件。dotnet publish -o <固定目录> 显式指定输出,别去猜默认路径——这条也适用于多 TFM 项目(每个 TFM 一个子目录)。
Alpine 用 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.CreateInstanceFunc<T> 委托(09 章)
基于反射的 ORM / 映射器源生成器版本(Dapper.AOT、Mapperly 等)
dynamic强类型 / JsonNode
反射式配置绑定Microsoft.Extensions.Configuration.Binder 的源生成器
  • 确实必须保留某些类型时,用 [DynamicDependency] 特性或 TrimmerRootDescriptor XML 文件显式告诉裁剪器「别删这个」。但这是补丁,不是设计。
<!-- 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 分析保留原因
第三方库的裁剪兼容性是最大的不确定因素。你自己的代码可以改,但依赖的某个库内部用了反射且没有标注,裁剪器就毫不知情地把它需要的类型删掉了——而且不会有任何警告(没标注就等于「声称自己是安全的」)。选依赖时看它有没有声明支持裁剪/AOT,是今天选型的一个新维度。
裁剪必须在真实环境跑一遍完整的测试。裁剪问题的特点是「编译通过、大部分功能正常、某个冷门路径运行时才炸」——单元测试跑的是未裁剪的产物,测不出来。正确做法是在 CI 里对裁剪后的产物跑一遍端到端/冒烟测试,把那些只在生产才走到的路径覆盖上。

NativeAOT 把 IL 提前编译成目标平台的机器码,产出一个不含运行时、不需要 JIT 的原生可执行文件。它是 .NET 在「冷启动」和「体积」上的终极答案,代价是放弃一整类动态能力。

(win-x64,同一个 hello world)

指标框架依赖单文件自包含NativeAOT
体积0.2 MB(需运行时)70.1 MB1.06 MB
启动(10 次取中位)54 ms70 ms24 ms
需要目标机装 .NET
  • 启动快约一倍,体积只有单文件的六十分之一。启动时间会因机器而异,但方向是确定的——没有 JIT 预热、没有运行时初始化。
  • Serverless(每次冷启动都算钱)、CLI 工具(用户等不了半秒)、容器密集部署(内存占用也更低)来说,这个差距是决定性的。

代价清单:这些能力没了

  • 没有 JITReflection.EmitExpression.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 需要 clangzlib 开发包;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 项目
AOT 与主流测试/ORM 生态存在真实冲突。Moq、NSubstitute 这类动态代理 mock 库依赖 Reflection.Emit,EF Core 的部分功能、多数反射式对象映射器也是。这不意味着不能用——测试项目本来就不需要 AOT 发布,只是要接受「测试跑在 JIT 下、生产跑在 AOT 下」这个差异,并在 CI 里对 AOT 产物做端到端验证。
先从 dotnet new webapiaotdotnet new console + PublishAot 起步,别拿存量大项目直接试。AOT 的兼容性问题会一次性全部涌出来,很难分辨哪个是真障碍。新项目从第一天就开着 PublishAot,每次引依赖时立刻知道兼容不兼容——增量地保持 AOT 就绪,比事后迁移容易一个数量级

.NET 的容器化今天有两条路:写 Dockerfile(可控)和 dotnet publish /t:PublishContainer(零 Dockerfile)。两条都成熟,按团队习惯选。

官方基础镜像怎么选

镜像体积量级用于
dotnet/sdk:10.0编译器 + 运行时只用于构建阶段
dotnet/aspnet:10.0ASP.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.3
容器里的 .NET 默认时区是 UTC、默认区域是不变区域、且可能没装 ICU 和 tzdata(13 章各有一卡)。这三件事叠加起来,让「本地跑得好、容器里日期和排序全变了」成为经典现象。chiseled 与 alpine 镜像尤其要确认:需要区域功能就显式装 icu-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 是最值得先加的一个:把 NullableTreatWarningsAsErrorsLangVersionAnalysisMode 统一写在这里,新项目自动继承,不会有人漏配。
  • 中央包管理解决「同一个包在三个项目里是三个版本」的问题——那种情况下 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 里PackageIdVersionAuthorsDescriptionPackageLicenseExpressionRepositoryUrl
  • 务必开 <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.org
在本机上验证过一个容易误诊的环境问题:dotnet 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 webapiMinimal API + OpenAPI + 示例端点
dotnet new webapiaotNativeAOT 就绪(CreateSlimBuilder + JSON 源生成器,16 章)

中间件管道:顺序就是一切

  • 中间件是一层套一层的洋葱:每个 app.UseXxx()注册顺序处理请求,再按相反顺序处理响应。
  • 顺序写错是静默失败:把 UseAuthentication 放在 UseRouting 之前、把 UseCors 放在错误的位置,都不会报错,只是不生效。
  • 推荐顺序(记住这一串就够)UseExceptionHandlerUseHttpsRedirectionUseStaticFilesUseRoutingUseCorsUseAuthenticationUseAuthorization → 端点。
  • 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();
一个端点只能有一个从请求体绑定的参数。写两个复杂类型参数会在启动时抛异常(好在不是运行时)。想接收多个对象就包成一个 DTO。另外 GET 请求默认不读请求体——给 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

ResultsTypedResults
返回类型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.jsonappsettings.{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.OpenApidotnet new webapi 模板则已经带上了。

让文档变得有用

  • TypedResults 与联合返回类型(本章第 3 卡)——框架能自动推断出每个状态码对应的响应体类型,不用手写 [ProducesResponseType]
  • .WithName().WithSummary().WithTags() 给端点加元数据;MapGroup 上加的会被组内所有端点继承。
  • 要 UI:接 ScalarScalar.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.ts
OpenAPI UI 不要在生产环境暴露。它把全部端点、参数结构、示例数据公开出去,等于给攻击者一份攻击面地图。默认模板用 IsDevelopment() 包住是对的。确实需要给外部合作方看,就单独部署一份文档站点或者加鉴权,而不是把生产 API 的 /scalar 直接开出去
把 OpenAPI 文档纳入版本库并在 CI 里比对:每次构建导出一份 JSON,和仓库里的对比,有差异就要求提交者确认。这样「不小心改了接口契约」会在 PR 阶段被发现,而不是等前端联调时。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 陷入重定向循环)。而且它必须放在管道最前面——放晚了,前面的中间件已经读过错误的值了。
存活探针(liveness)和就绪探针(readiness)要分开:存活探针只检查进程本身(Predicate = _ => false 表示不跑任何检查),就绪探针才检查数据库等依赖。混为一谈会导致「数据库抖动 → 就绪失败 → K8s 判定不存活 → 重启 Pod → 雪崩」——重启解决不了数据库的问题,只会让情况更糟。

测试与工具链

.NET 的测试与诊断工具链是这门生态最扎实的部分之一:建项目、跑测试、测覆盖率、格式化、静态分析全都是一条 dotnet 命令。这一章把它们串起来,并讲清「怎么写出可测试的代码」——那比任何测试框架都重要。

测试在 .NET 里是一个独立的项目,引用被测项目,用测试框架的特性标注方法。dotnet test 负责发现、运行、汇总。

三个框架,选一个就行

框架模板特点
xUnitdotnet new xunit社区默认;每个测试一个新实例,天然隔离
NUnitdotnet new nunit特性丰富,从 Java/JUnit 过来的人熟悉
MSTestdotnet new mstest微软自家,企业环境里常见
  • dotnet new xunit 生成的 csproj 带四个包:xunitxunit.runner.visualstudioMicrosoft.NET.Test.Sdkcoverlet.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);
}
过度 mock 会产出「怎么改都不会红、也怎么都测不出 bug」的测试。典型症状是一个测试里 mock 了五个依赖,然后断言「A 调用了 B.Foo() 一次」——这测的是实现细节,重构成 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();
}
EF Core 的 InMemory 提供程序不是数据库。它不校验外键、不支持事务语义、不翻译 SQL、对 Include 和原始查询的行为都与真库不同。用它写的集成测试全绿,上线照样因为「唯一约束冲突」「SQL 翻译不了」而挂。微软官方文档现在也建议不要用它做测试——用 Testcontainers 起真库,或者至少用 SQLite(虽然方言仍有差异)。
集成测试之间要保证数据隔离,三种做法从好到差:① 每个测试用独立的 schema 或数据库名;② 每个测试包在事务里最后回滚;③ 每个测试前清空表。第三种最慢也最容易漏,但在有触发器/序列的场景下有时只能这样。不要依赖测试的执行顺序——它们是并行的。

.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.CSharpRoslynator

.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 CA1031
AnalysisMode=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>
csproj 里的属性顺序无关但作用域有关<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 builddotnet 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-traceCPU 采样、事件追踪
dotnet-dump / dotnet-gcdump转储与堆分析
dotnet-monitor把上面几样包成 HTTP 接口,容器里当 sidecar
dotnet-efEF Core 迁移
dotnet-outdated-tool批量检查/升级依赖版本
dotnet-reportgenerator-globaltool覆盖率报告转 HTML
  • 本地工具(local tool)比全局工具更适合团队项目dotnet new tool-manifestdotnet tool install(不加 -g),版本写进 .config/dotnet-tools.json 并入库,队友 dotnet tool restore 就得到完全相同的版本。

排查问题的入口顺序

  • ① 服务变慢但 CPU 不高dotnet-counters 看线程池队列 → 多半是同步阻塞(11 章)。
  • ② 内存持续增长dotnet-gcdump 看堆上什么最多 → 多半是缓存无上限或事件没退订(14 章、05 章)。
  • ③ CPU 打满dotnet-trace CPU 采样 → 看火焰图找热点。
  • ④ 完全卡死无响应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 out
CI 里 actions/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 HybridWebView随宿主(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 = "";
        }
    }
}
UI 线程规则在每个框架里都存在,而且违反的表现是随机崩溃。后台线程直接改绑定的集合(ObservableCollection.Add)在 WPF 上抛异常、在别的框架上可能静默出错或界面错乱。规则:所有 UI 状态变更必须回到 UI 线程——用 Dispatcher.UIThread.Post(Avalonia)/ MainThread.BeginInvokeOnMainThread(MAUI),或者干脆用 await(有同步上下文时会自动回来,11 章)。
MVVM 的样板代码今天不用手写了。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(静态托管)
Blazor Server 的每个用户都占着一条 WebSocket 连接和一份服务端状态。这意味着:服务器重启会断开所有人(需要处理重连)、用户量与服务器内存直接挂钩、网络延迟直接变成 UI 卡顿(每次点击都要往返)。它非常适合内网低并发系统,非常不适合公网高并发场景——这不是调优能解决的,是模型决定的。
Blazor 最大的实际收益是「共享模型与校验」:DTO、枚举、业务规则、DataAnnotations 校验特性在前后端是同一份代码,改一处两边都变,不会出现「前端校验和后端校验不一致」这种经典问题。如果这一条对你的项目价值很大,Blazor 就值得认真评估。

需要调用操作系统 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);
P/Invoke 出错时的表现往往不是异常,而是进程直接消失。结构体布局错一个字段、调用约定不匹配、传了个已被 GC 移动的托管指针——这些会破坏原生栈或堆,结果是段错误/访问冲突,没有 .NET 异常、没有堆栈、日志里什么都没有(和 10 章的栈溢出一样)。写 P/Invoke 要格外小心,并且每一处都写测试。需要把托管数组传给原生代码时用 fixedGCHandle.Alloc(..., GCHandleType.Pinned) 固定住它。
反过来也可以:C# 能被 C 调用。[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 可进程内互调但耦合紧
JavagRPC / 消息队列;共享 protobuf 或 Avro schema
C / C++ / RustP/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.AISemantic 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");
EF Core 最经典的性能问题是 N+1 查询:遍历订单再访问 order.Customer.Name,每一条都会单独发一次 SQL。开发环境几十条数据看不出来,生产上一页 100 条就是 101 次往返。解法是 Include 预加载或 Select 投影诊断办法是打开 SQL 日志看一眼到底发了几条——这也是 08 章「IQueryable 掉回 IEnumerable」那条陷阱的近亲。
EF Core 与 Dapper 不是二选一。常见且健康的做法是:写操作和领域模型用 EF Core(变更跟踪、迁移、关系映射都很值钱),复杂查询和报表用 Dapper 手写 SQL(避开 LINQ 翻译不出来或翻译得很糟的情况)。两者可以共用同一个数据库连接与事务。

从这里到精通:路线图

本页覆盖了 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=true
最需要提防的不是学得慢,是学到过时的东西。C# 与 .NET 每年一个大版本,搜索引擎里排名靠前的答案常常来自 .NET Framework 时代——WebClientThreadArrayListBinaryFormatterpackages.configConfigurationManager、Web Forms,看到这些基本可以判定资料已经老了(00 章的 pitfall 讲过判据)。拿不准就去查官方文档的当前版本,或者直接看 dotnet/runtime 源码。
本页的每一条结论都可以自己复现。装好 SDK(01 章),把卡片里的代码抄进一个 .cs 文件,dotnet run x.cs 就能跑——.NET 10 的单文件应用让验证的成本低到几乎为零。看到任何断言心里犯嘀咕,就当场跑一遍;这比多读三遍文档有用得多,也是本页所有数字的产出方式。