一份面向零基础读者的 .NET 全栈式教程:从 C# 语法起步,经过工程化实践训练,最终具备构建可维护、可扩展的生产级应用的能力。
第一部分:核心基础 第二部分:工程化实践 第三部分:企业级开发
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────────┐
│ 01 .NET 平台与环境 │ │ 11 ASP.NET Core 入门 │ │ 17 分层架构与领域建模 │
│ 02 C# 基础语法 │ │ 12 依赖注入 │ │ 18 EF Core 数据访问 │
│ 03 方法/数组/字符串 │ │ 13 配置管理与选项模式 │ │ 19 常用设计模式 │
│ 04 面向对象(上) │ │ 14 日志记录 │ │ 20 性能优化 │
│ 05 面向对象(下) │ │ 15 单元测试 │ │ 21 安全防护 │
│ 06 结构体/枚举/记录 │ │ 16 调试与诊断 │ │ 22 异常处理与弹性设计 │
│ 07 集合与泛型 │ └─────────────────────┘ │ 23 持续集成与部署运维 │
│ 08 委托/事件/Lambda │ └─────────────────────────┘
│ 09 LINQ │
│ 10 异步编程 │
└─────────────────────┘
建议节奏:每天 1-2 小时,第一部分约 5-6 周,第二部分约 3 周,第三部分约 4-5 周。练习务必动手完成——看会了不等于会写。
| 章 | 文件 | 主题 |
|---|---|---|
| 01 | dotnet-platform-and-environment.md | .NET 平台概述与开发环境搭建 |
| 02 | csharp-basics.md | C# 基础语法:变量、类型与控制流 |
| 03 | methods-arrays-strings.md | 方法、数组与字符串 |
| 04 | oop-part1-classes.md | 面向对象编程(上):类与封装 |
| 05 | oop-part2-inheritance-polymorphism.md | 面向对象编程(下):继承、多态与接口 |
| 06 | structs-enums-records.md | 结构体、枚举、记录与可空性 |
| 07 | collections-generics.md | 集合与泛型 |
| 08 | delegates-events-lambda.md | 委托、事件与 Lambda 表达式 |
| 09 | linq.md | LINQ:统一的数据查询 |
| 10 | async-programming.md | 异步编程:async/await 与 Task |
| 章 | 文件 | 主题 |
|---|---|---|
| 11 | aspnetcore-webapi.md | ASP.NET Core Web API 入门 |
| 12 | dependency-injection.md | 依赖注入与服务生命周期 |
| 13 | configuration-options.md | 配置管理与选项模式 |
| 14 | logging.md | 日志记录 |
| 15 | unit-testing.md | 单元测试 |
| 16 | debugging-diagnostics.md | 调试、诊断与代码质量 |
| 章 | 文件 | 主题 |
|---|---|---|
| 17 | architecture-layers.md | 分层架构与领域建模 |
| 18 | efcore-data-access.md | EF Core 数据访问 |
| 19 | design-patterns.md | 企业开发常用设计模式 |
| 20 | performance-optimization.md | 性能优化 |
| 21 | security.md | 安全防护 |
| 22 | exception-handling-resilience.md | 异常处理与弹性设计 |
| 23 | cicd-devops.md | 持续集成与部署运维 |
| 附录 | 文件 | 内容 |
|---|---|---|
| A | dotnet-csharp-versions.md | .NET 与 C# 版本演进速查 |
| B | cli-commands-cheatsheet.md | dotnet CLI 与常用工具速查 |
| C | C-capstone-project.md | 贯穿项目「轻订单」需求书与里程碑 |
祝学习顺利。开始第一章:.NET 平台概述与开发环境搭建。
学完本章,你将能够:
.NET 是微软主导的开源开发平台,包含编程语言(C#、F#、VB.NET)、运行时(CLR)、基础类库(BCL)和一系列应用框架(ASP.NET Core、EF Core、MAUI 等)。用一句话概括:用 .NET,你可以用同一门语言、同一套类库,开发 Web 后端、桌面软件、移动 App、云服务甚至游戏。
理解 .NET 的命名混乱,历史是钥匙:
| 时期 | 名称 | 说明 |
|---|---|---|
| 2002-2019 | .NET Framework | 只支持 Windows,最高版本 4.8,现仅维护不再加新特性 |
| 2014-2019 | .NET Core 1.x-3.1 | 开源、跨平台重构版,性能大幅提升 |
| 2020 | .NET 5 | 统一命名起点,跳过版本 4 避免与 Framework 混淆 |
| 2021-2024 | .NET 6/7/8/9 | 每年 11 月发布一个大版本,偶数版本为 LTS |
| 2025- | .NET 10(LTS) | 本教程基准版本 |
本书所说的 ".NET" 一律指现代 .NET(5 及以后)。只有在维护老系统时你才会碰到 .NET Framework,那时 API 细节有差异,但 C# 语言本身几乎一致。
对企业后端开发而言,ASP.NET Core + EF Core 是 .NET 就业市场最主流的技术组合,也是本书的主线。
先用一张图记住四个名词,后续章节会反复出现:
┌────────────────────────────────────────────┐
│ 你写的 C# 源代码(.cs 文件) │
└──────────────┬─────────────────────────────┘
│ 编译(Roslyn 编译器)
▼
┌────────────────────────────────────────────┐
│ IL 中间语言 + 元数据(.dll / .exe) │
└──────────────┬─────────────────────────────┘
│ 运行时按需 JIT 编译为机器码
▼
┌────────────────────────────────────────────┐
│ CLR(Common Language Runtime)公共语言运行时 │
│ · JIT 即时编译 · 垃圾回收 GC │
│ · 类型安全与异常 · 线程与内存管理 │
├────────────────────────────────────────────┤
│ BCL(Base Class Library)基础类库 │
│ · 集合 · IO · 网络 · 文本 · 并发 …… │
└────────────────────────────────────────────┘
System.String、System.Collections.Generic.List<T>、文件读写、HttpClient 等。dotnet 命令行工具和模板;运行时(Runtime):只运行程序所需的最小组件。装了 SDK 一定包含运行时,反之不然。开发机上装 SDK,生产服务器可以只装 ASP.NET Core Runtime。.NET 每年 11 月发一个大版本,交替提供两类支持:
企业生产环境选 LTS,尝鲜新特性可用 STS。本教程基于 .NET 10 LTS。
Windows(推荐 winget,或从 dotnet.microsoft.com 下载安装包):
winget install Microsoft.DotNet.SDK.10
macOS:
brew install --cask dotnet-sdk
Linux(Ubuntu/Debian):参照官方文档用 apt 源安装,或使用安装脚本:
wget https://dot.net/v1/dotnet-install.sh
bash dotnet-install.sh --channel 10.0
三选一,均可:
打开终端(Windows 用 PowerShell 或 Windows Terminal):
dotnet --version # 应输出 10.x.xxx
dotnet --list-sdks # 列出所有已装 SDK
dotnet --list-runtimes # 列出所有已装运行时
只要 dotnet --version 能输出版本号,环境就绪。如果提示 dotnet 不是内部或外部命令,重启终端(安装后 PATH 未刷新是常见原因),仍不行则重装 SDK。
mkdir hello-dotnet
cd hello-dotnet
dotnet new console # 从模板创建控制台项目
dotnet run # 编译并运行
屏幕输出:
Hello, World!
恭喜——但更要紧的是弄明白刚才发生了什么。
hello-dotnet/
├── hello-dotnet.csproj # 项目清单文件(描述"这是什么项目")
├── Program.cs # 你的代码
├── obj/ # 编译中间产物(自动生成,勿手动改)
└── bin/ # 编译输出(自动生成)
hello-dotnet.csproj 内容类似:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
逐行解读:
TargetFramework:目标框架为 .NET 10;ImplicitUsings:自动引入常用命名空间(System、System.Collections.Generic 等),省去每个文件顶部的 using;Nullable:启用可空引用类型检查,让编译器帮你发现"空引用" bug(第 6 章详讲,先照单全收)。Program.cs:
// 顶级语句(top-level statements):C# 9 起,程序入口可以不写 Main 方法
Console.WriteLine("Hello, World!");
这在传统写法里等价于:
namespace HelloDotNet;
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
两种写法都对。小脚本用顶级语句,正式项目(尤其是有多个文件、需要组织结构时)推荐显式的 Main——本教程为了清晰,示例大多使用显式写法。
现在先记住五个,其余见附录 B:
| 命令 | 作用 |
|---|---|
dotnet new console -n 项目名 |
创建控制台项目 |
dotnet build |
编译,检查语法错误 |
dotnet run |
编译并运行 |
dotnet add package 包名 |
安装 NuGet 包 |
dotnet test |
运行测试(第 15 章使用) |
NuGet 是 .NET 的第三方库仓库(nuget.org)。比如要解析 JSON,不必手写,安装社区最流行的包:
dotnet add package Newtonsoft.Json
csproj 会自动多出一行:
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
之后即可 using Newtonsoft.Json; 使用它。依赖他人代码 = 依赖供应链,选包时优先选择下载量大、持续维护的包,第 21 章会系统讨论供应链安全。
贯穿项目「轻订单」现在诞生。创建解决方案骨架(解决方案 = 多个项目的容器,第 11 章起会拆分多项目):
mkdir liteorder && cd liteorder
dotnet new sln -n LiteOrder
dotnet new console -n LiteOrder.App
dotnet sln add LiteOrder.App
cd LiteOrder.App
dotnet run
输出 Hello, World! 即成功。把它提交到 Git:
git init
# .gitignore 中加入 bin/ 与 obj/(dotnet new gitignore 可自动生成)
dotnet new gitignore
git add . && git commit -m "init: liteorder console skeleton"
此后每章的里程碑都会在这个项目上累积,需求见附录 C。
dotnet build 报"未找到 SDK",说明只装了运行时;开发机请装完整 SDK。System.Web、app.config 等在现代 .NET 中不存在,遇到时先确认代码的版本来源。bin/、obj/ 提交进 Git。它们是可再生的编译产物,请加入 .gitignore。dotnet new console -n Foo 会在当前目录创建 Foo 文件夹;建议先 cd 到目标父目录再执行。dotnet new gitignore 生成 .NET 官方模板的 .gitignore,而不是手写。Nullable=enable 与 ImplicitUsings=enable(新模板默认已开)。dotnet new / build / run 三板斧可以创建和运行项目;NuGet 负责第三方包。dotnet --list-sdks 与 dotnet --list-runtimes,把你机器上的版本列出来,确认哪些是 LTS。Program.cs 改成输出你的名字和今天的日期(提示:DateTime.Now),运行成功。liteorder 解决方案,推送到 GitHub 或 Gitee 私有仓库(第 23 章的 CI/CD 会用到这个仓库),并确认 .gitignore 已排除 bin/ 与 obj/。上一章:.NET 平台概述 ← | README | 下一章:方法、数组与字符串 →
学完本章,你将能够:
if/else、switch(含模式匹配)、for、while、foreach 控制程序流程;C# 是静态强类型语言:每个变量都有确定类型,编译期就检查类型错误。声明变量的语法:
int quantity = 3; // 显式声明类型
var price = 12.5m; // var:让编译器推断类型(仍是强类型,price 是 decimal)
const decimal TaxRate = 0.06m; // 常量:编译期确定,之后不可再赋值
约定:C# 变量与参数用小驼峰(
unitPrice),常量与类成员的公开部分用大驼峰(PascalCase),本书全篇遵守。
| 类型 | 含义 | 大小/范围 | 示例 |
|---|---|---|---|
int |
32 位整数 | ±21 亿 | int count = 42; |
long |
64 位整数 | 约 ±9.2×10¹⁸ | long totalBytes = 10_000_000_000; |
short / byte |
小整数 / 无符号 8 位 | -32768~32767 / 0~255 | byte age = 30; |
double |
双精度浮点 | 约 15-16 位有效数字 | double ratio = 0.75; |
decimal |
十进制浮点 | 28-29 位有效数字,无二进制表示误差 | decimal price = 19.99m; |
bool |
布尔 | true / false |
bool isPaid = false; |
char |
单字符(UTF-16) | 用单引号 | char grade = 'A'; |
string |
字符串(不可变) | 用双引号 | string name = "轻订单"; |
两条铁律:
decimal,字面量加 m 后缀。double 用二进制存储,0.1 + 0.2 != 0.3,对钱算错分毫都是事故:Console.WriteLine(0.1 + 0.2 == 0.3); // False!
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
checked 强制抛异常:int max = int.MaxValue;
Console.WriteLine(max + 1); // -2147483648(静默溢出!)
Console.WriteLine(checked(max + 1)); // 抛 OverflowException
数字字面量可用下划线分隔提高可读性:1_000_000。
// 隐式转换:小 → 大,安全,自动发生
int i = 100;
long l = i;
// 显式转换(强制转换):大 → 小,可能丢失数据
double d = 3.99;
int truncated = (int)d; // 3:直接截断小数,不是四舍五入!
// 字符串 → 数值:Parse 会抛异常,TryParse 返回 bool 更安全
string input = "42";
int ok = int.Parse(input); // 解析失败抛 FormatException
if (int.TryParse(input, out int value))
{
Console.WriteLine($"解析成功:{value}");
}
else
{
Console.WriteLine("请输入合法数字");
}
// 数值 → 字符串
string s = value.ToString();
经验法则:处理用户输入一律用 TryParse——用户一定会输入非法内容,Parse 会把程序炸掉(异常机制见第 22 章的系统论述,先记住用法)。
int a = 10, b = 3;
Console.WriteLine(a / b); // 3 整数除法:直接丢弃余数!
Console.WriteLine(a % b); // 1 取余
Console.WriteLine(a / (double)b); // 3.333... 想要小数需先转浮点
Console.WriteLine(a == b); // false 比较(注意 == 是比较,= 是赋值)
Console.WriteLine(a != b); // true
Console.WriteLine(a > 5 && b < 5); // true 逻辑与(短路:左边 false 则不算右边)
Console.WriteLine(a > 5 || b < 5); // true 逻辑或
Console.WriteLine(!true); // false 逻辑非
int c = a + b;
c += 10; // 复合赋值,等价 c = c + 10
c++; // 自增
易错点:&&、|| 是短路求值——左边已能确定结果时右边不再执行。这既是性能优化,也是写"防御条件"的技巧:if (order != null && order.IsPaid),左边已排除 null,右边才敢访问成员。
C# 8 起还有空合并与范围检查等运算符(??、?.),第 6 章结合可空性详解。
decimal amount = 520m;
if (amount >= 500)
{
Console.WriteLine("符合免运费条件");
}
else if (amount >= 100)
{
Console.WriteLine("满 100 减 10 元");
}
else
{
Console.WriteLine("无优惠");
}
风格提示:C# 社区惯例始终写花括号,即使单行;省略括号是著名 bug 温床(Apple goto fail 漏洞即源于此)。
// 传统语句形式
switch (status)
{
case "Paid":
Console.WriteLine("已支付");
break;
case "Pending":
case "Created": // 多个 case 落到同一段
Console.WriteLine("未支付");
break;
default:
Console.WriteLine("未知状态");
break;
}
// switch 表达式(C# 8+):更简洁,还支持关系模式与 when 条件 —— 推荐写法
string label = status switch
{
"Paid" => "已支付",
"Pending" or "Created" => "未支付",
"Refunding" when amount > 0 => "部分退款中",
"Cancelled" or "Refunded" => "已关闭",
null => "状态为空",
_ => "未知状态"
};
switch 表达式是现代 C# 代码的标志性写法,配合第 5 章的模式匹配会更强大。
// for:已知次数
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"第 {i} 次循环");
}
// while:先判断后执行;do-while:先执行一次再判断
int stock = 3;
while (stock > 0)
{
stock--;
}
// foreach:遍历集合(第 7 章细讲),不能修改被遍历的集合本身
foreach (char ch in "LiteOrder")
{
Console.Write(ch + " ");
}
// break 跳出循环;continue 跳过本轮
for (int i = 0; i < 10; i++)
{
if (i == 3) continue; // 跳过 3
if (i == 8) break; // 到 8 停止
Console.Write(i + " "); // 输出 0 1 2 4 5 6 7
}
字符串不可变(immutable):任何"修改"都返回新字符串。第 3 章会深入,这里先掌握最常用的三件事。
// 1) 字符串插值($ 前缀):组装文本首选,可对齐、格式化
string product = "拿铁咖啡";
int qty = 2;
decimal unit = 28.5m;
Console.WriteLine($"{product} x {qty} = {unit * qty:C}"); // 拿铁咖啡 x 2 = ¥57.00
var now = DateTime.Now;
Console.WriteLine($"{now:yyyy-MM-dd HH:mm}"); // 2026-09-29 14:30
// 2) 常用成员
string raw = " LiteOrder ";
Console.WriteLine(raw.Trim()); // 去首尾空白
Console.WriteLine(raw.Trim().Length); // 9
Console.WriteLine(raw.Contains("Order")); // True
Console.WriteLine(raw.Replace(" ", "")); // LiteOrder
// 3) 多行原始字符串(C# 11):SQL、JSON 等多行文本的利器,无需转义引号
string sql = """
SELECT Id, Amount
FROM Orders
WHERE Status = 'Paid'
""";
比较字符串用 ==(C# 重载了它做值比较,与 Java 不同)或 string.Equals(a, b, StringComparison.OrdinalIgnoreCase) 做忽略大小写比较。
把本章知识拼成一个能跑的小程序——轻订单的雏形:读取商品单价与数量,按会员等级打折,输出订单摘要。
// Program.cs
using System.Globalization;
const decimal VipDiscount = 0.9m; // VIP 九折
const decimal FreeShippingThreshold = 99m;
const decimal ShippingFee = 8m;
Console.Write("商品名称:");
string product = Console.ReadLine()?.Trim() ?? "未命名商品";
Console.Write("单价(元):");
if (!decimal.TryParse(Console.ReadLine(), out decimal unitPrice) || unitPrice < 0)
{
Console.WriteLine("单价无效,程序退出");
return;
}
Console.Write("数量:");
if (!int.TryParse(Console.ReadLine(), out int qty) || qty <= 0)
{
Console.WriteLine("数量无效,程序退出");
return;
}
Console.Write("会员等级(1 普通 / 2 VIP):");
string level = Console.ReadLine()?.Trim() ?? "1";
decimal subtotal = unitPrice * qty;
bool isVip = level == "2";
decimal discount = isVip ? subtotal * (1 - VipDiscount) : 0m;
decimal payable = subtotal - discount;
decimal shipping = payable >= FreeShippingThreshold ? 0m : ShippingFee;
payable += shipping;
string vipTag = isVip ? " [VIP]" : "";
Console.WriteLine($"""
---------- 订单摘要{vipTag} ----------
商品:{product}
小计:{subtotal:C2}
优惠:-{discount:C2}
运费:{shipping:C2}
应付:{payable:C2}
""");
这个 40 行的小程序用到了:类型选择(金额 decimal)、TryParse 防御输入、switch/三元表达式、多行原始字符串插值。它就是贯穿项目的起点——后面每章都会让它更接近真实系统。
decimal。3 / 2 是 1 不是 1.5;需要小数时先把一侧转成 double/decimal。(int) 强转浮点是截断不是四舍五入。四舍五入用 Math.Round(x)(注意它默认银行家舍入,Math.Round(x, MidpointRounding.AwayFromZero) 才是传统四舍五入)。Parse 直接处理用户输入。失败即抛异常,用 TryParse。+ 大量拼接字符串。每次都生成新对象,性能差;应使用 StringBuilder(第 3 章)。&& 的短路顺序。防御性判断必须把 null 检查放在前面。var 于右侧类型显而易见处;类型不明显时显式声明,利于阅读。FreeShippingThreshold)。$"...",多行文本用原始字符串 """..."""。_ 兜底。TryParse(安全解析输入)。{a}×{b}={a*b,3} 中的 ,3 表示右对齐占 3 位)。git commit -m "feat: order amount calculator")。0.1 + 0.2 != 0.3 的根本原因是什么?如果面试官问"为什么不用 double 存钱",你如何用一句话回答?上一章:C# 基础语法 ← | README | 下一章:面向对象编程(上)→
学完本章,你将能够:
out、ref、可选参数、params)与方法重载;Array 工具类,用范围运算符切片;StringBuilder;方法是一段有名字的、可复用的代码。把重复逻辑收敛成方法,是控制复杂度的第一手段。
// [访问修饰符] [static] 返回类型 方法名(参数列表)
static decimal ApplyDiscount(decimal amount, decimal rate)
{
if (rate is < 0 or > 1)
{
throw new ArgumentOutOfRangeException(nameof(rate), "折扣率必须在 0 到 1 之间");
}
return amount * rate;
}
decimal payable = ApplyDiscount(200m, 0.9m); // 180
要点:
return 立即结束方法并返回值;返回类型为 void 表示不返回。throw new ArgumentOutOfRangeException(...) 在前置条件不满足时立即失败——**尽早失败(fail fast)**优于带着坏数据继续跑。nameof(rate) 取参数名的字符串,重命名时编译器会同步,优于硬编码 "rate"。static decimal Tax(decimal amount) => amount * 0.06m;C# 默认按值传递:方法收到的是实参的副本。对引用类型(如数组、字符串引用)而言,复制的是"引用",因此方法内修改对象内容会影响外部,但让参数指向新对象不会。
static void ChangeContent(int[] arr) // 修改内容:外部可见
{
arr[0] = 999;
}
static void Rebind(int[] arr) // 重新指向:外部不可见
{
arr = new int[] { 1, 1, 1 };
}
三个修饰符按需使用(不要滥用):
// out:方法"额外返回"一个值。最经典:TryParse
static bool TrySplitName(string full, out string first, out string last)
{
var parts = full.Split(' ', 2);
first = parts[0];
last = parts.Length > 1 ? parts[1] : "";
return parts.Length > 0;
}
if (TrySplitName("张 三丰", out string f, out string l))
{
Console.WriteLine($"姓:{f},名:{l}");
}
// ref:调用方必须先初始化,方法可读写。典型用于交换
static void Swap(ref int x, ref int y) => (x, y) = (y, x);
// params:可变数量参数
static decimal Total(params decimal[] amounts) => amounts.Sum();
decimal t = Total(10m, 20.5m, 3m);
经验:需要多个返回值时,优先考虑 out,更进一步用元组 (decimal subtotal, decimal discount) = (100m, 10m);(第 4 章后会用自定义类型,更清晰)。
// 可选参数:调用方可省略
static void PrintOrder(string orderNo, bool verbose = false, string title = "订单")
{
Console.WriteLine($"{title} {orderNo}(详细:{verbose})");
}
PrintOrder("SO-2026-0001");
PrintOrder("SO-2026-0001", verbose: true); // 命名参数:跳过中间可选参数
// 方法重载:同名不同参,编译器按实参类型选择
static decimal Calc(decimal price, int qty) => price * qty;
static decimal Calc(decimal price, int qty, decimal discount) => Calc(price, qty) * (1 - discount);
重载应保持语义一致(都是"计算");如果两个重载行为毫无关系,那应该改名。
方法内部还可以定义方法,适合只在本方法内复用的辅助逻辑:
static decimal OrderTotal(decimal[] items)
{
decimal SumWithTax(decimal sub) => sub * 1.06m; // 局部函数
return SumWithTax(items.Sum());
}
数组是定长、同类型的线性容器,内存连续,按下标访问 O(1)。
// 创建与初始化
int[] scores = new int[5]; // 5 个元素,默认值 0
int[] primes = { 2, 3, 5, 7, 11 }; // 字面量初始化
string[] names = new[] { "张三", "李四", "王五" };
// 访问与修改(下标从 0 开始,最大下标是 Length-1)
primes[0] = 2;
Console.WriteLine(primes.Length); // 5
Console.WriteLine(primes[^1]); // 11:^1 表示倒数第一个(C# 8)
// 范围运算符 ..:切片(返回新数组)
int[] first3 = primes[..3]; // 2 3 5
int[] last2 = primes[^2..]; // 7 11
常用工具(System.Array 静态方法与实例方法):
int[] nums = { 5, 2, 8, 1 };
Array.Sort(nums); // 就地排序:1 2 5 8
Array.Reverse(nums); // 反转
int idx = Array.IndexOf(nums, 5); // 查找下标,没有返回 -1
Array.Clear(nums); // 全部置默认值
foreach (int n in nums) // 遍历首选 foreach,不碰下标就不会越界
{
Console.WriteLine(n);
}
多维数组与交错数组:
int[,] grid = new int[3, 4]; // 3×4 矩形数组
grid[1, 2] = 7;
Console.WriteLine(grid.GetLength(0)); // 3(第 0 维长度)
int[][] jagged = new int[3][]; // 交错数组:数组的数组,各行长度可不同
jagged[0] = new int[] { 1 };
jagged[1] = new int[] { 1, 2 };
业务开发中矩形数组较少、集合(List<T>,第 7 章)更多,但解析二进制/表格数据时数组仍是利器。
string s = "LiteOrder";
s.ToUpper(); // ⚠️ 无效!返回的新字符串被丢弃,s 没变
s = s.ToUpper(); // ✅ 必须接住返回值
每次"修改"都新建字符串对象。循环拼接一万次就创建一万个中间对象——这是新手性能问题的头号来源。
var sb = new System.Text.StringBuilder();
for (int i = 1; i <= 100; i++)
{
sb.Append(i).Append(','); // 内部维护可变缓冲区,不产生中间字符串
}
string csv = sb.ToString().TrimEnd(',');
判断标准:三五个字符串拼一次,用插值即可;循环里拼接或超长文本,用 StringBuilder。
string raw = " SO-2026-0001,A ";
// 清理与判断
raw.Trim() // 去首尾空白
raw.TrimStart().TrimEnd() // 只去一侧
string.IsNullOrWhiteSpace(raw) // null/空/纯空白 → true(判空首选)
string.IsNullOrEmpty(raw) // null 或 ""
// 拆分与拼接
string line = "id=42;name=latte;price=28.5";
string[] fields = line.Split(';', StringSplitOptions.RemoveEmptyEntries);
string joined = string.Join(" | ", fields); // id=42 | name=latte | price=28.5
// 子串与查找
int pos = line.IndexOf(';'); // 找不到返回 -1
line[..pos] // "id=42"
line.Contains("latte", StringComparison.OrdinalIgnoreCase) // 忽略大小写
line.StartsWith("id="), line.EndsWith(".5")
// 替换与大小写
line.Replace("latte", "Mocha")
line.ToUpperInvariant() // 与文化无关的大小写转换(推荐 Invariant 系列)
// 格式化补充
string.Format("订单 {0} 金额 {1:C}", "SO-001", 28.5m)
28.5m.ToString("F2") // "28.50"
判空三兄弟的区别要刻在肌肉记忆里:null(没有对象)、""(空串)、" "(空白串)是三种状态;业务上"没填"通常用 string.IsNullOrWhiteSpace 判断。
练习把逻辑拆成方法,并处理批量数据:
// ---------- 数据 ----------
string[] products = { "拿铁咖啡", "美式咖啡", "抹茶蛋糕", "可颂" };
decimal[] prices = { 28.5m, 22m, 35m, 15m };
int[] stocks = { 100, 80, 20, 0 };
// ---------- 方法:职责单一 ----------
static void PrintCatalog(string[] products, decimal[] prices, int[] stocks)
{
Console.WriteLine("---------- 商品目录 ----------");
for (int i = 0; i < products.Length; i++)
{
string status = stocks[i] > 0 ? $"库存 {stocks[i]}" : "已售罄";
Console.WriteLine($"{i + 1}. {products[i]} ¥{prices[i]:F2} {status}");
}
}
static string NextOrderNo(DateTime now, int seq) =>
$"SO-{now:yyyyMMdd}-{seq:D4}"; // D4:补足 4 位,如 0007
static decimal Cheapest(decimal[] prices)
{
if (prices.Length == 0)
{
throw new InvalidOperationException("商品列表为空");
}
decimal min = prices[0];
foreach (decimal p in prices[1..])
{
if (p < min) min = p;
}
return min;
}
// ---------- 主流程 ----------
PrintCatalog(products, prices, stocks);
Console.WriteLine($"今日订单号示例:{NextOrderNo(DateTime.Now, 7)}");
Console.WriteLine($"最低价商品价格:{Cheapest(prices):C}");
注意 PrintCatalog 与数据读取解耦——把"打印"独立成方法,第 4 章你将看到同样的思路扩展成类。
s.ToUpper()、sb.Append(...) 之外的字符串方法均如此)。arr[arr.Length] 必抛 IndexOutOfRangeException;^1 指最后一个元素,但空数组的 ^1 同样越界。+= 拼接大字符串,应使用 StringBuilder。ref:绝大多数场景默认值传递即可,ref/out 只用于确有多返回值需求处。== 判空字符串却漏了 null:s == "" 在 s 为 null 时为 false 但不报错,容易掩盖问题;判空统一用 string.IsNullOrWhiteSpace。Split 的重载:Split(' ') 产生空元素,需要 RemoveEmptyEntries 选项处理连续分隔符。foreach 或范围运算符,少用手动下标。StringComparison(默认的比较受操作系统文化设置影响,跨机器行为可能不同)。(a, b) 元组或自定义类型,避免 3 个以上 out 参数。out 补充多返回值,params 接受可变参数;重载靠参数签名区分。^ 与 .. 提供优雅的首尾访问与切片;Array.Sort/IndexOf 是常用工具。StringBuilder;判空用 IsNullOrWhiteSpace。bool IsPalindrome(string s) 判断忽略大小写与空格的回文(如 "A man a plan")。测试至少 5 组输入。static (int max, int min, double avg) Stats(int[] nums),用元组返回数组的最大值、最小值、平均值,并处理空数组(抛异常还是返回 0?想清楚并写注释说明理由)。Split 解析),输出含所有明细行的订单摘要;订单号用 NextOrderNo 风格生成;提交 Git。ChangeContent 与 Rebind 的差异(3.1.1 节)说明了什么?为什么不建议用 ref 让方法"重新绑定"外部变量?上一章:方法、数组与字符串 ← | README | 下一章:面向对象编程(下)→
学完本章,你将能够:
static 成员的适用场景与风险;Product、Order 等类型。第 3 章末尾,轻订单用三个平行数组管理商品——加一个属性就要加一个数组,加一个商品要改四个地方。面向对象(OOP)的解法:把相关的数据与操作它们的方法捆绑成一个类型。
// 类:一张"商品"图纸
public class Product
{
// 属性:对外暴露的数据(含逻辑)
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; private set; } // 外部只读,修改权留在类内
// 方法:这个类型"会做"的事
public bool TryDeductStock(int quantity)
{
if (quantity <= 0 || quantity > Stock)
{
return false; // 库存不足,拒绝
}
Stock -= quantity;
return true;
}
}
// 对象:按图纸创建的实例
var latte = new Product();
latte.Name = "拿铁咖啡";
latte.Price = 28.5m;
bool ok = latte.TryDeductStock(2); // 库存 100 → 98
数据不再裸奔:Stock 不可能被外部改成负数,规则集中在类里,规则与数据住在一起,这就是封装的雏形。
public class Order
{
private static int _seed = 1000; // 静态字段:全类共享(见 4.4)
private readonly DateTime _createdAt; // 只能在声明处或构造函数中赋值
private List<string> _tags = new(); // 私有字段,外部不可见
}
约定:私有字段用 _camelCase。原则:字段一律 private,对外暴露一律走属性——这是封装的第一条纪律。
public class Order
{
private decimal _amount;
// 完整属性:get/set 逻辑可自定义
public decimal Amount
{
get => _amount;
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(nameof(Amount), "金额不能为负");
}
_amount = value;
}
}
// 自动属性:无额外逻辑时的简写(编译器自动生成后备字段)
public string OrderNo { get; set; }
// 只读自动属性:仅构造时可赋值 —— 业务对象常用(不可变风格)
public DateTime CreatedAt { get; }
// init(C# 9):只能在对象初始化时赋值,之后不可改
public string CreatedBy { get; init; }
// 计算属性:没有存储,每次现算
public bool IsLarge => Amount >= 500m;
}
属性 ≠ 字段的语法糖这么简单:属性是"带访问逻辑的数据出口",可以在 set 里做校验、在 get 里做转换、设断点排查"谁改了我的数据"。对外 API 用属性,是 .NET 类库设计的通用约定(如 DateTime.Now、string.Length)。
this 指当前实例,多数时候可省略,用于区分同名的参数与字段、或把当前对象传给其他方法:
public class ShoppingCart
{
private readonly List<Product> _items = new();
public void Add(Product product)
{
if (product is null)
{
throw new ArgumentNullException(nameof(product));
}
_items.Add(product);
}
public void Checkout()
{
// 把自己交给别的服务处理
OrderService.Place(this);
}
public decimal Total() => _items.Sum(p => p.Price); // LINQ,第 9 章详解
}
构造函数在 new 时执行,负责把对象"摆正"——一个刚出生就合法的对象,胜过千万次事后校验。
public class Order
{
private static int _seq = 0;
public string OrderNo { get; } // 只读属性:构造后不可变
public decimal Amount { get; }
public DateTime CreatedAt { get; }
// 主构造逻辑
public Order(decimal amount)
{
if (amount < 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
_seq++;
OrderNo = $"SO-{DateTime.Now:yyyyMMdd}-{_seq:D4}";
Amount = amount;
CreatedAt = DateTime.Now;
}
// 重载:委托给另一个构造函数,避免复制逻辑
public Order() : this(0m) { }
}
一旦定义了带参构造函数,编译器就不再生成无参默认构造——"必须给我金额才能创建订单"由此强制。
var order = new Order(88m)
{
// 只能给有 set/init 的属性赋值,按需书写,比多构造函数重载清爽
CreatedBy = "cashier-01",
};
参数直接写在类名后,整个类体可用,常用于"依赖注入"式类(第 12 章大量出现):
public class PriceCalculator(decimal taxRate)
{
public decimal WithTax(decimal amount) => amount * (1 + taxRate);
}
var calc = new PriceCalculator(0.06m);
主构造参数不是字段,需跨方法共享时才显式声明字段/属性承接。
static 成员不属于任何实例,通过类型名访问:
public static class OrderNoGenerator // 静态类:只能有静态成员,不能实例化
{
private static int _seq = 0; // 所有调用共享这一份
public static string Next() => $"SO-{DateTime.Now:yyyyMMdd}-{Interlocked.Increment(ref _seq):D4}";
}
// 用法:OrderNoGenerator.Next()
Math.Round)、常量、扩展点(如 DateTime.Parse)。| 修饰符 | 可见范围 | 使用频率 |
|---|---|---|
public |
任何代码 | 对外 API |
private |
本类内部 | 字段、辅助方法,默认选择 |
protected |
本类及子类 | 为继承预留(下一章) |
internal |
同一程序集内 | 团队内部实现,防外部误用 |
private protected / protected internal |
组合语义 | 少用 |
程序集(assembly)= 一次编译的产物(.dll/.exe),可粗略理解为"一个项目"。
封装的价值清单(面试常问,更是工程常识):
Stock 永不为负,因为唯一修改口有校验;internal 的实现随便改,只要 public 契约不变;把第 3 章的平行数组升级为真正的类型:
public class Product
{
public int Id { get; init; }
public string Name { get; init; } = "";
public decimal Price { get; init; }
public int Stock { get; private set; }
public Product(int id, string name, decimal price, int stock)
{
Id = id;
Name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("商品名不能为空", nameof(name)) : name;
Price = price >= 0 ? price : throw new ArgumentOutOfRangeException(nameof(price));
Stock = Math.Max(0, stock);
}
public bool TryDeductStock(int qty)
{
if (qty <= 0 || qty > Stock) return false;
Stock -= qty;
return true;
}
}
public class OrderItem
{
public Product Product { get; init; } = null!;
public int Quantity { get; }
public decimal Subtotal => Product.Price * Quantity; // 计算属性:现算,杜绝不同步
public OrderItem(Product product, int quantity)
{
Product = product ?? throw new ArgumentNullException(nameof(product));
Quantity = quantity > 0 ? quantity : throw new ArgumentOutOfRangeException(nameof(quantity));
}
}
public class Order
{
private readonly List<OrderItem> _items = new();
public string OrderNo { get; } = $"SO-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..6]}";
public IReadOnlyList<OrderItem> Items => _items; // 外部可读不可改列表结构
public decimal Subtotal => _items.Sum(i => i.Subtotal);
public bool IsEmpty => _items.Count == 0;
public void AddItem(Product product, int qty)
{
if (!product.TryDeductStock(qty))
{
throw new InvalidOperationException($"「{product.Name}」库存不足");
}
_items.Add(new OrderItem(product, qty));
}
public decimal Payable(decimal discountRate = 1m) => Subtotal * discountRate;
}
用法:
var latte = new Product(1, "拿铁咖啡", 28.5m, 100);
var cake = new Product(2, "抹茶蛋糕", 35m, 20);
var order = new Order();
order.AddItem(latte, 2);
order.AddItem(cake, 1);
Console.WriteLine($"{order.OrderNo} 小计:{order.Subtotal:C} VIP 应付:{order.Payable(0.9m):C}");
对比第 3 章:数据合法性在构造时强制(商品名不可能为空、库存不可能为负)、规则集中在类型内(扣库存只有 TryDeductStock 一个口)、OrderItem.Subtotal 用计算属性根除了"改了单价忘了改小计"这类不同步 bug。
private;对外能力 = 属性(数据)+ 方法(行为);需要收紧的项目内部实现用 internal。init + 只读属性优先,让对象尽量不可变。IReadOnlyList<T> / IReadOnlyCollection<T>,防外部绕过方法直接 Add/Remove。ArgumentException、InvalidOperationException),消息写清"什么错、期望什么"。static 属于类型而非实例:工具与常量适用,业务状态禁用。BankAccount 类:私有余额字段、Deposit/Withdraw 方法(余额不足与非法金额抛异常)、Balance 只读属性、交易后触发简单的控制台提示。用 6 组调用验证边界。Product 增加分类 Category 字符串属性与 IsOutOfStock 计算属性;思考 Price 用 init 而不是 set 的利弊。Product.cs、OrderItem.cs、Order.cs,改造主程序为"命令行购物":循环读命令 add <商品id> <数量>、list、total、pay <折扣率>、quit;所有输入走 TryParse 与校验;Git 提交。Order.Items 为什么返回 IReadOnlyList<OrderItem> 而不是直接返回 _items(List)?如果调用方需要增删,正确的通道是什么?上一章:面向对象编程(上)← | README | 下一章:结构体、枚举、记录与可空性 →
学完本章,你将能够:
base、sealed 与构造链;virtual/override/abstract 实现多态,说出多态解决的实际问题;is/as 与模式匹配安全地做类型判断与转换;ToString/Equals 等基类方法,知道组合为何常优于继承。继承表达 "是一个(is-a)":VipCustomer 是一种 Customer,自动获得其成员,并可扩展或改写行为。
public class Customer
{
public string Name { get; init; } = "";
public int Points { get; protected set; } // 子类可改,外部不可改
public Customer(string name) => Name = name;
public virtual decimal GetDiscount() => 1m; // virtual:允许子类改写
public void EarnPoints(decimal amount) => Points += (int)(amount / 10);
}
public class VipCustomer : Customer // 冒号即继承
{
public VipCustomer(string name) : base(name) { } // base(...):先执行父类构造
public override decimal GetDiscount() => 0.9m; // 改写:VIP 九折
}
public sealed class BlackCardCustomer : VipCustomer // sealed:禁止再被继承
{
public BlackCardCustomer(string name) : base(name) { }
public override decimal GetDiscount() => 0.85m;
}
要点:
object 是万物之祖);sealed 密封类:除非明确设计为可扩展,业务类建议默认不 virtual、必要时 sealed——开放的扩展点是设计承诺,不是免费的。base 调用父类实现;不加 new 修饰符地"重定义"同名成员是**隐藏(hiding)**而非改写,是经典 bug 来源:
public class A { public virtual void Run() => Console.WriteLine("A"); }
public class B : A { public override void Run() { base.Run(); Console.WriteLine("B"); } }
public class C : A { public new void Run() => Console.WriteLine("C"); } // 隐藏!
A x = new C();
x.Run(); // 输出 A!——变量类型决定走哪个(隐藏按静态类型分派)
A y = new B();
y.Run(); // 输出 A B —— 多态按运行时实际类型分派
想改写必须 override + 父成员 virtual/abstract;看到编译器建议加 new 时,先怀疑自己写错了。
多态(polymorphism)= 调用方只依赖抽象描述,运行时自动落到具体类型。上一节 x.Run() 已演示:变量声明为 A,行为却是子类的。
// 通知渠道的抽象
public abstract class Notifier // 抽象类:不能实例化,天生当父类
{
public abstract Task SendAsync(string to, string message); // 抽象成员:子类必须实现
public virtual bool IsEnabled => true; // 虚成员:可改写可默认
}
public class SmsNotifier : Notifier
{
public override Task SendAsync(string to, string message)
{
Console.WriteLine($"[短信→{to}] {message}");
return Task.CompletedTask; // 同步完成时返回它即可
}
}
public class EmailNotifier : Notifier
{
public override Task SendAsync(string to, string message)
{
Console.WriteLine($"[邮件→{to}] {message}");
return Task.CompletedTask;
}
}
// 调用方完全不知道、也不关心具体是什么渠道 —— 这就是多态的价值
static async Task BroadcastAsync(List<Notifier> notifiers, string to, string msg)
{
foreach (var n in notifiers)
{
if (n.IsEnabled) await n.SendAsync(to, msg);
}
}
多态解决的问题:新增"钉钉通知"只需新加一个 Notifier 子类,BroadcastAsync 一行不改——对扩展开放、对修改关闭(开闭原则,第 19 章展开)。没有多态,你会写出 if 渠道==短信 else if 渠道==邮件 else if ... 且每加渠道都改一遍的代码。
abstract 类与 virtual 成员的取舍:抽象成员强制每个子类实现(合同);虚成员给默认实现(便利)。既要有共同逻辑又要子类差异化时用抽象类;只想定合同用接口(下节)。
接口(interface)声明"会做什么(can-do)",不关心调用方是什么类型:
public interface IPayment
{
string ChannelName { get; }
Task<PayResult> PayAsync(decimal amount, string orderNo);
}
public record PayResult(bool Success, string? TransactionId, string? Message);
public class WechatPay : IPayment
{
public string ChannelName => "微信支付";
public Task<PayResult> PayAsync(decimal amount, string orderNo) { /* 对接微信 API */ }
}
public class Alipay : IPayment { /* 同样实现 */ }
关键区别:
| 抽象类 | 接口 | |
|---|---|---|
| 关系语义 | is-a(是什么) | can-do(能做什么) |
| 数量 | 单继承 | 一个类可实现多个接口 |
| 成员 | 可含状态与实现 | 传统上无状态;C# 8 起可有默认实现 |
| 典型用途 | 共享骨架代码 | 定义能力与服务边界 |
工程上接口用得更多——依赖注入(第 12 章)、单元测试替身(第 15 章)、分层架构(第 17 章)全部建立在"面向接口"上:
public interface IOrderRepository // 订单"仓库"的合同:谁存取订单
{
Task<Order?> FindAsync(string orderNo);
Task SaveAsync(Order order);
}
// 业务代码只认合同,不认具体实现(内存版、EF Core 版、Mock 版都行)
public class OrderService(IOrderRepository repo) // C# 12 主构造函数
{
public async Task PayAsync(string orderNo)
{
var order = await repo.FindAsync(orderNo)
?? throw new InvalidOperationException($"订单 {orderNo} 不存在");
// ...支付逻辑
await repo.SaveAsync(order);
}
}
一个类实现多个接口很常见:
public class OrderService : IOrderRepository, IDisposable { /* ... */ }
接口也可含默认实现(C# 8+)与静态抽象成员(C# 11 泛型数学用),进阶时再学,先用最朴素的"纯合同"接口即可。
object obj = new VipCustomer("张三");
// 传统写法
if (obj is Customer) Console.WriteLine("是客户");
VipCustomer? vip = obj as VipCustomer; // 失败返回 null 而不抛异常
// 声明模式:判断 + 强转一步到位(推荐)
if (obj is VipCustomer v) { Console.WriteLine(v.Name); }
// switch + 类型/属性模式(C# 8/9+):业务规则映射利器
static string Describe(object item) => item switch
{
VipCustomer { Points: > 1000 } c => $"高积分 VIP:{c.Name}",
VipCustomer c => $"VIP:{c.Name}",
Customer { Name.Length: 0 } => "匿名客户",
Customer c => $"普通客户:{c.Name}",
null => "空",
_ => "未知"
};
// 关系模式与逻辑模式也常用于区间判断
static string Level(int points) => points switch
{
< 100 => "青铜",
< 500 => "白银",
< 1000 => "黄金",
_ => "钻石"
};
所有类型隐式继承 object,其三个虚方法值得了解:
public class Product
{
public int Id { get; init; }
public string Name { get; init; } = "";
public override string ToString() => $"Product({Id}, {Name})"; // 打印/日志友好
// 值相等:同 Id 即同商品(默认 Equals 比较引用,重写后按内容比)
public override bool Equals(object? obj) => obj is Product p && p.Id == Id;
public override int GetHashCode() => Id.GetHashCode();
}
日常业务更推荐用第 6 章的 record 自动获得值相等语义,手写 Equals/GetHashCode 仅在定制场景出现。
继承是最强的耦合:父类内部改动会波及所有子类(脆弱基类问题)。当关系是"有一个(has-a)"而非"是一个"时,用组合:
// ❌ 为了复用打印功能而继承 ReportPrinter —— 只是为了"用",不是"是"
// ✅ 组合:把能力作为字段注入
public class OrderReporter
{
private readonly ReportPrinter _printer; // has-a
public OrderReporter(ReportPrinter printer) => _printer = printer;
}
判断口诀:能答"X 是一种 Y"才继承;只能说"X 用到了 Y",组合。框架设计里 Logger、HttpClient、你的仓储与服务的注入,全是组合。
// —— 折扣策略:抽象类承载公共骨架 ——
public abstract class DiscountPolicy
{
public abstract string Name { get; }
// 模板方法:固定流程,差异点留给子类
public decimal Apply(decimal subtotal)
{
var off = CalculateOff(subtotal);
return Math.Clamp(subtotal - off, 0, subtotal); // 折扣不超过小计
}
protected abstract decimal CalculateOff(decimal subtotal);
}
public class NoDiscount : DiscountPolicy
{
public override string Name => "无优惠";
protected override decimal CalculateOff(decimal subtotal) => 0m;
}
public class VipDiscount : DiscountPolicy
{
public override string Name => "VIP 九折";
protected override decimal CalculateOff(decimal subtotal) => subtotal * 0.1m;
}
public class FullReduction : DiscountPolicy // 满 200 减 20
{
public override string Name => "满200减20";
protected override decimal CalculateOff(decimal subtotal)
=> subtotal >= 200m ? 20m : 0m;
}
// —— 结账:只依赖 DiscountPolicy 抽象 ——
static void Checkout(Order order, DiscountPolicy policy)
{
var payable = policy.Apply(order.Subtotal);
Console.WriteLine($"[{policy.Name}] 应付:{payable:C}");
}
DiscountPolicy[] policies = { new NoDiscount(), new VipDiscount(), new FullReduction() };
foreach (var p in policies)
{
Checkout(order, p); // 同一调用,三种行为
}
这段代码就是第 19 章"策略模式"的原型——你已经无师自通了核心思想:用多态把变化的规则从调用方剥离。
IOrderService 塞 20 个方法,实现类被迫空实现。接口应小而内聚(接口隔离原则)。as 后不判 null 直接用:obj as T 失败返回 null,下一行就 NullReferenceException;用 is T x 声明模式更安全。sealed、成员非虚;确有扩展需求再开放 virtual/abstract——扩展点是设计承诺。IOrderRepository 而非具体类,为第 12 章 DI、15 章测试铺路。I 开头表达能力(IDisposable、IPayment)。is T x 模式;多分支类型行为用 switch 表达式 + 模式匹配。record(下一章),而不是手写 Equals/GetHashCode。base 调父类实现、sealed 终结扩展。virtual/override/abstract 让"同一调用、不同实现",是开闭原则的语法基石。is T x 声明模式与 switch 模式匹配是现代 C# 处理类型与分支的利器。Shape(抽象属性 Area、虚方法 Describe()),派生 Circle、Rectangle、Triangle。遍历 List<Shape> 打印各自面积,体会多态。ILogger 接口(void Log(string message)),实现 ConsoleLogger 与 FileLogger(追加到文件)。写一个使用 ILogger 的 App 类,运行时切换两种实现且不改 App 代码。pay 命令接受策略名 none/vip/full,用 switch 表达式选择策略;新增"会员积分":VipCustomer 继承 Customer,消费自动积分、积分抵扣(100 分抵 1 元);Git 提交。IOrderRepository 让单元测试变得可行?(提示:测试时传入一个自己写的假实现。)这如何解释"面向接口"不只是语法习惯,而是可测试性的前提?上一章:面向对象编程(下)← | README | 下一章:集合与泛型 →
学完本章,你将能够:
enum 表达有限状态集,用 struct 建模小型值对象;record 优雅地定义不可变数据载体,掌握 with 与解构;?.、??、??= 等空安全运算符。C# 类型系统两大阵营,行为差异必须刻进直觉:
值类型(struct):变量里装的就是数据本身
int, decimal, bool, char, DateTime, 你自定义的 struct
赋值/传参 → 完整复制一份
引用类型(class):变量里装的是"地址",数据在堆上
string, 数组, List<T>, 你自定义的 class
赋值/传参 → 复制的是地址,两者指向同一份数据
// 值类型:各是各的
int a = 10;
int b = a;
b = 99;
Console.WriteLine(a); // 10
// 引用类型:同一个对象
var o1 = new Order(88m);
var o2 = o1; // 没有新对象,只是多了一个"遥控器"
o2.Amount = 100m; // (假设 Amount 可写)
Console.WriteLine(o1.Amount); // 100 —— o1 被波及!
字符串虽是引用类型,但因不可变(每次"修改"产生新对象),行为上近似值类型。装箱(boxing):值类型被当作 object/接口使用时会复制到堆上,有性能成本,现代泛型集合(第 7 章)已基本规避。
枚举为"有限个取值"的领域概念提供类型安全表达——比字符串常量强(编译期检查、防拼写错),比魔法数字强(自解释)。
public enum OrderStatus
{
Created, // 0(默认从 0 递增)
Paid, // 1
Shipped, // 2
Completed, // 3
Cancelled, // 4
}
OrderStatus s = OrderStatus.Paid;
Console.WriteLine((int)s); // 1
Console.WriteLine(s.ToString()); // "Paid"
// 字符串 → 枚举:Enum.TryParse 安全解析
if (Enum.TryParse<OrderStatus>("paid", ignoreCase: true, out var parsed))
{
Console.WriteLine($"解析成功:{parsed}");
}
// 指定底层值与显示值(序列化、历史兼容常用)
public enum PayChannel
{
Unknown = 0,
Wechat = 1,
Alipay = 2,
BankCard = 3,
}
枚举设计建议:保留 0 值作 Unknown/None(字段默认值不会踩雷);状态机的非法流转校验放在业务层(枚举本身不限制跳转);对外 API 传输用字符串而非数字,可读性好且利于排查。
[Flags]
public enum OrderTag
{
None = 0,
Urgent = 1 << 0, // 1
Gift = 1 << 1, // 2
Prepaid = 1 << 2, // 4 注意 2 的幂,才能互不干扰地组合
}
var tag = OrderTag.Urgent | OrderTag.Gift; // 组合
bool urgent = tag.HasFlag(OrderTag.Urgent); // True
struct 是值类型,适合小、短命、值语义的数据(一个点、一个金额、一个坐标):
public readonly struct Money // readonly struct:全字段不可变,推荐
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "CNY")
{
if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
Amount = amount;
Currency = currency;
}
public static Money operator +(Money a, Money b) => // 运算符重载
a.Currency == b.Currency
? new Money(a.Amount + b.Amount, a.Currency)
: throw new InvalidOperationException("币种不同不能相加");
public override string ToString() => $"{Amount:0.00} {Currency}";
}
var m1 = new Money(10m);
var m2 = m1; // 完整复制,互不影响
使用准绳:默认用 class;只有当类型满足"逻辑上是一个值(如 Money(10, CNY) 与另一个 Money(10, CNY) 本质等价)、体积小(≤16 字节最佳)、不会被频繁复制放大"时才用 struct。拿不准就 class——struct 复制成本与误用风险更高。
业务开发大量数据类只为"装数据":DTO、API 请求/响应、事件。record(C# 9)就是为此而生:
// 位置参数语法:一行定义 + 构造 + 不可变 + 值相等 + ToString
public record ProductDto(int Id, string Name, decimal Price);
var p1 = new ProductDto(1, "拿铁", 28.5m);
var p2 = new ProductDto(1, "拿铁", 28.5m);
Console.WriteLine(p1 == p2); // True!record 是"值相等"(逐成员比较,class 默认比引用)
Console.WriteLine(p1); // ProductDto { Id = 1, Name = 拿铁, Price = 28.5 }
// with 表达式:基于旧值派生新对象(原对象不变)
var discounted = p1 with { Price = 25.9m };
Console.WriteLine(discounted); // Id/Name 沿用,Price 更新
// 解构:按位置取出
var (id, name, price) = p1;
// 需要逻辑时加普通属性/方法
public record Money2(decimal Amount, string Currency)
{
public bool IsZero => Amount == 0m;
}
变体速查:
record:引用类型的不可变数据载体(最常用);record struct(C# 10):值语义数据载体,小数据用;record class:同 record,显式写法。class 与 record 的选择:有身份、有生命周期、有行为的实体(Order、Customer)用 class;纯数据快照(DTO、配置项、事件)用 record。判断口诀:"两个值相同的订单是同一张订单吗?"——不是,订单是实体;"两个值相同的价格是同一个价格吗?"——是,价格是值。
NullReferenceException 是 .NET 世界排名第一的运行时异常。可空引用类型(C# 8+,新项目默认开启,csproj 中 <Nullable>enable</Nullable>)让编译器在编译期揪出隐患:
string name = null; // ⚠️ 编译警告:不可空类型不能赋 null
string? nickname = null; // ✅ string? 明确声明"可能为空"
Console.WriteLine(name.Length); // 编译器警告:可能 NullReference
Console.WriteLine(nickname.Length); // 更严重的警告:直接访问可空成员
Console.WriteLine(nickname?.Length); // ✅ 空条件运算符:null 时整个表达式为 null
空安全工具箱(务必练熟):
string? input = Console.ReadLine(); // ReadLine 可能返回 null!
// 1) ?. 空条件:链式安全访问,一处 null 全链为 null
int? len = input?.Trim().Length;
// 2) ?? 空合并:为 null 时给默认值
string name = input ?? "匿名用户";
// 3) ??= 为 null 时才赋值
List<string>? cache = null;
cache ??= new List<string>();
// 4) 显式判空 + 收窄:判过后编译器认定非空
if (input is not null)
{
Console.WriteLine(input.Length); // 此处无警告
}
// 5) 抛出式判空:失败即快速失败(方法参数校验标准姿势)
string MustNonEmpty(string? s) => string.IsNullOrWhiteSpace(s)
? throw new ArgumentException("不能为空")
: s;
// 6) !! null 容忍(谨慎):告诉编译器"闭嘴,我保证非空"——编译器不再检查
Product p = FindProduct(id)!; // 找不到时运行时照样炸,! 只骗编译器不解决问题
核心心法:? 声明"可能为空",把"是否可能为空"变成类型信息。API 作者用 ? 明示契约(FindAsync 返回 Order? = 可能找不到),调用方被编译器强制处理空分支——整条链路的空安全从"口头约定"升级为"编译期保证"。
// —— 状态用枚举,状态流转在业务层校验 ——
public enum OrderStatus { Created = 0, Paid = 1, Shipped = 2, Completed = 3, Cancelled = 4 }
public class Order
{
public OrderStatus Status { get; private set; } = OrderStatus.Created;
public bool CanTransitionTo(OrderStatus next) => (Status, next) switch
{
(OrderStatus.Created, OrderStatus.Paid) => true,
(OrderStatus.Created, OrderStatus.Cancelled) => true,
(OrderStatus.Paid, OrderStatus.Shipped) => true,
(OrderStatus.Shipped, OrderStatus.Completed) => true,
_ => false
};
public void TransitionTo(OrderStatus next)
{
if (!CanTransitionTo(next))
{
throw new InvalidOperationException($"非法状态流转:{Status} → {next}");
}
Status = next;
}
}
// —— 地址:record 值对象,两个内容相同的地址"就是"同一个地址 ——
public record Address(string Province, string City, string Detail)
{
public string Full => $"{Province}{City}{Detail}";
}
// —— 可空性:收货地址在下单时可能还没填 ——
public record Shipment(Address? Address, string? TrackingNo, DateTime? ShippedAt)
{
public bool IsShipped => TrackingNo is not null;
public string Describe() => (Address, TrackingNo) switch
{
(null, null) => "待发货(未填写地址)",
(null, _) => "异常:已发货但无地址", // 类型系统逼你想全边界
({ } addr, null) => $"待发货(地址:{addr.Full})",
({ } addr, string tracking) => $"已发货:{tracking},发往 {addr.Full}"
};
}
模式匹配解构可空元组把所有组合穷举——这正是 NRT 的思维方式:编译器逼你考虑每一种空与不空的组合。
! 消警告:! 是对编译器撒谎,null 该来还是来。正确做法是把 ? 用好、判空做实。if (status == 1) 比字符串还难排查;一律用枚举名。Enum.Parse 直接信用户输入:非法值抛异常,用 Enum.TryParse。Flags 枚举值取 2 的幂:1|2|3 会重叠,HasFlag 判断错乱。-warnaserror,第 23 章)。?:Find 返回 T?,Get 返回 T(找不到抛异常)——调用方一眼看懂契约。record;需要派生修改时用 with。readonly struct + 运算符重载塑造领域值类型(Money、Quantity),让"金额单位混淆"这类 bug 在编译期暴露。enum 类型安全地表达有限取值,Flags 支持位组合;流转校验在业务层。struct 适合小型值语义数据,默认优先 class;record 是不可变数据载体的最优解,配 with 与解构。? 声明、?./??/??= 处理、模式匹配穷举,! 是最后的豁免而非工具。Season 枚举与 Describe(Season s) 方法(switch 表达式),再实现 Enum.TryParse 循环读取用户输入直到合法。readonly struct Temperature(double Celsius):支持 +、- 运算符与 ToFahrenheit();用两组相等实例验证值语义。Order 接入 OrderStatus 状态机(6.6 的实现),pay 命令只允许 Created → Paid,ship 只允许 Paid → Shipped,非法操作输出原因;Shipment 用 record 建模并在 ship 命令后打印物流描述;Git 提交。Order 用 class、Address 用 record 的理由分别是什么?如果把 Order 改成 record,with 生成的新订单与旧订单在业务上是什么关系?这说明实体与值的本质区别在哪?上一章:结构体、枚举、记录与可空性 ← | README | 下一章:委托、事件与 Lambda 表达式 →
学完本章,你将能够:
List<T>、Dictionary<TKey, TValue>、HashSet<T>、Queue<T>、Stack<T>;IEnumerable<T> 与 yield 迭代器,看清所有集合的共同底座;where 约束;没有泛型的年代,容器装 object——放进去什么类型都行,取出来还得强转,装错类型要到运行时才炸,值类型还要装箱:
// 泛型出现前的老写法(ArrayList),现在不要用
var list = new System.Collections.ArrayList();
list.Add(1);
list.Add("hello");
int sum = 0;
foreach (object o in list) { sum += (int)o; } // 运行到 "hello" 才抛异常!
// 泛型:T 在编译期确定,装错类型直接编译不过
var orders = new List<int> { 1, 2, 3 };
// orders.Add("hello"); // ❌ 编译错误 —— 类型错误在编译期被拦截
泛型(generic)= 类型的参数化:List<T> 中的 T 是占位符,使用时代入具体类型。一份代码服务所有类型,且零装箱、编译期检查。
绝大多数"一组东西"都用它——可增长、按下标访问、增删查改齐全:
var products = new List<Product>
{
new(1, "拿铁咖啡", 28.5m, 100),
new(2, "美式咖啡", 22m, 80),
};
products.Add(new Product(3, "抹茶蛋糕", 35m, 20)); // 尾部追加
products.AddRange([new Product(4, "可颂", 15m, 50)]);// 批量追加(C# 12 集合表达式)
products[0].Name; // 下标访问,O(1)
products.Insert(1, new Product(5, "热可可", 25m, 30));
products.Remove(products[^1]); // 按对象删(找到第一个相等项)
products.RemoveAt(0); // 按下标删
bool has = products.Contains(p => p.Id == 2); // ⚠️ 注意:Contains 是值相等判断,按条件查找用 Exists/Find
Product? found = products.Find(p => p.Id == 2);
int idx = products.FindIndex(p => p.Name == "美式咖啡");
products.RemoveAll(p => p.Stock == 0);
products.Sort((a, b) => a.Price.CompareTo(b.Price)); // 按价格排序(委托,下章展开)
products.Reverse();
Console.WriteLine(products.Count); // 元素数(不是 Capacity)
products.Clear();
性能直觉:List<T> 内部是数组,超容量时扩容翻倍拷贝——批量添加时预先 new List<T>(capacity: 10000) 可避免多次扩容;按下标访问 O(1),中间插入/删除 O(n)。
按"键"直查"值",平均 O(1)——把"遍历查找"升级为"哈希直查"是业务代码最常见的一次性能与清晰度双赢:
// 商品编号 → 商品 的目录
var catalog = new Dictionary<int, Product>
{
[1] = new(1, "拿铁咖啡", 28.5m, 100),
[2] = new(2, "美式咖啡", 22m, 80),
};
catalog.Add(3, new Product(3, "抹茶蛋糕", 35m, 20));
catalog[1] = new(1, "拿铁(大)", 32m, 100); // 键存在则是覆盖
// 安全读取:ContainsKey + 索引器 = 两次查找;TryGetValue 一次搞定(推荐)
if (catalog.TryGetValue(2, out Product? product))
{
Console.WriteLine(product.Name);
}
// 直接索引器:键不存在时抛 KeyNotFoundException —— 仅在确定存在或想显式失败时用
var p2 = catalog[2];
// 遍历:键值对
foreach (KeyValuePair<int, Product> kv in catalog)
{
Console.WriteLine($"#{kv.Key}: {kv.Value.Name}");
}
foreach (var (id, prod) in catalog) // 解构写法更简洁
{
Console.WriteLine($"#{id}: {prod.Name}");
}
// 只看键 / 只看值
foreach (int id in catalog.Keys) { }
foreach (Product prod in catalog.Values) { }
catalog.Remove(3);
Console.WriteLine(catalog.Count);
bool hasKey = catalog.ContainsKey(1);
// ❌ 每次查询都 O(n) 扫全表
static Product? FindById(List<Product> list, int id)
=> list.FirstOrDefault(p => p.Id == id);
// ✅ 构建/维护一份 O(1) 索引
var catalog = list.ToDictionary(p => p.Id);
注意键的要求:不可为 null、不可变、正确实现 GetHashCode/Equals(record 自动满足值语义哈希;用可变对象当键、之后再改它的字段 = 哈希错乱,元素"失踪")。
去重与成员判断 O(1),无序:
var todayBuyers = new HashSet<string> { "u1", "u2", "u3" };
todayBuyers.Add("u1"); // 重复添加被忽略(返回 false)
bool seen = todayBuyers.Contains("u2"); // True,O(1)
var vipBuyers = new HashSet<string> { "u2", "u4" };
正确写法(集合运算方法就地修改调用者,需要结果集时用 LINQ,第 9 章):
todayBuyers.IntersectWith(vipBuyers); // todayBuyers 变为交集 {u2}
todayBuyers.UnionWith(vipBuyers); // 并集
todayBuyers.ExceptWith(vipBuyers); // 差集:从 todayBuyers 移除 vipBuyers
其余常用容器:Queue<T> 先进先出(Enqueue/Dequeue/TryPeek,如任务队列)、Stack<T> 后进先出(Push/Pop,如撤销操作)、PriorityQueue<TElement, TPriority> 优先队列、SortedDictionary<TKey,TValue>/SortedList 保持键序。
IEnumerable<T> 是所有集合的最小公共协议:"我可以被 foreach"。方法签名返回 IEnumerable<T> 是 .NET 生态的通用语言(LINQ 的入口,第 9 章)。
// yield:写一个"惰性"序列 —— 被枚举到哪,算到哪
static IEnumerable<int> EvenNumbers(int upTo)
{
for (int i = 0; i <= upTo; i += 2)
{
yield return i; // 每次产出一个,调用方 foreach 拉一个
}
}
foreach (int n in EvenNumbers(10))
{
Console.WriteLine(n);
}
yield 两个特质:
EvenNumbers(1_000_000) 瞬间返回,不预先算完——消费端取 3 个就只算 3 个;注意:yield 方法内不能有 return 值(只能 yield return / yield break),且枚举未走完前方法体不会全部执行——"我以为它已经跑了"是新手常见错觉。
// 交换任意引用/值类型的一对变量 —— 一份实现服务所有类型
static void Swap<T>(ref T a, ref T b) => (a, b) = (b, a);
// 约束:T 必须实现 IComparable<T>,才能调用 CompareTo
static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
Max(3, 7); // 7
Max("apple", "banana"); // banana
// 一个最小可用的内存缓存(第 20 章会换成生产级方案)
public class MemoryCache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _store = new();
public TValue? Get(TKey key) => _store.TryGetValue(key, out var v) ? v : default;
public void Set(TKey key, TValue value) => _store[key] = value;
public bool Remove(TKey key) => _store.Remove(key);
}
// 常用约束一览
where T : struct // T 是值类型
where T : class // T 是引用类型
where T : notnull // T 不可为 null
where T : new() // T 有无参构造(方法内可 new T())
where T : IComparable<T> // T 实现某接口(可多重列出)
where T : BaseEntity // T 是 BaseEntity 或其子类
where T : class, IEntity, new() // 组合约束
什么时候自己写泛型:当一段逻辑对"类型"无感、只关心结构(缓存、分页包装、结果封装)时泛型化;业务逻辑本身通常不需要。框架级泛型你已经在用:List<T>、Task<T>(第 10 章)、ActionResult<T>(第 11 章)。
IEnumerable<T> 可枚举(最弱承诺:只能 foreach)
└ ICollection<T> 可增删、有 Count
└ IList<T> 可按下标读写(List<T> 实现)
IReadOnlyList<T> 只读 + 下标(对外暴露的推荐类型)
IReadOnlyCollection<T> 只读 + Count
对外 API 用只读接口(第 4 章的 IReadOnlyList<T>),内部用具体类型——调用方拿到的是"视图",改不了的承诺由类型系统担保。
选型速查:
| 需求 | 选择 |
|---|---|
| 保序列表、可重复 | List<T> |
| 键 → 值直查 | Dictionary<TKey, TValue> |
| 去重/成员判断 | HashSet<T> |
| 先进先出 | Queue<T> |
| 后进先出 | Stack<T> |
| 固定长度、数值计算 | 数组 T[] |
| 惰性/流式序列 | yield 返回 IEnumerable<T> |
用集合给轻订单搭一个"迷你数据库",它将服务到第 17 章(届时被仓储接口抽象隔离,替换为 EF Core):
public class ProductCatalog
{
private readonly Dictionary<int, Product> _byId = new();
private readonly List<Product> _all = [];
public void Add(Product product)
{
if (!_byId.TryAdd(product.Id, product)) // TryAdd:存在则返回 false,原子又顺手
{
throw new InvalidOperationException($"商品 {product.Id} 已存在");
}
_all.Add(product);
}
public Product? Find(int id) => _byId.TryGetValue(id, out var p) ? p : null;
// 按分类返回(假设 Product 已加 Category 属性)—— 惰性枚举
public IEnumerable<Product> ByCategory(string category) =>
_all.Where(p => p.Category == category);
// 需要分类索引的场合:一次性建立 Dictionary<string, List<Product>>
public Dictionary<string, List<Product>> GroupByCategory() =>
_all.GroupBy(p => p.Category)
.ToDictionary(g => g.Key, g => g.ToList());
}
// 购物车:同一商品只出现一行,数量累加 —— HashSet 判断 + Dictionary 计数
public class Cart
{
private readonly Dictionary<int, int> _qtyByProductId = new(); // 商品Id → 数量
public void Add(int productId, int qty)
{
_qtyByProductId[productId] = _qtyByProductId.GetValueOrDefault(productId) + qty;
}
public IReadOnlyDictionary<int, int> Items => _qtyByProductId;
}
要点:TryGetValue/TryAdd 优先于 ContainsKey + 索引器;对外只暴露 IReadOnly*;"按条件查询"先用 LINQ 惰性返回,性能不足再建显式索引字典。
InvalidOperationException(集合被修改)。需要删时:倒序 for 循环按索引删、或先 ToList() 快照再改原集合、或 RemoveAll。TryGetValue/GetValueOrDefault。List<T> 的 Contains 误当"按条件查找":它做值相等比较;按条件用 Exists/Find/LINQ Any。IEnumerable<T> 已经"执行完":yield/LINQ 是惰性的,多次枚举会重复执行(甚至结果不同),需要复用时 ToList() 物化。Dictionary/List 非线程安全,多线程读写需 ConcurrentDictionary<TKey,TValue> 等(第 10 章)。IList<Product> list = new List<Product>();(或更常见:字段/返回值用最窄的够用接口)。List<T>/Dictionary 传容量。[]),调用方免判空——"返回 null 还是空集合"的答案几乎总是空集合。where T : notnull 比裸 T 表达了更多契约,编译器与调用方都受益。List<T> 动态数组、Dictionary 哈希映射(读取用 TryGetValue)、HashSet 去重、Queue/Stack 顺序容器。IEnumerable<T> 是集合最小协议;yield 提供惰性流式序列。where 让泛型代码能做更多且更安全。Dictionary<string, List<string>> 实现简易单词倒排索引:Add(sentence) 把每个单词映射到出现过的句子,Query(word) 返回句子列表。static IEnumerable<T> TakeLast<T>(IEnumerable<T> source, int n)(不使用 LINQ 的 TakeLast,用 Queue<T> 维护最近 n 个)。ProductCatalog(Dictionary 索引),购物车换成 Cart;新增命令 find <id>、category <分类名>;给 Product 增加 Category;用 GroupByCategory 输出分类汇总报表;Git 提交。ToDictionary 索引方案在什么情况下会退化甚至出 bug?(提示:商品持续新增时要维护索引;键冲突会抛异常。)"索引"与"真相"应如何保持一致?上一章:集合与泛型 ← | README | 下一章:LINQ →
学完本章,你将能够:
Action/Func/Predicate 内置委托类型;event 实现"发布-订阅",知道事件的线程安全注意事项;List.Sort(Comparison<T>)、事件回调等框架 API 背后的委托机制——它们是第 9 章 LINQ 的前置知识。委托(delegate)是方法的类型化引用——把方法当作"值"存进变量、当参数传递。它让代码可以描述"做什么"交给调用方决定"何时做、对谁做"。
// 声明一个委托类型:描述"这类方法长什么样"(两个 decimal 进,一个 decimal 出)
public delegate decimal PriceRule(decimal subtotal);
// 三个签名兼容的方法
static decimal NoRule(decimal s) => s;
static decimal VipRule(decimal s) => s * 0.9m;
static decimal FullReductionRule(decimal s) => s >= 200m ? s - 20m : s;
// 委托变量的三种赋值方式
PriceRule rule = VipRule; // 方法组转换(最常用)
PriceRule rule2 = s => s >= 100m ? s - 5m : s; // Lambda(下一节)
PriceRule rule3 = delegate (decimal s) { return s; }; // 匿名方法(老写法,了解即可)
// 调用委托 = 调用它引用的方法
Console.WriteLine(rule(300m)); // 270
委托是类型,声明一次到处使用;但自定义委托类型 mostly 已被内置泛型委托取代,直接用下面三个即可。
// Action:无返回值(0-16 个参数)
Action<string> log = msg => Console.WriteLine($"[LOG] {msg}");
Action notify = () => log("没参数也能存");
// Func:最后一个类型参数是返回值
Func<int, int, decimal> divide = (a, b) => (decimal)a / b;
Func<decimal, bool> isFree = amount => amount == 0m; // 常用别名:判断谓词
Predicate<decimal> isFree2 = amount => amount == 0m; // 等价于 Func<decimal,bool>,老 API 见
// 实战:把策略当参数传入 —— 行为参数化
static decimal Checkout(decimal subtotal, PriceRule rule) => rule(subtotal);
Checkout(300m, VipRule); // 270
Checkout(300m, FullReductionRule); // 280
多播委托:一个委托变量可用 += 挂多个方法,调用时按序全执行(返回值只留最后一个)——事件机制的底层就是它,但业务代码里直接用多播的场景很少,见 8.4。
Lambda 是匿名方法的简洁写法:(参数) => 表达式或语句块。
// 形态速览
Func<int, int> square = x => x * x; // 单参数可省括号
Func<int, int, int> add = (a, b) => a + b; // 多参数要括号
Action print = () => Console.WriteLine("hi"); // 无参数
Func<int, int> bulky = x => // 多语句用花括号 + return
{
int doubled = x * 2;
return doubled + 1;
};
// 类型推断:从委托类型/目标 API 推出参数类型,通常无需写明
List<int> nums = [3, 1, 2];
nums.Sort((a, b) => a.CompareTo(b)); // Comparison<int> 委托
Lambda 捕获外围变量(closure,闭包):捕获的是变量本身而非当时的值——
int bonus = 10;
Func<decimal, decimal> withBonus = amount => amount + bonus;
bonus = 20; // 之后改变量,Lambda 里"看得见"
Console.WriteLine(withBonus(100m)); // 120,不是 110!
// 经典陷阱:循环变量捕获
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.Write(i + " ")); // for 的 i 是每次迭代共享的吗?
}
foreach (var a in actions) a(); // 输出 0 1 2(for 循环变量每次迭代新建,C# 5+ 行为)
// 但如果捕获的是循环体里声明的共享变量,或 foreach 旧版本行为不同 —— 需要小心
闭包心法:Lambda 活多久,被捕获的变量就活多久(编译器会把它装箱进闭包对象延长生命周期)——既是强大特性,也是"意外持有大对象导致内存泄漏"的常见来源。
你已经用过很多隐式委托:
var products = new List<Product> { /* ... */ };
products.Find(p => p.Id == 2); // Predicate<Product>
products.RemoveAll(p => p.Stock == 0);
products.Sort((a, b) => a.Price.CompareTo(b.Price));
products.ForEach(p => Console.WriteLine(p.Name)); // Action<Product>(List 专用, LINQ 用 Select)
// Timer / Task / 事件回调全是委托
var timer = new System.Threading.Timer(
_ => Console.WriteLine("tick"), // TimerCallback
null, 1000, 1000);
Lambda 让"一小段行为"内联在调用处,代码意图与数据流同屏——这正是 LINQ 链式查询可读性的来源。
事件(event)= 受管控的多播委托:发布者定义"发生了什么",订阅者注册"发生时我做什么"。发布者不关心谁订阅——组件解耦的标准手段。
// 发布者
public class OrderProcessor
{
// event 关键字:外部只能 += / -=,不能赋值覆盖、不能主动触发(封装)
public event EventHandler<OrderEventArgs>? OrderPaid;
public void Pay(string orderNo)
{
// ...支付处理...
OnOrderPaid(new OrderEventArgs(orderNo, DateTime.Now));
}
protected virtual void OnOrderPaid(OrderEventArgs e)
=> OrderPaid?.Invoke(this, e); // 标准触发写法:?. 防无订阅者
}
public record OrderEventArgs(string OrderNo, DateTime PaidAt) : EventArgs;
// 订阅者
var processor = new OrderProcessor();
processor.OrderPaid += (sender, e) =>
Console.WriteLine($"[库存] 收到支付,准备发货:{e.OrderNo}");
processor.OrderPaid += (sender, e) =>
Console.WriteLine($"[通知] 发送支付成功短信:{e.OrderNo}");
processor.Pay("SO-20260929-0001");
// [库存] 收到支付,准备发货:SO-20260929-0001
// [通知] 发送支付成功短信:SO-20260929-0001
要点:
event 与普通多播委托字段的差别是访问限制:外部只能订阅/退订,不能清空别人、不能触发事件——事件只能由声明它的类触发;EventHandler<TEventArgs> 是标准事件签名(object? sender, TEventArgs e),社区约定俗成,遵循它工具与读者都舒服;-=(持有强引用,不退订 = 订阅者被发布者引用 = 内存泄漏高发区;UI 事件与长生命周期单例尤其注意);OrderPaid?.Invoke 已是安全写法(字段式事件的调用是原子的)。用事件把"支付成功后要做的 N 件事"从支付逻辑里解耦——后续加会员积分、加开票,都不改 Pay 方法一行:
public class OrderService
{
public event EventHandler<OrderEventArgs>? OrderPaid;
public void Pay(Order order)
{
if (order.Status != OrderStatus.Created)
{
throw new InvalidOperationException($"订单 {order.OrderNo} 当前状态不可支付");
}
order.TransitionTo(OrderStatus.Paid);
// 支付完成后广播:订阅者各自处理自己的事
OrderPaid?.Invoke(this, new OrderEventArgs(order.OrderNo, DateTime.Now));
}
}
// 组装(Main 或组合根)
var svc = new OrderService();
svc.OrderPaid += (_, e) => InventoryService.Reserve(e.OrderNo); // 预占库存
svc.OrderPaid += (_, e) => NotificationService.Sms(e.OrderNo); // 发短信
svc.OrderPaid += (_, e) => PointsService.Award(e.OrderNo, points: 10); // 加积分
svc.Pay(order);
注意事件模型的适用边界:同步、进程内、不保证可靠性——订阅者抛异常会中断广播链。跨服务、必须送达的消息(短信、开票)生产环境走消息队列(第 19、23 章),事件是它的进程内原型。
Action/Func;只有语义特殊(如 EventHandler)才自定义。Func/Action,参数个数克制(≤2 个可读性尚可,更多改传 record)。EventHandler<TEventArgs> + 可空事件字段 + ?.Invoke 触发的标准三件套。async void 之外的一切方式处理异步:事件处理器除外(async void 是事件签名的唯一合法场景,第 10 章展开)。Action/Func 覆盖几乎所有场景。(参数) => 主体 是匿名方法简写,配合捕获外围变量形成闭包——捕获的是变量不是值。+=/-=,只能发布者触发;EventHandler<T> + ?.Invoke 是标准写法。static List<T> MyWhere<T>(List<T> source, Func<T, bool> predicate)(不用 LINQ),对一组订单按"金额 > 100"过滤并打印。EventEmitter 泛型类:Subscribe(Action<string> handler)、Emit(string msg)、Unsubscribe;验证订阅/退订/多播行为。OrderService.OrderPaid 事件 + 三个订阅者(库存预占、短信通知、积分发放),每个订阅者控制台输出自己的动作;再给 PointsService 增加退订演示;Git 提交。order 变量"——如果这个 Lambda 被存起来延迟执行(比如事件触发时才跑),而 order 后来被改了,会发生什么?如何规避?上一章:委托、事件与 Lambda ← | README | 下一章:异步编程 →
学完本章,你将能够:
IEnumerable<T>(内存)与 IQueryable<T>(可翻译,为 EF Core 铺垫);LINQ(Language Integrated Query,语言集成查询)= 用统一的 C# 语法查询一切数据源:内存集合、数据库、XML、JSON。你写一份"查询描述",LINQ 提供者负责翻译执行。
var orders = new List<Order> { /* ... */ };
// 方法语法(主流,本教程默认)
var big = orders
.Where(o => o.Subtotal >= 100m)
.OrderByDescending(o => o.Subtotal)
.Select(o => new { o.OrderNo, o.Subtotal })
.ToList();
// 查询语法(同一语义的另一种写法,from..where..select)
var big2 = (from o in orders
where o.Subtotal >= 100m
orderby o.Subtotal descending
select new { o.OrderNo, o.Subtotal }).ToList();
两种写法编译为相同代码。方法语法覆盖全部操作符、链式调用贴合调试,是工程主流;查询语法在 join 场景略 readable。本文全用方法语法。
LINQ 查询默认不执行——它只构建"查询计划",枚举(foreach、ToList)时才真正跑:
var nums = new List<int> { 1, 2, 3 };
var query = nums.Where(n => { Console.WriteLine($"检查 {n}"); return n > 1; });
Console.WriteLine("查询已定义,但上面一行都没输出");
var result = query.ToList(); // 此刻才逐个检查:输出 检查1 检查2 检查3
Console.WriteLine(result.Count); // 2
nums.Add(99);
var again = query.ToList(); // 再枚举:重新执行!检查1 检查2 检查3 检查99
Console.WriteLine(again.Count); // 3 —— 同一个 query,两次结果不同
两条铁律由此而来:
var list = query.ToList();;立即执行的操作符是例外:Count()、Sum()、First()、Any() 等聚合/取值类会当场执行。
// Where:条件过滤
var unpaid = orders.Where(o => o.Status == OrderStatus.Created);
// Select:一对一投影(变形/瘦身)
var summaries = orders.Select(o => new OrderSummary(o.OrderNo, o.Subtotal));
// SelectMany:一对多拍平(每个订单多个明细 → 明细流)
var allItems = orders.SelectMany(o => o.Items);
// 带索引的 Where
var top10 = orders.Where((o, i) => i < 10);
// 投影中访问外层元素:Select 的重载 (元素, 下标)
var ranked = orders.Select((o, i) => new { Rank = i + 1, o.OrderNo });
var sorted = orders
.OrderByDescending(o => o.Subtotal) // 主键:金额降序
.ThenBy(o => o.CreatedAt) // 次键:时间升序(同额先单)
.ThenByDescending(o => o.OrderNo)
.ToList();
注意:OrderBy 是稳定排序(相等元素保持原相对顺序),且多次链 OrderBy 只有最后一个生效(后者覆盖前者)——多级排序必须用 ThenBy 系列。
// 按状态分组:GroupBy 产出 IGrouping<TKey, TSource> 序列
var byStatus = orders.GroupBy(o => o.Status);
foreach (var g in byStatus)
{
Console.WriteLine($"{g.Key}: {g.Count()} 单,合计 {g.Sum(o => o.Subtotal):C}");
}
// 分组直接聚合出报表行
var report = orders
.GroupBy(o => o.CreatedAt.Date) // 按天
.Select(g => new DailyReport(
Date: g.Key,
OrderCount: g.Count(),
Revenue: g.Sum(o => o.Subtotal)))
.OrderBy(r => r.Date)
.ToList();
// 每组内取 Top N:经典组合拳 —— 每个分类下销量前 2 的商品
var top2PerCategory = products
.GroupBy(p => p.Category)
.Select(g => new { Category = g.Key, Top = g.OrderByDescending(p => p.Sales).Take(2).ToList() });
orders.Count(); // 总数(带条件:Count(o => o.IsPaid))
orders.Sum(o => o.Subtotal); // 合计
orders.Average(o => o.Subtotal); // 均值(空序列抛异常!)
orders.Max(o => o.Subtotal);
orders.Min(o => o.Subtotal);
orders.Any(o => o.Subtotal > 1000m); // 存在性判断(存在即短路,性能优于 Count()>0)
orders.All(o => o.IsPaid); // 全部满足?
orders.Contains(specificOrder); // 值相等判断(实体比引用;record 比内容)
性能习惯:判断"有没有"用 Any(),不要 Count() > 0(后者可能数完整个序列)。
const int PageSize = 20;
int page = 3; // 第 3 页(从 1 计)
var pageData = orders
.OrderByDescending(o => o.CreatedAt) // 分页必须有稳定排序,否则翻页丢数据/重复
.Skip((page - 1) * PageSize)
.Take(PageSize)
.ToList();
// Chunk(.NET 6+):切成固定大小的批次,批量处理大集合常用
foreach (var batch in orders.Chunk(500))
{
// 每 500 单一批处理
}
orders.First(o => o.IsPaid); // 没有 → 抛异常
orders.FirstOrDefault(o => o.IsPaid); // 没有 → null/默认值("查找"默认用它)
orders.Single(o => o.OrderNo == "SO-001");// 要求恰好一个:0 个或多个都抛 —— 强校验用
orders.SingleOrDefault(...);
// First vs Single 的语义差异:First = 第一个就行;Single = 必须唯一,多了就是数据 bug
a.Union(b) // 并集(去重)
a.Intersect(b) // 交集
a.Except(b) // 差集:在 a 不在 b
a.Distinct() // 去重;按属性去重用 DistinctBy(p => p.Category)(.NET 6+)
a.Concat(b) // 拼接(不去重)
a.Zip(b) // 配对:(a1,b1), (a2,b2)...
a.Aggregate(...) // 自定义折叠:求积、拼接等
a.DefaultIfEmpty() // 空序列给默认值,避免 Average 抛异常
a.AsEnumerable() // 显式切换为内存枚举(EF Core 场景有用)
a.DistinctBy / OrderBy / ThenBy ...By 系列(.NET 6+) // MaxBy/MinBy(p => p.Price) 直接取极值元素
query.ToList(); // List:最常用
query.ToArray(); // 数组:不再变化的数据
query.ToDictionary(p => p.Id); // 建索引(键重复抛异常!)
query.ToDictionary(p => p.Id, p => p.Name); // 键 → 投影值
query.ToLookup(p => p.Category); // 一键多值(键可重复,类似 GroupBy 的物化版)
IEnumerable<T>:内存查询,你的 Lambda 就是运行逻辑;IQueryable<T>:可翻译查询,Lambda 是"表达式树",EF Core 把它翻译成 SQL 在数据库执行。// 对 IQueryable 做 ToList 之前,操作都会翻译进 SQL(数据库过滤,只传回 20 行)
var page = db.Orders.Where(o => o.IsPaid).OrderByDescending(o => o.CreatedAt)
.Skip(20).Take(20).ToList();
// 一旦 AsEnumerable()/ToList() 之后,后续操作全在应用内存里跑 —— 数据已全部拉回来了
var page2 = db.Orders.ToList().Where(o => o.IsPaid).Take(20); // ❌ 全表拉回内存再过滤!
第 18 章详谈,现在记住:对数据库查询,先 Where/Skip/Take 再 ToList,顺序即生死。
把本章操作符串成一份真实的经营日报:
public record OrderRow(string OrderNo, DateTime CreatedAt, OrderStatus Status, string Category, string ProductName, decimal Subtotal);
public static class SalesReport
{
public static DailyReport Build(DateTime day, IEnumerable<OrderRow> rows)
{
var today = rows.Where(r => r.CreatedAt.Date == day.Date);
var paid = today.Where(r => r.Status is OrderStatus.Paid or OrderStatus.Shipped or OrderStatus.Completed);
var topProducts = paid
.GroupBy(r => r.ProductName)
.Select(g => new ProductRank(g.Key, Sales: g.Sum(r => r.Subtotal), Count: g.Count()))
.OrderByDescending(p => p.Sales)
.Take(5)
.ToList();
var byCategory = paid
.GroupBy(r => r.Category)
.ToDictionary(g => g.Key, g => g.Sum(r => r.Subtotal));
return new DailyReport(
Date: day,
TotalOrders: today.Count(),
PaidOrders: paid.Count(),
Revenue: paid.Sum(r => r.Subtotal),
AvgOrder: paid.Any() ? paid.Average(r => r.Subtotal) : 0m, // 空序列 Average 抛异常:先判空
TopProducts: topProducts,
RevenueByCategory: byCategory);
}
}
public record ProductRank(string Name, decimal Sales, int Count);
public record DailyReport(DateTime Date, int TotalOrders, int PaidOrders, decimal Revenue,
decimal AvgOrder, List<ProductRank> TopProducts,
Dictionary<string, decimal> RevenueByCategory);
渲染:
var report = SalesReport.Build(DateTime.Today, allRows);
Console.WriteLine($"==== {report.Date:yyyy-MM-dd} 经营日报 ====");
Console.WriteLine($"订单 {report.TotalOrders}(支付 {report.PaidOrders}) 营收 {report.Revenue:C} 客单 {report.AvgOrder:C}");
Console.WriteLine("-- 热销 TOP5 --");
foreach (var (i, p) in report.TopProducts.Select((p, i) => (i, p)))
{
Console.WriteLine($"{i + 1}. {p.Name} {p.Count} 件 {p.Sales:C}");
}
Console.WriteLine("-- 品类分布 --");
foreach (var (cat, rev) in report.RevenueByCategory.OrderByDescending(kv => kv.Value))
{
Console.WriteLine($"{cat}: {rev:C}");
}
这一份报表里用到了:Where(日切与状态过滤)、GroupBy(商品与品类两个维度)、Sum/Count/Average、OrderByDescending + Take(Top N)、ToDictionary(物化)、Select((p,i)=>...)(排名编号)、Any(判空防护)。
Average/First/Single/Min/Max 直接跑在可能为空的序列上:抛异常;判空 Any() 或用 FirstOrDefault/DefaultIfEmpty。ToList 再过滤/分页:全表进内存;Where/Skip/Take 必须在物化之前(9.4)。OrderBy 期待多级排序:只有最后一个生效,多级用 ThenBy。Select/Where 里做副作用(改状态、发请求):LINQ 是查询不是流程控制,延迟执行会让副作用"不知道什么时候发生"。Distinct() 对实体不生效:class 按引用比较,内容相同的两个对象都保留;用 DistinctBy 或换 record。ToList(),命名清楚(paidOrders 而非 query1)。Any 判存在、FirstOrDefault 找单个、Single 做唯一性断言,各司其职。Average/Max 空序列抛异常,报表格子显示 0 比崩溃好。ToList()。Where/Select/SelectMany/OrderBy(+ThenBy)/GroupBy/Sum,Count,Average/Any,All/Skip,Take/First,Single/ToDictionary。IEnumerable 内存执行,IQueryable 翻译执行——对数据库查询,过滤与分页要赶在物化之前。MaxBy)、每个分类的平均价、销量 Top3、按价格区间(<20/20-50/>50)分组的数量。Aggregate 把商品名列表拼成 "A、B、C";用 Zip 把两组数据(商品、销量)配对输出。SalesReport(可造 20 条模拟订单数据),增加命令 report 输出日报、report top 只输出热销榜;再实现 page <n>:对全部订单按时间倒序分页展示(每页 3 条);Git 提交。orders.Where(...).Count() 与 orders.Count(...) 有何区别?编译后一样吗(提示:两者都会立即执行,但前者多一层枚举包装)?那 Where(...).ToList().Count 呢——问题出在哪?上一章:LINQ ← | README | 下一章:ASP.NET Core Web API 入门 →
学完本章,你将能够:
async/await 方法,理解 Task/Task<T> 的返回语义;Task.WhenAll/WhenAny 组织并发 I/O,用 CancellationToken 支持取消;async void、.Result 死锁、"假异步";一次数据库查询或 HTTP 调用动辄几十毫秒,期间 CPU 什么都没干,纯粹在等网络。同步模型里这个线程干等;Web 服务器线程池有限(默认每核心若干线程),几百个请求并发就把线程耗光——后续请求排队甚至超时。
同步: 线程 A ──发起查询──【干等 50ms】──拿到结果──释放
50ms 内这个线程谁也用不了
异步: 线程 A ──发起查询──立即释放(去服务别的请求)
────── 50ms 后查询完成 ── 某个空闲线程被唤醒继续处理
等待期间零线程占用,吞吐量倍增
一句话:异步不是"更快",而是"等待时不占线程"。对 CPU 密集计算它无能为力;对 I/O 密集(数据库、HTTP、文件、消息队列)它是吞吐量的生命线。现代 ASP.NET Core 全栈异步,本章是第二、三部分的地基。
Task:异步操作本身(无返回值);Task<T>:异步操作 + 未来产出的 T;Task 是"热的":创建即已开始执行(区别于某些语言的"冷任务"需手动启动)。// 一个模拟 I/O 的异步方法:网络延迟期间不占线程
static async Task<string> FetchExchangeRateAsync(string currency)
{
Console.WriteLine($"开始查询 {currency} 汇率…(线程 {Environment.CurrentManagedThreadId})");
await Task.Delay(100); // 模拟 100ms 网络等待
return currency switch { "USD" => "7.12", "EUR" => "7.85", _ => "1.00" };
}
await 的语义:"这个还没好?好,本方法在这里挂起、当前线程立即回去干别的;结果就绪时由基础设施找线程从这行继续。"
static async Task Main() // 入口也可 async(顶级语句本身就是 Main 内部)
{
string rate = await FetchExchangeRateAsync("USD"); // 挂起点:等待时不占线程
Console.WriteLine($"USD 汇率 {rate}(线程 {Environment.CurrentManagedThreadId})");
}
观察两次打印的线程号大概率不同——await 之后的代码可能运行在另一个线程上,这就是为什么异步方法里不要依赖"线程本地"状态(如 ThreadStatic、某些旧式上下文)。
public async Task<Order> CreateOrderAsync(...) // ① 返回 Task/Task<T>;异步方法按约定加 Async 后缀
{
var product = await catalog.FindAsync(id); // ② await 消费异步结果
var order = new Order(product);
await repository.SaveAsync(order); // ③ 依次 await:串行
return order; // ④ 直接 return T(不是 Task<T>,编译器包好了)
}
public async Task ProcessAsync() { ... } // 无返回值用 Task,别用 void(见误区)
串行 await 三个 100ms 的调用 = 300ms;并发发起 = 100ms。相互独立的 I/O 并发化,是异步编程最直接的收益:
// ❌ 串行:300ms
var rate = await FetchExchangeRateAsync("USD");
var stock = await CheckStockAsync(1);
var coupon= await LoadCouponAsync(userId);
// ✅ 并发:≈100ms
Task<string> rateTask = FetchExchangeRateAsync("USD"); // 先发起(不 await)
Task<int> stockTask = CheckStockAsync(1);
Task<Coupon> couponTask = LoadCouponAsync(userId);
await Task.WhenAll(rateTask, stockTask, couponTask); // 再统一等
string rate = rateTask.Result; // WhenAll 完成后取 .Result 是安全的(已完成,不会阻塞)
int stock = stockTask.Result;
要点:
await FetchAsync() 写在赋值处就退化为串行;Task.WhenAll 中任一任务抛异常,聚合为 AggregateException(await 时抛第一个),其余任务仍会继续跑完;要逐个检查结果时,WhenAll 后访问各任务的 .Result/重新 await 各任务;Task.WhenAny:任一完成即返回——实现超时、竞速(先到先得):// 带超时的等待
var work = FetchExchangeRateAsync("USD");
var timeout = Task.Delay(3000);
if (await Task.WhenAny(work, timeout) == timeout)
{
throw new TimeoutException("汇率查询超时");
}
string rate = await work;
生产代码的超时/重试/熔断不必手写,第 22 章的 Polly/Pollyessentials 会接管,但底层机制就是 WhenAny。
长时间操作应当可以被叫停——搜索请求被用户取消、服务停止时中断后台任务。约定:异步方法接受 CancellationToken 参数(常为最后一个),并在合适的点检查。
static async Task<List<Product>> SearchAsync(string keyword, CancellationToken ct = default)
{
var results = new List<Product>();
foreach (var page in Enumerable.Range(1, 10))
{
ct.ThrowIfCancellationRequested(); // 协作式取消:检查点抛 OperationCanceledException
var batch = await FetchPageAsync(keyword, page, ct); // 传给底层 I/O(数据库/HTTP 都支持)
results.AddRange(batch);
}
return results;
}
// 调用方:超时自动取消
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try
{
var hits = await SearchAsync("咖啡", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("搜索被取消(超时或用户离开)");
}
取消是协作式的:令牌不会强行杀线程,方法在检查点自觉退出。ASP.NET Core 会把"客户端断开"自动映射到请求的 HttpContext.RequestAborted,你的服务层应一路透传这个 token。
分页拉取、逐条消费消息这类"边等边产"的场景:
static async IAsyncEnumerable<Order> StreamPaidOrdersAsync([EnumeratorCancellation] CancellationToken ct = default)
{
for (int page = 1; ; page++)
{
var batch = await FetchPageAsync(page, ct);
if (batch.Count == 0) yield break; // 没有更多数据
foreach (var o in batch) yield return o; // 逐条产出
}
}
await foreach (var order in StreamPaidOrdersAsync(ct))
{
Console.WriteLine(order.OrderNo); // 到一条处理一条,不整包加载
}
await 之后可能换了线程,跨 await 共享的可变状态就是共享资源:
// ❌ List<T>/Dictionary 非线程安全:并发请求同时写会丢数据甚至损坏内部结构
var unsafeList = new List<int>();
Parallel.For(0, 1000, i => unsafeList.Add(i)); // Count 可能 < 1000
// ✅ 并发集合(System.Collections.Concurrent)
var dict = new ConcurrentDictionary<string, int>();
dict.AddOrUpdate("latte", 1, (_, old) => old + 1); // 原子自增
// ✅ 或者:单写者 + 锁。注意 lock 块内不能 await!
private readonly object _gate = new();
lock (_gate) { _counter++; } // 同步临界区可以
// lock (_gate) { await SomethingAsync(); } // ❌ 编译错误,也绝不该这么设计
原则先行:能不共享就不共享(每个请求一个作用域对象,第 12 章 DI 的 Scoped 生命周期为此而生);必须共享就用并发集合或不可变结构。
下单时要:查商品、查汇率(跨境定价)、查用户优惠券、扣库存、发通知。查与查之间独立,扣库存依赖查商品的结果——典型"并发查 + 串行做":
public class OrderOrchestrator(
IProductCatalog catalog,
IExchangeRateService fx,
ICouponService coupons,
INotifier notifier)
{
public async Task<OrderReceipt> PlaceOrderAsync(int productId, int qty, int userId, CancellationToken ct = default)
{
// 阶段一:三个独立查询并发发起(省 2/3 等待时间)
Task<Product?> productTask = catalog.FindAsync(productId, ct);
Task<string> rateTask = fx.GetRateAsync("USD", ct);
Task<Coupon?> couponTask = coupons.FindBestAsync(userId, ct);
await Task.WhenAll(productTask, rateTask, couponTask);
var product = productTask.Result
?? throw new InvalidOperationException($"商品 {productId} 不存在");
if (product.Stock < qty)
{
throw new InvalidOperationException($"「{product.Name}」库存不足");
}
// 阶段二:有依赖的步骤串行
var order = new Order(product, qty);
if (couponTask.Result is { } coupon)
{
order.ApplyCoupon(coupon);
}
await repository.SaveAsync(order, ct);
// 阶段三:通知发出去就行,不等它也行 —— fire-and-forget 要谨慎(见误区)
_ = notifier.SendOrderPlacedAsync(order.OrderNo, ct); // 有意丢弃,需自带异常保护
return OrderReceipt.From(order, rateTask.Result);
}
}
_ = notifier.SendOrderPlacedAsync(...)(fire-and-forget)的纪律:必须确保方法内部捕获一切异常并记录日志,否则异常无人观察、静默丢失;生产上更常见的做法是丢给后台队列(第 23 章)。
async void:异常无法被调用方捕获(直接炸进程)、无法等待。唯一合法场景是事件处理器(async void OnClick(...));其他一律 async Task。task.Result、task.Wait()、.GetAwaiter().GetResult() 在有同步上下文的宿主里会死锁(结果就绪后无人能继续执行被阻塞的上下文),至少也白占一个线程。自顶向下全异步,阻塞调用只允许出现在无法改造的边界(如 Main 用顶级语句)。async 方法里没有 await,纯 CPU 计算包装成 Task.Run 还期待它省线程——省不了,只是增加调度开销。CPU 密集并行化用 Task.Run + 明确的并行收益评估。Thread.Sleep 混入异步:阻塞线程;等待一律 await Task.Delay。using/lock 作用域内跨 await:资源可能在 await 期间被释放/锁不可跨 await;把 await 移出作用域或重新设计生命周期。WhenAll 批量并发(注意控制并发度,避免压垮下游——可用 SemaphoreSlim 限流)。CancellationToken:从 Controller 到数据访问一路透传,客户端断开才真的能停止工作。.Result。ASP.NET Core 从 Controller 到 EF Core 原生异步,顺水推舟。Async 后缀、参数表末尾带 CancellationToken ct = default,是 .NET 社区的强约定。WhenAll 并发,有依赖的步骤串行;先启动后等待。Task/Task<T> 是异步操作占位;await 挂起方法而非线程,恢复点可能在别的线程。WhenAll 并发独立 I/O(先启动后等待),WhenAny 实现超时与竞速;CancellationToken 协作式取消,全链路透传。async void(事件除外)、不 .Result 阻塞、不造假异步、lock 内不 await、共享状态用并发集合。Task.Delay 的异步方法(如 查库存/查汇率/查物流),分别用串行 await 与 WhenAll 执行,用 Stopwatch 打印两种方式的耗时差。DownloadWithTimeoutAsync:用 WhenAny 对比 Task.Delay(2000),超时抛 TimeoutException;再用 CancellationTokenSource(TimeSpan.FromSeconds(2)) 实现同一效果,比较两种写法。OrderOrchestrator:库存、优惠券、汇率三个查询并发;下单后异步发通知(fire-and-forget 带异常保护);所有方法接受 CancellationToken;命令行 order 命令演示编排并打印耗时;Git 提交。lock 块内不允许 await?(提示:锁的持有者是线程,await 后恢复可能在别的线程——谁来释放锁?)那需要"跨异步操作的互斥"该怎么办?(预告:SemaphoreSlim.WaitAsync。)上一章:异步编程 ← | README | 下一章:依赖注入 →
学完本章,你将能够:
前后端分离、小程序、第三方对接……所有这些集成的通用语言就是 HTTP API。REST 风格的核心:URL 表示资源,HTTP 方法表示对资源的操作,状态码表示结果。
| 方法 | 语义 | 幂等 | 示例 |
|---|---|---|---|
| GET | 读取,无副作用 | 是 | GET /api/orders/SO-001 |
| POST | 创建 / 触发动作 | 否 | POST /api/orders |
| PUT | 全量替换 | 是 | PUT /api/orders/SO-001 |
| PATCH | 部分更新 | 否 | PATCH /api/orders/SO-001 |
| DELETE | 删除 | 是 | DELETE /api/orders/SO-001 |
常用状态码:200 OK、201 Created(POST 成功 + Location 头)、204 No Content、400 Bad Request(参数错)、401/403(未认证/无权限)、404 Not Found、409 Conflict(业务冲突如重复下单)、500(服务器内部错误)。
设计示例(轻订单):
GET /api/products?category=咖啡&page=1 商品列表(过滤+分页)
POST /api/products 新增商品
GET /api/orders/{orderNo} 查询单个订单
POST /api/orders 创建订单
POST /api/orders/{orderNo}/pay 支付(动作类接口用"资源+动词子路径")
GET /api/reports/daily?date=2026-09-29 经营日报
HTTP 请求
│
▼
Kestrel(内置高性能跨平台 Web 服务器)
│
▼
中间件管道(按注册顺序串起来的处理链)
ExceptionHandler → HTTPS重定向 → 路由 → 认证 → 授权 → 自定义中间件 → 端点
│
▼
端点(Minimal API / Controller 的 Action)
│
└─ 依赖注入容器解析服务 → 执行业务 → 返回结果
dotnet new webapi -n LiteOrder.Api # 默认 Minimal API 模板
cd LiteOrder.Api
dotnet run
模板 Program.cs(.NET 10):
var builder = WebApplication.CreateBuilder(args); // 1) 主机构建器:注册服务
builder.Services.AddOpenApi(); // 2) OpenAPI 文档支持
var app = builder.Build(); // 3) 构建 app:进入中间件配置阶段
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // 4) 开发环境暴露 /openapi/v1.json
}
app.MapGet("/weatherforecast", () => ...); // 5) 一个示例端点
app.Run(); // 6) 启动并阻塞监听
app.Environment.IsDevelopment() 依据 ASPNETCORE_ENVIRONMENT 环境变量(Development/Staging/Production),按环境切换行为是标准实践。
调试时用浏览器或 curl 访问 http://localhost:5xxx/openapi/v1.json;装上 Swagger UI / Scalar(第 14 章起示例中加入)可得到可视化接口页。
// 各种来源的参数自动绑定
app.MapGet("/api/products/{id:int}", (int id) => ...); // 路由参数(:int 是路由约束)
app.MapGet("/api/products", (string? category, int page = 1) => ...); // 查询参数(?category=)
app.MapPost("/api/products", (CreateProductRequest req) => ...); // 请求体(JSON 反序列化)
app.MapGet("/api/debug", (HttpContext ctx) => ...); // 特殊类型:整包注入
绑定规则:简单类型(int/string/Guid…)默认从路由或查询取;复杂类型(record/class)从请求体 JSON 反序列化。
public record CreateProductRequest(string Name, string Category, decimal Price, int Stock);
public record ProductResponse(int Id, string Name, string Category, decimal Price, int Stock);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductStore>(); // 临时用单例内存存储,第 12/18 章替换
var app = builder.Build();
app.MapGet("/api/products", (ProductStore store, string? category) =>
{
var query = store.All();
var items = (category is null ? query : query.Where(p => p.Category == category))
.Select(p => p.ToResponse())
.ToList();
return Results.Ok(items); // 200
});
app.MapGet("/api/products/{id:int}", (int id, ProductStore store) =>
store.Find(id) is { } p
? Results.Ok(p.ToResponse())
: Results.NotFound(new { message = $"商品 {id} 不存在" })); // 404
app.MapPost("/api/products", (CreateProductRequest req, ProductStore store) =>
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Price <= 0)
{
return Results.BadRequest(new { message = "名称必填且价格必须大于 0" }); // 400
}
var product = store.Add(req.Name, req.Category, req.Price, req.Stock);
return Results.Created($"/api/products/{product.Id}", product.ToResponse()); // 201 + Location
});
app.MapDelete("/api/products/{id:int}", (int id, ProductStore store) =>
store.Remove(id) ? Results.NoContent() : Results.NotFound()); // 204 / 404
app.Run();
关键习惯:
Results.Ok/Created/NoContent/BadRequest/NotFound/Conflict/Problem(...) 显式表达语义;CreateProductRequest(入参)、ProductResponse(出参)是 API 契约,别把领域实体直接序列化出去(第 17 章强化);new { message = ... } 或 Results.Problem),而不是裸字符串。团队规范或大型项目常用 Controller(特性路由 + 显式类):
[ApiController]
[Route("api/[controller]")] // → /api/Products
public class ProductsController(ProductStore store) : ControllerBase // 主构造函数注入
{
[HttpGet]
public ActionResult<IEnumerable<ProductResponse>> List([FromQuery] string? category) =>
Ok(store.All().Where(p => category is null || p.Category == category)
.Select(p => p.ToResponse()).ToList());
[HttpGet("{id:int}")]
public ActionResult<ProductResponse> Get(int id) =>
store.Find(id) is { } p ? Ok(p.ToResponse()) : NotFound();
[HttpPost]
public ActionResult<ProductResponse> Create(CreateProductRequest req) { /* 同上 */ }
}
// Program.cs 需注册:builder.Services.AddControllers(); app.MapControllers();
取舍:Minimal API 轻、直观、启动快,中小服务与微服务首选;Controller 结构化强、过滤器/模型绑定生态成熟,大团队规范友好。本书主体用 Minimal API,概念两者通用。
手写 if 校验能跑但不优雅。ASP.NET Core 生态主流是 DataAnnotations 或 FluentValidation:
using System.ComponentModel.DataAnnotations;
public record CreateProductRequest(
[Required, StringLength(50)] string Name,
[StringLength(20)] string Category,
[Range(0.01, 99999)] decimal Price,
[Range(0, 100000)] int Stock);
// Minimal API 自动验证:builder.Services.AddValidation();(.NET 10 内建,基于 DataAnnotations)
// 校验失败自动返回 400 + ProblemDetails,无需手写 if
第 21 章安全与第 22 章错误规范会继续强化"入口处把非法请求挡掉"的原则。
把前十章的领域代码搬进 API 项目(领域类放 Domain/ 文件夹,服务逻辑放 Services/):
// Services/OrderService.cs —— 领域逻辑不感知 HTTP(第 17 章会正式分层)
public class OrderService(ProductStore products, OrderStore orders)
{
public Order Create(int productId, int qty)
{
var product = products.Find(productId)
?? throw new NotFoundException($"商品 {productId} 不存在");
if (product.Stock < qty)
{
throw new ConflictException($"「{product.Name}」库存不足");
}
product.TryDeductStock(qty);
return orders.Add(product, qty);
}
}
// 自定义异常类型(第 22 章系统化)
public class NotFoundException(string msg) : Exception(msg);
public class ConflictException(string msg) : Exception(msg);
// Program.cs —— 端点翻译 HTTP 语义
app.MapPost("/api/orders", (CreateOrderRequest req, OrderService svc) =>
{
try
{
var order = svc.Create(req.ProductId, req.Quantity);
return Results.Created($"/api/orders/{order.OrderNo}", OrderResponse.From(order));
}
catch (NotFoundException ex) { return Results.NotFound(new { ex.Message }); }
catch (ConflictException ex) { return Results.Conflict(new { ex.Message }); }
});
app.MapPost("/api/orders/{orderNo}/pay", (string orderNo, OrderService svc) =>
{
var order = orders.Find(orderNo);
if (order is null) return Results.NotFound();
if (order.Status != OrderStatus.Created)
{
return Results.Conflict(new { message = $"订单状态 {order.Status} 不可支付" });
}
order.TransitionTo(OrderStatus.Paid);
return Results.Ok(OrderResponse.From(order));
});
测试(用 curl 或任意 HTTP 工具):
curl -X POST http://localhost:5000/api/products -H "Content-Type: application/json" \
-d '{"name":"拿铁咖啡","category":"咖啡","price":28.5,"stock":100}'
curl -X POST http://localhost:5000/api/orders -H "Content-Type: application/json" \
-d '{"productId":1,"quantity":2}'
curl http://localhost:5000/api/orders/SO-20260929-0001
从"命令行菜单"到"HTTP API",轻订单正式获得了被任何客户端调用的能力——同时单例内存存储在重启后丢失数据、多实例不共享的问题也暴露了,这正是后续章节逐一解决的(12 章 DI 规范化、18 章数据库)。
.Result;全链路异步(第 10 章)。/orders/{no}/pay);版本前缀 /api/v1 预留演进空间。Results.* 显式状态码;错误响应结构统一(第 22 章引入 RFC 9457 ProblemDetails)。launchSettings.json 固定端口;dotnet watch 热重载提速开发。Results.* 表达语义;入口验证挡非法请求。GET/POST/PATCH/DELETE /api/todos),内存存储,PATCH 支持"完成标记"。?page=1&pageSize=10,返回 { items, total, page, pageSize })与按名称模糊搜索。LiteOrder.Api 项目加入解决方案,把 M6-M9 的领域与服务代码迁入;实现商品 CRUD、创建订单、支付、日报四个端点;验证时注意并发场景下单例存储的问题并记录到项目 README 的"已知问题"里(它们是 17/18/20 章的引子);Git 提交。上一章:ASP.NET Core Web API 入门 ← | README | 下一章:配置管理与选项模式 →
学完本章,你将能够:
回想第 5 章的 OrderService——它内部 new OrderRepository()。这个"自己造依赖"的写法有三个死结:
OrderService 源码、重新编译;OrderService 的单元测试必然拖上真实的 OrderRepository(进而拖上数据库);OrderService 还需要日志、配置、汇率服务……每个都要自己 new,配置散落各处。依赖注入(Dependency Injection,DI)把"创建依赖"这件事从类内部挪到外部容器:
传统:OrderService 内部 new OrderRepository() —— 类自己控制依赖(控制正转)
IoC :容器创建 OrderRepository(),塞给 OrderService —— 控制权反转给外部
// 面向接口(第 5 章)+ 构造函数注入
public interface IOrderRepository
{
Task<Order?> FindAsync(string orderNo, CancellationToken ct = default);
Task SaveAsync(Order order, CancellationToken ct = default);
}
public class OrderService(IOrderRepository repo, IExchangeRateService fx) // 只声明"我需要什么"
{
public async Task<Order> CreateAsync(int productId, int qty, CancellationToken ct)
{
var product = await repo.FindAsync(productId.ToString(), ct) ?? ...;
// ...
await repo.SaveAsync(order, ct);
return order;
}
}
OrderService 不知道也不关心 IOrderRepository 是内存版、EF Core 版还是测试替身——容器负责装配。测试时传入假实现,隔离即达成(第 15 章的基石)。
ASP.NET Core 容器注册三件套:接口 → 实现 → 生命周期。
// Program.cs
builder.Services.AddSingleton<ProductStore>(); // 具体类直接注册
builder.Services.AddSingleton<IOrderRepository, InMemoryOrderRepository>(); // 接口 → 实现
builder.Services.AddScoped<OrderService>(); // 业务服务
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
三种生命周期的确切含义:
| 生命周期 | 实例数 | 适用 | 典型例子 |
|---|---|---|---|
| Singleton | 整个应用 1 个 | 无状态工具、共享缓存、HttpClient 工厂 | 配置、内存缓存 |
| Scoped | 每个请求 1 个 | 携带请求上下文的状态(DbContext、当前用户) | EF Core DbContext |
| Transient | 每次解析 1 个 | 轻量无状态服务 | 一次性工具类 |
// 验证生命周期的实验
app.MapGet("/di-demo",
(ServiceA a1, ServiceA a2, // 同一请求内解析两次
IEnumerable<ServiceB> bs) => // 一次解析出多个
{
// a1 与 a2 是否同一个实例,取决于 ServiceA 注册的生命周期
return Results.Ok(new { same = ReferenceEquals(a1, a2), count = bs.Count() });
});
// 工厂注册:构造逻辑复杂时
builder.Services.AddSingleton<IClock>(_ => new SystemClock(TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")));
// 按环境切换实现(开发用假的,生产用真的)
if (builder.Environment.IsDevelopment())
{
builder.Services.AddSingleton<IExchangeRateService, FakeExchangeRateService>();
}
else
{
builder.Services.AddHttpClient<IExchangeRateService, HttpExchangeRateService>();
}
// 同一接口多实现:解析 IEnumerable<T> 全拿到(第 19 章策略模式的容器化版本)
builder.Services.AddSingleton<IPayment, WechatPay>();
builder.Services.AddSingleton<IPayment, Alipay>();
// 消费方:public PaymentRouter(IEnumerable<IPayment> payments) —— 全部注入,运行时按渠道选择
// 手动指定实例
builder.Services.AddSingleton<AppOptions>(myPrebuiltOptions);
服务多了以后,按功能模块封装注册——这是 .NET 生态的通用惯例(AddControllers、AddSignalR 都是这个模式):
// Extensions/OrderModuleExtensions.cs
public static class OrderModuleExtensions
{
public static IServiceCollection AddOrderModule(this IServiceCollection services)
{
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddScoped<OrderService>();
services.AddSingleton<IPayment, WechatPay>();
services.AddSingleton<IPayment, Alipay>();
return services; // 返回自身支持链式调用
}
}
// Program.cs
builder.Services.AddOrderModule();
容器自动装配类与端点的依赖,嵌套依赖自动递归解析:
public class OrderService(IOrderRepository repo, ILogger<OrderService> logger, TimeProvider clock)
{
// 三个依赖全部由容器提供;repo 自己的依赖也由容器递归解决
}
app.MapPost("/api/orders", async (CreateOrderRequest req, OrderService svc, CancellationToken ct) =>
// Minimal API 端点参数:容器里注册的类型自动注入;req 来自请求体;ct 是请求取消令牌
{
var order = await svc.CreateAsync(req.ProductId, req.Quantity, ct);
return Results.Created($"/api/orders/{order.OrderNo}", order);
});
TimeProvider(.NET 8+)是获取时间的抽象——替代DateTime.Now直呼,测试时注入假时钟控制"当前时间"。业务代码依赖抽象时间是个小而美的实践。
app.MapGet("/x", (HttpContext ctx) =>
{
var svc = ctx.RequestServices.GetRequiredService<OrderService>(); // 运行时才解析
// ...
});
服务定位器反模式:依赖隐藏在方法体内,签名上看不出来;解析失败推迟到运行时;测试更难。仅在有明确生命周期原因(如在中间件里按请求解析 Scoped 服务)时使用,日常一律构造函数注入。
长寿命服务捕获短寿命服务——Singleton 引用 Scoped,Scoped 会活到应用关闭,等效于泄漏:
// ❌ 编译不报错、启动时容器检测直接抛异常(ASP.NET Core 默认 ValidateScopes)
builder.Services.AddSingleton<CacheWarmer>(); // Singleton
builder.Services.AddScoped<AppDbContext>(); // Scoped
public class CacheWarmer(AppDbContext db) { } // ❌ Singleton 囚禁了 Scoped
// InvalidOperationException: Cannot consume scoped service ... from singleton
解法:Singleton 需要 Scoped 服务时,注入 IServiceScopeFactory,用时创建作用域:
public class CacheWarmer(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); // 每轮一个新作用域
await WarmAsync(db, ct);
}
}
Singleton 天生被所有请求共享——所有请求都在并发访问它:
// ❌ 无保护的字典:并发读写迟早 IndexOutOfRange/数据丢失
public class RateCache { private readonly Dictionary<string, decimal> _map = new(); ... }
// ✅ 并发安全容器或不可变替换
public class RateCache
{
private readonly ConcurrentDictionary<string, decimal> _map = new();
public decimal Get(string ccy) => _map.GetValueOrDefault(ccy, 1m);
public void Update(string ccy, decimal rate) => _map[ccy] = rate;
}
经验法则:业务对象(订单、购物车)绝不 Singleton;Singleton 服务尽量无状态,有状态必并发安全。
Transient 在每次解析时新建——一个 Singleton 内嵌 10 个 Transient 链,一次请求可能创建几十个对象。轻量无状态才用 Transient,拿不准用 Scoped。
把 11 章遗留的"手 new 存储"体系收编进 DI,并隔离内存实现与业务服务:
// —— 基础设施接口:谁存谁取,业务不管 ——
public interface IProductStore
{
Task<Product?> FindAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<Product>> AllAsync(CancellationToken ct = default);
Task SaveAsync(Product product, CancellationToken ct = default);
}
public interface IOrderStore
{
Task<Order?> FindAsync(string orderNo, CancellationToken ct = default);
Task SaveAsync(Order order, CancellationToken ct = default);
}
// —— 内存实现(被第 18 章的 EF Core 实现原位替换,业务代码零改动)——
public class InMemoryProductStore : IProductStore
{
private readonly ConcurrentDictionary<int, Product> _byId = new(); // Singleton 并发安全
public Task<Product?> FindAsync(int id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id));
public Task<IReadOnlyList<Product>> AllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Product>>([.. _byId.Values]);
public Task SaveAsync(Product p, CancellationToken ct = default)
{ _byId[p.Id] = p; return Task.CompletedTask; }
}
// —— 模块化装配 ——
public static class OrderModuleExtensions
{
public static IServiceCollection AddOrderModule(this IServiceCollection services)
{
services.AddSingleton<IProductStore, InMemoryProductStore>();
services.AddSingleton<IOrderStore, InMemoryOrderStore>();
services.AddScoped<OrderService>(); // 请求级业务服务
services.AddSingleton<PaymentRouter>();
return services;
}
}
// Program.cs
builder.Services.AddOrderModule();
OrderService 改为依赖 IOrderStore/IProductStore 接口后:第 18 章换成 EF Core 实现只是改一行注册;第 15 章单元测试直接注入假存储——DI 的投资开始分红。
GetRequiredService 在业务代码各处出现):依赖不可见、错误延迟暴露。构造函数注入是默认答案。IHostedService。OrderService : IOrderService 只有 OrderService 实现):接口是为了抽象边界,不是为了仪式感;无第二实现且不作为对外契约的类,直接注册具体类即可。AddXxxModule() 扩展方法,Program.cs 只剩一行行装配调用。HttpContext.RequestServices 只用于中间件/过滤器的按作用域解析。IPrinter 接口的三个实现(A/B/C),写端点注入 IEnumerable<IPrinter> 依次调用;再把生命周期改成 Scoped,同请求解析两次验证同一实例。AppDbContext 或自定义 Scoped 类),观察启动异常信息,再用 IServiceScopeFactory 修复。IProductStore/IOrderStore 接口 + 内存实现(ConcurrentDictionary);OrderService 全部依赖接口;注册代码抽成 AddOrderModule();用 TimeProvider 抽象时间注入订单号生成;Git 提交。上一章:依赖注入 ← | README | 下一章:日志记录 →
学完本章,你将能够:
IOptions/IOptionsSnapshot/IOptionsMonitor 并正确选择;WebApplicationBuilder 启动时自动装配多个配置源,后注册者覆盖先注册者:
appsettings.json 基础配置(随代码走,不含秘密)
↑ 覆盖
appsettings.{Environment}.json 环境专属(如 appsettings.Development.json)
↑ 覆盖
用户机密(user-secrets,仅开发机) 本机开发时的敏感值
↑ 覆盖
环境变量 部署环境注入(容器/云平台标准姿势)
↑ 覆盖
命令行参数 临时覆盖(--Key Value)
同一配置键的最终值由"最上层"来源决定——代码里只管读键,不用关心它来自哪。这就是配置体系的意义:同一份应用代码,开发、测试、生产靠不同配置组合出不同行为。
appsettings.json:
{
"Order": {
"VipDiscountRate": 0.9,
"FreeShippingThreshold": 99,
"ShippingFee": 8,
"MaxItemsPerOrder": 50
},
"Payment": {
"DefaultChannel": "Wechat",
"Wechat": { "AppId": "wx-123456" }
},
"Logging": { "LogLevel": { "Default": "Information" } }
}
三种读取方式:
// 1) IConfiguration 直接读取(应急/一次性场景)
var rate = app.Configuration["Order:VipDiscountRate"]; // 冒号分隔层级,值是 string
var threshold = app.Configuration.GetValue<decimal>("Order:FreeShippingThreshold", 99m); // 类型转换 + 默认值
// 2) GetSection 整段
var orderSection = app.Configuration.GetSection("Order");
// 3) 选项模式(13.3,推荐)
直接散用 IConfiguration 的问题:字符串键拼错运行时静默拿到 null、类型不安全、每个类各自解析、无法校验。它的正确归宿是被"选项模式"封装。
// Options/OrderOptions.cs —— 强类型的配置载体
public sealed class OrderOptions
{
public const string SectionName = "Order";
[Range(0.5, 1.0)]
public decimal VipDiscountRate { get; init; } = 1.0m;
[Range(0, 100000)]
public decimal FreeShippingThreshold { get; init; } = 99m;
[Range(0, 1000)]
public decimal ShippingFee { get; init; } = 8m;
[Range(1, 500)]
public int MaxItemsPerOrder { get; init; } = 50;
}
// Program.cs —— 绑定 + 数据注解校验 + 启动即校验
builder.Services
.AddOptions<OrderOptions>()
.Bind(builder.Configuration.GetSection(OrderOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // 配置非法 → 应用启动就失败,而不是第一次用到才炸
// 若不想用注解,可用自定义委托校验
builder.Services.AddOptions<OrderOptions>()
.Bind(builder.Configuration.GetSection("Order"))
.Validate(o => o.ShippingFee <= o.FreeShippingThreshold,
"运费不应高于免邮门槛(配置自相矛盾)")
.ValidateOnStart();
ValidateOnStart 的价值:配置错误是部署期事故,让它 fail fast 在启动阶段,别让第一次下单时才发现折扣率是 null。
public class OrderService(IOptions<OrderOptions> options) // ① IOptions
{
public decimal CalculateShipping(decimal payable)
{
var o = options.Value; // .Value 拿到实例(Singleton,应用内不变)
return payable >= o.FreeShippingThreshold ? 0m : o.ShippingFee;
}
}
| 接口 | 生命周期 | 热更新 | 场景 |
|---|---|---|---|
IOptions<T> |
Singleton | 否 | 绝大多数场景:配置在进程内视为固定 |
IOptionsSnapshot<T> |
Scoped(每请求重建) | 是(每请求重读) | 同一请求内一致性读取 + 期望改动生效 |
IOptionsMonitor<T>.CurrentValue |
Singleton | 是(变更即时可见 + OnChange 回调) | 后台服务读取最新值、响应变更事件 |
选择很简单:默认 IOptions<T>;后台长驻服务需要感知变更用 IOptionsMonitor<T>;IOptionsSnapshot<T> 用得少,需要"请求级快照"时才用。
IOptionsMonitor<T>.Get("name") 支持同一类型多套配置(如多个渠道各自的限流参数),进阶场景再学。
// appsettings.Development.json —— 开发机专属覆盖
{
"Logging": { "LogLevel": { "Default": "Debug", "Microsoft.AspNetCore": "Warning" } },
"Order": { "FreeShippingThreshold": 0 } // 开发时免邮,方便测试
}
ASPNETCORE_ENVIRONMENT 决定(Development/Staging/Production);铁律:连接字符串、API Key、密码永远不进代码库。(.gitignore、Code Review、第 21 章扫描工具三道防线)
dotnet user-secrets init # 在 csproj 生成 UserSecretsId(本机随机 GUID)
dotnet user-secrets set "Payment:Wechat:AppSecret" "本机开发密钥-不入库"
dotnet user-secrets list
机密明文存于用户目录(Windows 在 %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json),读取方式与普通配置完全一致(Configuration["Payment:Wechat:AppSecret"] 或选项绑定)——代码零感知,仓库里干净。
# 环境变量按"双下划线"映射配置层级
export Payment__Wechat__AppSecret="<值由密钥服务下发,仓库与文档中不落明文>"
# 对应配置键 Payment:Wechat:AppSecret
云上部署用平台密钥管理(Azure Key Vault / AWS Secrets Manager / 阿里云 KMS),应用按需拉取。原则统一:机密以"引用 + 权限"进入部署清单,明文只存在于密钥服务。
把散落在各章的魔法数字全部收编:
// 1) 定义选项(13.3.1 的 OrderOptions)
// 2) 注册绑定与校验
builder.Services.AddOptions<OrderOptions>()
.Bind(builder.Configuration.GetSection("Order"))
.ValidateDataAnnotations()
.ValidateOnStart();
// 3) 业务服务消费
public class OrderService(
IProductStore products,
IOrderStore orders,
IOptions<OrderOptions> options,
TimeProvider clock)
{
private OrderOptions Opt => options.Value;
public async Task<Order> CreateAsync(int productId, int qty, CancellationToken ct = default)
{
if (qty > Opt.MaxItemsPerOrder)
{
throw new ConflictException($"单笔订单最多 {Opt.MaxItemsPerOrder} 件");
}
// ... 下单逻辑 ...
}
public decimal CalculatePayable(decimal subtotal, bool isVip)
{
var afterDiscount = isVip ? subtotal * Opt.VipDiscountRate : subtotal;
var shipping = afterDiscount >= Opt.FreeShippingThreshold ? 0m : Opt.ShippingFee;
return afterDiscount + shipping;
}
}
// 4) 测试友好的收益(第 15 章兑现):测试里直接 new OrderOptions { FreeShippingThreshold = 0 } 注入,
// 不需要任何配置文件
启动时校验配置是否完整合理(少一个键、类型不对、值越界 → 启动失败并指出原因);不同环境给不同值(开发免邮、生产 99 元);修改业务规则不再需要改代码重新发布——这就是配置化的完整闭环。
IOptions 是全局单实例,任何人在运行时改它的属性影响所有请求——选项类用 init/sealed 保持只读。IOptions 热更新:它不更新(进程内缓存);需要热更新用 IOptionsMonitor。ValidateOnStart 缺席:配置错误潜伏到首次使用;启动期校验让运维在发布流水线就发现。: 在环境变量里是 __(双下划线),写错则读到的永远是 null。ValidateOnStart。sealed + init 只读属性,给合理默认值。IOptions<T> 或直接接受纯参数(测试构造纯对象)。AddOptions<T>().Bind().ValidateDataAnnotations().ValidateOnStart(),业务服务注入 IOptions<T>(默认)/IOptionsMonitor<T>(热更新)。ASPNETCORE_ENVIRONMENT 驱动,行为按环境自动切换。Greeting 配置段(Prefix、Suffix),用选项模式注入并校验(Prefix 必填),故意配置越界值观察 ValidateOnStart 的启动报错。Demo:ApiKey(值随意但当作真实机密对待),写端点 /secret-check 只输出"已配置/未配置"(不回显值——为什么?),并把 appsettings.json 确认干净后提交。OrderOptions(折扣率、免邮门槛、运费、单笔上限),接入 ValidateOnStart;新增 appsettings.Development.json 给开发专属值;README 记录每个配置键的含义;Git 提交(提交前 git diff --cached 确认无敏感值混入)。上一章:配置管理与选项模式 ← | README | 下一章:单元测试 →
学完本章,你将能够:
ILogger<T> 按级别规范记录日志,理解六大级别的语义边界;控制台输出:无级别、无时间戳、无来源、无法开关、无法集中收集、进程结束即蒸发。生产系统需要的日志是可检索的事件流——某个请求出了什么错、影响哪些订单、耗时多少。ILogger 与日志基础设施把"写日志"升级为"发事件"。
public class OrderService(IOrderStore orders, ILogger<OrderService> logger)
{
public async Task<Order> CreateAsync(int productId, int qty, CancellationToken ct = default)
{
logger.LogInformation("创建订单开始 ProductId={ProductId} Qty={Qty}", productId, qty);
// ...业务逻辑...
logger.LogDebug("内部细节:库存检查耗时 {ElapsedMs}ms", sw.ElapsedMilliseconds);
if (qty > 100)
{
logger.LogWarning("大额订单创建 OrderNo={OrderNo} Qty={Qty},建议人工复核", order.OrderNo, qty);
}
try
{
await orders.SaveAsync(order, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "订单保存失败 OrderNo={OrderNo}", order.OrderNo);
throw; // 记了还要抛,交给上层决定后续(第 22 章)
}
logger.LogInformation("订单创建成功 OrderNo={OrderNo} Amount={Amount}", order.OrderNo, order.Subtotal);
return order;
}
}
六大级别与使用语义:
| 级别 | 语义 | 示例 |
|---|---|---|
Trace |
最细粒度调试细节 | 函数进出、字节流 |
Debug |
开发调试信息 | 内部状态、中间值 |
Information |
正常业务里程碑 | 订单创建成功、服务启动 |
Warning |
异常但可自愈/需关注 | 重试、库存偏低、慢查询 |
Error |
当前操作失败 | 保存失败、外部接口 5xx |
Critical |
系统级灾难 | 数据库不可用、支付渠道整体故障 |
级别是过滤器不是装饰:生产环境默认 Information 以上,Debug/Trace 自动屏蔽——所以 Debug 日志可以放心写细,成本由配置控制。
// ✅ 正确:消息模板保持常量,数据走命名占位符
logger.LogInformation("订单支付成功 OrderNo={OrderNo} Amount={Amount}", order.OrderNo, amount);
// ❌ 错误:字符串插值 —— 日志只是一句死文本,检索系统无法按 OrderNo 过滤聚合
logger.LogInformation($"订单支付成功 OrderNo={order.OrderNo} Amount={amount}");
区别在 sink:结构化日志存储后可以查询"过去一小时 OrderNo=SO-001 的全部日志"、"Amount>1000 的 Warning 分布"。占位符命名用 PascalCase 属性名,是 .NET 社区约定。同理,循环里写日志注意量级,海量重复事件考虑采样或聚合后输出。
// ✅ 异常对象作为第一参数:完整堆栈入日志
logger.LogError(ex, "支付回调处理失败 OrderNo={OrderNo}", orderNo);
// ❌ 只记 ex.Message:丢失堆栈与内部异常链,排查无从下手
logger.LogError("支付回调失败:{Msg}", ex.Message);
// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning", // 框架噪音调低
"Microsoft.EntityFrameworkCore": "Warning",
"LiteOrder": "Debug" // 自己的命名空间调细
}
}
}
按"类别"(通常是类的全名命名空间)精细控制级别——第三方库太吵就把它调 Warning,自己模块排查时临时调 Debug。这是级别过滤体系的日常用法。
using (logger.BeginScope(new Dictionary<string, object>
{
["OrderNo"] = order.OrderNo,
["TraceId"] = httpContext.TraceIdentifier,
}))
{
logger.LogInformation("扣减库存"); // 本作用域内所有日志自动携带 OrderNo/TraceId
logger.LogInformation("发送通知");
}
作用域数据随每条日志一起输出,检索时"按 OrderNo 一网打尽整个流程"。ASP.NET Core + W3C TraceId 格式默认给每个请求挂 TraceId——多服务链路里同一请求的日志自动可关联(第 23 章分布式追踪的伏笔)。
内建日志写文件很弱(无滚动切割)。生产标配是 Serilog:
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.File
// Program.cs
builder.Host.UseSerilog((context, config) => config
.ReadFrom.Configuration(context.Configuration) // 配置化
.Enrich.FromLogContext()
.WriteTo.Console() // 开发:控制台
.WriteTo.File( // 生产:滚动文件
"logs/liteorder-.log",
rollingInterval: RollingInterval.Day, // 按天切割
retainedFileCountLimit: 14)); // 保留 14 天
var app = builder.Build();
app.UseSerilogRequestLogging(); // 每请求一行:方法、路径、状态码、耗时
app.MapGet("/api/orders/{no}", (string no, IOrderStore store) =>
{
// 之后所有 ILogger 正常用,Serilog 在底层接管输出
});
// appsettings.json 配置化控制
{
"Serilog": {
"MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning" } }
}
}
进阶路线:日志文件 → 集中式平台(Seq、Elasticsearch/ELK、云日志服务)聚合检索;再往上是 OpenTelemetry——把日志、指标(Metrics)、分布式追踪(Traces)三合一按开放标准导出(第 23 章)。学习阶段先把内建 ILogger 用规范,切换 Serilog/OTel 只是改管道不改业务代码——这正是抽象的价值。
为"下单 → 支付 → 发货"建立可追查的日志线:
public class OrderService(IProductStore products, IOrderStore orders, ILogger<OrderService> logger)
{
public async Task PayAsync(string orderNo, CancellationToken ct = default)
{
logger.LogInformation("支付处理开始 OrderNo={OrderNo}", orderNo);
var order = await orders.FindAsync(orderNo, ct);
if (order is null)
{
logger.LogWarning("支付目标不存在 OrderNo={OrderNo}", orderNo);
throw new NotFoundException($"订单 {orderNo} 不存在");
}
if (order.Status != OrderStatus.Created)
{
// 业务拒绝是 Information/Warning 级别(不是 Error——不是系统故障)
logger.LogInformation("支付被拒绝(状态不符)OrderNo={OrderNo} Status={Status}",
orderNo, order.Status);
throw new ConflictException($"订单状态 {order.Status} 不可支付");
}
order.TransitionTo(OrderStatus.Paid);
await orders.SaveAsync(order, ct);
logger.LogInformation("支付成功 OrderNo={OrderNo} Amount={Amount} PaidAt={PaidAt:O}",
orderNo, order.Subtotal, DateTime.UtcNow);
// PaidAt:O 输出 ISO8601 带时区 —— 日志时间一律 UTC/带时区,跨机器排查不踩时区坑
}
}
轻订单日志规范(可写入团队 README):
Information(开始/成功),异常路径 Warning(业务拒绝)或 Error(系统故障);OrderNo 等业务主键占位符,实现"一单一条线"检索;$"..."):结构化能力归零;占位符 + 参数才是可查询日志。LogError 第一参数。ILogger<T> 注入使用;框架与自研模块分级别控制(Logging:LogLevel 按命名空间)。BeginScope 与请求 TraceId。ILogger 六级别是过滤器与告警语义:Trace/Debug 细节、Information 里程碑、Warning 需关注、Error/Critical 故障。BeginScope 与 TraceId 串联请求链路。BankAccount(第 4 章练习)加日志:存取款成功 Information、余额不足 Warning、异常 Error;故意触发一次异常,观察控制台输出的完整堆栈。Microsoft.AspNetCore 调到 Warning,自己模块调到 Debug;写一条 Debug 与一条 Information 日志验证过滤生效。上一章:日志记录 ← | README | 下一章:调试、诊断与代码质量 →
学完本章,你将能够:
TimeProvider 假时钟控制时间,测出"限时逻辑";单元测试 = 用代码验证代码的最小单元(类/方法)行为符合预期。它买到的东西:
测试金字塔:单元测试(多而快)→ 集成测试(少而真)→ 端到端测试(极少数关键路径)。绝大多数业务规则靠单元测试覆盖,本章主攻单元测试,15.6 简介集成测试。
创建测试项目并挂到解决方案:
dotnet new xunit -n LiteOrder.Tests
dotnet sln add LiteOrder.Tests
dotnet add LiteOrder.Tests reference LiteOrder.Api # 引用被测项目
dotnet test
public class DiscountPolicyTests
{
[Fact] // 无参数测试
public void Apply_VipDiscount_Returns90Percent()
{
// Arrange(准备):构造被测对象与输入
var policy = new VipDiscount();
decimal subtotal = 200m;
// Act(执行):调用被测行为
var payable = policy.Apply(subtotal);
// Assert(断言):验证结果
Assert.Equal(180m, payable);
}
[Fact]
public void Apply_FullReduction_Below200_NoDiscount()
{
var policy = new FullReduction();
Assert.Equal(150m, policy.Apply(150m));
}
}
命名约定 方法_场景_预期(或中文可读名),看名字就知道测什么。一个测试只断言一个行为——失败了能立刻定位。
同一逻辑多组输入用 [Theory] 消除复制粘贴:
[Theory]
[InlineData(200, 20)] // 每行一组数据
[InlineData(199, 0)]
[InlineData(500, 20)]
[InlineData(0, 0)]
public void Apply_FullReduction_MapCorrectly(decimal subtotal, decimal expectedOff)
{
var policy = new FullReduction();
Assert.Equal(subtotal - expectedOff, policy.Apply(subtotal));
}
Assert.Equal(expected, actual); // 值相等(浮点加精度参数:Assert.Equal(0.3, 0.1+0.2, 5))
Assert.NotEqual(a, b);
Assert.True(condition);
Assert.Null(obj); Assert.NotNull(obj);
Assert.Throws<InvalidOperationException>(() => svc.Pay(orderNo)); // 期望异常
var ex = Assert.Throws<ConflictException>(() => svc.Pay(orderNo)); // 捕获异常进一步断言消息
Assert.Contains("库存不足", ex.Message);
Assert.StartsWith/EndsWith/Matches(...) // 字符串
collection: Assert.Contains(item, list); Assert.Empty(list); Assert.Single(list);
被测类依赖数据库/HTTP/时间——这些在单测里都要换成"听话的假货"。第 12 章 DI 埋的伏笔在此兑现:依赖接口 + 构造函数注入 = 可注入替身。
// 假存储:行为简单可预期,不碰真数据库
public class FakeOrderStore : IOrderStore
{
public Dictionary<string, Order> Saved { get; } = new();
private readonly Dictionary<string, Order> _existing = [];
public FakeOrderStore Seed(Order order) { _existing[order.OrderNo] = order; return this; }
public Task<Order?> FindAsync(string orderNo, CancellationToken ct = default)
=> Task.FromResult(_existing.GetValueOrDefault(orderNo));
public Task SaveAsync(Order order, CancellationToken ct = default)
{ Saved[order.OrderNo] = order; _existing[order.OrderNo] = order; return Task.CompletedTask; }
}
// 测试
[Fact]
public async Task Pay_CreatedOrder_TransitionsToPaid()
{
// Arrange
var order = new Order(100m); // 新单默认 Created
var store = new FakeOrderStore().Seed(order);
var svc = new OrderService(store, NullLogger<OrderService>.Instance); // 日志替身
// Act
await svc.PayAsync(order.OrderNo);
// Assert
Assert.Equal(OrderStatus.Paid, order.Status);
Assert.Contains(order.OrderNo, store.Saved.Keys); // 验证交互:确实保存了
}
NullLogger<T>.Instance(Microsoft.Extensions.Logging.Abstractions 包)是日志的标准静默替身。
dotnet add package NSubstitute
[Fact]
public async Task Pay_NonExistentOrder_Throws()
{
var store = Substitute.For<IOrderStore>();
store.FindAsync("SO-404", Arg.Any<CancellationToken>())
.Returns((Order?)null); // 指定桩行为
var svc = new OrderService(store, NullLogger<OrderService>.Instance);
await Assert.ThrowsAsync<NotFoundException>(() => svc.PayAsync("SO-404"));
await store.DidNotReceive().SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>()); // 验证不该发生的交互
}
手写 Fake 与 Mock 的取舍:行为简单的依赖手写 Fake 复用性最好;偶尔用到的复杂接口用 NSubstitute 现场桩。共同的铁律是不要 Mock 被测对象本身——Mock 一切等于什么都没测。
public class FakeTimeProvider(DateTime start) : TimeProvider // Microsoft.Extensions.TimeProvider.Testing 包
{
public override DateTime GetUtcNow() => start.ToUniversalTime();
public override DateTimeOffset GetNow() => new(start);
}
[Fact]
public void NextOrderNo_UsesInjectedClock()
{
var clock = new FakeTimeProvider(new DateTime(2026, 9, 29, 10, 0, 0));
var gen = new OrderNoGenerator(clock);
Assert.StartsWith("SO-20260929-", gen.Next());
}
时间不确定是"测试随机失败"的头号来源——业务代码经 TimeProvider 取时间(第 12 章已注入),测试就完全确定。
好测试的判断标准不是行数而是断言的规则。以轻订单为例,梳理"必须防回归"的规则:
public class OrderRulesTests
{
// 状态机规则
[Fact] public void Created_Can_Pay() { ... } // Created → Paid 允许
[Fact] public void Completed_Cannot_Pay_Throws() { ... } // 终态不可再流转
[Fact] public void Created_Cannot_Ship_Throws() { ... } // 未支付不可发货
// 金额规则
[Theory]
[InlineData(200, 0.9, 180)] // VIP 折扣
[InlineData(50, 1.0, 50)]
public void Payable_WithDiscount(decimal sub, decimal rate, decimal expect) { ... }
[Fact] public void Shipping_Free_OverThreshold() { ... } // 满 99 免邮
[Fact] public void Shipping_Charged_UnderThreshold() { ... }
// 边界与非法输入
[Fact] public void Create_ZeroQty_Throws() { ... }
[Fact] public void Create_InsufficientStock_Throws() { ... }
[Theory]
[InlineData(-1)] [InlineData(0)]
public void Create_InvalidQty_Throws(int qty) { ... }
}
优先测边界:0、1、最大值、刚好越界、空集合、null。bug 最爱藏在边界。
dotnet test --collect:"XPlat Code Coverage" # coverlet 收集(xunit 模板自带)
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coveragereport
覆盖率是仪表不是目标:80% 但全是 getter/setter 的测试毫无价值;核心规则 100%、边界全覆盖比整体数字重要。CI 门禁可设"新增代码覆盖率不低于 X%"(第 23 章),避免纯数字内卷。
单元测试隔离一切外部,集成测试反过来验证组合正确——真实的路由、DI、序列化、验证管道:
// Microsoft.AspNetCore.Mvc.Testing 包
public class OrdersApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task CreateOrder_Returns201()
{
var client = factory.CreateClient(); // 内存中起完整应用(不占端口)
var resp = await client.PostAsJsonAsync("/api/orders",
new { productId = 1, quantity = 2 });
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
}
}
WebApplicationFactory 启动真实 Host(可替换数据库为内存版/测试容器)。它属于"少而真"的一层:覆盖关键用户路径,不做穷举。
完整的项目测试布局:
LiteOrder.Tests/
├── Domain/ # 纯领域规则:状态机、金额计算、库存扣减
│ ├── OrderRulesTests.cs
│ └── DiscountPolicyTests.cs
├── Services/ # 服务编排:依赖替身注入
│ ├── OrderServicePayTests.cs
│ └── OrderServiceCreateTests.cs
└── Api/ # 集成测试:HTTP 层行为
└── OrdersApiTests.cs
一次真实的修 bug 闭环(展示测试如何保护你):
Apply_VipAndFullReduction_DiscountsInCorrectOrder;OrderService.CalculatePayable 的计算顺序;Assert.NotNull(result) 不验证内容;只验证"没炸"不验证"算对了"。断言要与规则精确对齐。TimeProvider,随机注入种子,网络用替身。dotnet watch test 边写边测;CI 全量跑(第 23 章)。[Fact] 单例、[Theory]+[InlineData] 参数化、Assert.Throws 验异常;AAA 结构、一测一行。TimeProvider 锁定时间——这些全都依赖第 12 章的"依赖接口 + 构造注入"。WebApplicationFactory)验证 HTTP 管道组合,少量关键路径。Temperature 结构体写测试:运算正确性、华氏转换、非法温度(低于绝对零度)抛异常。FullReduction 补齐 Theory 边界(199/200/201)与"折扣不超过小计"(Math.Clamp 分支)测试。LiteOrder.Tests 项目:领域规则(状态机全部合法/非法流转、金额与运费计算全部边界)、服务层(Fake 存储注入,覆盖库存不足、订单不存在、重复支付)、API 集成测试(创建/支付两个端点);目标核心规则覆盖完整;Git 提交。上一章:单元测试 ← | README | 下一章:分层架构与领域建模 →
学完本章,你将能够:
dotnet watch、热重载与 dotnet-counters 提升开发与排查效率;.editorconfig,把低级错误挡在编译期;打印日志猜问题("print 调试")效率极低——每改一行要重跑一遍。调试器让你停在案发现场,把所有变量看个清楚。
| 操作 | 快捷键(VS / VS Code) | 说明 |
|---|---|---|
| 设断点 | F9 / 点击行号左侧 | 程序运行到该行暂停 |
| 启动调试 | F5 / F5 | 带调试器运行 |
| 单步跳过(Step Over) | F10 / F10 | 执行当前行,不进入函数内部 |
| 单步进入(Step Into) | F11 / F11 | 进入当前调用的函数内部 |
| 单步跳出(Step Out) | Shift+F11 / Shift+F11 | 执行完当前函数并返回 |
| 继续运行 | F5 / F5 | 跑到下一个断点 |
| 查看悬停值 | 鼠标悬停变量 | 直接看当前值 |
| 监视(Watch) | Watch 窗口 | 常驻观察表达式 |
| 调用栈(Call Stack) | 调试侧栏 | 当前执行点是怎么走到这的 |
调试会话里你能看到:每个变量的当前值、调用栈(谁调的谁)、以及"在这行暂停时整个程序的状态"——信息量是日志的一万倍。
qty > 100、order is null——只有满足条件才停。循环 1000 次的 bug,条件断点一击即中,不用按 999 次 F5;症状:轻订单 CalculatePayable 对 VIP 订单算出的金额偶尔不对。
subtotal=300、isVip=true,一切正常;ApplyDiscount,发现内部用的 VipDiscountRate 是 0.9——也没问题;isVip 来自另一个查询——根因找到了:两条路径取会员状态的方式不一致;注意调试器的心理学价值:它强制你形成"假设 → 验证 → 缩小范围"的科学流程,而不是对着代码干瞪眼。
编译器其实早已提示(NRT,第 6 章)。定位法:
. 解引用的对象哪个为 null;? 表达可能为空、在边界判空或 fail fast),而不是在崩的地方加 ?. 掩盖。lock 检查(共享可变状态是否都有保护);并发集合替代普通集合;压测工具放大并发让问题稳定复现;先测量、后优化(第 20 章系统展开)。快速手段:
dotnet-counters monitor --process-id <pid> # CPU/GC/线程池实时指标
dotnet-trace collect --process-id <pid> # 采集性能事件,VS/PerfView 分析
dotnet-dump collect --process-id <pid> # 内存转储,分析内存泄漏
慢查询先看"是否 N+1 / 是否拉全表"(第 18/20 章),算法复杂度问题用 BenchmarkDotNet 做微基准:
[MemoryDiagnoser]
public class StringBench
{
[Benchmark(Baseline = true)]
public string Concat() { var s = ""; for (int i = 0; i < 1000; i++) s += i; return s; }
[Benchmark]
public string Builder() { var sb = new StringBuilder(); for (int i = 0; i < 1000; i++) sb.Append(i); return sb.ToString(); }
}
// dotnet run -c Release -- --filter *StringBench* —— 用数据说话,不猜
dotnet watch run # 代码改动自动重启
dotnet watch test # 测试驱动:保存即跑测试
热重载(Hot Reload):dotnet watch 默认在改动方法体时直接热应用(不重启进程),配合 Web 项目开发近乎实时反馈。改动涉及结构(新类型、签名变化)时自动降级为重启。
诊断运行中进程(生产排查预演,第 23 章再用一次):
dotnet tool install -g dotnet-counters dotnet-trace dotnet-dump
编译器不只是报语法错——Roslyn 分析器持续做"代码体检"。
放在仓库根目录,IDE 与 dotnet build 都遵守:
root = true
[*.cs]
indent_style = space
indent_size = 4
charset = utf-8-bom
# 命名与风格建议
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_prefer_braces = true:warning
# 质量红线
dotnet_diagnostic.CA2007.severity = none # 按团队取舍裁剪规则
dotnet_analyzer_diagnostic.category-Security.severity = error
<!-- csproj -->
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>NU1701</WarningsNotAsErrors> <!-- 少数豁免 -->
</PropertyGroup>
新代码警告清零,旧代码逐步还债——警告即未修的 bug,被 TreatWarningsAsErrors 关进笼子后,编译通过=低级错误清零。CI 中同样生效(第 23 章)。
评审查什么(可用作团队清单):
质量基线落地:.editorconfig(风格)+ TreatWarningsAsErrors(编译期)+ 测试门禁(行为)+ 评审清单(设计与人味),四层防线把质量从"个人自觉"变成"流程默认"。
体检清单(照做即可):
products.Find 返回 null 继续使用,观察调试器定位(对比第 6 章 NRT 的编译期警告——双保险);qty == 37 命中;TreatWarningsAsErrors,逐个消灭现有警告(大概率是一批 null 隐患);GroupBy+Sum 与手写循环的性能,体会"先测量";git tag quality-checkpoint && git push --tags。dotnet-counters/BenchmarkDotNet 拿数据,再决定是否优化(多数"性能问题"其实是算法或 N+1)。dotnet watch 与热重载缩短反馈环;dotnet-counters/trace/dump 是进程级诊断三件套。上一章:调试、诊断与代码质量 ← | README | 下一章:EF Core 数据访问 →
学完本章,你将能够:
轻订单到第 16 章为止是单项目:领域类、内存存储、HTTP 端点、配置全在一个文件夹里。它现在还没崩,但已经能感觉到:
分层(Layering)的本质是按"变化的理由"切分代码,并规定依赖只能朝一个方向流动:
┌────────────────────┐
│ Api 层 │ HTTP 端点、认证、序列化 —— 变化理由:对外协议
└────────┬───────────┘
│ 依赖 ▼
┌────────────────────┐
│ Application 层 │ 用例编排、事务边界 —— 变化理由:业务流程
└────────┬───────────┘
│ 依赖 ▼(但仓储接口定义在这层!)
┌────────────────────┐
│ Domain 层 │ 实体、值对象、领域规则 —— 变化理由:业务规则本身
└────────▲───────────┘
│ 实现接口(依赖倒置!Infrastructure 认识 Domain,反之不认识)
┌────────┴───────────┐
│ Infrastructure 层 │ EF Core、外部 API、消息队列 —— 变化理由:技术选型
└────────────────────┘
依赖规则(依赖倒置的应用):
这就是"整洁架构/洋葱架构"的核心思想:技术细节在外圈,业务核心在内圈,依赖永远由外指向内。
| 层 | 项目 | 放什么 | 不放什么 |
|---|---|---|---|
| Domain | LiteOrder.Domain |
实体(Order/Product)、值对象(Money/Address)、枚举(OrderStatus)、领域异常、领域服务接口 | EF 注解、DTO、HTTP 概念 |
| Application | LiteOrder.Application |
用例服务(OrderService)、仓储接口(IOrderStore)、DTO(Request/Response)、校验、事务边界 | SQL、HTTP、数据库驱动 |
| Infrastructure | LiteOrder.Infrastructure |
EF Core DbContext、仓储实现、汇率 HTTP 客户端、邮件/短信实现 | 业务规则 |
| Api | LiteOrder.Api |
Minimal API 端点/Controller、认证授权配置、DI 装配、中间件 | 业务逻辑 |
判断某个类放哪层的试金石:"它因为什么而变?"——因为业务规则变 → Domain;因为流程/交互变 → Application;因为换数据库/换短信商变 → Infrastructure;因为协议/前端变 → Api。
dotnet new classlib -n LiteOrder.Domain
dotnet new classlib -n LiteOrder.Application
dotnet new classlib -n LiteOrder.Infrastructure
dotnet new webapi -n LiteOrder.Api
dotnet sln add LiteOrder.Domain LiteOrder.Application LiteOrder.Infrastructure LiteOrder.Api
# 依赖关系
dotnet add LiteOrder.Application reference LiteOrder.Domain
dotnet add LiteOrder.Infrastructure reference LiteOrder.Application
dotnet add LiteOrder.Api reference LiteOrder.Application LiteOrder.Infrastructure
# 注意:Domain 谁也不引用;Api 不直接引用 Domain(必须的话也只引用其类型,不直接 new Infrastructure)
// LiteOrder.Domain/Order.cs —— 无任何框架引用
public class Order
{
private readonly List<OrderItem> _items = [];
public string OrderNo { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Created;
public IReadOnlyList<OrderItem> Items => _items;
public decimal Subtotal => _items.Sum(i => i.Subtotal);
internal Order(string orderNo) => OrderNo = orderNo; // 工厂/持久化构造(18 章 EF 需要时再放宽)
public static Order Create(IEnumerable<(Product product, int qty)> lines, IOrderNoGenerator noGen)
{
var order = new Order(noGen.Next());
foreach (var (product, qty) in lines)
{
order.AddItem(product, qty);
}
return order;
}
public void AddItem(Product product, int qty) { /* 库存校验 + 扣减 + 加入明细 */ }
public void Pay() => TransitionTo(OrderStatus.Paid);
public void Ship() => TransitionTo(OrderStatus.Shipped);
private void TransitionTo(OrderStatus next)
{
if (!CanTransitionTo(next))
{
throw new DomainException($"非法状态流转:{Status} → {next}"); // 领域异常也在 Domain
}
Status = next;
}
// CanTransitionTo 同第 6 章
}
领域层特征:行为与规则在实体上(充血模型),而不是把实体当数据袋、规则全写在服务里(贫血模型,17.5 讨论)。
// LiteOrder.Application/Abstractions/IOrderStore.cs —— 仓储接口定义在"使用它的一侧"
public interface IOrderStore
{
Task<Order?> FindAsync(string orderNo, CancellationToken ct = default);
Task SaveAsync(Order order, CancellationToken ct = default);
}
public interface IOrderNoGenerator { string Next(); }
// LiteOrder.Application/DTO —— 对外契约,与领域对象解耦
public record CreateOrderRequest(int ProductId, int Quantity);
public record OrderResponse(string OrderNo, string Status, decimal Subtotal, DateTime CreatedAt);
// LiteOrder.Application/Services/OrderService.cs —— 用例编排
public class OrderService(IProductStore products, IOrderStore orders, IOrderNoGenerator noGen, TimeProvider clock)
{
public async Task<OrderResponse> CreateAsync(CreateOrderRequest req, CancellationToken ct = default)
{
var product = await products.FindAsync(req.ProductId, ct)
?? throw new NotFoundException($"商品 {req.ProductId} 不存在");
var order = Order.Create([(product, req.Quantity)], noGen);
await orders.SaveAsync(order, ct);
return OrderResponse.From(order);
}
}
注意仓储接口 IOrderStore 定义在 Application(使用方),实现才在 Infrastructure——这是依赖倒置的具体落点,也是很多初学者放错位置的地方。
// LiteOrder.Infrastructure/Persistence/InMemoryOrderStore.cs(18 章换成 EfOrderStore)
public class InMemoryOrderStore : IOrderStore
{
private readonly ConcurrentDictionary<string, Order> _map = new();
public Task<Order?> FindAsync(string orderNo, CancellationToken ct = default)
=> Task.FromResult(_map.GetValueOrDefault(orderNo));
public Task SaveAsync(Order order, CancellationToken ct = default)
{ _map[order.OrderNo] = order; return Task.CompletedTask; }
}
// LiteOrder.Api/Program.cs —— 组合根:唯一知道"所有具体实现"的地方
builder.Services.AddApplication(); // Application 模块装配(服务、校验)
builder.Services.AddInfrastructure(builder.Configuration); // 技术细节装配
// Domain 无需注册——它只是被创建的数据+规则
**组合根(Composition Root)**原则:整个应用只有入口项目知道"接口→实现"的完整映射,其余各层只见接口。换数据库、换短信商,改动被压缩到 Infrastructure 与组合根一行注册。
| 对象 | 目的 | 生命周期 | 特征 |
|---|---|---|---|
| 领域模型(Order) | 承载规则 | 长期 | 有行为、有状态校验 |
| DTO(OrderResponse) | 跨边界传输 | 瞬时 | record、纯数据、可序列化 |
| 持久化实体(18 章 EF 映射类) | 存储映射 | 长期 | 贴合表结构 |
三条纪律:
// ❌ 贫血:实体只是属性袋,规则散落在服务
public class OrderDto { public string Status { get; set; } = ""; }
public class OrderService
{
public void Pay(OrderDto o)
{
if (o.Status == "Created") o.Status = "Paid"; // 状态机规则到处复制
// 另一个服务里忘了校验 → 非法状态流通了
}
}
// ✅ 充血:规则长在实体上,任何入口都绕不过
public class Order
{
public OrderStatus Status { get; private set; } = OrderStatus.Created;
public void Pay() => TransitionTo(OrderStatus.Paid); // 非法流转?编译不过或抛领域异常
}
判定口诀:改动状态的规则放实体(保证不变量 invariant 永不被破坏);跨实体/跨系统的编排放应用服务;纯计算放领域服务或值对象。贫血模型是"把面向对象写成存储过程"的典型反模式,也是业务 bug 的高发地。
分层解决"系统内部"的结构问题。当团队规模与部署节奏进一步分裂(多个团队抢同一个发布窗口),才谈服务拆分:
重构完成后的验证清单:
LiteOrder.Domain 的 csproj 里没有任何 PackageReference(纯 C#);LiteOrder.Domain.Tests,毫秒级跑完、不启动 Web 主机;InMemoryOrderStore 换成一个故意抛 NotImplementedException 的 BrokenOrderStore——只改组合根一行,其余代码零改动;这说明依赖倒置真的成立;new Order(:应只在组合根/Application 出现——端点只做翻译;Microsoft.EntityFrameworkCore 在 Domain/Application 的 using。这套验收本身就是架构的"单元测试"——架构不是画的图,是能被检查的依赖约束。(团队规模大时可加架构测试工具如 NetArchTest 用代码断言依赖规则。)
DateTime.Now 直呼、配置直读、静态单例:核心被环境绑架,测试与环境隔离失效——依赖抽象(TimeProvider、接口)。上一章:分层架构与领域建模 ← | README | 下一章:企业开发常用设计模式 →
学完本章,你将能够:
SaveChanges 的原子性;Include)与 N+1 查询,熟练 AsNoTracking 与投影;IQueryable 伏笔);对象-关系映射(ORM)解决"对象世界 ↔ 表世界"的翻译问题。EF Core 是 .NET 官方 ORM:你写 C# 对象与 LINQ,它生成 SQL、执行查询、把行转回对象。
适用判断:CRUD 与报表类业务(占企业开发 90%)用它高效可靠;极端性能场景(亿级行扫描、复杂窗口函数)可下沉 Dapper/手写 SQL——两者可共存于同一系统。
dotnet add LiteOrder.Infrastructure package Microsoft.EntityFrameworkCore.Sqlite # 学习用 SQLite,零部署
dotnet add LiteOrder.Infrastructure package Microsoft.EntityFrameworkCore.Design # 迁移工具
dotnet tool install -g dotnet-ef
// LiteOrder.Infrastructure/Persistence/OrderDbContext.cs
public class OrderDbContext(DbContextOptions<OrderDbContext> options) : DbContext(options)
{
public DbSet<OrderRow> Orders => Set<OrderRow>();
public DbSet<ProductRow> Products => Set<ProductRow>();
protected override void OnModelCreating(ModelBuilder b) // Fluent API 配置(推荐,优于注解)
{
b.Entity<ProductRow>(e =>
{
e.ToTable("Products");
e.HasKey(p => p.Id);
e.Property(p => p.Id).ValueGeneratedNever(); // 业务给 Id(演示用;生产常用自增/雪花)
e.Property(p => p.Name).HasMaxLength(50).IsRequired();
e.Property(p => p.Price).HasPrecision(18, 2); // decimal 精度必须显式声明!
e.HasIndex(p => p.Category); // 常用过滤列建索引
});
b.Entity<OrderRow>(e =>
{
e.ToTable("Orders");
e.HasKey(o => o.OrderNo);
e.Property(o => o.OrderNo).HasMaxLength(24);
e.Property(o => o.Status).HasConversion<string>(); // 枚举存字符串:可读、加值不乱序
e.HasMany(o => o.Items).WithOne().HasForeignKey("OrderNo"); // 一对多
e.Property<byte[]>("RowVersion").IsRowVersion(); // 乐观并发令牌
});
}
}
// 持久化实体(第 17 章"三类对象"之一:贴合表结构)
public class OrderRow
{
public string OrderNo { get; set; } = "";
public OrderStatus Status { get; set; }
public decimal Subtotal { get; set; }
public DateTime CreatedAtUtc { get; set; }
public List<OrderItemRow> Items { get; set; } = [];
}
要点:
decimal 一定要声明精度(HasPrecision(18,2)),否则 EF 默认映射可能丢精度——金额事故高发点;HasConversion<string>):新增枚举值无需迁移、日志可读;性能敏感的巨表再考虑 int;OnModelCreating(或拆 IEntityTypeConfiguration<T>),不要把 EF 特性撒进 Domain 类——第 17 章的依赖纪律。// LiteOrder.Infrastructure/DependencyInjection.cs
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<OrderDbContext>(opt =>
opt.UseSqlite(config.GetConnectionString("OrderDb"))); // 连接串来自配置(含机密 → user-secrets/环境变量)
services.AddScoped<IOrderStore, EfOrderStore>(); // 仓储实现原位替换
services.AddScoped<IProductStore, EfProductStore>();
return services;
}
// appsettings.json(不含机密)
{ "ConnectionStrings": { "OrderDb": "Data Source=liteorder.db" } }
// 机密型连接串(账号密码)→ dotnet user-secrets set "ConnectionStrings:OrderDb" "..."(13 章铁律)
DbContext 注册为 Scoped(每请求一个实例)——EF Core 的 DbContext 非线程安全,请求级隔离是官方设计(第 12 章 Scoped 生命周期的教科书案例)。
Code First 的核心循环:改模型 → 生成迁移 → 应用到库。
dotnet ef migrations add AddProductsAndOrders # 生成迁移快照与代码
dotnet ef database update # 应用迁移,建表
dotnet ef migrations script # 导出 SQL 脚本(生产变更评审用)
dotnet ef migrations list / remove # 查看/撤销
迁移文件随代码入库(可评审、可重放)。生产变更纪律:database update 只在开发环境直用;生产走"生成 SQL 脚本 → DBA/流水线评审执行",杜绝裸奔变更。
第 17 章的依赖倒置在此兑现:EF 实体与领域对象分离,映射收在 Infrastructure:
public class EfOrderStore(OrderDbContext db) : IOrderStore
{
public async Task<Order?> FindAsync(string orderNo, CancellationToken ct = default)
{
var row = await db.Orders
.Include(o => o.Items) // 关联数据显式加载
.FirstOrDefaultAsync(o => o.OrderNo == orderNo, ct);
return row?.ToDomain(); // 行 → 领域对象
}
public async Task SaveAsync(Order order, CancellationToken ct = default)
{
var row = await db.Orders.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.OrderNo == order.OrderNo, ct);
if (row is null)
{
db.Orders.Add(OrderRow.From(order)); // 新增
}
else
{
db.Entry(row).CurrentValues.SetValues(OrderRow.From(order)); // 标量字段覆盖
}
await db.SaveChangesAsync(ct); // 统一提交(原子)
}
}
第 9 章的伏笔在此引爆——IQueryable 被 EF 翻译为 SQL:
// ✅ 翻译进 SQL:只取第 3 页 20 行,数据库过滤排序
var page = await db.Orders
.Where(o => o.Status == OrderStatus.Paid) // WHERE
.OrderByDescending(o => o.CreatedAtUtc) // ORDER BY
.Skip((pageNo - 1) * 20).Take(20) // OFFSET/FETCH
.Select(o => new OrderSummary(o.OrderNo, o.Subtotal)) // SELECT 三个列(不是整行!)
.AsNoTracking() // 只读查询:跳过变更追踪
.ToListAsync(ct);
// ❌ 客户端求值:EF 拉回整表在内存里过滤(.NET 3+ 对部分场景直接抛异常提醒)
var bad = db.Orders.ToList().Where(o => o.Status == OrderStatus.Paid).Take(20);
// ❌ 忘了 AsNoTracking:只读报表场景白白付出变更追踪内存与时间
// N+1 事故:先查 20 个订单(1 条 SQL),再对每个订单查明细(20 条 SQL)
var orders = await db.Orders.Take(20).ToListAsync();
foreach (var o in orders)
{
Console.WriteLine(o.Items.Count); // 触发 20 条额外查询!
}
// ✅ Include 预加载:1 条 JOIN 语句拿全
var orders2 = await db.Orders.Include(o => o.Items).Take(20).ToListAsync();
// ✅ 投影更优:需要几个列就取几个列,连实体都不回
var rows = await db.Orders
.Select(o => new { o.OrderNo, ItemCount = o.Items.Count })
.ToListAsync();
N+1 是 EF 应用性能问题的头号杀手(第 20 章还有它的完整档案)。排查习惯:开发环境把 EF 日志调 Information 看生成的 SQL(Microsoft.EntityFrameworkCore.Database.Command),每条业务操作对应几条 SQL 一目了然。
SaveChanges 本身是原子的:一次提交内所有变更要么全成要么全无——单聚合保存不需要手开事务;SaveChanges/多聚合的操作需要显式事务:await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
// 多个 SaveChanges / 多表操作
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
IsRowVersion() 令牌让 EF 在 UPDATE 的 WHERE 里带上版本号,不匹配即抛 DbUpdateConcurrencyException:catch (DbUpdateConcurrencyException)
{
throw new ConflictException("数据已被他人修改,请刷新后重试"); // 409 Conflict
}
// 迁移后初始化演示数据
static async Task SeedAsync(OrderDbContext db)
{
if (await db.Products.AnyAsync()) return;
db.Products.AddRange(
new ProductRow { Id = 1, Name = "拿铁咖啡", Category = "咖啡", Price = 28.5m, Stock = 100 },
new ProductRow { Id = 2, Name = "抹茶蛋糕", Category = "烘焙", Price = 35m, Stock = 20 });
await db.SaveChangesAsync();
}
第 15 章的集成测试切换到真实数据库方案:SQLite 内存库(DataSource=:memory:,连接不断则数据常驻)或 Testcontainers 起真 PostgreSQL——测试要跑在和生产同类的数据库上才验得真。
把第 12 章留下的"内存存储重启即丢"问题正式终结:
EfOrderStore/EfProductStore,组合根一行切换(AddInfrastructure 内部替换注册);ConflictException(RowVersion 检测)→ 端点返回 409;GroupBy 在 SQL 端执行(GroupBy 聚合翻译),数据量百万级也不怕;至此轻订单拥有了完整的持久化能力,而这一切的接入成本是:两个新类 + 一行注册 + 若干迁移——第 17 章分层的分红时刻。
Include 或投影解决,开发期盯 SQL 日志。ToList 再过滤/分页:全表进内存;过滤排序分页必须在 IQueryable 上完成(18.4)。AsNoTracking:白付追踪开销;写场景才需要追踪。database update 绕过评审;生产走脚本评审执行。IEntityTypeConfiguration<T> 分文件),Domain 保持纯净。AsNoTracking + 投影 + 服务端过滤分页;写操作才加载实体。HasPrecision(18,2);枚举字符串化;常用过滤列建索引。DbContext Scoped 生命周期;SaveChanges 原子提交;跨聚合显式事务;RowVersion 乐观并发。IQueryable 一直链到 ToListAsync)、Include 防 N+1、AsNoTracking + 投影加速只读。上一章:EF Core 数据访问 ← | README | 下一章:性能优化 →
学完本章,你将能够:
前置提醒:你已经无师自通了一半——第 5 章的多态是策略与模板方法的原型,第 8 章事件是观察者,第 11/12 章的 DI 是工厂与单例的容器化。本章是"把它们点破并系统化"。
模式不是简历关键词堆砌,而是三个真实问题的答案:变化隔离(策略/工厂/观察者)、能力增强(装饰器/代理)、异构对接(适配器/防腐层)。判断该不该用模式只有一条标准:当前问题是否真的存在——模式是对问题的回应,不是对代码的装饰。
问题:轻订单要接微信、支付宝、银行卡三种支付,且未来会加第四种。if-else 分支会随渠道线性增长,且每加一个渠道都要改同一个方法。
// 策略接口 + 多实现(第 5 章已写)
public interface IPayment
{
string Channel { get; }
Task<PayResult> PayAsync(PayRequest request, CancellationToken ct);
}
// 路由器:容器注入全部策略(第 12 章"多实现"),按渠道名挑选
public class PaymentRouter(IEnumerable<IPayment> payments)
{
private readonly Dictionary<string, IPayment> _map =
payments.ToDictionary(p => p.Channel, StringComparer.OrdinalIgnoreCase);
public IPayment Resolve(string channel) =>
_map.TryGetValue(channel, out var impl)
? impl
: throw new NotFoundException($"支付渠道 {channel} 未接入");
}
// 新增渠道 = 新增一个类 + 一行注册,路由器与调用方零改动 —— 开闭原则落地
DI 容器让策略模式薄到几乎隐形:接口即策略,IEnumerable<IPayment> 即策略集。同一套手法通用于:折扣策略(第 5 章)、导出格式、通知渠道、风控规则。
问题:对象的创建本身有逻辑(选实现、组装依赖、读取配置),散落在调用方会复制蔓延。
// 简单工厂/静态工厂:一个入口集中创建逻辑
public static class ReportFactory
{
public static IReport Create(string kind) => kind switch
{
"daily" => new DailyReport(),
"monthly" => new MonthlyReport(),
_ => throw new ArgumentException($"未知报表类型 {kind}")
};
}
// 容器即工厂:注册处决定一切,调用方只拿接口 —— 企业开发的主流形态
builder.Services.AddScoped<IReport>(sp => sp.GetRequiredService<Config>()
.Kind switch { "daily" => new DailyReport(), ... });
你早已是工厂的重度用户——IHttpClientFactory(工厂管理 HttpClient 生命周期与连接池)、IServiceScopeFactory(第 12 章的 Scoped 工厂)、ILoggerFactory 全是官方工厂。手写 new 有复杂逻辑时,就到了收口成工厂的时机;纯 new Product(1, "x", ...) 不需要工厂。
对象需要多步、可选、顺序敏感的组装时:
// 你天天在用:Fluent 建造者
var host = Host.CreateApplicationBuilder(args)
.ConfigureServices(...) // 链式、分步、返回自身
.Build();
// 自己写一个
public class OrderQueryBuilder
{
private readonly List<Func<IQueryable<Order>, IQueryable<Order>>> _steps = [];
public OrderQueryBuilder OnlyPaid() { _steps.Add(q => q.Where(o => o.IsPaid)); return this; }
public OrderQueryBuilder LatestFirst() { _steps.Add(q => q.OrderByDescending(o => o.CreatedAt)); return this; }
public IQueryable<Order> Build(IQueryable<Order> source) => _steps.Aggregate(source, (q, s) => s(q));
}
建造者与策略的组合(先攒步骤、最后统一应用)在查询组装、规则引擎中极常见。
问题:给 IOrderService 加"记录耗时"或"重试"能力,又不想污染业务代码、不想改调用方。
public interface IOrderService
{
Task<OrderResponse> CreateAsync(CreateOrderRequest req, CancellationToken ct);
}
// 原实现不动;装饰器实现同一接口,包一层
public class TimedOrderService(IOrderService inner, ILogger<TimedOrderService> logger) : IOrderService
{
public async Task<OrderResponse> CreateAsync(CreateOrderRequest req, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
return await inner.CreateAsync(req, ct);
}
finally
{
logger.LogInformation("CreateOrder 耗时={ElapsedMs}ms", sw.ElapsedMilliseconds);
}
}
}
// 装配:洋葱式套娃 —— 容器手工装饰
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<IOrderService>(sp =>
new TimedOrderService(sp.GetRequiredService<OrderService>(), sp.GetRequiredService<ILogger<TimedOrderService>>()));
装饰器 = 横切关注点的解药:计时、重试、缓存、审计都能做成独立装饰器按需叠加。ASP.NET Core 的中间件管道就是请求级装饰器的序列; Scrutor(Decorate<TService, TDecorator>())与 MediatR Pipeline 让装饰器装配自动化。
问题:第三方 SDK 的模型、命名、异常风格与你格格不入,直接侵入业务层——外部一变,业务层跟着碎。
// 外部 SDK 的世界观(无法控制)
public class ThirdPaySdk
{
public Task<string> DoPay(decimal amt, string callback, string sign) { ... } // 命名怪、无取消、异常是字符串码
}
// 防腐层(Anti-Corruption Layer):适配器把外部世界翻译成领域语言
public class WechatPayAdapter(ThirdPaySdk sdk, IOptions<PaymentOptions> opt) : IPayment
{
public string Channel => "Wechat";
public async Task<PayResult> PayAsync(PayRequest request, CancellationToken ct)
{
try
{
var txId = await sdk.DoPay(request.Amount, opt.Value.CallbackUrl, Sign(request));
return new PayResult(true, txId, null);
}
catch (SdkException ex) // 外部异常 → 领域异常,风格差异到此为止
{
return new PayResult(false, null, ex.Code switch { "E1" => "余额不足", _ => "渠道异常" });
}
}
}
规则:第三方依赖只允许出现在 Infrastructure;业务只见自己的接口。将来换支付服务商 = 换适配器,业务层毫发无损。对接任何"不在你掌控中的系统"(支付、短信、物流、旧系统)都套这层。
第 8 章的 C# 事件是进程内观察者。企业级延伸是领域事件(Domain Event):
// 领域事件 = 已发生事实的 record(过去式命名)
public record OrderPaid(string OrderNo, decimal Amount, DateTime PaidAtUtc);
// 聚合在状态变化时收集事件
public class Order
{
private readonly List<object> _events = [];
public IReadOnlyList<object> DomainEvents => _events;
public void Pay()
{
TransitionTo(OrderStatus.Paid);
_events.Add(new OrderPaid(OrderNo, Subtotal, DateTime.UtcNow)); // 只记录"发生过什么"
}
}
// 应用服务保存后统一派发(进程内 → 第 23 章跨服务消息队列)
public async Task PayAsync(string orderNo, CancellationToken ct)
{
var order = ...;
order.Pay();
await store.SaveAsync(order, ct);
foreach (var e in order.DomainEvents)
{
await dispatcher.PublishAsync(e, ct); // 订阅者:积分、通知、对账……各自独立扩展
}
}
观察者谱系:C# event(同步、进程内)→ 领域事件 + 内存派发 → 消息队列(跨进程、可靠投递)。演进的是可靠性与边界,不变的是"发布者不关心订阅者"。
AddSingleton 即单例,且可测试(测试里注册一个新实例即可)。别再手写 Instance 属性;需要全局唯一状态时先问"它是不是应该进缓存/数据库"(第 20 章)。DiscountPolicy.Apply)。现代替代手法是"组合注入一个函数/策略"(更灵活);模板方法在框架层仍大量存在(BackgroundService.ExecuteAsync)。组合本章模式完成支付模块:
端点 POST /api/orders/{no}/pay?channel=wechat
→ PaymentRouter(策略路由)
→ WechatPayAdapter(防腐层 → 第三方 SDK)
→ AlipayAdapter
→ 成功后 Order.Pay() 收集 OrderPaid 领域事件
→ 应用服务派发:积分订阅者、通知订阅者、对账订阅者
验收:新增"银联渠道"只新增 UnionPayAdapter + 一行注册;新增"对账订阅者"只新增一个订阅类;把微信换成别家服务商只改适配器内部。三处扩展互不干扰——这就是模式组合的价值。
IEnumerable<T>)+ 防腐层适配器 + 领域事件派发,覆盖绝大多数企业扩展点。上一章:企业开发常用设计模式 ← | README | 下一章:安全防护 →
学完本章,你将能够:
dotnet-counters 与负载测试;**性能优化的第一定律:猜测的瓶颈几乎总是错的。**正确流程:
建立基线(当前多慢?) → 定位热点(哪里慢?) → 修复(最小改动)
→ 再测量(快了多少?回归了什么?) → 压测验证
工具箱:
| 工具 | 用途 | 场景 |
|---|---|---|
dotnet-counters monitor |
CPU/GC/线程池/请求速率实时面板 | 快速体检 |
dotnet-trace / PerfView |
CPU 采样火焰图 | 找热点函数 |
dotnet-dump + 内存分析 |
堆转储 | 内存泄漏、大对象 |
| BenchmarkDotNet | 微基准(方法级纳秒/分配对比) | 算法与数据结构选型 |
| 负载测试(k6/bombardier/crank) | 全链路 QPS/P95 延迟 | 容量评估与压测 |
EF SQL 日志 / dotnet ef 诊断 |
数据库行为 | 查询优化(18 章) |
// BenchmarkDotNet:数据结构选型示例
[MemoryDiagnoser] // 同时报告内存分配
public class LookupBench
{
private readonly List<Product> _list = Enumerable.Range(0, 10_000)
.Select(i => new Product(i, $"p{i}", 1m, 1)).ToList();
private readonly Dictionary<int, Product> _dict = _list.ToDictionary(p => p.Id);
[Benchmark(Baseline = true)]
public Product? ListScan() => _list.FirstOrDefault(p => p.Id == 9_999); // O(n)
[Benchmark]
public Product? DictLookup() => _dict.GetValueOrDefault(9_999); // O(1)
}
同时先立性能预算再写代码(SLO 思维):如"列表接口 P95 < 200ms @ 100 QPS""日报计算 < 2s"。没有预算的优化没有终点。
// 杀手一:N+1 —— 20 个订单 = 21 条 SQL
var orders = await db.Orders.Take(20).ToListAsync();
foreach (var o in orders) { Console.WriteLine(o.Items.Count); } // 每次触发查询!
// 处方 A:Include 一次 JOIN 拉全
var orders2 = await db.Orders.Include(o => o.Items).Take(20).ToListAsync();
// 处方 B:投影只要所需字段(常常更快——不回整行实体)
var rows = await db.Orders.Select(o => new { o.OrderNo, Count = o.Items.Count }).ToListAsync();
// 杀手二:缺索引 —— WHERE/ORDER BY 的列没索引 → 全表扫描
// 处方:高频过滤/排序列建索引(18 章 HasIndex),上线前用执行计划验证
b.Entity<OrderRow>().HasIndex(o => new { o.Status, o.CreatedAtUtc }); // 组合索引覆盖常用查询
// 杀手三:全表拉取 —— 先 ToList 再过滤/分页
var bad = db.Orders.ToList().Where(...).Take(20);
// 处方:IQueryable 一路链到 ToListAsync(18 章),Skip/Take 翻译成 SQL
// 报表只要两列就 Select 两列,别把整行对象搬回来
var report = await db.Orders
.Where(o => o.CreatedAtUtc >= from)
.GroupBy(o => o.Status)
.Select(g => new { Status = g.Key, Cnt = g.Count(), Sum = g.Sum(o => o.Subtotal) })
.ToListAsync(); // GROUP BY 在数据库执行
// 批量插入:逐条 Add+SaveChanges(1 万行 = 1 万次往返)改为批量
db.AddRange(rows); // 一次性添加
await db.SaveChangesAsync(ct); // 单事务批量提交
// 更大批量(>几万行)用 SqlBulkCopy / EF Core 7+ 的 ExecuteUpdate/ExecuteDelete
await db.Orders.Where(o => o.Expired).ExecuteDeleteAsync(ct); // 集合操作不过实体,最快
ADO.NET 默认启用连接池(连接字符串 Max Pool Size 控制),不要手动管理 SqlConnection 生命周期——await using 用完即还,池里复用。看到"连接超时"先查是不是连接泄漏(没释放)或池太小。
builder.Services.AddMemoryCache();
public class ProductQueryService(IProductStore store, IMemoryCache cache)
{
public async Task<Product?> GetAsync(int id, CancellationToken ct = default)
{
return await cache.GetOrCreateAsync($"product:{id}", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); // 必须设过期!
entry.Size = 1; // 配合 SizeLimit 限容
return await store.FindAsync(id, ct);
});
}
}
适用:单实例或"各实例各缓存一份也无所谓"的读密集数据(商品、配置、汇率)。
多实例部署或需要跨实例共享时,用 IDistributedCache(或 StackExchange.Redis 原生客户端):
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = config.GetConnectionString("Redis"));
public async Task<string?> GetRateAsync(string ccy, CancellationToken ct)
{
var cached = await cache.GetStringAsync($"fx:{ccy}", ct);
if (cached is not null) return cached;
var rate = await FetchRemoteAsync(ccy, ct);
await cache.SetStringAsync($"fx:{ccy}", rate, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
}, ct);
return rate;
}
| 坑 | 场景 | 处方 |
|---|---|---|
| 穿透 | 大量查询不存在的 key,全部打到库 | 空结果也缓存(短 TTL);入口校验 |
| 击穿 | 热点 key 过期瞬间,并发重建压垮库 | 互斥重建(SemaphoreSlim 单飞);热点不过期+逻辑过期 |
| 雪崩 | 大批 key 同时过期 | TTL 加随机抖动;多级缓存 |
一致性处方:写库后失效缓存(cache-aside 标准姿势),TTL 兜底;对一致性敏感的数据(库存)不缓存或用短 TTL + 版本校验——缓存是用一致性换性能,先确认业务能接受。
// 循环拼接 → StringBuilder(第 3 章);插值少量拼接无所谓
// 大文本处理用 Span<T> 零拷贝切片
ReadOnlySpan<char> line = "SO-20260929-0001,latte,28.5";
int comma = line.IndexOf(',');
ReadOnlySpan<char> orderNo = line[..comma]; // 切片不分配新字符串!
// 高频大缓冲区用数组池,减少 GC 压力
var pool = ArrayPool<byte>.Shared;
byte[] buf = pool.Rent(64 * 1024);
try { /* 使用 buf */ }
finally { pool.Return(buf); }
GC 分代回收(Gen0/1/2),性能优化方向是"少分配"而非"手动回收"(.NET 没有手动释放堆内存的概念):高频路径避免每请求大分配、善用池化与 Span、IDisposable(文件/连接/流)用 using 确定性释放非托管资源。dotnet-counters 里 Gen2 频繁、%Time in GC 高就是分配过多的信号。
WhenAll;批量外部调用用 SemaphoreSlim 限并发度,防止压垮下游反被限流拖垮:using var gate = new SemaphoreSlim(10); // 最多 10 个并发请求
var results = await Task.WhenAll(ids.Select(async id =>
{
await gate.WaitAsync(ct);
try { return await FetchAsync(id, ct); }
finally { gate.Release(); }
}));
AddResponseCompression);System.Text.Json 默认即高性能;序列化选项全局统一,避免每请求 new options。目标:商品列表接口 P95 从 800ms 降到 <100ms。
dotnet-counters + 负载测试 100 并发 5 分钟,P95=800ms,GC 正常,CPU 30%——瓶颈不在计算,大概率在数据库往返;Category+Status 组合索引,执行计划从全表扫描变索引查找(P95 → 140ms);IMemoryCache(5 分钟 TTL + 商品更新时主动失效),命中率 95%(P95 → 45ms);复盘:80% 的收益来自数据库层(N+1 与索引),缓存是最后一块拼图而不是第一反应——先让 SQL 正确,再让缓存锦上添花。
for 与 foreach 的纳秒差,无视 O(n²) 嵌套循环与 N+1;复杂度与 I/O 次数永远优先。AsNoTracking+投影+服务端分页;开发期开 SQL 日志;可疑查询看执行计划。using 释放。上一章:性能优化 ← | README | 下一章:异常处理与弹性设计 →
学完本章,你将能够:
安全是属性不是功能:不能"以后再补",架构每一步都要考虑(本章把前文散落的安全点收拢并补齐)。
铁律:密码绝不明文、绝不 MD5/SHA 直接哈希(可被彩虹表/暴力破解秒掉)。用慢哈希 + 盐的专用算法(PBKDF2/bcrypt/Argon2):
// 注册:生成随机盐 + 慢哈希
public static string HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2( // PBKDF2,.NET 内建
password, salt, iterations: 100_000, HashAlgorithmName.SHA256, outputLength: 32);
return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; // 盐与哈希一起存
}
// 登录:用同样的盐重算并恒时比较
public static bool Verify(string password, string stored)
{
var (saltB64, hashB64) = (stored[..stored.IndexOf('.')], stored[(stored.IndexOf('.') + 1)..]);
byte[] salt = Convert.FromBase64String(saltB64);
byte[] expected = Convert.FromBase64String(hashB64);
byte[] actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected); // 恒时比较防时序侧信道
}
数据库被拖走时,攻击者拿到的只是"每条都要几万次运算才能试一个密码"的哈希——这为用户争取到改密码的时间。
JWT(JSON Web Token)是无状态令牌:服务端签名,客户端每请求携带,服务端验签即认证——不用存会话,天然适配多实例:
dotnet add LiteOrder.Api package Microsoft.AspNetCore.Authentication.JwtBearer
// 登录端点:验密码 → 签发 token
app.MapPost("/api/auth/login", async (LoginRequest req, IUserStore users, IConfiguration cfg) =>
{
var user = await users.FindByNameAsync(req.UserName);
if (user is null || !PasswordHasher.Verify(req.Password, user.PasswordHash))
{
return Results.Unauthorized(); // 统一失败信息,不提示"用户不存在"(防枚举)
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Role, user.Role), // 角色进 claims,授权层消费
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(cfg["Jwt:SigningKey"]!)); // 签名密钥来自配置/密钥服务(第 13 章铁律)
var token = new JwtSecurityToken(
issuer: cfg["Jwt:Issuer"],
audience: cfg["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(2),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return Results.Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) });
});
// Program.cs:注册 JWT 验证
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!)),
ValidateLifetime = true,
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication(); // 顺序重要:先认证(你是谁)
app.UseAuthorization(); // 后授权(你能干啥)
Token 安全要点:短有效期(小时级)+ 刷新机制;签名密钥进密钥服务并支持轮换;HTTPS 强制(token 明文传输即裸奔);token 中不放敏感信息(JWT 只是编码非加密,base64 可解)。
// 角色授权(粗粒度)
app.MapPost("/api/products", [Authorize(Roles = "Admin")] (CreateProductRequest req) => ...);
// 策略授权(细粒度、可测试、可复用)—— 生产推荐
builder.Services.AddAuthorizationBuilder()
.AddPolicy("OrderOwner", p => p.Requirements.Add(new SameUserRequirement()))
.AddPolicy("CanRefund", p => p.RequireRole("Admin", "Finance"));
app.MapPost("/api/orders/{no}/refund",
[Authorize(Policy = "CanRefund")] (string no) => ...);
资源级授权必须服务端自查:前端隐藏按钮(UI 层控制)只是体验优化,接口层必须验证"这笔订单是不是他的"——用策略处理器实现 SameUserRequirement,或服务内显式校验。漏掉资源级校验 = 越权访问(IDOR),OWASP 常年前五。
// ❌ 字符串拼接 SQL:' OR '1'='1 即可拖库
var sql = $"SELECT * FROM Users WHERE Name = '{name}'";
// ✅ 参数化(EF Core 永远参数化;手写 SQL 用参数占位)
var users = await db.Users.Where(u => u.Name == name).ToListAsync();
var rows = await db.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {name}").ToListAsync();
EF Core 的 LINQ 全部翻译为参数化 SQL——用 ORM 本身就是最强防注入;只有手写拼接字符串才危险。
Content-Type、前端框架默认转义勿关闭;UseAntiforgery)与 SameSite 属性。凡是有"根据用户输入发起网络请求"的功能(抓取网页头像、webhook 回调、URL 预览),都必须防护 SSRF——攻击者提交 http://169.254.169.254/(云元数据)、内网地址,让你的服务器替他打内网:
public static async Task<string> FetchUserUrlAsync(string url, IHttpClientFactory f, CancellationToken ct)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new ValidationException("仅支持 http/https");
}
var hostEntry = await Dns.GetHostEntryAsync(uri.Host, ct); // 解析后校验 IP(防 DNS 重绑定需连接时再校)
if (hostEntry.AddressList.Any(ip =>
IPAddress.IsLoopback(ip) ||
ip.AddressFamily == AddressFamily.InterNetworkV6 && ip.IsIPv6LinkLocal ||
IsPrivateRange(ip))) // 拒绝内网/环回/链路本地
{
throw new ValidationException("不允许访问内网地址");
}
var client = f.CreateClient("outbound"); // 独立出网客户端:超时、重定向关、响应大小限制
client.AllowAutoRedirect = false; // 重定向可绕过校验,必须关或逐跳重验
return await client.GetStringAsync(uri, ct);
}
static bool IsPrivateRange(IPAddress ip)
=> ip.GetAddressBytes() is [10, ..] or [192, 168, ..] or [172, >= 16 and <= 31, ..];
通用出网防线清单:白名单域名优先、校验 scheme 与解析后 IP、禁自动重定向(或重定向后重验)、限制响应大小与超时、独立出网 HttpClient 隔离凭据。
.NET 7+ 内建限流中间件:
builder.Services.AddRateLimiter(o =>
{
o.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 100; // 每窗口 100 次
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
app.UseRateLimiter();
app.MapGet("/api/products", ...).RequireRateLimiting("api");
// 登录接口单独更严:如每 IP 每分钟 5 次,防暴力破解
限流保护的是可用性(资源耗尽类拒绝服务)与反滥用(撞库、爬虫)。登录、发短信、支付回调等敏感端点单独加更严策略。
dotnet list package --vulnerable --include-transitive # 连同传递依赖一起审计
CI 中定期跑(第 23 章流水线),锁定下载量大、持续维护的包;新增依赖走评审——你 import 的每一行代码都会在生产运行。
app.UseHttpsRedirection() + HSTS(生产);证书自动续期纳入运维;内网服务间调用同样加密(零信任思维)。按检查清单逐项加固并验收:
| 加固项 | 落点 |
|---|---|
| JWT 登录 + 角色策略(Admin/Staff) | POST /api/auth/login;管理接口挂 Authorize |
| 资源级授权 | 订单查询/退款校验"请求者是否订单归属方" |
| 密码 PBKDF2 存储 | 注册/登录两端;恒时比较 |
| 限流 | 全局 100/min;登录 5/min/IP |
| SQL 注入 | 全 LINQ 参数化;手写 SQL 清零或 FromSqlInterpolated |
| SSRF | 汇率代理/图片抓取接口按 21.4.3 清单防护 |
| 机密 | Jwt:SigningKey、连接串全部 user-secrets/环境变量 |
| 依赖审计 | CI 跑 dotnet list package --vulnerable |
| 日志脱敏 | 登录日志只记用户 Id,不记密码/token |
自查方法(除了人工评审):用工具扫一遍 OWASP 类问题(ZAP/Burp 主动扫描)、构造越权请求(拿 A 用户 token 访问 B 的订单,应 403)、提交注入 payload 验证参数化、提交内网 URL 验证 SSRF 拦截。
FromSqlRaw($"...{input}") 拼接照样注入;动态排序字段等场景仍需白名单。OWASP Top 10(2021)对照速查:
| 类别 | 本教程落点 |
|---|---|
| A01 访问控制失效 | 21.3 资源级授权 |
| A02 加密失败 | 21.2 密码哈希、HTTPS、机密管理 |
| A03 注入 | 21.4 SQL/XSS 防御 |
| A04 不安全设计 | 第 17 章边界 + 本章威胁建模 |
| A05 安全配置错误 | 第 13 章环境配置、默认安全 |
| A06 易受攻击组件 | 21.6 依赖审计 |
| A07 认证授权失败 | 21.2/21.3 全部 |
| A08 数据完整性失败 | 第 23 章 CI 制品签名(进阶) |
| A09 日志监控不足 | 第 14 章日志 + 第 23 章监控 |
| A10 SSRF | 21.4.3 防护清单 |
上一章:安全防护 ← | README | 下一章:持续集成与部署运维 →
学完本章,你将能够:
when 过滤与资源清理;| 预期内失败(Exceptional but Expected) | 程序缺陷(Exceptional and Unexpected) | |
|---|---|---|
| 例子 | 库存不足、订单不存在、参数非法、支付渠道超时 | 空引用、索引越界、逻辑漏洞 |
| 处理 | 抛业务异常 → 翻译成明确响应 | 让它炸(快速失败),靠日志与监控修复 |
| 日志级别 | Warning/Information | Error/Critical |
新手最常见的错误是把两类混为一谈:用异常做常规流程控制(每次"库存不足"都堆栈展开一遍,慢且逻辑淹没在 catch 里),或反过来吞掉一切异常假装系统健康。正确姿势:能预判的业务失败显式建模为异常并妥善翻译;无法预判的缺陷让它大声失败。
int code 返回 -1):错误码会被调用方忽略,异常不会——.NET 的惯例就是异常;// Domain/Exceptions.cs —— 领域层的异常词汇表
public abstract class DomainException(string message) : Exception(message); // 业务异常基类
public sealed class NotFoundException(string entity, object key)
: DomainException($"{entity} '{key}' 不存在");
public sealed class ConflictException(string message) : DomainException(message); // 状态冲突/并发
public sealed class ValidationException(string message) : DomainException(message); // 入参/规则不满足
设计要点:异常类型即语义——catch 方按类型分支,不解析消息文本;异常继承树保持浅(两层足够);每个异常携带业务主键上下文(日志与响应都用得上)。
第 11 章每个端点手写 catch 的写法在真实项目里不可持续(几十个端点、几十份重复翻译)。全局处理一次搞定——.NET 8+ 内建 IExceptionHandler:
// Api/ErrorHandling/DomainExceptionHandler.cs
public class DomainExceptionHandler(ILogger<DomainExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is not DomainException domainEx) return false; // 不是我的菜,放行给下一个 handler
var (status, title) = domainEx switch
{
NotFoundException => (StatusCodes.Status404NotFound, "资源不存在"),
ConflictException => (StatusCodes.Status409Conflict, "业务冲突"),
ValidationException => (StatusCodes.Status400BadRequest, "请求无效"),
_ => (StatusCodes.Status400BadRequest, "业务规则未满足")
};
logger.LogWarning("业务异常 {Type}:{Message} TraceId={TraceId}",
domainEx.GetType().Name, domainEx.Message, ctx.TraceIdentifier); // 预期内失败:Warning 级
await Results.Problem(
title: title,
detail: domainEx.Message,
statusCode: status,
extensions: new Dictionary<string, object?>
{
["traceId"] = ctx.TraceIdentifier // 前端可上报,运维可按它查日志
}).ExecuteAsync(ctx);
return true; // 已处理,管线终止
}
}
// 未预期的异常:一律 500,隐藏细节(细节只进日志!)
public class FallbackExceptionHandler(ILogger<FallbackExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
logger.LogError(ex, "未处理异常 TraceId={TraceId} Path={Path}", ctx.TraceIdentifier, ctx.Request.Path);
await Results.Problem(
title: "服务器内部错误",
detail: "请稍后重试,或凭 traceId 联系客服",
statusCode: StatusCodes.Status500InternalServerError,
extensions: new Dictionary<string, object?> { ["traceId"] = ctx.TraceIdentifier }
).ExecuteAsync(ctx);
return true;
}
}
// Program.cs —— 注册顺序:具体 → 兜底
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>();
app.UseExceptionHandler();
从此端点回归纯粹:业务代码只管抛领域异常,HTTP 翻译集中在一处。响应体遵循 RFC 9457(ProblemDetails)标准格式:
{
"type": "https://httpstatuses.com/409",
"title": "业务冲突",
"status": 409,
"detail": "订单状态 Paid 不可支付",
"traceId": "00-8f3c...-01"
}
两条安全纪律:未预期异常的 detail 绝不回显堆栈/连接串(只进日志);所有响应带 traceId 建立用户反馈 ↔ 日志的桥梁。
// ❌ 吞异常:错误消失,系统带病运行,排查无门
try { await SaveAsync(); } catch { }
// ❌ 吞了再记一行 Messge:堆栈全丢(第 14 章已讲)
catch (Exception ex) { logger.LogError(ex.Message); }
// ❌ 异常做流程控制:100 次循环 99 次抛 TryParse 式异常
foreach (var s in inputs) { try { n = int.Parse(s); } catch { n = 0; } }
// ✅ Try 模式(TryParse/TryGetValue)或先校验
// ❌ catch 后忘 throw:异常被"降级",上层完全不知情
catch (Exception) { rollback(); } // 应 catch { rollback(); throw; } 或包上下文后 throw new ...
// ✅ 重新抛出保持堆栈:裸 throw;包装时用 InnerException
catch (SqlException ex)
{
throw new DataUnavailableException("订单数据暂不可用", ex); // 保留 inner = 保留完整线索
}
资源清理与异常无关地执行:using/await using(编译为 try-finally)。when 过滤器让 catch 更精准且不吞不该吞的:catch (HttpRequestException ex) when (ex.StatusCode >= 500)。
异常处理管"自己别乱",弹性(resilience)管"别人乱了自己还能活"。任何外部调用(HTTP、数据库、消息队列)都可能:超时、抖动、间歇失败、整体宕机。四种基本策略:
| 策略 | 解决 | 要点 |
|---|---|---|
| 超时 Timeout | 无限等待拖垮资源 | 一切外部调用必有超时(HttpClient 默认 100s 太长,显式设) |
| 重试 Retry | 瞬时抖动(网络闪断、503) | 间隔指数退避 + 抖动;只重试幂等操作;次数有限(2-3) |
| 熔断 Circuit Breaker | 下游持续故障,重试风暴雪上加霜 | 连续 N 次失败 → 直接拒绝一段时间 → 半开试探恢复 |
| 降级 Fallback | 依赖不可用时保核心可用 | 返回缓存值/默认值/明确的功能降级提示 |
幂等(idempotency)是重试的门票:GET 天然幂等;POST 下单 重试可能重复下单——解决法是幂等键(客户端生成唯一请求 Id,服务端去重)或把"创建"设计为可重入(同键返回已存在资源)。支付回调天然要求幂等处理(渠道可能重发)。
dotnet add package Microsoft.Extensions.Http.Resilience # .NET 8+ 官方封装(基于 Polly v8)
// 命名客户端 + 标准弹性管线(超时→重试→熔断 已按最佳实践编排)
builder.Services.AddHttpClient<ExchangeRateService>(c =>
{
c.BaseAddress = new Uri(builder.Configuration["Fx:BaseUrl"]!);
c.Timeout = TimeSpan.FromSeconds(5); // 总超时兜底
})
.AddStandardResilienceHandler(o =>
{
o.Retry.MaxRetryAttempts = 2; // 重试 2 次(退避+抖动内建)
o.Retry.ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError);
o.CircuitBreaker.FailureRatio = 0.5; // 50% 失败率触发熔断
o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3); // 单次尝试超时
});
手写管线(理解原理用,Polly v8 语法):
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddTimeout(TimeSpan.FromSeconds(3)) // 单次超时
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential, // 1s → 2s 指数退避
UseJitter = true, // 抖动防雪崩同步
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => (int)r.StatusCode >= 500) // 只重试服务器类错误,4xx 是业务错不重试
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 0.5,
MinimumThroughput = 10, // 样本够多才判定
BreakDuration = TimeSpan.FromSeconds(15)
})
.Build();
// 汇率服务不可用时:用缓存旧值,核心下单流程不受阻
public async Task<decimal> GetRateAsync(string ccy, CancellationToken ct)
{
try
{
var rate = await remote.GetAsync(ccy, ct);
lastGood[ccy] = (rate, DateTime.UtcNow); // 顺手记录最近好值
return rate;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutRejectedException)
{
if (lastGood.TryGetValue(ccy, out var cached) && DateTime.UtcNow - cached.At < TimeSpan.FromHours(1))
{
logger.LogWarning("汇率服务故障,降级使用 {Min} 分钟前缓存值 Ccy={Ccy}", (int)(DateTime.UtcNow - cached.At).TotalMinutes, ccy);
return cached.Rate;
}
throw new DomainException("汇率暂不可用,请稍后重试"); // 没有兜底值:明确拒绝而非瞎猜 1.0
}
}
降级的纪律:明确告知(日志 + 对用户的提示),绝不静默返回可能错误的数据;降级方案本身要有过期机制。
PaymentNotification 表按渠道流水号唯一索引,重复通知直接返回成功;AddStandardResilienceHandler(超时 3s + 重试 2 + 熔断);汇率故障时降级到缓存(22.5.2);IExceptionHandler)统一翻译为 RFC 9457 ProblemDetails;traceId 贯穿用户反馈与日志;细节只进日志。上一章:异常处理与弹性设计 ← | README | 附录 A:版本速查 →
学完本章,你将能够:
开发者 push ──▶ CI 流水线 ──▶ 制品(镜像)──▶ CD 部署 ──▶ 运行环境
│构建│测试│审计│ │灰度│验证│ │监控│告警│回滚
原则先行:流水线是质量门禁而非打包脚本——任何没有通过测试与审计的代码都不该有机会接触生产。
feature/xxx 分支开发 → PR → 评审 + CI 必须绿 → 合并主干;feat:/fix:/refactor: 前缀),配合语义化版本(SemVer:主.次.修订)与变更日志。发布 = 打 tag:git tag v1.4.0 && git push --tags 触发发布流水线——可追溯、可回滚(回滚 = 重新部署上一个 tag 的制品)。
.github/workflows/ci.yml:
name: ci
on:
push:
branches: [ main ]
pull_request:
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Build (警告即错误)
run: dotnet build --no-restore -warnaserror
- name: Test
run: dotnet test --no-build --collect:"XPlat Code Coverage"
- name: 依赖漏洞审计
run: dotnet list package --vulnerable --include-transitive
continue-on-error: false # 有已知漏洞则流水线失败(第 21 章供应链)
- name: 上传覆盖率
uses: actions/upload-artifact@v4
with:
name: coverage
path: '**/coverage.cobertura.xml'
要点:
dotnet build -warnaserror 让第 16 章的"警告即错误"在 CI 强制执行;secrets.XXX),yaml 里永不出现明文凭据(第 13/21 章铁律在流水线的延伸)。发布工作流(tag 触发,构建并推送镜像):
name: release
on:
push:
tags: [ 'v*' ]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # 平台下发,无需手配
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ---- 阶段一:构建 ----
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish LiteOrder.Api -c Release -o /app --no-restore
# ---- 阶段二:运行(只带运行时,镜像小、攻击面小)----
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
# 容器内用非 root 运行(最小权限,第 21 章原则)
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "LiteOrder.Api.dll"]
多阶段的收益:构建工具(SDK 几个 GB)不进最终镜像;ASPNETCORE_HTTP_PORTS=8080 是 .NET 8+ 容器默认端口;生产配置全靠环境变量注入(第 13 章体系在这里收口)。
# docker-compose.yml
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_pwd # 机密走文件/环境变量,不进 yaml
secrets: [ db_pwd ]
volumes:
- pgdata:/var/lib/postgresql/data
api:
build: .
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__OrderDb: Host=db;Database=liteorder;Username=app;Password_FILE_PLACEHOLDER
depends_on:
- db
ports:
- "8080:8080"
secrets:
db_pwd:
file: ./db_pwd.txt # 本地开发用;真实环境由编排平台/密钥服务注入
volumes:
pgdata:
示例中占位符仅示意结构:真实部署中连接串密码一律由密钥管理(环境变量注入/K8s Secret/云密钥服务)提供,compose/镜像/yaml 中零明文。
数据库迁移的纪律(第 18 章的延续):
dotnet ef migrations script 生成、评审、执行),应用启动时不做迁移(多实例并发迁移是事故);builder.Services.AddHealthChecks()
.AddDbContextCheck<OrderDbContext>("database", tags: ["ready"]);
app.MapHealthChecks("/health/live", new() { Predicate = _ => false }); // 存活:进程在即返回 200
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") }); // 就绪:依赖可用才 200
| 支柱 | 内容 | 工具 |
|---|---|---|
| 日志 | 结构化事件(第 14 章) | Seq/ELK/云日志服务 |
| 指标 | QPS、延迟 P95/P99、错误率、GC、线程池 | OpenTelemetry + Prometheus/Grafana、云监控 |
| 追踪 | 跨服务请求链路(TraceId 贯穿,第 14/22 章伏笔) | OpenTelemetry + Jaeger/云追踪 |
// OpenTelemetry 接入骨架
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("LiteOrder.Api"))
.WithMetrics(m => m.AddAspNetCoreInstrumentation().AddRuntimeInstrumentation())
.WithTracing(t => t.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation());
告警基于症状而非原因:给用户可见的 SLO 告警(错误率 >1%、P95 >预算),而不是"CPU 80%"(可能是正常高峰)——第 20 章的性能预算在这里变成运维契约。
GET /health/ready 冒烟 → 通知团队;至此贯穿项目完成最后的形态:分层架构 + EF Core 持久化 + 安全加固 + 弹性容错 + 流水线部署 + 可观测运行——一个可以真实托管的轻量生产系统。
全书完。 你已经走完"从零到企业级"的完整路径:语言基础 → 工程实践 → 架构与运维。下一步建议:把轻订单继续做深(多租户、缓存一致性、消息队列),或选一个真实业务场景从零复刻一遍——架构能力只有在真实约束里才会长出来。附录 A、B、C 提供速查与项目蓝图。
| 版本 | 发布 | 支持类型 | 终止支持 | 关键变化 |
|---|---|---|---|---|
| .NET Framework 4.8 | 2019 | 仅维护 | 随 Windows | 旧体系终点,仅 Windows |
| .NET Core 3.1 | 2019 | LTS | 2022-12 | 跨平台成熟期 |
| .NET 5 | 2020 | 终止 | 2022-05 | 统一命名起点 |
| .NET 6 | 2021 | LTS | 2024-11 | Minimal API、C# 10、热重载 |
| .NET 7 | 2022 | STS | 2024-05 | 原生 AOT 成熟、限流中间件 |
| .NET 8 | 2023 | LTS | 2026-11 | Blazor 全栈、TimeProvider、默认 AOT 化容器镜像 |
| .NET 9 | 2024 | STS | 2026-05 | 性能增强、LINQ 新方法 |
| .NET 10 | 2025 | LTS | 2028-11 | 本教程基准;C# 14、运行时持续优化 |
选择策略:新项目选当前 LTS;维护中的 LTS 项目按路线图计划升级;STS 用于尝鲜与短期项目。
| C# | 随 .NET | 代表特性 | 本教程使用处 |
|---|---|---|---|
| 8.0 | Core 3.0 | switch 表达式、默认接口实现、可空引用类型(引入) | 第 2、5、6 章 |
| 9.0 | .NET 5 | record、init、顶层语句、模式匹配增强 | 第 1、6 章 |
| 10.0 | .NET 6 | record struct、全局 using、字符串插值增强 | 第 6 章 |
| 11.0 | .NET 7 | 原始字符串 """、列表模式、required 成员 |
第 2 章 |
| 12.0 | .NET 8 | 主构造函数、集合表达式 [..]、lambda 可选参数 |
第 4、7 章 |
| 13.0 | .NET 9 | params 集合扩展、锁对象语义 | — |
| 14.0 | .NET 10 | 扩展成员等语言增强 | — |
按教程顺序,你会自然用到:可空性 → switch 表达式 → record/init → 原始字符串 → 主构造函数 → 集合表达式。
dotnet.microsoft.com/platform/support/policy/dotnet-coredotnet --list-sdks / dotnet --list-runtimes附录 A ← | README | 附录 C:贯穿项目需求书 →
dotnet new sln -n MySolution # 创建解决方案
dotnet new console -n App # 控制台项目
dotnet new webapi -n Api # Web API 项目(Minimal API)
dotnet new xunit -n Tests # 测试项目
dotnet new classlib -n Core # 类库项目
dotnet sln add App Tests # 项目加入解决方案
dotnet add Tests reference App # 项目引用
dotnet new gitignore # 生成官方 .gitignore
dotnet build # 编译(-c Release 指定配置)
dotnet build -warnaserror # 警告视为错误(CI 门禁)
dotnet run # 运行(--project 指定项目)
dotnet run --project Api --urls http://localhost:5000
dotnet watch run # 热重载运行
dotnet watch test # 保存即跑测试
dotnet publish -c Release -o ./publish # 发布
dotnet publish -c Release -r linux-x64 --self-contained false
dotnet add package Serilog.AspNetCore # 安装包
dotnet remove package Serilog.AspNetCore
dotnet list package # 列出依赖
dotnet list package --vulnerable --include-transitive # 漏洞审计(含传递依赖)
dotnet test # 跑全部测试
dotnet test --filter "FullyQualifiedName~OrderServiceTests"
dotnet test --collect:"XPlat Code Coverage" # 收集覆盖率
dotnet tool install -g dotnet-reportgenerator-globaltool # 覆盖率报告生成器
dotnet tool install -g dotnet-ef # 安装 EF 工具(一次性)
dotnet ef migrations add InitSchema # 生成迁移
dotnet ef database update # 应用迁移(开发环境)
dotnet ef migrations script # 生成 SQL 脚本(生产评审用)
dotnet ef migrations list / remove
dotnet user-secrets init # 初始化用户机密
dotnet user-secrets set "Jwt:SigningKey" "本机开发密钥"
dotnet user-secrets list / clear
dotnet tool install -g dotnet-counters dotnet-trace dotnet-dump
dotnet-counters monitor --process-id <pid> # 实时指标:CPU/GC/线程池
dotnet-trace collect --process-id <pid> # 性能采样
dotnet-dump collect --process-id <pid> # 内存转储
dotnet-dump analyze <dump文件> # 分析转储
dotnet format # 按 .editorconfig 格式化
dotnet format --verify-no-changes # CI 中校验格式
dotnet nuget locals all --clear # 清理 NuGet 缓存
docker build -t liteorder:dev .
docker run -p 8080:8080 -e ASPNETCORE_ENVIRONMENT=Development liteorder:dev
docker compose up -d # 编排启动(app + db)
docker compose logs -f api
「轻订单 LiteOrder」是一个面向小微商户(咖啡店、便利店、小餐饮)的订单管理系统:管理商品目录、受理订单、支持多种支付渠道、产出经营报表。它的定位贯穿本教程:
Created → Paid → Shipped → Completed
│
└→ Cancelled(仅 Created 可取消)
| 里程碑 | 章节 | 交付物 | 现状痛点(改造动机) |
|---|---|---|---|
| M0 | 第 1 章 | 解决方案骨架 + Git 仓库 | — |
| M1 | 第 2 章 | 单笔订单金额计算(控制台) | — |
| M2 | 第 3 章 | 多商品明细、订单号生成 | 手工作业难扩展 |
| M3 | 第 4 章 | Product/OrderItem/Order 类 | 平行数组不可维护 |
| M4 | 第 5 章 | 折扣策略体系 + 会员积分 | if-else 分支蔓延 |
| M5 | 第 6 章 | 订单状态机 + 物流记录 | 状态用字符串裸奔 |
| M6 | 第 7 章 | 商品目录 + 购物车(集合) | 查找靠遍历 |
| M7 | 第 8 章 | 支付后事件广播 | 后续动作硬编码 |
| M8 | 第 9 章 | 经营日报 + 分页 | 报表逻辑散乱 |
| M9 | 第 10 章 | 异步编排(并发查 + 通知) | 串行等待浪费时间 |
| M10 | 第 11 章 | Web API(商品/订单/支付/日报) | 只能自己用 |
| M11 | 第 12 章 | 依赖注入 + 存储接口化 | 换实现要改业务代码 |
| M12 | 第 13 章 | 业务参数配置化 + 机密管理 | 魔法数字硬编码 |
| M13 | 第 14 章 | Serilog 结构化日志 | 出问题查无可查 |
| M14 | 第 15 章 | 测试套件(领域+服务+API) | 改动后不敢保证没坏 |
| — | 第 16 章 | 质量体检(调试/分析器/基准) | 低级错误靠自觉 |
| M15 | 第 17 章 | 四层架构重构 | 换数据库伤筋动骨 |
| M16 | 第 18 章 | EF Core 持久化 | 重启丢数据 |
| M17 | 第 19 章 | 支付适配器 + 领域事件正式版 | 第三方代码侵入业务 |
| M18 | 第 20 章 | 读路径优化(N+1/索引/缓存) | 数据量上来就慢 |
| M19 | 第 21 章 | 安全加固(JWT/限流/SSRF/审计) | 裸奔不可上线 |
| M20 | 第 22 章 | 全局异常翻译 + 出网弹性 | 依赖抖动全站躺平 |
| M21 | 第 23 章 | CI/CD + 容器化 + 可观测 | 部署靠手、故障靠喊 |
里程碑编号与章节不一一对应(部分章节是纯改造/体检),以"痛点 → 解法"为准。
学习时建议为关键决策留痕(写到项目 docs/ 里),示范:
ADR-001 数据库选型
背景:学习阶段零部署成本,未来需平滑换 PostgreSQL
决策:开发用 SQLite,EF Core 抽象保证可迁移;连接串配置化
后果:接受 SQLite 并发限制;迁移到 PG 时仅需换 provider 包与连接串
ADR-002 状态机归属
背景:订单状态流转规则曾被端点与服务各自校验,出现漏洞
决策:流转规则收敛到领域实体(充血),TransitionTo 唯一入口
后果:非法流转在领域层被强制拒绝;新增状态只改一处
完成主线后可选挑战(也是简历级亮点):
dotnet-tutorial/
README.md
.mimosa/
finding-ledger/
history/
run-20260929T051556216Z-19808-a4edb88bf945.json
hook-status/
sess_32754303-eace-469d-8441-b1a8939ce9c8-df37480f6e.json
reports/
task-review-sess_32754303-eace-469d-8441-b1a8939ce9c8-20260929T051556216Z-19808-a4edb88bf945.json
task-review-sess_32754303-eace-469d-8441-b1a8939ce9c8.json
appendix/
A-dotnet-csharp-versions.md
B-cli-commands-cheatsheet.md
C-capstone-project.md
part1-core/
01-dotnet-platform-and-environment.md
02-csharp-basics.md
03-methods-arrays-strings.md
04-oop-part1-classes.md
05-oop-part2-inheritance-polymorphism.md
06-structs-enums-records.md
07-collections-generics.md
08-delegates-events-lambda.md
09-linq.md
10-async-programming.md
part2-engineering/
11-aspnetcore-webapi.md
12-dependency-injection.md
13-configuration-options.md
14-logging.md
15-unit-testing.md
16-debugging-diagnostics.md
part3-enterprise/
17-architecture-layers.md
18-efcore-data-access.md
19-design-patterns.md
20-performance-optimization.md
21-security.md
22-exception-handling-resilience.md
23-cicd-devops.md完整源码 Gitee 仓库浏览 →