.NET / C# 开发教程

dotnet & C# · .NET 平台与环境、C# 基础、方法数组字符串、面向对象、泛型与集合、LINQ、异步、ASP.NET Core Web
← 返回教程馆
4阶段
27章节
27源文档
12 周计划配套生态

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
part1 core0/0
part2 engine0/0
part3 enterp0/0
appendix0/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

📖 .NET 开发从零到企业级系统自学教程 README.md

一份面向零基础读者的 .NET 全栈式教程:从 C# 语法起步,经过工程化实践训练,最终具备构建可维护、可扩展的生产级应用的能力。

教程定位

  • 技术基准:.NET 10(LTS)/ C# 14 / ASP.NET Core / EF Core。绝大多数内容同样适用于 .NET 8(LTS)与 .NET 9,个别新特性会显式标注版本要求。
  • 主线语言:C#,兼述 .NET 运行时机制(CLR、GC、JIT),知其然也知其所以然。
  • 贯穿项目:「轻订单 LiteOrder」——一个面向小微商户的订单管理系统。它从第 2 章的一小段控制台代码起步,随章节推进逐步演进:控制台程序 → Web API → 分层架构 → 数据库持久化 → 缓存与安全加固 → 容器化上线。完整需求书见 附录 C。
  • 组织方式:三大部分共 23 章 + 3 个附录,每章固定结构:学习目标 → 正文讲解 → 常见误区 → 最佳实践 → 典型业务场景 → 小结 → 动手练习。

学习路线图

 第一部分:核心基础          第二部分:工程化实践         第三部分:企业级开发
┌─────────────────────┐   ┌─────────────────────┐   ┌─────────────────────────┐
│ 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-10 章)核心基础:打牢 C# 语言与 .NET 平台功底。学完后你能独立编写结构清晰的控制台应用,读懂绝大多数 C# 代码。
  • 第二部分(第 11-16 章)工程化实践:进入真实团队的开发方式——用 ASP.NET Core 对外提供 API,用依赖注入解耦、配置中心化管理、结构化日志排查问题,用单元测试与调试工具守住质量底线。
  • 第三部分(第 17-23 章)企业级开发:架构设计、设计模式、数据访问、性能、安全、异常与弹性、CI/CD——这些决定一个系统能否在生产环境长期稳定运行的主题。

建议节奏:每天 1-2 小时,第一部分约 5-6 周,第二部分约 3 周,第三部分约 4-5 周。练习务必动手完成——看会了不等于会写。

目录

第一部分:核心基础(part1-core/)

章 文件 主题
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

第二部分:工程化实践(part2-engineering/)

章 文件 主题
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 调试、诊断与代码质量

第三部分:企业级开发(part3-enterprise/)

章 文件 主题
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 持续集成与部署运维

附录(appendix/)

附录 文件 内容
A dotnet-csharp-versions.md .NET 与 C# 版本演进速查
B cli-commands-cheatsheet.md dotnet CLI 与常用工具速查
C C-capstone-project.md 贯穿项目「轻订单」需求书与里程碑

如何使用本教程

  1. 按顺序阅读:章节之间存在依赖(第 12 章依赖第 11 章的项目,第 18 章依赖第 17 章的架构),不建议跳跃。
  2. 跟着贯穿项目走:每章「典型业务场景」板块描述该章知识点在「轻订单」中的落点,附录 C 有完整的里程碑对照表。建议你同步搭建自己的项目代码库,用 Git 提交每个里程碑。
  3. 完成动手练习:每章末尾的练习分为基础题(巩固语法/概念)与实战题(扩展贯穿项目)。部分练习没有唯一答案,重点是思路。
  4. 环境自验:本教程所有示例基于 .NET 10 SDK 编写。写作环境未逐一编译运行全部代码,遇到问题时优先用第 16 章的调试方法定位,或对照官方文档核对 API。

前置要求

  • 会使用电脑、了解基本的软件概念即可,不需要任何编程经验。
  • 一台 Windows / macOS / Linux 电脑(.NET 全平台支持;本教程以 Windows + VS Code / Visual Studio 为主演示)。
  • 建议安装 Git 并了解基本操作(提交、分支),第 23 章的 CI/CD 会用到。

祝学习顺利。开始第一章:.NET 平台概述与开发环境搭建。

📄 第 1 章 .NET 平台概述与开发环境搭建 01-dotnet-platform-and-environment.md

README | 下一章:C# 基础语法 →

学习目标

学完本章,你将能够:

  1. 说清 .NET 是什么、能做什么,以及它与 .NET Framework、.NET Core 的关系;
  2. 理解 SDK、运行时、CLR、BCL 这几个贯穿全书的名词;
  3. 在自己的电脑上安装 .NET SDK 与开发工具,并验证安装成功;
  4. 创建、运行你的第一个 C# 程序,并读懂项目文件结构;
  5. 了解 NuGet 包管理的基本用法。

1.1 .NET 是什么

.NET 是微软主导的开源开发平台,包含编程语言(C#、F#、VB.NET)、运行时(CLR)、基础类库(BCL)和一系列应用框架(ASP.NET Core、EF Core、MAUI 等)。用一句话概括:用 .NET,你可以用同一门语言、同一套类库,开发 Web 后端、桌面软件、移动 App、云服务甚至游戏。

1.1.1 一段简短的历史

理解 .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# 语言本身几乎一致。

1.1.2 .NET 能做什么

  • Web 后端 / API:ASP.NET Core,全球主流基准测试中的常青树,本教程第二、三部分的主战场;
  • 桌面应用:WPF、Windows Forms(Windows),.NET MAUI(跨 Windows/macOS/iOS/Android);
  • 云原生服务:Azure、AWS 的函数计算与容器服务均一等支持;
  • 游戏:Unity 引擎使用 C# 作为脚本语言;
  • AI 与数据:ML.NET、Semantic Kernel(大模型应用编排)、TorchSharp;
  • 物联网 / 嵌入式:.NET nanoFramework、IoT 库。

对企业后端开发而言,ASP.NET Core + EF Core 是 .NET 就业市场最主流的技术组合,也是本书的主线。

1.1.3 核心概念速览

先用一张图记住四个名词,后续章节会反复出现:

┌────────────────────────────────────────────┐
│  你写的 C# 源代码(.cs 文件)                │
└──────────────┬─────────────────────────────┘
               │ 编译(Roslyn 编译器)
               ▼
┌────────────────────────────────────────────┐
│  IL 中间语言 + 元数据(.dll / .exe)         │
└──────────────┬─────────────────────────────┘
               │ 运行时按需 JIT 编译为机器码
               ▼
┌────────────────────────────────────────────┐
│  CLR(Common Language Runtime)公共语言运行时 │
│  · JIT 即时编译    · 垃圾回收 GC             │
│  · 类型安全与异常   · 线程与内存管理          │
├────────────────────────────────────────────┤
│  BCL(Base Class Library)基础类库           │
│  · 集合  · IO  · 网络  · 文本  · 并发 ……     │
└────────────────────────────────────────────┘
  • CLR:运行时的"发动机"。你写的代码编译成 IL(中间语言),运行时由 CLR 的 JIT 编译器即时编译成所在 CPU 的机器码执行;内存分配与回收(垃圾回收,GC)也由 CLR 接管——这就是 C# 里基本不需要手动释放内存的原因。
  • BCL:微软预先写好的海量工具类,如 System.String、System.Collections.Generic.List<T>、文件读写、HttpClient 等。
  • SDK(Software Development Kit):开发用的工具包,包含编译器、dotnet 命令行工具和模板;运行时(Runtime):只运行程序所需的最小组件。装了 SDK 一定包含运行时,反之不然。开发机上装 SDK,生产服务器可以只装 ASP.NET Core Runtime。

1.1.4 版本策略:LTS 与 STS

.NET 每年 11 月发一个大版本,交替提供两类支持:

  • LTS(Long-Term Support):3 年支持,如 .NET 8(2023)、.NET 10(2025);
  • STS(Standard-Term Support):18 个月支持,如 .NET 9。

企业生产环境选 LTS,尝鲜新特性可用 STS。本教程基于 .NET 10 LTS。

1.2 搭建开发环境

1.2.1 安装 .NET SDK

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

1.2.2 安装开发工具

三选一,均可:

  1. Visual Studio 2022/2026(Community 免费版,仅 Windows):功能最全的 IDE,调试器、性能分析器、界面设计器一应俱全。安装时勾选 ".NET 桌面开发" 与 "ASP.NET 和 Web 开发" 两个工作负载。
  2. Visual Studio Code + C# Dev Kit 扩展(全平台):轻量,本教程的演示界面以它为准。
  3. JetBrains Rider(全平台,个人非商用免费):重构与代码分析体验出色。

1.2.3 验证安装

打开终端(Windows 用 PowerShell 或 Windows Terminal):

dotnet --version        # 应输出 10.x.xxx
dotnet --list-sdks      # 列出所有已装 SDK
dotnet --list-runtimes  # 列出所有已装运行时

只要 dotnet --version 能输出版本号,环境就绪。如果提示 dotnet 不是内部或外部命令,重启终端(安装后 PATH 未刷新是常见原因),仍不行则重装 SDK。

1.3 第一个 C# 程序

1.3.1 创建并运行

mkdir hello-dotnet
cd hello-dotnet
dotnet new console     # 从模板创建控制台项目
dotnet run             # 编译并运行

屏幕输出:

Hello, World!

恭喜——但更要紧的是弄明白刚才发生了什么。

1.3.2 读懂项目结构

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——本教程为了清晰,示例大多使用显式写法。

1.3.3 常用 dotnet 命令

现在先记住五个,其余见附录 B:

命令 作用
dotnet new console -n 项目名 创建控制台项目
dotnet build 编译,检查语法错误
dotnet run 编译并运行
dotnet add package 包名 安装 NuGet 包
dotnet test 运行测试(第 15 章使用)

1.3.4 NuGet:.NET 的包管理器

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。

常见误区

  1. 分不清 SDK 与运行时。命令 dotnet build 报"未找到 SDK",说明只装了运行时;开发机请装完整 SDK。
  2. 把 .NET Framework 的老教程/老代码直接照搬到现代 .NET。老代码里的 System.Web、app.config 等在现代 .NET 中不存在,遇到时先确认代码的版本来源。
  3. 把 bin/、obj/ 提交进 Git。它们是可再生的编译产物,请加入 .gitignore。
  4. 以为 .NET 只能跑在 Windows 上。现代 .NET 支持 Linux、macOS、容器,大量生产部署在 Linux。
  5. 项目名与文件夹名不一致导致混乱。dotnet new console -n Foo 会在当前目录创建 Foo 文件夹;建议先 cd 到目标父目录再执行。

最佳实践

  1. 用 dotnet new gitignore 生成 .NET 官方模板的 .gitignore,而不是手写。
  2. 新项目一律开启 Nullable=enable 与 ImplicitUsings=enable(新模板默认已开)。
  3. 学习阶段就用 Git 管理代码,每个里程碑一次提交——它是你免费的"存档点"。
  4. 报错先读错误信息全文。编译错误信息通常直接指出文件、行号与原因,比搜索更高效。

小结

  • .NET = 语言(C#)+ 运行时(CLR,含 JIT 与 GC)+ 基础类库(BCL)+ 应用框架(ASP.NET Core 等)。
  • 源代码 → IL → CLR 执行;GC 让你无需手动管理内存。
  • SDK 用于开发(含运行时),运行时仅用于运行程序;生产环境选 LTS 版本。
  • dotnet new / build / run 三板斧可以创建和运行项目;NuGet 负责第三方包。

动手练习

  1. 基础:安装 SDK 后运行 dotnet --list-sdks 与 dotnet --list-runtimes,把你机器上的版本列出来,确认哪些是 LTS。
  2. 基础:创建第二个项目,把 Program.cs 改成输出你的名字和今天的日期(提示:DateTime.Now),运行成功。
  3. 实战(轻订单 M0):按 1.4 节初始化 liteorder 解决方案,推送到 GitHub 或 Gitee 私有仓库(第 23 章的 CI/CD 会用到这个仓库),并确认 .gitignore 已排除 bin/ 与 obj/。
  4. 思考:企业为什么倾向 LTS 而不是最新 STS?如果你们的系统需要某 STS 版本的独有特性,迁移前应考虑哪些风险?

README | 下一章:C# 基础语法 →

📄 第 2 章 C# 基础语法:变量、类型与控制流 02-csharp-basics.md

上一章:.NET 平台概述 ← | README | 下一章:方法、数组与字符串 →

学习目标

学完本章,你将能够:

  1. 声明变量与常量,正确选择数值、布尔、字符、字符串等基本类型;
  2. 在不同类型之间安全地转换,理解整数除法与浮点误差两个经典陷阱;
  3. 使用 if/else、switch(含模式匹配)、for、while、foreach 控制程序流程;
  4. 用字符串插值组装输出内容;
  5. 独立编写一个小型命令行程序(如订单金额计算器)。

2.1 变量、常量与类型

C# 是静态强类型语言:每个变量都有确定类型,编译期就检查类型错误。声明变量的语法:

int quantity = 3;              // 显式声明类型
var price = 12.5m;             // var:让编译器推断类型(仍是强类型,price 是 decimal)
const decimal TaxRate = 0.06m; // 常量:编译期确定,之后不可再赋值

约定:C# 变量与参数用小驼峰(unitPrice),常量与类成员的公开部分用大驼峰(PascalCase),本书全篇遵守。

2.1.1 核心基本类型

类型 含义 大小/范围 示例
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 = "轻订单";

两条铁律:

  1. 金额一律用 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
    
  2. 整数运算可能溢出。默认静默回绕,关键计算用 checked 强制抛异常:
    int max = int.MaxValue;
    Console.WriteLine(max + 1);                        // -2147483648(静默溢出!)
    Console.WriteLine(checked(max + 1));               // 抛 OverflowException
    

数字字面量可用下划线分隔提高可读性:1_000_000。

2.1.2 类型转换

// 隐式转换:小 → 大,安全,自动发生
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 章的系统论述,先记住用法)。

2.2 运算符

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 章结合可空性详解。

2.3 控制流

2.3.1 if / else

decimal amount = 520m;

if (amount >= 500)
{
    Console.WriteLine("符合免运费条件");
}
else if (amount >= 100)
{
    Console.WriteLine("满 100 减 10 元");
}
else
{
    Console.WriteLine("无优惠");
}

风格提示:C# 社区惯例始终写花括号,即使单行;省略括号是著名 bug 温床(Apple goto fail 漏洞即源于此)。

2.3.2 switch:传统写法与表达式写法

// 传统语句形式
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 章的模式匹配会更强大。

2.3.3 循环

// 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
}

2.4 字符串基础

字符串不可变(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/三元表达式、多行原始字符串插值。它就是贯穿项目的起点——后面每章都会让它更接近真实系统。

常见误区

  1. 用 double 存金额。浮点二进制误差会在累计计算中放大,请用 decimal。
  2. 整数除法想当然。3 / 2 是 1 不是 1.5;需要小数时先把一侧转成 double/decimal。
  3. (int) 强转浮点是截断不是四舍五入。四舍五入用 Math.Round(x)(注意它默认银行家舍入,Math.Round(x, MidpointRounding.AwayFromZero) 才是传统四舍五入)。
  4. Parse 直接处理用户输入。失败即抛异常,用 TryParse。
  5. 在循环里用 + 大量拼接字符串。每次都生成新对象,性能差;应使用 StringBuilder(第 3 章)。
  6. 忘记 && 的短路顺序。防御性判断必须把 null 检查放在前面。

最佳实践

  1. 优先 var 于右侧类型显而易见处;类型不明显时显式声明,利于阅读。
  2. 魔法数字提取为命名常量(如 FreeShippingThreshold)。
  3. 用户输入永远:Trim → TryParse → 范围校验。
  4. 文本组装用插值 $"...",多行文本用原始字符串 """..."""。
  5. switch 表达式优先于长串 if-else,分支覆盖含 _ 兜底。

小结

  • C# 静态强类型:选对类型是正确性的第一道防线,金额 decimal、大整数 long、计数 int。
  • 转换三件套:隐式(安全)、显式(可能丢数据)、TryParse(安全解析输入)。
  • 控制流:if/else 处理二元与级联分支;switch 表达式适合多分支映射;for 计数、while 条件、foreach 遍历。
  • 字符串不可变;插值与原始字符串是现代写法。

动手练习

  1. 基础:编写程序,输入三角形三边长,判断能否构成三角形(任意两边之和大于第三边),能则输出周长与面积(海伦公式)。
  2. 基础:用 switch 表达式把 1-7 的数字映射为"星期一"到"星期日",非法输入输出"无效"。
  3. 基础:打印九九乘法表(两层 for 循环),要求对齐输出(提示:{a}×{b}={a*b,3} 中的 ,3 表示右对齐占 3 位)。
  4. 实战(轻订单 M1):扩展 2.5 节程序:增加"满 200 减 20"活动(与 VIP 折扣叠加,优惠先算折扣再算满减);输入非法时给出具体原因而不是笼统的"无效";用 Git 提交(git commit -m "feat: order amount calculator")。
  5. 思考:0.1 + 0.2 != 0.3 的根本原因是什么?如果面试官问"为什么不用 double 存钱",你如何用一句话回答?

上一章:.NET 平台概述 ← | README | 下一章:方法、数组与字符串 →

📄 第 3 章 方法、数组与字符串 03-methods-arrays-strings.md

上一章:C# 基础语法 ← | README | 下一章:面向对象编程(上)→

学习目标

学完本章,你将能够:

  1. 定义与调用方法,理解参数传递(值、out、ref、可选参数、params)与方法重载;
  2. 使用一维/多维数组与 Array 工具类,用范围运算符切片;
  3. 解释字符串不可变性及其性能含义,正确使用 StringBuilder;
  4. 熟练使用字符串的拆分、拼接、比较、格式化等常用操作;
  5. 把过程式代码拆分为职责单一的方法,为第 4 章面向对象做准备。

3.1 方法

方法是一段有名字的、可复用的代码。把重复逻辑收敛成方法,是控制复杂度的第一手段。

// [访问修饰符] [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;

3.1.1 参数传递

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 章后会用自定义类型,更清晰)。

3.1.2 可选参数、命名参数与重载

// 可选参数:调用方可省略
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);

重载应保持语义一致(都是"计算");如果两个重载行为毫无关系,那应该改名。

3.1.3 局部函数

方法内部还可以定义方法,适合只在本方法内复用的辅助逻辑:

static decimal OrderTotal(decimal[] items)
{
    decimal SumWithTax(decimal sub) => sub * 1.06m;   // 局部函数
    return SumWithTax(items.Sum());
}

3.2 数组

数组是定长、同类型的线性容器,内存连续,按下标访问 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 章)更多,但解析二进制/表格数据时数组仍是利器。

3.3 字符串深入

3.3.1 不可变性意味着什么

string s = "LiteOrder";
s.ToUpper();                  // ⚠️ 无效!返回的新字符串被丢弃,s 没变
s = s.ToUpper();              // ✅ 必须接住返回值

每次"修改"都新建字符串对象。循环拼接一万次就创建一万个中间对象——这是新手性能问题的头号来源。

3.3.2 StringBuilder:高频拼接的正确姿势

var sb = new System.Text.StringBuilder();
for (int i = 1; i <= 100; i++)
{
    sb.Append(i).Append(',');      // 内部维护可变缓冲区,不产生中间字符串
}
string csv = sb.ToString().TrimEnd(',');

判断标准:三五个字符串拼一次,用插值即可;循环里拼接或超长文本,用 StringBuilder。

3.3.3 常用 API 速览

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 章你将看到同样的思路扩展成类。

常见误区

  1. 调用返回新对象的方法却不接返回值(s.ToUpper()、sb.Append(...) 之外的字符串方法均如此)。
  2. 数组越界:arr[arr.Length] 必抛 IndexOutOfRangeException;^1 指最后一个元素,但空数组的 ^1 同样越界。
  3. 循环中用 += 拼接大字符串,应使用 StringBuilder。
  4. 滥用 ref:绝大多数场景默认值传递即可,ref/out 只用于确有多返回值需求处。
  5. 用 == 判空字符串却漏了 null:s == "" 在 s 为 null 时为 false 但不报错,容易掩盖问题;判空统一用 string.IsNullOrWhiteSpace。
  6. 混淆 Split 的重载:Split(' ') 产生空元素,需要 RemoveEmptyEntries 选项处理连续分隔符。

最佳实践

  1. 方法保持单一职责:一个方法只做一件事,超过约 30 行就想想能不能拆。
  2. 参数做前置校验并 fail fast,给出带参数名的明确异常。
  3. 遍历容器优先 foreach 或范围运算符,少用手动下标。
  4. 字符串比较涉及用户输入时指定 StringComparison(默认的比较受操作系统文化设置影响,跨机器行为可能不同)。
  5. 多返回值优先 (a, b) 元组或自定义类型,避免 3 个以上 out 参数。

小结

  • 方法 = 复用的最小单元;参数默认按值传递,out 补充多返回值,params 接受可变参数;重载靠参数签名区分。
  • 数组定长同型,^ 与 .. 提供优雅的首尾访问与切片;Array.Sort/IndexOf 是常用工具。
  • 字符串不可变:每次修改产生新对象;高频拼接用 StringBuilder;判空用 IsNullOrWhiteSpace。
  • 拆分方法的核心标尺是职责单一与语义清晰。

动手练习

  1. 基础:写方法 bool IsPalindrome(string s) 判断忽略大小写与空格的回文(如 "A man a plan")。测试至少 5 组输入。
  2. 基础:写方法 static (int max, int min, double avg) Stats(int[] nums),用元组返回数组的最大值、最小值、平均值,并处理空数组(抛异常还是返回 0?想清楚并写注释说明理由)。
  3. 实战(轻订单 M2):把 2.5 节的订单计算程序升级:支持从控制台输入多个"商品名 单价 数量"(分号分隔的一整行,用 Split 解析),输出含所有明细行的订单摘要;订单号用 NextOrderNo 风格生成;提交 Git。
  4. 思考:ChangeContent 与 Rebind 的差异(3.1.1 节)说明了什么?为什么不建议用 ref 让方法"重新绑定"外部变量?

上一章:C# 基础语法 ← | README | 下一章:面向对象编程(上)→

📄 第 4 章 面向对象编程(上):类与封装 04-oop-part1-classes.md

上一章:方法、数组与字符串 ← | README | 下一章:面向对象编程(下)→

学习目标

学完本章,你将能够:

  1. 解释类与对象的关系,用类为业务概念建模;
  2. 定义字段、属性、方法与构造函数,正确选择访问修饰符实现封装;
  3. 使用对象初始化器与主构造函数(C# 12)创建对象;
  4. 理解 static 成员的适用场景与风险;
  5. 把「轻订单」中散落的过程式数据升级为 Product、Order 等类型。

4.1 从过程式到面向对象

第 3 章末尾,轻订单用三个平行数组管理商品——加一个属性就要加一个数组,加一个商品要改四个地方。面向对象(OOP)的解法:把相关的数据与操作它们的方法捆绑成一个类型。

  • 类(class):图纸/模板,定义"商品有哪些数据(属性)、能做什么(方法)";
  • 对象(object):按图纸造出的实例,内存中真实存在的具体数据。
// 类:一张"商品"图纸
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 不可能被外部改成负数,规则集中在类里,规则与数据住在一起,这就是封装的雏形。

4.2 字段、属性与方法

4.2.1 字段(field):类的私有数据

public class Order
{
    private static int _seed = 1000;   // 静态字段:全类共享(见 4.4)
    private readonly DateTime _createdAt;  // 只能在声明处或构造函数中赋值
    private List<string> _tags = new();    // 私有字段,外部不可见
}

约定:私有字段用 _camelCase。原则:字段一律 private,对外暴露一律走属性——这是封装的第一条纪律。

4.2.2 属性(property):带逻辑的数据出口

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)。

4.2.3 this 与方法

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 章详解
}

4.3 构造函数与对象创建

4.3.1 构造函数

构造函数在 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) { }
}

一旦定义了带参构造函数,编译器就不再生成无参默认构造——"必须给我金额才能创建订单"由此强制。

4.3.2 对象初始化器

var order = new Order(88m)
{
    // 只能给有 set/init 的属性赋值,按需书写,比多构造函数重载清爽
    CreatedBy = "cashier-01",
};

4.3.3 主构造函数(C# 12)

参数直接写在类名后,整个类体可用,常用于"依赖注入"式类(第 12 章大量出现):

public class PriceCalculator(decimal taxRate)
{
    public decimal WithTax(decimal amount) => amount * (1 + taxRate);
}
var calc = new PriceCalculator(0.06m);

主构造参数不是字段,需跨方法共享时才显式声明字段/属性承接。

4.4 static:属于类型本身

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)。
  • 风险:静态可变状态是全局变量——并发竞争、测试难隔离(测试之间互相污染)、多租户互串。业务状态(订单、库存、购物车)绝不要放静态字段;本章场景里先简单用静态计数器,第 12 章依赖注入会给出正规解法。

4.5 访问修饰符与封装

修饰符 可见范围 使用频率
public 任何代码 对外 API
private 本类内部 字段、辅助方法,默认选择
protected 本类及子类 为继承预留(下一章)
internal 同一程序集内 团队内部实现,防外部误用
private protected / protected internal 组合语义 少用

程序集(assembly)= 一次编译的产物(.dll/.exe),可粗略理解为"一个项目"。

封装的价值清单(面试常问,更是工程常识):

  1. 防非法状态:Stock 永不为负,因为唯一修改口有校验;
  2. 隔离变化:内部存储从单个字段换成计算值,调用方无感;
  3. 缩小 blast radius:internal 的实现随便改,只要 public 契约不变;
  4. 可测性:规则集中,测试只打一个口。

典型业务场景:轻订单的领域类型初版

把第 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。

常见误区

  1. public 字段直接暴露:绕过校验、无法加逻辑,重构时牵一发动全身。对外永远用属性。
  2. 万物皆 static:静态可变状态在并发、测试、多实例场景全是坑。工具函数可以 static,业务状态不行。
  3. 在属性 getter 里做重活(查库、发请求):调用方预期"取个值很便宜",副作用应放方法里。
  4. 构造函数里调用可被重写的虚方法:子类尚未初始化完,行为不可预期(下一章讲虚方法)。
  5. 上帝类(God Class):一个类又管商品又管订单又管打印。发现类名里出现"And"或职责说不清,就该拆。
  6. 为"以后可能用到"预留大量空属性:YAGNI 原则——按当前需求建模,用到了再加。

最佳实践

  1. 字段全 private;对外能力 = 属性(数据)+ 方法(行为);需要收紧的项目内部实现用 internal。
  2. 构造函数保证"出生即合法";init + 只读属性优先,让对象尽量不可变。
  3. 集合属性对外暴露 IReadOnlyList<T> / IReadOnlyCollection<T>,防外部绕过方法直接 Add/Remove。
  4. 校验失败抛带语义的异常(ArgumentException、InvalidOperationException),消息写清"什么错、期望什么"。
  5. 一个文件一个主要类型、文件名=类型名,是 .NET 社区默认约定。

小结

  • 类捆绑数据与行为;字段私有、属性出口、方法行为,是封装的基本盘。
  • 构造函数负责出生即合法;对象初始化器与主构造函数让创建代码更简洁。
  • 属性可校验、可计算(无存储)、可 init 只读——比裸字段强得多。
  • static 属于类型而非实例:工具与常量适用,业务状态禁用。
  • 访问修饰符从紧到松:private → protected/internal → public,默认给最小可见性。

动手练习

  1. 基础:实现 BankAccount 类:私有余额字段、Deposit/Withdraw 方法(余额不足与非法金额抛异常)、Balance 只读属性、交易后触发简单的控制台提示。用 6 组调用验证边界。
  2. 基础:把 4.6 的 Product 增加分类 Category 字符串属性与 IsOutOfStock 计算属性;思考 Price 用 init 而不是 set 的利弊。
  3. 实战(轻订单 M3):按 4.6 新建 Product.cs、OrderItem.cs、Order.cs,改造主程序为"命令行购物":循环读命令 add <商品id> <数量>、list、total、pay <折扣率>、quit;所有输入走 TryParse 与校验;Git 提交。
  4. 思考:Order.Items 为什么返回 IReadOnlyList<OrderItem> 而不是直接返回 _items(List)?如果调用方需要增删,正确的通道是什么?

上一章:方法、数组与字符串 ← | README | 下一章:面向对象编程(下)→

📄 第 5 章 面向对象编程(下):继承、多态与接口 05-oop-part2-inheritance-polymorphism.md

上一章:面向对象编程(上)← | README | 下一章:结构体、枚举、记录与可空性 →

学习目标

学完本章,你将能够:

  1. 使用继承复用与扩展类型,理解 base、sealed 与构造链;
  2. 用 virtual/override/abstract 实现多态,说出多态解决的实际问题;
  3. 定义与实现接口,理解"面向接口编程"如何降低耦合;
  4. 使用 is/as 与模式匹配安全地做类型判断与转换;
  5. 重写 ToString/Equals 等基类方法,知道组合为何常优于继承。

5.1 继承:is-a 关系与复用

继承表达 "是一个(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;
}

要点:

  • C# 是单继承:一个类只能有一个直接父类(object 是万物之祖);
  • 父类构造先于子类构造执行;
  • sealed 密封类:除非明确设计为可扩展,业务类建议默认不 virtual、必要时 sealed——开放的扩展点是设计承诺,不是免费的。

5.1.1 base 与成员隐藏

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 时,先怀疑自己写错了。

5.2 多态:同一接口,不同实现

多态(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 成员的取舍:抽象成员强制每个子类实现(合同);虚成员给默认实现(便利)。既要有共同逻辑又要子类差异化时用抽象类;只想定合同用接口(下节)。

5.3 接口:能力契约

接口(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 泛型数学用),进阶时再学,先用最朴素的"纯合同"接口即可。

5.4 类型检查与模式匹配

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  => "黄金",
    _       => "钻石"
};

5.5 万物之祖 object 与常用重写

所有类型隐式继承 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 仅在定制场景出现。

5.6 组合优于继承

继承是最强的耦合:父类内部改动会波及所有子类(脆弱基类问题)。当关系是"有一个(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 章"策略模式"的原型——你已经无师自通了核心思想:用多态把变化的规则从调用方剥离。

常见误区

  1. 为复用而继承:只想用父类的某个方法就把业务类挂上去,产生"狗不是一种厨房"式荒谬层次。先问 is-a 是否成立。
  2. 忘记 override 写成了 new 隐藏:多态失效且无编译错误(只有警告),行为诡异。
  3. 继承层次过深:超过两三层的继承链通常意味着设计失衡,优先拍平或改组合。
  4. 接口包含状态或过大的"上帝接口":IOrderService 塞 20 个方法,实现类被迫空实现。接口应小而内聚(接口隔离原则)。
  5. 用 as 后不判 null 直接用:obj as T 失败返回 null,下一行就 NullReferenceException;用 is T x 声明模式更安全。
  6. 在抽象类构造函数中调用抽象/虚成员:子类构造尚未执行,读到未初始化状态。

最佳实践

  1. 默认类 sealed、成员非虚;确有扩展需求再开放 virtual/abstract——扩展点是设计承诺。
  2. 面向接口设计服务边界:上层依赖 IOrderRepository 而非具体类,为第 12 章 DI、15 章测试铺路。
  3. 接口小而专(1-4 个成员常见),命名以 I 开头表达能力(IDisposable、IPayment)。
  4. 类型判断一律 is T x 模式;多分支类型行为用 switch 表达式 + 模式匹配。
  5. 需要值相等语义时优先 record(下一章),而不是手写 Equals/GetHashCode。

小结

  • 继承表达 is-a:单继承、父类构造先执行、base 调父类实现、sealed 终结扩展。
  • 多态:virtual/override/abstract 让"同一调用、不同实现",是开闭原则的语法基石。
  • 接口是能力契约:多实现、无状态、面向接口编程贯穿 DI/测试/架构全书。
  • is T x 声明模式与 switch 模式匹配是现代 C# 处理类型与分支的利器。
  • has-a 用组合、is-a 才继承;继承是强耦合,默认能组合则组合。

动手练习

  1. 基础:实现形状体系:抽象类 Shape(抽象属性 Area、虚方法 Describe()),派生 Circle、Rectangle、Triangle。遍历 List<Shape> 打印各自面积,体会多态。
  2. 基础:定义 ILogger 接口(void Log(string message)),实现 ConsoleLogger 与 FileLogger(追加到文件)。写一个使用 ILogger 的 App 类,运行时切换两种实现且不改 App 代码。
  3. 实战(轻订单 M4):把 5.7 的折扣体系接入 M3 项目:pay 命令接受策略名 none/vip/full,用 switch 表达式选择策略;新增"会员积分":VipCustomer 继承 Customer,消费自动积分、积分抵扣(100 分抵 1 元);Git 提交。
  4. 思考:为什么 IOrderRepository 让单元测试变得可行?(提示:测试时传入一个自己写的假实现。)这如何解释"面向接口"不只是语法习惯,而是可测试性的前提?

上一章:面向对象编程(上)← | README | 下一章:结构体、枚举、记录与可空性 →

📄 第 6 章 结构体、枚举、记录与可空性 06-structs-enums-records.md

上一章:面向对象编程(下)← | README | 下一章:集合与泛型 →

学习目标

学完本章,你将能够:

  1. 分清值类型与引用类型,预判赋值、传参时的复制语义;
  2. 用 enum 表达有限状态集,用 struct 建模小型值对象;
  3. 用 record 优雅地定义不可变数据载体,掌握 with 与解构;
  4. 启用并正确运用可空引用类型(NRT),消灭 NullReferenceException;
  5. 熟练使用 ?.、??、??= 等空安全运算符。

6.1 值类型与引用类型

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 章)已基本规避。

6.2 枚举(enum)

枚举为"有限个取值"的领域概念提供类型安全表达——比字符串常量强(编译期检查、防拼写错),比魔法数字强(自解释)。

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 传输用字符串而非数字,可读性好且利于排查。

6.2.1 Flags:组合位标志

[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

6.3 结构体(struct)

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 复制成本与误用风险更高。

6.4 记录(record):不可变数据载体

业务开发大量数据类只为"装数据":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。判断口诀:"两个值相同的订单是同一张订单吗?"——不是,订单是实体;"两个值相同的价格是同一个价格吗?"——是,价格是值。

6.5 可空引用类型(NRT)

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 的思维方式:编译器逼你考虑每一种空与不空的组合。

常见误区

  1. 到处用 ! 消警告:! 是对编译器撒谎,null 该来还是来。正确做法是把 ? 用好、判空做实。
  2. struct 滥用:大 struct 每次传参都整块复制,反而更慢;含可变字段的 struct 在集合中修改"不起效"(复制体被丢掉)。
  3. 枚举与魔法数字混用:if (status == 1) 比字符串还难排查;一律用枚举名。
  4. Enum.Parse 直接信用户输入:非法值抛异常,用 Enum.TryParse。
  5. record 当万能钥匙:需要 ORM 跟踪变化的实体(第 18 章)不适合 record 的值语义/不可变风格,实体用 class。
  6. 忘记 Flags 枚举值取 2 的幂:1|2|3 会重叠,HasFlag 判断错乱。

最佳实践

  1. 新项目开启 NRT,把警告当错误对待(CI 中 -warnaserror,第 23 章)。
  2. 对外 API 的返回类型如实标注 ?:Find 返回 T?,Get 返回 T(找不到抛异常)——调用方一眼看懂契约。
  3. 有限取值的领域概念(状态、渠道、类型)一律枚举,并把流转规则集中到一个方法。
  4. DTO、事件、配置快照用 record;需要派生修改时用 with。
  5. readonly struct + 运算符重载塑造领域值类型(Money、Quantity),让"金额单位混淆"这类 bug 在编译期暴露。

小结

  • 值类型复制数据、引用类型共享数据;字符串因不可变而表现近似值类型。
  • enum 类型安全地表达有限取值,Flags 支持位组合;流转校验在业务层。
  • struct 适合小型值语义数据,默认优先 class;record 是不可变数据载体的最优解,配 with 与解构。
  • NRT 把空安全做成编译期契约:? 声明、?./??/??= 处理、模式匹配穷举,! 是最后的豁免而非工具。

动手练习

  1. 基础:定义 Season 枚举与 Describe(Season s) 方法(switch 表达式),再实现 Enum.TryParse 循环读取用户输入直到合法。
  2. 基础:实现 readonly struct Temperature(double Celsius):支持 +、- 运算符与 ToFahrenheit();用两组相等实例验证值语义。
  3. 实战(轻订单 M5):给 Order 接入 OrderStatus 状态机(6.6 的实现),pay 命令只允许 Created → Paid,ship 只允许 Paid → Shipped,非法操作输出原因;Shipment 用 record 建模并在 ship 命令后打印物流描述;Git 提交。
  4. 思考:Order 用 class、Address 用 record 的理由分别是什么?如果把 Order 改成 record,with 生成的新订单与旧订单在业务上是什么关系?这说明实体与值的本质区别在哪?

上一章:面向对象编程(下)← | README | 下一章:集合与泛型 →

📄 第 7 章 集合与泛型 07-collections-generics.md

上一章:结构体、枚举、记录与可空性 ← | README | 下一章:委托、事件与 Lambda 表达式 →

学习目标

学完本章,你将能够:

  1. 解释泛型解决的两大问题:类型安全与性能;
  2. 熟练使用 List<T>、Dictionary<TKey, TValue>、HashSet<T>、Queue<T>、Stack<T>;
  3. 理解 IEnumerable<T> 与 yield 迭代器,看清所有集合的共同底座;
  4. 编写泛型方法与泛型类,运用 where 约束;
  5. 依据场景选对集合类型,避开"遍历中修改"等经典错误。

7.1 为什么需要泛型

没有泛型的年代,容器装 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 是占位符,使用时代入具体类型。一份代码服务所有类型,且零装箱、编译期检查。

7.2 List:动态数组

绝大多数"一组东西"都用它——可增长、按下标访问、增删查改齐全:

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)。

7.3 Dictionary<TKey, TValue>:键值映射

按"键"直查"值",平均 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);

7.3.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 自动满足值语义哈希;用可变对象当键、之后再改它的字段 = 哈希错乱,元素"失踪")。

7.4 HashSet 与数学集合运算

去重与成员判断 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 保持键序。

7.5 IEnumerable 与 yield 迭代器

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 两个特质:

  1. 惰性:调用 EvenNumbers(1_000_000) 瞬间返回,不预先算完——消费端取 3 个就只算 3 个;
  2. 流式:不物化整个列表,内存占用小。分页导出、读大文件逐行处理都靠它。

注意:yield 方法内不能有 return 值(只能 yield return / yield break),且枚举未走完前方法体不会全部执行——"我以为它已经跑了"是新手常见错觉。

7.6 编写泛型代码

7.6.1 泛型方法

// 交换任意引用/值类型的一对变量 —— 一份实现服务所有类型
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

7.6.2 泛型类与常用约束

// 一个最小可用的内存缓存(第 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 章)。

7.7 集合接口体系与选型

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 惰性返回,性能不足再建显式索引字典。

常见误区

  1. foreach 中增删元素:抛 InvalidOperationException(集合被修改)。需要删时:倒序 for 循环按索引删、或先 ToList() 快照再改原集合、或 RemoveAll。
  2. Dictionary 索引器当读取器:键不存在直接炸。读取一律 TryGetValue/GetValueOrDefault。
  3. 可变对象做字典键:插入后修改了参与哈希的字段,查找永久失灵。
  4. List<T> 的 Contains 误当"按条件查找":它做值相等比较;按条件用 Exists/Find/LINQ Any。
  5. 以为 IEnumerable<T> 已经"执行完":yield/LINQ 是惰性的,多次枚举会重复执行(甚至结果不同),需要复用时 ToList() 物化。
  6. 并发场景用普通集合:Dictionary/List 非线程安全,多线程读写需 ConcurrentDictionary<TKey,TValue> 等(第 10 章)。

最佳实践

  1. 声明类型用接口、创建用具体类:IList<Product> list = new List<Product>();(或更常见:字段/返回值用最窄的够用接口)。
  2. 预知大小时给 List<T>/Dictionary 传容量。
  3. 返回集合的方法:空时返回空集合而非 null([]),调用方免判空——"返回 null 还是空集合"的答案几乎总是空集合。
  4. 键选择稳定、简单、不可变的字段(Id、规范化字符串)。
  5. 泛型约束尽量具体:where T : notnull 比裸 T 表达了更多契约,编译器与调用方都受益。

小结

  • 泛型 = 类型参数化:编译期类型安全 + 免装箱性能,是 .NET 容器体系的地基。
  • List<T> 动态数组、Dictionary 哈希映射(读取用 TryGetValue)、HashSet 去重、Queue/Stack 顺序容器。
  • IEnumerable<T> 是集合最小协议;yield 提供惰性流式序列。
  • 自己写泛型的信号:逻辑对类型无感;约束 where 让泛型代码能做更多且更安全。
  • 对外暴露只读接口,空集合优于 null,foreach 中不修改集合。

动手练习

  1. 基础:用 Dictionary<string, List<string>> 实现简易单词倒排索引:Add(sentence) 把每个单词映射到出现过的句子,Query(word) 返回句子列表。
  2. 基础:实现泛型方法 static IEnumerable<T> TakeLast<T>(IEnumerable<T> source, int n)(不使用 LINQ 的 TakeLast,用 Queue<T> 维护最近 n 个)。
  3. 实战(轻订单 M6):把 M3-M5 的商品数组换成 ProductCatalog(Dictionary 索引),购物车换成 Cart;新增命令 find <id>、category <分类名>;给 Product 增加 Category;用 GroupByCategory 输出分类汇总报表;Git 提交。
  4. 思考:7.3.1 的 ToDictionary 索引方案在什么情况下会退化甚至出 bug?(提示:商品持续新增时要维护索引;键冲突会抛异常。)"索引"与"真相"应如何保持一致?

上一章:结构体、枚举、记录与可空性 ← | README | 下一章:委托、事件与 Lambda 表达式 →

📄 第 8 章 委托、事件与 Lambda 表达式 08-delegates-events-lambda.md

上一章:集合与泛型 ← | README | 下一章:LINQ →

学习目标

学完本章,你将能够:

  1. 解释委托是什么,理解"方法也能当参数传递"的价值;
  2. 使用 Action/Func/Predicate 内置委托类型;
  3. 编写 Lambda 表达式与闭包,识别闭包的经典陷阱;
  4. 用 event 实现"发布-订阅",知道事件的线程安全注意事项;
  5. 读懂 List.Sort(Comparison<T>)、事件回调等框架 API 背后的委托机制——它们是第 9 章 LINQ 的前置知识。

8.1 委托:方法的类型

委托(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 已被内置泛型委托取代,直接用下面三个即可。

8.1.1 内置委托三剑客

// 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。

8.2 Lambda 表达式

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 活多久,被捕获的变量就活多久(编译器会把它装箱进闭包对象延长生命周期)——既是强大特性,也是"意外持有大对象导致内存泄漏"的常见来源。

8.3 委托驱动的框架 API

你已经用过很多隐式委托:

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 链式查询可读性的来源。

8.4 事件:发布-订阅

事件(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 章),事件是它的进程内原型。

常见误区

  1. 以为闭包捕获"值":捕获的是变量引用,之后修改变量会影响 Lambda 行为;需要快照就先复制到局部变量。
  2. 忘记退订事件:长寿命发布者 + 短寿命订阅者 = 订阅者无法被回收,内存泄漏。
  3. 在事件处理器里抛异常:会打断后续订阅者的执行;处理器内部自行 try-catch(第 22 章)。
  4. 自定义委托类型满天飞:优先 Action/Func;只有语义特殊(如 EventHandler)才自定义。
  5. Lambda 里写复杂逻辑:超过三五行的 Lambda 应提取为命名方法——"看得见的意图"比"少敲几个字"重要。
  6. 在多播委托上依赖返回值:只有最后一个的返回值保留,其余丢弃。

最佳实践

  1. API 设计中"行为参数化"优先 Func/Action,参数个数克制(≤2 个可读性尚可,更多改传 record)。
  2. 事件用 EventHandler<TEventArgs> + 可空事件字段 + ?.Invoke 触发的标准三件套。
  3. 闭包内避免捕获大对象/长生命周期资源;定时器、事件订阅注意退订与释放。
  4. 用 async void 之外的一切方式处理异步:事件处理器除外(async void 是事件签名的唯一合法场景,第 10 章展开)。

小结

  • 委托把方法当值传递,Action/Func 覆盖几乎所有场景。
  • Lambda (参数) => 主体 是匿名方法简写,配合捕获外围变量形成闭包——捕获的是变量不是值。
  • 事件是受管控的多播委托:只能 +=/-=,只能发布者触发;EventHandler<T> + ?.Invoke 是标准写法。
  • 行为参数化与发布-订阅是"把变化隔离"的两大基础手法,直接通向 LINQ(下章)与设计模式(第 19 章)。

动手练习

  1. 基础:实现 static List<T> MyWhere<T>(List<T> source, Func<T, bool> predicate)(不用 LINQ),对一组订单按"金额 > 100"过滤并打印。
  2. 基础:写一个 EventEmitter 泛型类:Subscribe(Action<string> handler)、Emit(string msg)、Unsubscribe;验证订阅/退订/多播行为。
  3. 实战(轻订单 M7):把 M6 的支付流程改为事件驱动:OrderService.OrderPaid 事件 + 三个订阅者(库存预占、短信通知、积分发放),每个订阅者控制台输出自己的动作;再给 PointsService 增加退订演示;Git 提交。
  4. 思考:闭包捕获与"lambda 里用了外层的 order 变量"——如果这个 Lambda 被存起来延迟执行(比如事件触发时才跑),而 order 后来被改了,会发生什么?如何规避?

上一章:集合与泛型 ← | README | 下一章:LINQ →

📄 第 9 章 LINQ:统一的数据查询 09-linq.md

上一章:委托、事件与 Lambda ← | README | 下一章:异步编程 →

学习目标

学完本章,你将能够:

  1. 用方法语法(链式 Lambda)与查询语法两种风格编写 LINQ 查询;
  2. 熟练运用过滤、投影、排序、分组、聚合、分区等核心操作符解决真实业务查询;
  3. 理解延迟执行与物化,避免"多次枚举"与"以为已经执行"两类 bug;
  4. 区分 IEnumerable<T>(内存)与 IQueryable<T>(可翻译,为 EF Core 铺垫);
  5. 为轻订单写出销售报表、热销榜、分页列表等典型查询。

9.1 LINQ 是什么

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。本文全用方法语法。

9.2 延迟执行:LINQ 的第一性原理

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,两次结果不同

两条铁律由此而来:

  1. 要复用/多次遍历的结果,先物化:var list = query.ToList();;
  2. 要"当下"的快照,先物化:否则底层数据变了,结果跟着变。

立即执行的操作符是例外:Count()、Sum()、First()、Any() 等聚合/取值类会当场执行。

9.3 核心操作符

9.3.1 过滤与投影

// 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 });

9.3.2 排序

var sorted = orders
    .OrderByDescending(o => o.Subtotal)      // 主键:金额降序
    .ThenBy(o => o.CreatedAt)                // 次键:时间升序(同额先单)
    .ThenByDescending(o => o.OrderNo)
    .ToList();

注意:OrderBy 是稳定排序(相等元素保持原相对顺序),且多次链 OrderBy 只有最后一个生效(后者覆盖前者)——多级排序必须用 ThenBy 系列。

9.3.3 分组

// 按状态分组: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() });

9.3.4 聚合与量化

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(后者可能数完整个序列)。

9.3.5 分区:分页的原料

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 单一批处理
}

9.3.6 元素获取

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

9.3.7 集合运算与杂项

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) 直接取极值元素

9.3.8 物化家族

query.ToList();                       // List:最常用
query.ToArray();                      // 数组:不再变化的数据
query.ToDictionary(p => p.Id);        // 建索引(键重复抛异常!)
query.ToDictionary(p => p.Id, p => p.Name);   // 键 → 投影值
query.ToLookup(p => p.Category);      // 一键多值(键可重复,类似 GroupBy 的物化版)

9.4 IEnumerable vs IQueryable:一个伏笔

  • 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(判空防护)。

常见误区

  1. 多次枚举未物化的查询:数据库场景是重复执行 SQL(性能事故),内存场景可能结果不一致。
  2. Average/First/Single/Min/Max 直接跑在可能为空的序列上:抛异常;判空 Any() 或用 FirstOrDefault/DefaultIfEmpty。
  3. 对数据库查询先 ToList 再过滤/分页:全表进内存;Where/Skip/Take 必须在物化之前(9.4)。
  4. 链多个 OrderBy 期待多级排序:只有最后一个生效,多级用 ThenBy。
  5. 在 Select/Where 里做副作用(改状态、发请求):LINQ 是查询不是流程控制,延迟执行会让副作用"不知道什么时候发生"。
  6. Distinct() 对实体不生效:class 按引用比较,内容相同的两个对象都保留;用 DistinctBy 或换 record。

最佳实践

  1. 查询链自上而下读起来像句子:先过滤(缩小数据面)→ 再排序/分组 → 投影 → 最后物化。
  2. 需要多次使用的查询结果一律 ToList(),命名清楚(paidOrders 而非 query1)。
  3. Any 判存在、FirstOrDefault 找单个、Single 做唯一性断言,各司其职。
  4. 聚合前先想空集:Average/Max 空序列抛异常,报表格子显示 0 比崩溃好。
  5. 写复杂查询时把中间结果存成有意义的变量,别一条链写到 15 行。

小结

  • LINQ 用统一语法查询集合/数据库/文档;方法语法是工程主流。
  • 延迟执行是第一性原理:查询是计划,枚举才执行;复用与快照先 ToList()。
  • 核心操作符:Where/Select/SelectMany/OrderBy(+ThenBy)/GroupBy/Sum,Count,Average/Any,All/Skip,Take/First,Single/ToDictionary。
  • IEnumerable 内存执行,IQueryable 翻译执行——对数据库查询,过滤与分页要赶在物化之前。
  • 报表类需求的套路:过滤 → 分组 → 聚合 → 排序取 Top → 物化渲染。

动手练习

  1. 基础:给定一组商品(名称、分类、价格、销量),用 LINQ 分别输出:价格最高的商品(MaxBy)、每个分类的平均价、销量 Top3、按价格区间(<20/20-50/>50)分组的数量。
  2. 基础:用 Aggregate 把商品名列表拼成 "A、B、C";用 Zip 把两组数据(商品、销量)配对输出。
  3. 实战(轻订单 M8):实现 9.5 的 SalesReport(可造 20 条模拟订单数据),增加命令 report 输出日报、report top 只输出热销榜;再实现 page <n>:对全部订单按时间倒序分页展示(每页 3 条);Git 提交。
  4. 思考:orders.Where(...).Count() 与 orders.Count(...) 有何区别?编译后一样吗(提示:两者都会立即执行,但前者多一层枚举包装)?那 Where(...).ToList().Count 呢——问题出在哪?

上一章:委托、事件与 Lambda ← | README | 下一章:异步编程 →

📄 第 10 章 异步编程:async/await 与 Task 10-async-programming.md

上一章:LINQ ← | README | 下一章:ASP.NET Core Web API 入门 →

学习目标

学完本章,你将能够:

  1. 解释异步解决的核心问题:I/O 等待期间的线程占用与吞吐量瓶颈;
  2. 编写正确的 async/await 方法,理解 Task/Task<T> 的返回语义;
  3. 用 Task.WhenAll/WhenAny 组织并发 I/O,用 CancellationToken 支持取消;
  4. 避开三大经典事故:async void、.Result 死锁、"假异步";
  5. 理解线程安全的基本盘,会在异步代码中使用并发集合。

10.1 为什么需要异步

一次数据库查询或 HTTP 调用动辄几十毫秒,期间 CPU 什么都没干,纯粹在等网络。同步模型里这个线程干等;Web 服务器线程池有限(默认每核心若干线程),几百个请求并发就把线程耗光——后续请求排队甚至超时。

同步:  线程 A ──发起查询──【干等 50ms】──拿到结果──释放
        50ms 内这个线程谁也用不了

异步:  线程 A ──发起查询──立即释放(去服务别的请求)
        ────── 50ms 后查询完成 ── 某个空闲线程被唤醒继续处理
        等待期间零线程占用,吞吐量倍增

一句话:异步不是"更快",而是"等待时不占线程"。对 CPU 密集计算它无能为力;对 I/O 密集(数据库、HTTP、文件、消息队列)它是吞吐量的生命线。现代 ASP.NET Core 全栈异步,本章是第二、三部分的地基。

10.2 Task 与 async/await

10.2.1 Task:一个"未来才有结果"的占位

  • 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" };
}

10.2.2 await:暂停本方法,不阻塞线程

await 的语义:"这个还没好?好,本方法在这里挂起、当前线程立即回去干别的;结果就绪时由基础设施找线程从这行继续。"

static async Task Main()                          // 入口也可 async(顶级语句本身就是 Main 内部)
{
    string rate = await FetchExchangeRateAsync("USD");   // 挂起点:等待时不占线程
    Console.WriteLine($"USD 汇率 {rate}(线程 {Environment.CurrentManagedThreadId})");
}

观察两次打印的线程号大概率不同——await 之后的代码可能运行在另一个线程上,这就是为什么异步方法里不要依赖"线程本地"状态(如 ThreadStatic、某些旧式上下文)。

10.2.3 编写 async 方法的规则

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(见误区)

10.3 并发执行:WhenAll

串行 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。

10.4 取消:CancellationToken

长时间操作应当可以被叫停——搜索请求被用户取消、服务停止时中断后台任务。约定:异步方法接受 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。

10.5 异步流与并发集合

10.5.1 IAsyncEnumerable:异步的 yield

分页拉取、逐条消费消息这类"边等边产"的场景:

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);                         // 到一条处理一条,不整包加载
}

10.5.2 并发集合与线程安全

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 章)。

常见误区

  1. async void:异常无法被调用方捕获(直接炸进程)、无法等待。唯一合法场景是事件处理器(async void OnClick(...));其他一律 async Task。
  2. 同步阻塞异步:task.Result、task.Wait()、.GetAwaiter().GetResult() 在有同步上下文的宿主里会死锁(结果就绪后无人能继续执行被阻塞的上下文),至少也白占一个线程。自顶向下全异步,阻塞调用只允许出现在无法改造的边界(如 Main 用顶级语句)。
  3. 假异步:async 方法里没有 await,纯 CPU 计算包装成 Task.Run 还期待它省线程——省不了,只是增加调度开销。CPU 密集并行化用 Task.Run + 明确的并行收益评估。
  4. Thread.Sleep 混入异步:阻塞线程;等待一律 await Task.Delay。
  5. 在 using/lock 作用域内跨 await:资源可能在 await 期间被释放/锁不可跨 await;把 await 移出作用域或重新设计生命周期。
  6. 循环里串行 await N 个独立请求:改 WhenAll 批量并发(注意控制并发度,避免压垮下游——可用 SemaphoreSlim 限流)。
  7. 忘传 CancellationToken:从 Controller 到数据访问一路透传,客户端断开才真的能停止工作。

最佳实践

  1. 异步一路到底:入口异步,则整条调用链异步,避免中途 .Result。ASP.NET Core 从 Controller 到 EF Core 原生异步,顺水推舟。
  2. 方法名带 Async 后缀、参数表末尾带 CancellationToken ct = default,是 .NET 社区的强约定。
  3. 独立 I/O 用 WhenAll 并发,有依赖的步骤串行;先启动后等待。
  4. fire-and-forget 必须包异常处理并记日志,或有界队列兜底。
  5. 跨 await 的共享状态:并发集合 / 不可变 / 锁(锁内禁 await)三选一,并写注释说明线程安全假设。

小结

  • 异步解决"I/O 等待占线程":等待期间线程归队,吞吐量数倍提升;对 CPU 密集无益。
  • Task/Task<T> 是异步操作占位;await 挂起方法而非线程,恢复点可能在别的线程。
  • WhenAll 并发独立 I/O(先启动后等待),WhenAny 实现超时与竞速;CancellationToken 协作式取消,全链路透传。
  • 红线:不写 async void(事件除外)、不 .Result 阻塞、不造假异步、lock 内不 await、共享状态用并发集合。
  • 编排套路:并发查(WhenAll)→ 串行做(有依赖)→ 可靠后台化(队列)。

动手练习

  1. 基础:写三个模拟耗时 Task.Delay 的异步方法(如 查库存/查汇率/查物流),分别用串行 await 与 WhenAll 执行,用 Stopwatch 打印两种方式的耗时差。
  2. 基础:实现带超时的 DownloadWithTimeoutAsync:用 WhenAny 对比 Task.Delay(2000),超时抛 TimeoutException;再用 CancellationTokenSource(TimeSpan.FromSeconds(2)) 实现同一效果,比较两种写法。
  3. 实战(轻订单 M9):把 M8 的订单流程改造为 OrderOrchestrator:库存、优惠券、汇率三个查询并发;下单后异步发通知(fire-and-forget 带异常保护);所有方法接受 CancellationToken;命令行 order 命令演示编排并打印耗时;Git 提交。
  4. 思考:为什么 lock 块内不允许 await?(提示:锁的持有者是线程,await 后恢复可能在别的线程——谁来释放锁?)那需要"跨异步操作的互斥"该怎么办?(预告:SemaphoreSlim.WaitAsync。)

上一章:LINQ ← | README | 下一章:ASP.NET Core Web API 入门 →

📄 第 11 章 ASP.NET Core Web API 入门 11-aspnetcore-webapi.md

上一章:异步编程 ← | README | 下一章:依赖注入 →

学习目标

学完本章,你将能够:

  1. 说清 HTTP 服务的基本工作方式与 REST 风格的资源建模;
  2. 解释 ASP.NET Core 的两大支柱:Kestrel 服务器与中间件管道;
  3. 用 Minimal API 与 Controller 两种风格实现 CRUD 接口;
  4. 正确处理路由、参数绑定、状态码与错误响应;
  5. 把「轻订单」从控制台程序升级为对外的 HTTP API。

11.1 Web API 与 REST 快速入门

前后端分离、小程序、第三方对接……所有这些集成的通用语言就是 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     经营日报

11.2 ASP.NET Core 架构一瞥

HTTP 请求
   │
   ▼
Kestrel(内置高性能跨平台 Web 服务器)
   │
   ▼
中间件管道(按注册顺序串起来的处理链)
   ExceptionHandler → HTTPS重定向 → 路由 → 认证 → 授权 → 自定义中间件 → 端点
   │
   ▼
端点(Minimal API / Controller 的 Action)
   │
   └─ 依赖注入容器解析服务 → 执行业务 → 返回结果
  • Kestrel:ASP.NET Core 自带的服务器,生产环境可直接暴露,或放在 Nginx/IIS 后面(反向代理);
  • 中间件(Middleware):每个请求穿过一串中间件,每个都可以处理、短路或放行——日志、认证、异常处理都是中间件;
  • 依赖注入容器:一等公民内建(第 12 章展开);
  • Host(主机):把配置、日志、DI 容器、服务器打包启动的"总装线"。

11.3 创建第一个 Web API

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 章起示例中加入)可得到可视化接口页。

11.4 Minimal API:轻量端点

11.4.1 基本形态与参数绑定

// 各种来源的参数自动绑定
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 反序列化。

11.4.2 一个完整的 CRUD

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();

关键习惯:

  • 端点只做"HTTP 翻译"(收参数、调业务、翻译状态码),业务逻辑放服务类——端点薄、服务厚;
  • 返回 Results.Ok/Created/NoContent/BadRequest/NotFound/Conflict/Problem(...) 显式表达语义;
  • 对外模型与领域对象分离:CreateProductRequest(入参)、ProductResponse(出参)是 API 契约,别把领域实体直接序列化出去(第 17 章强化);
  • 错误响应用结构化对象(new { message = ... } 或 Results.Problem),而不是裸字符串。

11.4.3 Controller 风格

团队规范或大型项目常用 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,概念两者通用。

11.5 请求验证

手写 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 章错误规范会继续强化"入口处把非法请求挡掉"的原则。

典型业务场景:轻订单上线 Web API

把前十章的领域代码搬进 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 章数据库)。

常见误区

  1. 端点里写全部业务逻辑(上帝端点):无法复用、无法测试。端点只做 HTTP 翻译。
  2. 领域实体直接对外序列化:泄露内部字段(如内部成本价)、锁死重构自由。用 Request/Response 模型隔离。
  3. 错误一律返回 200 + 错误 JSON:调用方无法用状态码编程;错误也应有正确语义(400/404/409/500)。
  4. 用 Singleton 存业务状态还以为没事:单实例勉强能跑,并发写、多实例部署、重启丢失全是坑——状态要进数据库(18 章)或缓存(20 章)。
  5. 同步阻塞方法混进异步管线:Controller/端点里调用 .Result;全链路异步(第 10 章)。
  6. 忘开 HTTPS / 在生产暴露调试端点:环境判断(IsDevelopment)要贯穿。

最佳实践

  1. API 契约先行:Request/Response record + DataAnnotations/FluentValidation 验证,OpenAPI 自动生成文档。
  2. 资源命名复数小写、子路径表达动作(/orders/{no}/pay);版本前缀 /api/v1 预留演进空间。
  3. 返回 Results.* 显式状态码;错误响应结构统一(第 22 章引入 RFC 9457 ProblemDetails)。
  4. 业务异常 → 状态码的翻译集中处理(异常中间件),不要每个端点手写 catch(第 22 章)。
  5. 本地调试用 launchSettings.json 固定端口;dotnet watch 热重载提速开发。

小结

  • REST:URL 即资源、方法即操作、状态码即结果;设计 API 先想资源模型。
  • ASP.NET Core = Kestrel + 中间件管道 + 内建 DI + 环境感知 Host。
  • Minimal API 适合轻快场景,Controller 适合大团队规范;两者都应"端点薄、服务厚"。
  • 参数绑定自动完成;返回 Results.* 表达语义;入口验证挡非法请求。
  • 对外契约(Request/Response)与领域模型分离。

动手练习

  1. 基础:用 Minimal API 实现一个"待办事项"API(GET/POST/PATCH/DELETE /api/todos),内存存储,PATCH 支持"完成标记"。
  2. 基础:给 11.6 的商品 CRUD 加上分页(?page=1&pageSize=10,返回 { items, total, page, pageSize })与按名称模糊搜索。
  3. 实战(轻订单 M10):新建 LiteOrder.Api 项目加入解决方案,把 M6-M9 的领域与服务代码迁入;实现商品 CRUD、创建订单、支付、日报四个端点;验证时注意并发场景下单例存储的问题并记录到项目 README 的"已知问题"里(它们是 17/18/20 章的引子);Git 提交。
  4. 思考:为什么说"把领域实体直接返回"会锁死重构?举出一个具体场景(如内部字段、ORM 懒加载代理对象)说明风险。

上一章:异步编程 ← | README | 下一章:依赖注入 →

📄 第 12 章 依赖注入与服务生命周期 12-dependency-injection.md

上一章:ASP.NET Core Web API 入门 ← | README | 下一章:配置管理与选项模式 →

学习目标

学完本章,你将能够:

  1. 解释"控制反转/依赖注入"解决的核心问题:new 硬编码导致的耦合与不可测试;
  2. 在 ASP.NET Core 中注册服务并区分三种生命周期(Singleton/Scoped/Transient);
  3. 通过构造函数注入消费服务,掌握接口注册、工厂注册与批量注册;
  4. 识别生命周期陷阱(Captive Dependency、Singleton 并发状态);
  5. 把轻订单的服务层完全交给容器管理,为单元测试铺平道路。

12.1 为什么需要依赖注入

回想第 5 章的 OrderService——它内部 new OrderRepository()。这个"自己造依赖"的写法有三个死结:

  1. 换实现要改代码:内存仓储换成数据库仓储,改 OrderService 源码、重新编译;
  2. 无法测试:OrderService 的单元测试必然拖上真实的 OrderRepository(进而拖上数据库);
  3. 依赖链手工维护: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 章的基石)。

12.2 注册服务与生命周期

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() });
});

12.2.1 更多注册姿势

// 工厂注册:构造逻辑复杂时
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);

12.2.2 装配扩展方法:让 Program.cs 保持整洁

服务多了以后,按功能模块封装注册——这是 .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();

12.3 消费服务

12.3.1 构造函数注入

容器自动装配类与端点的依赖,嵌套依赖自动递归解析:

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 直呼,测试时注入假时钟控制"当前时间"。业务代码依赖抽象时间是个小而美的实践。

12.3.2 请求中途手动解析(服务定位器,慎用)

app.MapGet("/x", (HttpContext ctx) =>
{
    var svc = ctx.RequestServices.GetRequiredService<OrderService>();   // 运行时才解析
    // ...
});

服务定位器反模式:依赖隐藏在方法体内,签名上看不出来;解析失败推迟到运行时;测试更难。仅在有明确生命周期原因(如在中间件里按请求解析 Scoped 服务)时使用,日常一律构造函数注入。

12.4 生命周期陷阱

12.4.1 Captive Dependency(囚徒依赖)

长寿命服务捕获短寿命服务——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);
    }
}

12.4.2 Singleton 里的可变状态与并发

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 服务尽量无状态,有状态必并发安全。

12.4.3 Transient 放大器

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 的投资开始分红。

常见误区

  1. 服务定位器满天飞(GetRequiredService 在业务代码各处出现):依赖不可见、错误延迟暴露。构造函数注入是默认答案。
  2. Singleton 引用 Scoped(Captive Dependency):启动即抛异常(开发环境),生产关闭校验时则是隐性能泄漏。
  3. 在 Singleton/静态类里积累请求级状态(当前用户、购物车):请求之间互串,多用户环境重大事故源。请求态放 Scoped 或显式传递。
  4. 构造函数里做重活(I/O、复杂初始化):容器解析发生在请求路径上;重活放方法首次调用或 IHostedService。
  5. 为每个类配一个接口(OrderService : IOrderService 只有 OrderService 实现):接口是为了抽象边界,不是为了仪式感;无第二实现且不作为对外契约的类,直接注册具体类即可。
  6. 忘记注册却正常跑:在端点里手 new 了兜底——运行到那才发现。启动校验(开发环境默认开启)会帮你提前暴露。

最佳实践

  1. 按模块封装 AddXxxModule() 扩展方法,Program.cs 只剩一行行装配调用。
  2. 生命周期从长到短选择:无状态 Singleton → 携带请求态 Scoped → 轻量瞬态 Transient。
  3. Singleton 服务无状态优先;有状态必并发安全(Concurrent 集合/锁/不可变)。
  4. 消费依赖一律构造函数注入;HttpContext.RequestServices 只用于中间件/过滤器的按作用域解析。
  5. 抽象接口服务于边界与测试,不为接口而接口。

小结

  • DI 把"创建依赖"外置:类声明需要什么,容器负责装配——换来可替换、可测试、可组合。
  • 三种生命周期:Singleton(全局 1 个)、Scoped(每请求 1 个)、Transient(每解析 1 个)。
  • 注册支持接口映射、工厂、多实现、模块化扩展方法;消费靠构造函数注入,Minimal API 端点直接参数注入。
  • 红线:Singleton 不得引用 Scoped;Singleton 可变状态必须并发安全;构造函数不做重活。
  • 面向接口 + 容器装配,是 15 章可测试性与 17 章分层架构的技术底座。

动手练习

  1. 基础:注册一个 IPrinter 接口的三个实现(A/B/C),写端点注入 IEnumerable<IPrinter> 依次调用;再把生命周期改成 Scoped,同请求解析两次验证同一实例。
  2. 基础:故意制造 Captive Dependency(Singleton 构造函数注入 AppDbContext 或自定义 Scoped 类),观察启动异常信息,再用 IServiceScopeFactory 修复。
  3. 实战(轻订单 M11):按 12.5 改造轻订单:IProductStore/IOrderStore 接口 + 内存实现(ConcurrentDictionary);OrderService 全部依赖接口;注册代码抽成 AddOrderModule();用 TimeProvider 抽象时间注入订单号生成;Git 提交。
  4. 思考:为什么"每个类一个接口"是仪式感、"面向抽象"却是真需求?判断一个接口是否值得存在的标准是什么?(提示:有无第二种实现的真实可能——测试替身算不算?)

上一章:ASP.NET Core Web API 入门 ← | README | 下一章:配置管理与选项模式 →

📄 第 13 章 配置管理与选项模式 13-configuration-options.md

上一章:依赖注入 ← | README | 下一章:日志记录 →

学习目标

学完本章,你将能够:

  1. 使用 .NET 统一配置体系:JSON 文件、环境变量、命令行、用户机密的分层覆盖;
  2. 按环境组织配置(Development/Staging/Production);
  3. 用选项模式(Options Pattern)把配置强类型化、校验化、注入化;
  4. 区分 IOptions/IOptionsSnapshot/IOptionsMonitor 并正确选择;
  5. 把轻订单的业务参数(折扣、运费阈值)从硬编码迁移为可配置项,并掌握密钥管理的正确姿势。

13.1 统一配置体系

WebApplicationBuilder 启动时自动装配多个配置源,后注册者覆盖先注册者:

appsettings.json                       基础配置(随代码走,不含秘密)
  ↑ 覆盖
appsettings.{Environment}.json         环境专属(如 appsettings.Development.json)
  ↑ 覆盖
用户机密(user-secrets,仅开发机)       本机开发时的敏感值
  ↑ 覆盖
环境变量                                部署环境注入(容器/云平台标准姿势)
  ↑ 覆盖
命令行参数                              临时覆盖(--Key Value)

同一配置键的最终值由"最上层"来源决定——代码里只管读键,不用关心它来自哪。这就是配置体系的意义:同一份应用代码,开发、测试、生产靠不同配置组合出不同行为。

13.2 读取配置

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、类型不安全、每个类各自解析、无法校验。它的正确归宿是被"选项模式"封装。

13.3 选项模式(Options Pattern)

13.3.1 定义选项类 + 绑定 + 校验

// 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。

13.3.2 消费选项:三张面孔

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> 用得少,需要"请求级快照"时才用。

13.3.3 命名选项与多实例(了解)

IOptionsMonitor<T>.Get("name") 支持同一类型多套配置(如多个渠道各自的限流参数),进阶场景再学。

13.4 按环境组织配置

// appsettings.Development.json —— 开发机专属覆盖
{
  "Logging": { "LogLevel": { "Default": "Debug", "Microsoft.AspNetCore": "Warning" } },
  "Order": { "FreeShippingThreshold": 0 }      // 开发时免邮,方便测试
}
  • 环境由 ASPNETCORE_ENVIRONMENT 决定(Development/Staging/Production);
  • 生产配置策略:appsettings.json 放结构与非敏感默认值,敏感值与差异化值全部走环境变量或密钥管理服务(容器编排、K8s ConfigMap/Secret、云厂商配置中心)。

13.5 秘密管理:绝不提交凭据

铁律:连接字符串、API Key、密码永远不进代码库。(.gitignore、Code Review、第 21 章扫描工具三道防线)

13.5.1 开发:user-secrets

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"] 或选项绑定)——代码零感知,仓库里干净。

13.5.2 生产:环境变量 / 密钥服务

# 环境变量按"双下划线"映射配置层级
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 元);修改业务规则不再需要改代码重新发布——这就是配置化的完整闭环。

常见误区

  1. 机密提交进 appsettings.json / 代码:历史提交里永远删不干净(需要轮换全部密钥),这是真实事故的重灾区。
  2. 散用字符串键读配置:键拼错静默 null、无类型无校验。统一走选项模式。
  3. 选项类可变 + Singleton 共享:IOptions 是全局单实例,任何人在运行时改它的属性影响所有请求——选项类用 init/sealed 保持只读。
  4. 期待 IOptions 热更新:它不更新(进程内缓存);需要热更新用 IOptionsMonitor。
  5. ValidateOnStart 缺席:配置错误潜伏到首次使用;启动期校验让运维在发布流水线就发现。
  6. 环境变量映射符记错:JSON 的 : 在环境变量里是 __(双下划线),写错则读到的永远是 null。

最佳实践

  1. 配置分层:结构与非敏感默认值进 appsettings.json;环境差异进环境专属文件;机密与生产差异化值进环境变量/密钥服务。
  2. 所有被业务消费的配置走选项模式:强类型 + 数据注解 + ValidateOnStart。
  3. 选项类 sealed + init 只读属性,给合理默认值。
  4. 依赖配置的服务为测试留缝:接受 IOptions<T> 或直接接受纯参数(测试构造纯对象)。
  5. CI 中加配置完整性冒烟检查(启动应用 → 健康检查通过 → 销毁),第 23 章落地。

小结

  • .NET 配置体系多源分层覆盖:JSON → 环境专属文件 → user-secrets → 环境变量 → 命令行,代码只管读。
  • 选项模式把配置强类型化:AddOptions<T>().Bind().ValidateDataAnnotations().ValidateOnStart(),业务服务注入 IOptions<T>(默认)/IOptionsMonitor<T>(热更新)。
  • 环境维度由 ASPNETCORE_ENVIRONMENT 驱动,行为按环境自动切换。
  • 机密管理铁律:开发用 user-secrets,生产用环境变量/密钥服务,仓库里永远零明文凭据。

动手练习

  1. 基础:给应用加 Greeting 配置段(Prefix、Suffix),用选项模式注入并校验(Prefix 必填),故意配置越界值观察 ValidateOnStart 的启动报错。
  2. 基础:用 user-secrets 存一个 Demo:ApiKey(值随意但当作真实机密对待),写端点 /secret-check 只输出"已配置/未配置"(不回显值——为什么?),并把 appsettings.json 确认干净后提交。
  3. 实战(轻订单 M12):把轻订单全部业务常量迁移到 OrderOptions(折扣率、免邮门槛、运费、单笔上限),接入 ValidateOnStart;新增 appsettings.Development.json 给开发专属值;README 记录每个配置键的含义;Git 提交(提交前 git diff --cached 确认无敏感值混入)。
  4. 思考:为什么"配置校验失败要在启动时炸"优于"运行到才炸"?从故障发现时间(MTTD)、爆炸半径、发布流水线三个角度分析。

上一章:依赖注入 ← | README | 下一章:日志记录 →

📄 第 14 章 日志记录 14-logging.md

上一章:配置管理与选项模式 ← | README | 下一章:单元测试 →

学习目标

学完本章,你将能够:

  1. 使用 ILogger<T> 按级别规范记录日志,理解六大级别的语义边界;
  2. 编写结构化日志(消息模板 + 命名占位符),让它可查询、可聚合;
  3. 通过配置按来源/环境调整日志级别,用日志作用域串联一次请求;
  4. 集成 Serilog 实现文件滚动与集中式日志,了解 OpenTelemetry 的定位;
  5. 为轻订单建立"出事能查、查之能中"的日志体系,并守住敏感信息红线。

14.1 为什么不用 Console.WriteLine

控制台输出:无级别、无时间戳、无来源、无法开关、无法集中收集、进程结束即蒸发。生产系统需要的日志是可检索的事件流——某个请求出了什么错、影响哪些订单、耗时多少。ILogger 与日志基础设施把"写日志"升级为"发事件"。

14.2 ILogger 基础

14.2.1 注入与级别

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 日志可以放心写细,成本由配置控制。

14.2.2 结构化日志:模板与占位符

// ✅ 正确:消息模板保持常量,数据走命名占位符
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 社区约定。同理,循环里写日志注意量级,海量重复事件考虑采样或聚合后输出。

14.2.3 异常日志的正确姿势

// ✅ 异常对象作为第一参数:完整堆栈入日志
logger.LogError(ex, "支付回调处理失败 OrderNo={OrderNo}", orderNo);

// ❌ 只记 ex.Message:丢失堆栈与内部异常链,排查无从下手
logger.LogError("支付回调失败:{Msg}", ex.Message);

14.3 配置日志

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",        // 框架噪音调低
      "Microsoft.EntityFrameworkCore": "Warning",
      "LiteOrder": "Debug"                      // 自己的命名空间调细
    }
  }
}

按"类别"(通常是类的全名命名空间)精细控制级别——第三方库太吵就把它调 Warning,自己模块排查时临时调 Debug。这是级别过滤体系的日常用法。

14.3.1 日志作用域:串联一次业务流

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 章分布式追踪的伏笔)。

14.4 Serilog 与集中式日志

内建日志写文件很弱(无滚动切割)。生产标配是 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):

  1. 每个业务里程碑一条 Information(开始/成功),异常路径 Warning(业务拒绝)或 Error(系统故障);
  2. 所有日志携带 OrderNo 等业务主键占位符,实现"一单一条线"检索;
  3. 金额、状态等关键决策字段入日志;循环内逐条日志必须评估量级;
  4. 绝不记录:密码、token、完整卡号、用户身份证号(脱敏后尾 4 位可以)。

常见误区

  1. 消息模板里用字符串插值($"..."):结构化能力归零;占位符 + 参数才是可查询日志。
  2. 记异常只记 Message:堆栈、InnerException 全丢。异常对象作为 LogError 第一参数。
  3. 级别失当:把业务拒绝(预期内)记成 Error(值班半夜被叫醒);把系统故障记成 Information(无人发现)。级别语义即告警语义。
  4. 敏感信息入日志:密码/token/完整证件号——日志平台权限远宽于数据库,等于泄露放大器。
  5. 日志风暴:高频循环内无脑 LogInformation,磁盘被打满。量大事件聚合输出或降 Debug。
  6. 用日志当调试器:开发期频繁改代码加日志再跑一遍,效率极低——第 16 章的断点调试才是正途。

最佳实践

  1. ILogger<T> 注入使用;框架与自研模块分级别控制(Logging:LogLevel 按命名空间)。
  2. 消息模板人类可读("订单支付成功"),占位符机读(OrderNo/Amount);PascalCase 命名。
  3. 业务主键贯穿日志;善用 BeginScope 与请求 TraceId。
  4. 时间统一 UTC + ISO8601;日志留存有滚动与上限策略。
  5. 上生产前把日志输出接进集中式平台,本地开发 Console + 文件足够。

小结

  • ILogger 六级别是过滤器与告警语义:Trace/Debug 细节、Information 里程碑、Warning 需关注、Error/Critical 故障。
  • 结构化日志:常量模板 + 命名占位符,让日志可检索可聚合;异常对象作第一参数保留堆栈。
  • 配置按类别调级别;BeginScope 与 TraceId 串联请求链路。
  • Serilog 补齐文件滚动与集中收集,OpenTelemetry 是日志/指标/追踪的统一未来。
  • 敏感信息红线:密码、token、完整证件号永不入日志。

动手练习

  1. 基础:给 BankAccount(第 4 章练习)加日志:存取款成功 Information、余额不足 Warning、异常 Error;故意触发一次异常,观察控制台输出的完整堆栈。
  2. 基础:配置把 Microsoft.AspNetCore 调到 Warning,自己模块调到 Debug;写一条 Debug 与一条 Information 日志验证过滤生效。
  3. 实战(轻订单 M13):接入 Serilog(Console + 按天滚动文件);为下单/支付/发货链路补齐结构化日志(含 OrderNo/Amount/Status 占位符);实现"一单一条线"验证:跑三笔订单后,在日志文件中按 OrderNo 检索各自完整链路;Git 提交。
  4. 思考:为什么说"日志级别即告警语义"?如果一个团队把所有异常都记 Warning,会给值班与 SLO 造成什么影响?

上一章:配置管理与选项模式 ← | README | 下一章:单元测试 →

📄 第 15 章 单元测试 15-unit-testing.md

上一章:日志记录 ← | README | 下一章:调试、诊断与代码质量 →

学习目标

学完本章,你将能够:

  1. 理解测试金字塔中单元测试的定位与投资回报;
  2. 用 xUnit 编写规范的单元测试:AAA 结构、参数化、异常断言;
  3. 用测试替身(Fake/Mock)隔离外部依赖,理解 DI 如何让"可测试"成为可能;
  4. 用 TimeProvider 假时钟控制时间,测出"限时逻辑";
  5. 为轻订单的核心业务规则建立回归测试网,并认识集成测试与覆盖率。

15.1 为什么写单元测试

单元测试 = 用代码验证代码的最小单元(类/方法)行为符合预期。它买到的东西:

  1. 回归防护:改代码后一键验证没改坏别人——敢重构的前提;
  2. 活文档:测试即用法示例,描述"输入 X 应得 Y";
  3. 设计压力:难写测试的代码几乎都是耦合过重的代码——可测试性倒逼好设计(第 4/12 章的接口与 DI 在此会师);
  4. 协作保险:CI 里每次提交自动跑(第 23 章),坏代码进不了主干。

测试金字塔:单元测试(多而快)→ 集成测试(少而真)→ 端到端测试(极少数关键路径)。绝大多数业务规则靠单元测试覆盖,本章主攻单元测试,15.6 简介集成测试。

15.2 xUnit 快速上手

创建测试项目并挂到解决方案:

dotnet new xunit -n LiteOrder.Tests
dotnet sln add LiteOrder.Tests
dotnet add LiteOrder.Tests reference LiteOrder.Api    # 引用被测项目
dotnet test

15.2.1 AAA 结构与第一个测试

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));
    }
}

命名约定 方法_场景_预期(或中文可读名),看名字就知道测什么。一个测试只断言一个行为——失败了能立刻定位。

15.2.2 参数化测试 Theory

同一逻辑多组输入用 [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));
}

15.2.3 常用断言

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);

15.3 测试替身:隔离外部依赖

被测类依赖数据库/HTTP/时间——这些在单测里都要换成"听话的假货"。第 12 章 DI 埋的伏笔在此兑现:依赖接口 + 构造函数注入 = 可注入替身。

15.3.1 手写 Fake(首选,直观)

// 假存储:行为简单可预期,不碰真数据库
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 包)是日志的标准静默替身。

15.3.2 Mock 框架(NSubstitute)

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 一切等于什么都没测。

15.3.3 假时钟 TimeProvider

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 章已注入),测试就完全确定。

15.4 测什么:业务规则清单

好测试的判断标准不是行数而是断言的规则。以轻订单为例,梳理"必须防回归"的规则:

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 最爱藏在边界。

15.5 覆盖率与纪律

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 章),避免纯数字内卷。

15.6 集成测试一瞥

单元测试隔离一切外部,集成测试反过来验证组合正确——真实的路由、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 闭环(展示测试如何保护你):

  1. 用户报告:VIP 用了满减后折扣叠加错误(先折后减 vs 先减后折);
  2. 写一个复现 bug 的失败测试:Apply_VipAndFullReduction_DiscountsInCorrectOrder;
  3. 修 OrderService.CalculatePayable 的计算顺序;
  4. 测试转绿;提交时说明"该测试锁定优惠叠加顺序规则"——这个 bug 永远不会再回来。

常见误区

  1. 测试之间共享状态:静态字段、共享集合——改一个测试的执行顺序全盘崩。xUnit 每个测试方法新建测试类实例,保持无状态;共享昂贵资源用 fixture 显式声明。
  2. 断言太弱:只 Assert.NotNull(result) 不验证内容;只验证"没炸"不验证"算对了"。断言要与规则精确对齐。
  3. Mock 一切:被测逻辑本身也被 Mock 掉的测试是同义反复;DTO/值对象不需要 Mock。
  4. 测私有方法:私有成员是实现细节,测公有行为;"必须测私有才能测到"说明该类职责过多,拆它。
  5. 依赖当前时间/随机数/网络:测试不确定即不可信。时间走 TimeProvider,随机注入种子,网络用替身。
  6. 只写 happy path:异常、边界、并发重入才是事故主场。

最佳实践

  1. AAA 三段清晰;一个测试一个行为;命名即文档。
  2. 纯函数/领域规则(金额、状态机、校验)优先覆盖——它们又快又稳定,是回归网的主干。
  3. 修 bug 先写失败测试再修复,让 bug 成为永久回归用例。
  4. 测试代码也是产品代码:同样遵守命名与可读性标准。
  5. 本地跑 dotnet watch test 边写边测;CI 全量跑(第 23 章)。

小结

  • 单元测试买回回归防护与重构信心;金字塔主体,快而多。
  • xUnit:[Fact] 单例、[Theory]+[InlineData] 参数化、Assert.Throws 验异常;AAA 结构、一测一行。
  • 测试替身隔离外部:手写 Fake 优先、NSubstitute 灵活桩、TimeProvider 锁定时间——这些全都依赖第 12 章的"依赖接口 + 构造注入"。
  • 测规则不测行数:状态机、金额、校验的边界与异常路径优先;覆盖率是仪表不是 KPI。
  • 集成测试(WebApplicationFactory)验证 HTTP 管道组合,少量关键路径。

动手练习

  1. 基础:为第 6 章的 Temperature 结构体写测试:运算正确性、华氏转换、非法温度(低于绝对零度)抛异常。
  2. 基础:为 15.2 的 FullReduction 补齐 Theory 边界(199/200/201)与"折扣不超过小计"(Math.Clamp 分支)测试。
  3. 实战(轻订单 M14):建立 LiteOrder.Tests 项目:领域规则(状态机全部合法/非法流转、金额与运费计算全部边界)、服务层(Fake 存储注入,覆盖库存不足、订单不存在、重复支付)、API 集成测试(创建/支付两个端点);目标核心规则覆盖完整;Git 提交。
  4. 思考:为什么说"可测试性"倒逼出好设计?举出本章遇到的三个"为了可测而做的设计"(接口、TimeProvider、替身可注入的日志),并说明如果不存在这些设计,测试会遇到什么障碍。

上一章:日志记录 ← | README | 下一章:调试、诊断与代码质量 →

📄 第 16 章 调试、诊断与代码质量 16-debugging-diagnostics.md

上一章:单元测试 ← | README | 下一章:分层架构与领域建模 →

学习目标

学完本章,你将能够:

  1. 熟练使用断点、步进、监视、条件断点定位逻辑错误;
  2. 按 bug 类型选择诊断手段:空引用、逻辑错、并发、性能问题各有套路;
  3. 用 dotnet watch、热重载与 dotnet-counters 提升开发与排查效率;
  4. 配置编译器分析器与 .editorconfig,把低级错误挡在编译期;
  5. 建立代码评审与质量基线,让"质量"从个人自觉变成流程保障。

16.1 断点调试:最核心的排查技能

打印日志猜问题("print 调试")效率极低——每改一行要重跑一遍。调试器让你停在案发现场,把所有变量看个清楚。

16.1.1 核心操作(以 VS / VS Code 为例)

操作 快捷键(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) 调试侧栏 当前执行点是怎么走到这的

调试会话里你能看到:每个变量的当前值、调用栈(谁调的谁)、以及"在这行暂停时整个程序的状态"——信息量是日志的一万倍。

16.1.2 进阶断点

  • 条件断点:右键断点 → 条件。qty > 100、order is null——只有满足条件才停。循环 1000 次的 bug,条件断点一击即中,不用按 999 次 F5;
  • 命中次数断点:第 N 次命中才停;
  • 数据断点/对象追踪(VS):某对象属性被改时暂停——"谁改了我的数据"定位神器;
  • 函数断点:按方法名下断点,适合大型调用链。

16.1.3 一次完整的调试走查

症状:轻订单 CalculatePayable 对 VIP 订单算出的金额偶尔不对。

  1. 在折扣计算行设断点,F5 启动并复现;
  2. 悬停各变量:subtotal=300、isVip=true,一切正常;
  3. F11 步入 ApplyDiscount,发现内部用的 VipDiscountRate 是 0.9——也没问题;
  4. 查看"调用栈":发现这个调用来自退款重算路径,那里传入的 isVip 来自另一个查询——根因找到了:两条路径取会员状态的方式不一致;
  5. 修复后按 F5 全速跑测试验证。

注意调试器的心理学价值:它强制你形成"假设 → 验证 → 缩小范围"的科学流程,而不是对着代码干瞪眼。

16.2 按 bug 类型选诊断套路

16.2.1 NullReferenceException

编译器其实早已提示(NRT,第 6 章)。定位法:

  1. 读异常堆栈第一行(哪一文件哪一行),点过去;
  2. 检查该行所有 . 解引用的对象哪个为 null;
  3. 向上追"它为什么是 null"(数据没查到?条件没初始化?);
  4. 修复在源头(用 ? 表达可能为空、在边界判空或 fail fast),而不是在崩的地方加 ?. 掩盖。

16.2.2 逻辑错误(结果不对但不抛异常)

  1. 缩小范围:先确定"错误从哪一步开始"——用调试器在流程中段设断点,检查中间状态;
  2. 二分法:怀疑 10 行代码时,在中点断点看状态,一半一半淘汰;
  3. 对照测试:把复现场景写成单元测试(第 15 章),修好后永久驻守。

16.2.3 并发问题(偶发、调试器下消失)

  • 症状特征:偶发、难复现、开着调试器就正常——调试器改变了时序,并发 bug 在调试器下"消失"是常态;
  • 手段:日志(带线程 Id/TraceId)替代断点;lock 检查(共享可变状态是否都有保护);并发集合替代普通集合;压测工具放大并发让问题稳定复现;
  • 预防胜于排查:默认不可变、状态收敛到数据库/缓存、Singleton 无状态(第 10/12 章)。

16.2.4 性能问题

先测量、后优化(第 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*   —— 用数据说话,不猜

16.3 开发效率工具

dotnet watch run          # 代码改动自动重启
dotnet watch test         # 测试驱动:保存即跑测试

热重载(Hot Reload):dotnet watch 默认在改动方法体时直接热应用(不重启进程),配合 Web 项目开发近乎实时反馈。改动涉及结构(新类型、签名变化)时自动降级为重启。

诊断运行中进程(生产排查预演,第 23 章再用一次):

dotnet tool install -g dotnet-counters dotnet-trace dotnet-dump

16.4 静态分析与代码风格

编译器不只是报语法错——Roslyn 分析器持续做"代码体检"。

16.4.1 .editorconfig:团队风格单一来源

放在仓库根目录,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

16.4.2 把警告当错误

<!-- csproj -->
<PropertyGroup>
  <Nullable>enable</Nullable>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <WarningsNotAsErrors>NU1701</WarningsNotAsErrors>   <!-- 少数豁免 -->
</PropertyGroup>

新代码警告清零,旧代码逐步还债——警告即未修的 bug,被 TreatWarningsAsErrors 关进笼子后,编译通过=低级错误清零。CI 中同样生效(第 23 章)。

16.4.3 更多静态检查

  • 内建 CA 规则(正确性/安全/性能)与 IDE 建议已覆盖大半场景;
  • 社区强工具:Roslynator(补充数百规则)、Security Code Scan;
  • AI/规则驱动的全量审计在团队有条件时上马(如安全扫描流水线,第 21/23 章)。

16.5 代码评审与质量基线

评审查什么(可用作团队清单):

  1. 正确性:边界、空、异常路径、并发;是否有对应测试;
  2. 可读性:命名达意、方法单一职责、无超长函数;
  3. 安全:无硬编码机密、输入校验、日志无敏感信息;
  4. 一致性:遵守 .editorconfig 与既有模式(评审不是审美战争,以仓库规范为准);
  5. 向后兼容:public API 变更是否破坏调用方。

质量基线落地:.editorconfig(风格)+ TreatWarningsAsErrors(编译期)+ 测试门禁(行为)+ 评审清单(设计与人味),四层防线把质量从"个人自觉"变成"流程默认"。

典型业务场景:给轻订单做一次"质量体检"

体检清单(照做即可):

  1. 调试器走查一条完整业务流:下单 → 支付 → 发货,每步检查变量与预期一致;
  2. 构造一次 NRE:products.Find 返回 null 继续使用,观察调试器定位(对比第 6 章 NRT 的编译期警告——双保险);
  3. 条件断点实战:循环下单 100 次,条件断点 qty == 37 命中;
  4. 静态分析清零:开启 TreatWarningsAsErrors,逐个消灭现有警告(大概率是一批 null 隐患);
  5. BenchmarkDotNet 验证(选做):对比第 9 章报表里 GroupBy+Sum 与手写循环的性能,体会"先测量";
  6. 全部通过后打 tag:git tag quality-checkpoint && git push --tags。

常见误区

  1. print 调试为主:改代码→重跑→再打印的循环效率极低,且忘记删调试打印污染日志;断点才是主武器。
  2. 忽略编译警告:CS8618(可空未初始化)、CS8600 系列几乎都是未来的 NRE;"能跑就行"让警告堆积成山。
  3. 以为调试器看到的就是全部:它不改变优化后的 Release 代码行为(Release 下断点位置漂移是正常现象);并发时序在调试器下失真。
  4. 微基准在 Debug 模式跑:JIT 优化未启用,结果全错;基准必须 Release + 预热(BenchmarkDotNet 已自动处理)。
  5. 把 .editorconfig 当个人审美战场:它是团队公约,工具自动执行,讨论一次统一后不再反复争论。

最佳实践

  1. 遇到 bug:先复现(可复现测试)→ 断点观察 → 假设验证 → 修复 → 回归测试;顺序不能乱。
  2. 新项目开箱即配:NRT + TreatWarningsAsErrors + .editorconfig,低成本拦下整类低级错误。
  3. 性能疑虑先上 dotnet-counters/BenchmarkDotNet 拿数据,再决定是否优化(多数"性能问题"其实是算法或 N+1)。
  4. 评审清单入模板(PR 描述里自查),机器能查的交给机器(分析器、CI),人只看设计与语义。

小结

  • 断点调试 = 停在现场看状态:步进、监视、调用栈、条件断点;比 print 调试高一个数量级。
  • 按 bug 选武器:NRE 看堆栈修源头、逻辑错用二分+测试、并发靠日志与时序检查、性能先测量(counters/trace/BenchmarkDotNet)。
  • dotnet watch 与热重载缩短反馈环;dotnet-counters/trace/dump 是进程级诊断三件套。
  • 静态分析四件套(NRT、分析器、.editorconfig、警告即错误)把低级错误挡在编译期;评审清单守住设计与安全。

上一章:单元测试 ← | README | 下一章:分层架构与领域建模 →

📄 第 17 章 分层架构与领域建模 17-architecture-layers.md

上一章:调试、诊断与代码质量 ← | README | 下一章:EF Core 数据访问 →

学习目标

学完本章,你将能够:

  1. 说清分层架构的动机:隔离变化、控制依赖方向、并行开发与独立测试;
  2. 搭建经典分层解决方案:Domain / Application / Infrastructure / Api 四个项目;
  3. 理解依赖倒置在分层中的核心地位(依赖抽象、由外向内);
  4. 区分领域模型、DTO、持久化实体三类对象,终结"一个模型走天下";
  5. 把轻订单从单项目重构为分层结构,为 EF Core 与设计模式铺路。

17.1 为什么要分层

轻订单到第 16 章为止是单项目:领域类、内存存储、HTTP 端点、配置全在一个文件夹里。它现在还没崩,但已经能感觉到:

  • 换数据库要同时翻找"哪些类和数据沾边";
  • 单元测试被迫连 HTTP 层一起启动;
  • 新同事面对 30 个文件分不清"哪层是业务、哪层是技术细节";
  • 任何改动都可能碰到任何文件——没有边界,就没有局部性。

分层(Layering)的本质是按"变化的理由"切分代码,并规定依赖只能朝一个方向流动:

        ┌────────────────────┐
        │      Api 层        │  HTTP 端点、认证、序列化 —— 变化理由:对外协议
        └────────┬───────────┘
                 │ 依赖 ▼
        ┌────────────────────┐
        │   Application 层   │  用例编排、事务边界 —— 变化理由:业务流程
        └────────┬───────────┘
                 │ 依赖 ▼(但仓储接口定义在这层!)
        ┌────────────────────┐
        │    Domain 层       │  实体、值对象、领域规则 —— 变化理由:业务规则本身
        └────────▲───────────┘
                 │ 实现接口(依赖倒置!Infrastructure 认识 Domain,反之不认识)
        ┌────────┴───────────┐
        │ Infrastructure 层  │  EF Core、外部 API、消息队列 —— 变化理由:技术选型
        └────────────────────┘

依赖规则(依赖倒置的应用):

  1. Api → Application → Domain,单向;
  2. Infrastructure 实现 Application/Domain 定义的接口(仓储接口),只有它知道 EF Core 的存在;
  3. Domain 不依赖任何层——纯 C# 类,零框架引用,因此可被完整单元测试;
  4. 若 Domain 必须反过来定义 Infrastructure 需要的东西,定义接口让 Infrastructure 依赖 Domain,方向仍然朝内。

这就是"整洁架构/洋葱架构"的核心思想:技术细节在外圈,业务核心在内圈,依赖永远由外指向内。

17.2 四层各自的职责

层 项目 放什么 不放什么
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。

17.3 轻订单分层重构

17.3.1 建立解决方案骨架

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)

17.3.2 Domain:纯业务核心

// 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 讨论)。

17.3.3 Application:用例与契约

// 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——这是依赖倒置的具体落点,也是很多初学者放错位置的地方。

17.3.4 Infrastructure 与 Api

// 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 与组合根一行注册。

17.4 三类对象的纪律

对象 目的 生命周期 特征
领域模型(Order) 承载规则 长期 有行为、有状态校验
DTO(OrderResponse) 跨边界传输 瞬时 record、纯数据、可序列化
持久化实体(18 章 EF 映射类) 存储映射 长期 贴合表结构

三条纪律:

  1. HTTP 输入 → DTO → Application 翻译为领域操作 → DTO 输出;端点不直接操作领域对象序列化(防内部字段泄露与耦合,第 11 章已立此规矩);
  2. 领域对象不携带序列化注解——为了 JSON 重命名把 Domain 沾上 JSON 特性,是依赖方向被突破的开始;
  3. DTO 与领域模型的映射代码集中在 Application(手工映射或 Mapperly/AutoMapper),显式、可测。

17.5 贫血 vs 充血:规则该住哪

// ❌ 贫血:实体只是属性袋,规则散落在服务
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 的高发地。

17.6 规模的边界:模块化单体与微服务

分层解决"系统内部"的结构问题。当团队规模与部署节奏进一步分裂(多个团队抢同一个发布窗口),才谈服务拆分:

  • 模块化单体(推荐默认):单进程部署,但按业务模块(订单/库存/支付)在层内再分区,模块间只走接口/事件;
  • 微服务:按模块独立部署、独立数据库。带来分布式事务、链路追踪、运维复杂度等一揽子新问题;
  • 拆分时机:部署节奏冲突 > 团队规模 > 技术诉求。业务没有痛就别拆——分布式是拿复杂度换扩展性,轻订单全程保持模块化单体即可。

典型业务场景:轻订单的架构改造验收

重构完成后的验证清单:

  1. LiteOrder.Domain 的 csproj 里没有任何 PackageReference(纯 C#);
  2. 全部领域测试(第 15 章)迁到 LiteOrder.Domain.Tests,毫秒级跑完、不启动 Web 主机;
  3. 把 InMemoryOrderStore 换成一个故意抛 NotImplementedException 的 BrokenOrderStore——只改组合根一行,其余代码零改动;这说明依赖倒置真的成立;
  4. Api 层全局搜索 new Order(:应只在组合根/Application 出现——端点只做翻译;
  5. 目录里 grep 不到 Microsoft.EntityFrameworkCore 在 Domain/Application 的 using。

这套验收本身就是架构的"单元测试"——架构不是画的图,是能被检查的依赖约束。(团队规模大时可加架构测试工具如 NetArchTest 用代码断言依赖规则。)

常见误区

  1. 分层 = 文件夹分层:把三层全放一个项目里只分文件夹,靠自觉不靠引用关系——约束力为零。分层必须落到项目(程序集)边界。
  2. 仓储接口放在 Infrastructure:依赖倒置被架空,Domain/Application 被迫认识 EF。接口随使用方走。
  3. 一个模型走天下:领域对象直接当 HTTP 出参 + EF 实体 + 内部传输——三重耦合锁死演化。三类对象各司其职。
  4. 过度分层:两人小项目切出九层、每个方法透传三层。层数随复杂度增长,每层都要有存在的理由(变化理由不同才值得隔离)。
  5. Domain 里出现 DateTime.Now 直呼、配置直读、静态单例:核心被环境绑架,测试与环境隔离失效——依赖抽象(TimeProvider、接口)。

最佳实践

  1. 依赖方向铁律:Api → Application → Domain;Infrastructure 只在组合根与接口实现处接入。
  2. 领域层零框架依赖(连 JSON 注解都不要),换来毫秒级测试与最高演化自由。
  3. 用"变化理由"划分层与模块;用验收清单(grep/引用检查)持续验证架构未被侵蚀。
  4. 从模块化单体起步,微服务按团队与部署痛点而非潮流决定。
  5. DTO/领域/持久化三类对象分离,映射集中在 Application。

小结

  • 分层的动机是隔离变化与单向依赖:Api → Application → Domain,Infrastructure 依赖内层接口实现技术细节。
  • 依赖倒置是分层的灵魂:仓储接口定义在应用层,实现在外层;组合根唯一知晓全部具体实现。
  • 领域/DTO/持久化三类对象分离;规则住进实体(充血),编排交给应用服务。
  • 默认模块化单体,微服务是团队与部署问题成熟后的选项。
  • 架构是可检查的约束,不是墙上的图。

上一章:调试、诊断与代码质量 ← | README | 下一章:EF Core 数据访问 →

📄 第 18 章 EF Core 数据访问 18-efcore-data-access.md

上一章:分层架构与领域建模 ← | README | 下一章:企业开发常用设计模式 →

学习目标

学完本章,你将能够:

  1. 用 EF Core(Code First)建模、生成迁移并把数据真正落库;
  2. 执行增删改查,理解变更追踪与 SaveChanges 的原子性;
  3. 正确处理关联加载(Include)与 N+1 查询,熟练 AsNoTracking 与投影;
  4. 分页、过滤、排序在数据库端正确执行(衔接第 9 章 IQueryable 伏笔);
  5. 把轻订单的内存存储替换为 EF Core 实现——业务代码与测试几乎零改动。

18.1 ORM 与 EF Core 定位

对象-关系映射(ORM)解决"对象世界 ↔ 表世界"的翻译问题。EF Core 是 .NET 官方 ORM:你写 C# 对象与 LINQ,它生成 SQL、执行查询、把行转回对象。

适用判断:CRUD 与报表类业务(占企业开发 90%)用它高效可靠;极端性能场景(亿级行扫描、复杂窗口函数)可下沉 Dapper/手写 SQL——两者可共存于同一系统。

18.2 接入 EF Core

dotnet add LiteOrder.Infrastructure package Microsoft.EntityFrameworkCore.Sqlite   # 学习用 SQLite,零部署
dotnet add LiteOrder.Infrastructure package Microsoft.EntityFrameworkCore.Design    # 迁移工具
dotnet tool install -g dotnet-ef

18.2.1 DbContext 与模型

// 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 章的依赖纪律。

18.2.2 连接字符串与注册

// 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 生命周期的教科书案例)。

18.2.3 迁移(Migrations)

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/流水线评审执行",杜绝裸奔变更。

18.3 仓储实现:领域对象 ↔ 持久化实体

第 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);                    // 统一提交(原子)
    }
}

18.4 查询:让数据库干活

第 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:只读报表场景白白付出变更追踪内存与时间

18.4.1 关联加载与 N+1

// 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 一目了然。

18.4.2 事务与并发

  • 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
}

18.5 种子数据与集成验证

// 迁移后初始化演示数据
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 章留下的"内存存储重启即丢"问题正式终结:

  1. Infrastructure 增加 EfOrderStore/EfProductStore,组合根一行切换(AddInfrastructure 内部替换注册);
  2. 下单 → 支付 → 重启 API → 查询订单:数据还在;
  3. 并发支付同一订单:第二次抛 ConflictException(RowVersion 检测)→ 端点返回 409;
  4. 日报查询改写为 EF 查询:GroupBy 在 SQL 端执行(GroupBy 聚合翻译),数据量百万级也不怕;
  5. 测试套件:领域测试不动(它们本来就不碰存储);集成测试用 SQLite 内存库跑全部 API 用例。

至此轻订单拥有了完整的持久化能力,而这一切的接入成本是:两个新类 + 一行注册 + 若干迁移——第 17 章分层的分红时刻。

常见误区

  1. DbContext 单例化或跨请求共享:非线程安全,并发必炸;必须 Scoped。
  2. N+1 查询:循环里访问导航属性触发逐条 SQL;Include 或投影解决,开发期盯 SQL 日志。
  3. 先 ToList 再过滤/分页:全表进内存;过滤排序分页必须在 IQueryable 上完成(18.4)。
  4. 只读查询不加 AsNoTracking:白付追踪开销;写场景才需要追踪。
  5. decimal 不声明精度:默认映射丢精度,金额对不上账。
  6. 迁移直接打生产库:database update 绕过评审;生产走脚本评审执行。
  7. 在领域对象上贴 EF/JSON 特性:依赖方向被击穿(第 17 章);映射放 Infrastructure。

最佳实践

  1. 模型配置全走 Fluent API(或 IEntityTypeConfiguration<T> 分文件),Domain 保持纯净。
  2. 查询三板斧默认打开:AsNoTracking + 投影 + 服务端过滤分页;写操作才加载实体。
  3. 金额 HasPrecision(18,2);枚举字符串化;常用过滤列建索引。
  4. 开发期开 EF SQL 日志,让"数据库干了什么"可见。
  5. 并发写用 RowVersion 乐观锁 + 409 语义;跨聚合操作显式事务。
  6. 连接字符串含机密一律走 user-secrets/环境变量/密钥服务(第 13/21 章)。

小结

  • EF Core = Code First 建模 + LINQ 翻译 SQL + 变更追踪 + 迁移体系;CRUD 主战场。
  • DbContext Scoped 生命周期;SaveChanges 原子提交;跨聚合显式事务;RowVersion 乐观并发。
  • 查询铁律:服务端过滤/排序/分页(IQueryable 一直链到 ToListAsync)、Include 防 N+1、AsNoTracking + 投影加速只读。
  • 迁移是数据库的 Git:开发即改即更,生产走脚本评审。
  • 分层架构下,EF Core 被封装在 Infrastructure,业务与测试不感知——第 17 章的红利兑现。

上一章:分层架构与领域建模 ← | README | 下一章:企业开发常用设计模式 →

📄 第 19 章 企业开发常用设计模式 19-design-patterns.md

上一章:EF Core 数据访问 ← | README | 下一章:性能优化 →

学习目标

学完本章,你将能够:

  1. 理解设计模式的本质:给"反复出现的设计问题"一套可沟通的词汇与成熟解法;
  2. 在 .NET 语境下落地六个高频模式:策略、工厂、装饰器、适配器、观察者、建造者;
  3. 认识 DI 容器本身就是模式引擎(单例/工厂/装饰器都由容器原生提供);
  4. 识别"为模式而模式"的反面案例,掌握"何时不用";
  5. 给轻订单装上多支付渠道与外部服务防腐层。

前置提醒:你已经无师自通了一半——第 5 章的多态是策略与模板方法的原型,第 8 章事件是观察者,第 11/12 章的 DI 是工厂与单例的容器化。本章是"把它们点破并系统化"。

19.1 模式的正确打开方式

模式不是简历关键词堆砌,而是三个真实问题的答案:变化隔离(策略/工厂/观察者)、能力增强(装饰器/代理)、异构对接(适配器/防腐层)。判断该不该用模式只有一条标准:当前问题是否真的存在——模式是对问题的回应,不是对代码的装饰。

19.2 策略模式:一族可互换的算法

问题:轻订单要接微信、支付宝、银行卡三种支付,且未来会加第四种。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 章)、导出格式、通知渠道、风控规则。

19.3 工厂模式:把"怎么创建"收口

问题:对象的创建本身有逻辑(选实现、组装依赖、读取配置),散落在调用方会复制蔓延。

// 简单工厂/静态工厂:一个入口集中创建逻辑
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", ...) 不需要工厂。

19.3.1 建造者:分步组装复杂对象

对象需要多步、可选、顺序敏感的组装时:

// 你天天在用: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));
}

建造者与策略的组合(先攒步骤、最后统一应用)在查询组装、规则引擎中极常见。

19.4 装饰器:不改原类,叠加能力

问题:给 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 让装饰器装配自动化。

19.5 适配器与防腐层:异构对接的缓冲带

问题:第三方 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;业务只见自己的接口。将来换支付服务商 = 换适配器,业务层毫发无损。对接任何"不在你掌控中的系统"(支付、短信、物流、旧系统)都套这层。

19.6 观察者:从事件到领域事件

第 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(同步、进程内)→ 领域事件 + 内存派发 → 消息队列(跨进程、可靠投递)。演进的是可靠性与边界,不变的是"发布者不关心订阅者"。

19.7 单例与模板方法:容器时代的注脚

  • 单例:手写"双检锁"单例已成历史——容器 AddSingleton 即单例,且可测试(测试里注册一个新实例即可)。别再手写 Instance 属性;需要全局唯一状态时先问"它是不是应该进缓存/数据库"(第 20 章)。
  • 模板方法:父类定骨架、子类填步骤(第 5 章 DiscountPolicy.Apply)。现代替代手法是"组合注入一个函数/策略"(更灵活);模板方法在框架层仍大量存在(BackgroundService.ExecuteAsync)。

典型业务场景:轻订单的支付体系正式版

组合本章模式完成支付模块:

端点 POST /api/orders/{no}/pay?channel=wechat
  → PaymentRouter(策略路由)
      → WechatPayAdapter(防腐层 → 第三方 SDK)
      → AlipayAdapter
  → 成功后 Order.Pay() 收集 OrderPaid 领域事件
  → 应用服务派发:积分订阅者、通知订阅者、对账订阅者

验收:新增"银联渠道"只新增 UnionPayAdapter + 一行注册;新增"对账订阅者"只新增一个订阅类;把微信换成别家服务商只改适配器内部。三处扩展互不干扰——这就是模式组合的价值。

常见误区

  1. 为模式而模式:只有一个实现却搭"抽象工厂家族";三层包装只为调一个函数。模式出现前,先确认问题存在。
  2. 过度抽象的配置化:"以防将来换"而搭建 YAGNI 架构。将来来了再加(策略模式加一个类很容易——前提是接口边界当时留对了)。
  3. 装饰器套娃失控:一个服务外面套 5 层装饰器,调用栈深不可测。三层以上考虑重构关注点(合并或换中间件/管道)。
  4. 防腐层只包半截:适配器里还漏出第三方类型(返回它们的 DTO、抛它们的异常)——防腐失败,等于没包。
  5. 领域事件当请求-响应用:事件是"通知已发生的事",订阅者不该阻塞主流程、不该互相依赖执行顺序;强顺序/强一致需求走显式编排。

最佳实践

  1. 接口边界先行(第 5/12/17 章的功课),模式随问题自然生长——好架构让加模式便宜,坏架构让每个模式都是手术。
  2. 横切能力(计时/重试/缓存)优先装饰器或中间件,业务代码保持纯净。
  3. 第三方依赖一律防腐层隔离,只在 Infrastructure 出现其类型与异常。
  4. 状态变化用领域事件表达,扩展点(新订阅者)与修改点(聚合)分离。
  5. DI 容器是模式引擎:单例/工厂/策略集免费获得,别手写基础设施。

小结

  • 模式是"问题→解法"的词典:策略换算法、工厂收创建、装饰器叠能力、适配器挡异构、观察者解发布。
  • DI 容器原生提供单例/工厂/策略集;中间件管道与 BackgroundService 是框架级装饰器/模板方法。
  • 高频组合:策略路由(IEnumerable<T>)+ 防腐层适配器 + 领域事件派发,覆盖绝大多数企业扩展点。
  • 最大的纪律是克制:先有真实问题,再上模式;接口边界正确比模式本身重要。

上一章:EF Core 数据访问 ← | README | 下一章:性能优化 →

📄 第 20 章 性能优化 20-performance-optimization.md

上一章:企业开发常用设计模式 ← | README | 下一章:安全防护 →

学习目标

学完本章,你将能够:

  1. 树立"先测量后优化"的工作法,会用 BenchmarkDotNet、dotnet-counters 与负载测试;
  2. 排查并消灭数据库层三大性能杀手:N+1、缺索引、全表拉取;
  3. 用内存缓存与分布式缓存削峰读压力,避开穿透/击穿/雪崩三坑;
  4. 掌握 .NET 层面的高频优化点:字符串、异步并发、序列化与 GC 友好写法;
  5. 为轻订单建立性能预算与验证手段。

20.1 方法论:没有测量就没有优化

**性能优化的第一定律:猜测的瓶颈几乎总是错的。**正确流程:

建立基线(当前多慢?) → 定位热点(哪里慢?) → 修复(最小改动)
   → 再测量(快了多少?回归了什么?) → 压测验证

工具箱:

工具 用途 场景
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"。没有预算的优化没有终点。

20.2 数据库层:最常见也最值得的优化

20.2.1 三大杀手与处方

// 杀手一: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

20.2.2 只取所需与批量写入

// 报表只要两列就 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);   // 集合操作不过实体,最快

20.2.3 连接池

ADO.NET 默认启用连接池(连接字符串 Max Pool Size 控制),不要手动管理 SqlConnection 生命周期——await using 用完即还,池里复用。看到"连接超时"先查是不是连接泄漏(没释放)或池太小。

20.3 缓存:读多写少的银弹(但有代价)

20.3.1 内存缓存 IMemoryCache

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);
        });
    }
}

适用:单实例或"各实例各缓存一份也无所谓"的读密集数据(商品、配置、汇率)。

20.3.2 分布式缓存(Redis)

多实例部署或需要跨实例共享时,用 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;
}

20.3.3 缓存三坑与一致性

坑 场景 处方
穿透 大量查询不存在的 key,全部打到库 空结果也缓存(短 TTL);入口校验
击穿 热点 key 过期瞬间,并发重建压垮库 互斥重建(SemaphoreSlim 单飞);热点不过期+逻辑过期
雪崩 大批 key 同时过期 TTL 加随机抖动;多级缓存

一致性处方:写库后失效缓存(cache-aside 标准姿势),TTL 兜底;对一致性敏感的数据(库存)不缓存或用短 TTL + 版本校验——缓存是用一致性换性能,先确认业务能接受。

20.4 .NET 层高频优化点

20.4.1 字符串与分配

// 循环拼接 → 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); }

20.4.2 GC 基础认知

GC 分代回收(Gen0/1/2),性能优化方向是"少分配"而非"手动回收"(.NET 没有手动释放堆内存的概念):高频路径避免每请求大分配、善用池化与 Span、IDisposable(文件/连接/流)用 using 确定性释放非托管资源。dotnet-counters 里 Gen2 频繁、%Time in GC 高就是分配过多的信号。

20.4.3 异步与并发

  • 全链路异步(第 10 章)是吞吐量的地基;同步阻塞在线程池上是高并发系统的头号杀手;
  • 独立 I/O 用 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(); }
}));

20.4.4 序列化与响应

  • 返回体瘦身:投影/DTO 只带前端要的字段;列表接口默认分页;
  • 大 JSON 输出开启响应压缩(AddResponseCompression);
  • System.Text.Json 默认即高性能;序列化选项全局统一,避免每请求 new options。

典型业务场景:轻订单的读路径优化

目标:商品列表接口 P95 从 800ms 降到 <100ms。

  1. 测量基线:dotnet-counters + 负载测试 100 并发 5 分钟,P95=800ms,GC 正常,CPU 30%——瓶颈不在计算,大概率在数据库往返;
  2. SQL 日志诊断:列表接口一条 SQL,但每页渲染触发明细查询——N+1(每页 10 商品 = 11 条 SQL);
  3. 修复一:改为投影查询,一条 SQL 出全部数据(P95 → 320ms);
  4. 修复二:Category+Status 组合索引,执行计划从全表扫描变索引查找(P95 → 140ms);
  5. 修复三:商品目录加入 IMemoryCache(5 分钟 TTL + 商品更新时主动失效),命中率 95%(P95 → 45ms);
  6. 回归验证:跑全量测试(缓存必须可绕过——写路径直连库);再压测确认无新瓶颈;把"商品列表 P95<100ms"写进性能预算文档。

复盘:80% 的收益来自数据库层(N+1 与索引),缓存是最后一块拼图而不是第一反应——先让 SQL 正确,再让缓存锦上添花。

常见误区

  1. 凭直觉优化:把精力花在"看起来慢"的代码上,真正热点在数据库往返;测量先于一切。
  2. 微优化 vs 算法:纠结 for 与 foreach 的纳秒差,无视 O(n²) 嵌套循环与 N+1;复杂度与 I/O 次数永远优先。
  3. 缓存无过期、无容量上限:内存缓存变成内存泄漏;一律 TTL + SizeLimit。
  4. 缓存一切:库存、余额等强一致数据缓存后超卖/对不上账;先问一致性能否接受。
  5. 压测打到生产或压测环境与生产配置悬殊(数据库规模、实例数),得出假结论。
  6. 过早优化:需求未稳就上 Span/对象池级优化,可读性归零。先正确、再清晰、最后才快(有数据证明需要快)。

最佳实践

  1. 性能预算写入需求(P95/QPS/资源上限),CI 里跑关键接口的冒烟压测防劣化(第 23 章)。
  2. EF 全局习惯:AsNoTracking+投影+服务端分页;开发期开 SQL 日志;可疑查询看执行计划。
  3. 缓存四要素:TTL、容量限制、失效时机、穿透/击穿预案。
  4. 高并发纪律:全异步、WhenAll + 限流、连接池托管、using 释放。
  5. 每次优化留下测量数据(前后对比),性能回归可追溯。

小结

  • 方法论:基线 → 定位 → 最小修复 → 复测 → 压测;BenchmarkDotNet 管方法级、counters/trace 管进程级、k6 管全链路。
  • 数据库层是主战场:N+1(Include/投影)、索引(组合索引覆盖查询)、服务端分页过滤、批量写入。
  • 缓存削读压力:IMemoryCache 单机、Redis 跨实例;TTL+限容必设,穿透/击穿/雪崩有预案;一致性敏感数据慎缓存。
  • .NET 层:全异步+限并发、少分配(Span/池化)、DTO 瘦身与分页。
  • 顺序:先正确,再清晰,最后有据可依地变快。

上一章:企业开发常用设计模式 ← | README | 下一章:安全防护 →

📄 第 21 章 安全防护 21-security.md

上一章:性能优化 ← | README | 下一章:异常处理与弹性设计 →

学习目标

学完本章,你将能够:

  1. 建立"纵深防御 + 最小权限"的安全思维,理解认证与授权的区别;
  2. 实现 JWT 认证与基于策略的授权,正确存储与校验密码;
  3. 防御注入类攻击:SQL 注入、XSS、CSRF、SSRF,理解"不信任输入"的落地;
  4. 用限流保护服务可用性,守住机密管理与日志脱敏红线;
  5. 对照 OWASP Top 10 与依赖漏洞扫描,为轻订单做一次安全加固。

21.1 安全思维三原则

  1. 不信任任何输入:用户、第三方回调、内部系统传来的数据都可能被构造——一切入口校验(第 11 章)只是第一道;
  2. 纵深防御:验证、授权、参数化、限流、审计多层设防,单层失守不至于全线崩溃;
  3. 最小权限:服务、数据库账号、密钥都只给必需的权限;泄漏时的爆炸半径由权限上限决定。

安全是属性不是功能:不能"以后再补",架构每一步都要考虑(本章把前文散落的安全点收拢并补齐)。

21.2 认证(Authentication):你是谁

21.2.1 密码的正确存法

铁律:密码绝不明文、绝不 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);   // 恒时比较防时序侧信道
}

数据库被拖走时,攻击者拿到的只是"每条都要几万次运算才能试一个密码"的哈希——这为用户争取到改密码的时间。

21.2.2 JWT 认证

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 可解)。

21.3 授权(Authorization):你能做什么

// 角色授权(粗粒度)
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 常年前五。

21.4 注入与前端攻击防御

21.4.1 SQL 注入

// ❌ 字符串拼接 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 本身就是最强防注入;只有手写拼接字符串才危险。

21.4.2 XSS 与 CSRF

  • XSS(跨站脚本):用户输入的内容原样输出到页面时执行恶意脚本。API 场景的防线:输入校验/净化(富文本尤其)、返回时正确 Content-Type、前端框架默认转义勿关闭;
  • CSRF(跨站请求伪造):浏览器自动携带 Cookie 的站点易被伪造请求。JWT 放 Authorization 头(不放 Cookie)天然免疫;若用 Cookie 会话则启用反伪造令牌(UseAntiforgery)与 SameSite 属性。

21.4.3 SSRF:服务端请求伪造

凡是有"根据用户输入发起网络请求"的功能(抓取网页头像、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 隔离凭据。

21.5 可用性与滥用防护:限流

.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 次,防暴力破解

限流保护的是可用性(资源耗尽类拒绝服务)与反滥用(撞库、爬虫)。登录、发短信、支付回调等敏感端点单独加更严策略。

21.6 机密、日志与依赖的安全红线

  1. 机密管理(第 13 章铁律回顾):仓库零明文;开发 user-secrets、生产密钥服务;签名密钥/连接串支持轮换;CI 产物不落机密;
  2. 日志脱敏(第 14 章红线):密码、token、完整卡号/证件号永不入日志;需要追踪用哈希或尾 4 位;
  3. 依赖供应链:
dotnet list package --vulnerable --include-transitive    # 连同传递依赖一起审计

CI 中定期跑(第 23 章流水线),锁定下载量大、持续维护的包;新增依赖走评审——你 import 的每一行代码都会在生产运行。

  1. HTTPS 全程: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 拦截。

常见误区

  1. 自创加密/自写哈希:密码学自己造轮子几乎必错;用框架与标准算法(PBKDF2、AES-GCM、TLS)。
  2. 权限只在前端隐藏:UI 隐藏 ≠ 服务端禁止;每个接口独立做认证+授权+资源校验。
  3. JWT 万能且永不过期:无刷新机制的长效 token 泄漏即灾难;短效+刷新+必要时黑名单。
  4. 错误信息帮攻击者枚举:"用户不存在"vs"密码错误"分开提示 = 送给攻击者用户名清单;统一为"用户名或密码错误"。
  5. 内网服务不需要安全:内网被攻破一处即可横向移动(零信任);服务间认证、最小网络策略照做。
  6. 认为 ORM 之下无注入:FromSqlRaw($"...{input}") 拼接照样注入;动态排序字段等场景仍需白名单。

最佳实践

  1. 入口三层过滤:验证(模型/注解)→ 授权(策略/角色)→ 资源校验(所有权),缺一不可。
  2. 密码 PBKDF2/Argon2 + 盐 + 恒时比较;机密走密钥服务;日志脱敏清单化。
  3. 凡"服务端按用户输入出网"必有 SSRF 防护(scheme 白名单、IP 校验、禁重定向、限响应)。
  4. 限流分层:全局兜底 + 敏感端点单独收紧 + 429 明确告知。
  5. 供应链纳入 CI:漏洞审计 + 依赖新增评审 + 定期升级。
  6. 每次功能上线过一遍 OWASP Top 10 对照清单(下表自查)。

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 防护清单

小结

  • 三原则:不信任输入、纵深防御、最小权限;安全是贯穿架构的属性。
  • 认证 = 你是谁(JWT + 密码慢哈希),授权 = 你能干啥(策略 + 资源级校验,UI 隐藏不算数)。
  • 注入防御:参数化 SQL、输出净化、反伪造、SSRF 出网防护清单。
  • 限流保可用、机密零入库、日志脱敏、依赖审计进 CI。
  • OWASP Top 10 对照清单是功能上线的固定安检环节。

上一章:性能优化 ← | README | 下一章:异常处理与弹性设计 →

📄 第 22 章 异常处理与弹性设计 22-exception-handling-resilience.md

上一章:安全防护 ← | README | 下一章:持续集成与部署运维 →

学习目标

学完本章,你将能够:

  1. 建立异常设计原则:区分"预期内失败"与"程序缺陷",掌握 fail fast 与异常安全性;
  2. 设计领域异常体系,用全局异常处理把异常统一翻译为 RFC 9457 ProblemDetails;
  3. 避免吞异常、异常流控制等反模式,正确使用 when 过滤与资源清理;
  4. 用 Polly 为外部依赖装配超时、重试、熔断,理解幂等是重试的前提;
  5. 给轻订单装上"全局异常翻译 + 出网弹性"的完整容错层。

22.1 异常的设计哲学

22.1.1 两类失败,两种处理

预期内失败(Exceptional but Expected) 程序缺陷(Exceptional and Unexpected)
例子 库存不足、订单不存在、参数非法、支付渠道超时 空引用、索引越界、逻辑漏洞
处理 抛业务异常 → 翻译成明确响应 让它炸(快速失败),靠日志与监控修复
日志级别 Warning/Information Error/Critical

新手最常见的错误是把两类混为一谈:用异常做常规流程控制(每次"库存不足"都堆栈展开一遍,慢且逻辑淹没在 catch 里),或反过来吞掉一切异常假装系统健康。正确姿势:能预判的业务失败显式建模为异常并妥善翻译;无法预判的缺陷让它大声失败。

22.1.2 异常设计原则

  1. 尽早失败:入口校验不过立刻抛(第 3/4 章的参数校验),带病运行只会把错误推迟到更难查的地方;
  2. 异常信息面向"要修它的人":说清"什么错、关键上下文(OrderNo 等)、期望什么",绝不包含敏感信息(第 21 章);
  3. 不要用返回值表达错误(int code 返回 -1):错误码会被调用方忽略,异常不会——.NET 的惯例就是异常;
  4. 抽象边界处翻译异常:低层异常(SqlException)不该穿透到高层调用方——仓储层把基础设施异常翻译为领域异常(NotFound/Conflict/DataUnavailable),防腐层同理(第 19 章)。

22.2 领域异常体系

// 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 方按类型分支,不解析消息文本;异常继承树保持浅(两层足够);每个异常携带业务主键上下文(日志与响应都用得上)。

22.3 全局异常处理:端点不写 try-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 建立用户反馈 ↔ 日志的桥梁。

22.4 异常反模式清单

// ❌ 吞异常:错误消失,系统带病运行,排查无门
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)。

22.5 弹性设计:当外部世界不可靠

异常处理管"自己别乱",弹性(resilience)管"别人乱了自己还能活"。任何外部调用(HTTP、数据库、消息队列)都可能:超时、抖动、间歇失败、整体宕机。四种基本策略:

策略 解决 要点
超时 Timeout 无限等待拖垮资源 一切外部调用必有超时(HttpClient 默认 100s 太长,显式设)
重试 Retry 瞬时抖动(网络闪断、503) 间隔指数退避 + 抖动;只重试幂等操作;次数有限(2-3)
熔断 Circuit Breaker 下游持续故障,重试风暴雪上加霜 连续 N 次失败 → 直接拒绝一段时间 → 半开试探恢复
降级 Fallback 依赖不可用时保核心可用 返回缓存值/默认值/明确的功能降级提示

幂等(idempotency)是重试的门票:GET 天然幂等;POST 下单 重试可能重复下单——解决法是幂等键(客户端生成唯一请求 Id,服务端去重)或把"创建"设计为可重入(同键返回已存在资源)。支付回调天然要求幂等处理(渠道可能重发)。

22.5.1 Polly:弹性策略的事实标准

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();

22.5.2 降级:让核心链路先活下来

// 汇率服务不可用时:用缓存旧值,核心下单流程不受阻
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
    }
}

降级的纪律:明确告知(日志 + 对用户的提示),绝不静默返回可能错误的数据;降级方案本身要有过期机制。

典型业务场景:轻订单的容错收官

  1. 全局异常翻译上线:删除所有端点级 try-catch;领域异常体系(22.2)+ 两个 handler(22.3);未预期异常 500 + traceId,业务异常 400/404/409;
  2. 支付回调幂等:渠道可能重发通知——PaymentNotification 表按渠道流水号唯一索引,重复通知直接返回成功;
  3. 出网弹性:汇率/短信两个 HttpClient 挂 AddStandardResilienceHandler(超时 3s + 重试 2 + 熔断);汇率故障时降级到缓存(22.5.2);
  4. 故障演练(纸面推演):人为把汇率服务指向不通的端口 → 观察日志:3 秒超时 → 2 次重试(退避)→ 熔断打开 → 降级缓存值 → 用户无感;恢复端口 → 半开试探 → 恢复正常;
  5. 把"哪些异常是预期的"写进团队文档:新人一查异常类型表就知道每个失败路径的处理契约。

常见误区

  1. catch 一切并吞掉:错误静默扩散,最终在离根因很远的地方爆炸;日志里却查无此错。
  2. 未预期异常的细节回给客户端:堆栈、SQL、内部路径都是攻击情报;对客户端只给 traceId。
  3. 重试非幂等写操作:重复下单/重复扣款;先幂等(幂等键/去重表)再重试。
  4. 无限/高频重试:下游已瘫还雪片重试 = 自我 DDOS;有限次数 + 退避 + 熔断。
  5. 超时缺失或过长:一个下游挂起拖住整条线程池;每个外部调用显式超时。
  6. 用异常跳层传播不翻译:SqlException 直接漏到 Controller;抽象边界处翻译(仓储→领域异常)。

最佳实践

  1. 预期内失败建模为领域异常 + 全局翻译为 ProblemDetails;端点零 try-catch。
  2. 未预期失败快速失败:Error 日志(完整堆栈)+ 500 + traceId,细节不出门。
  3. 所有出网调用四件套:显式超时、有限重试(退避+抖动、只重幂等/5xx)、熔断、降级预案。
  4. 写操作设计幂等键;回调类接口必幂等。
  5. 日志级别与异常分类绑定:业务异常 Warning、系统异常 Error/Critical(第 14 章的告警语义在此闭环)。

小结

  • 两类失败分开对待:业务失败显式建模(领域异常+翻译),程序缺陷快速失败(日志+监控)。
  • 全局异常处理(IExceptionHandler)统一翻译为 RFC 9457 ProblemDetails;traceId 贯穿用户反馈与日志;细节只进日志。
  • 反模式红线:不吞异常、不用异常做流程控制、重新抛出保留堆栈、边界处翻译。
  • 弹性四策略(超时/重试/熔断/降级)保护对外依赖;幂等是重试的前提;Polly/官方 ResilienceHandler 是标准工具。

上一章:安全防护 ← | README | 下一章:持续集成与部署运维 →

📄 第 23 章 持续集成与部署运维 23-cicd-devops.md

上一章:异常处理与弹性设计 ← | README | 附录 A:版本速查 →

学习目标

学完本章,你将能够:

  1. 理解 CI/CD 的目标:让"合并即验证、验证即可发布"成为日常;
  2. 编写 GitHub Actions 工作流:构建、测试、安全审计、发布镜像;
  3. 用多阶段 Dockerfile 容器化 .NET 应用,用 compose 本地编排数据库;
  4. 接入健康检查、结构化监控与告警,掌握基本部署策略与回滚;
  5. 把轻订单从"本机能跑"推进到"流水线自动验证 + 容器化部署 + 可观测"。

23.1 DevOps 全景:代码到生产的传送带

开发者 push ──▶ CI 流水线 ──▶ 制品(镜像)──▶ CD 部署 ──▶ 运行环境
                 │构建│测试│审计│            │灰度│验证│          │监控│告警│回滚
  • CI(持续集成):每次提交自动构建+测试+扫描,问题当天暴露;
  • CD(持续交付/部署):通过验证的制品自动/一键发布到环境;
  • 可观测:生产系统的日志/指标/追踪(第 14 章伏笔的终点站)。

原则先行:流水线是质量门禁而非打包脚本——任何没有通过测试与审计的代码都不该有机会接触生产。

23.2 版本管理与分支策略

  • 特性分支 + PR(中小团队主流):feature/xxx 分支开发 → PR → 评审 + CI 必须绿 → 合并主干;
  • 主干开发(trunk-based):短命小分支、开关控制未完成功能——持续部署团队的主流;
  • 提交信息规范化(feat:/fix:/refactor: 前缀),配合语义化版本(SemVer:主.次.修订)与变更日志。

发布 = 打 tag:git tag v1.4.0 && git push --tags 触发发布流水线——可追溯、可回滚(回滚 = 重新部署上一个 tag 的制品)。

23.3 GitHub Actions:CI 工作流

.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 强制执行;
  • PR 上每个 commit 都跑全量——合并前的绿灯是团队契约;
  • 机密通过 GitHub Secrets 注入(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

23.4 容器化部署

23.4.1 多阶段 Dockerfile

# ---- 阶段一:构建 ----
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 章体系在这里收口)。

23.4.2 本地编排:应用 + 数据库

# 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 中零明文。

23.5 部署策略与数据库变更

  • 直接替换(小系统):停机数秒到分钟,可接受即可;
  • 滚动更新:逐实例替换,编排平台默认(K8s/云容器服务);
  • 蓝绿:新旧两套环境,流量一键切换——回滚=切回去;
  • 金丝雀:新版本先接 5% 流量,观察指标无异常再放量。

数据库迁移的纪律(第 18 章的延续):

  1. 迁移必须向后兼容:先加列(可空/带默认)→ 部署新代码 → 清理旧列,两步走——绝不在同一发布里"改列+用新列";
  2. 迁移在部署新版本之前由流水线执行(dotnet ef migrations script 生成、评审、执行),应用启动时不做迁移(多实例并发迁移是事故);
  3. 每次迁移演练过回滚脚本(或至少验证可从备份恢复)。

23.6 健康检查与可观测

23.6.1 健康检查

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
  • liveness:进程活着吗?(死循环/死锁 → 重启容器)
  • readiness:能接流量吗?(数据库不可达 → 从负载均衡摘除,但别重启)
  • 编排平台与健康检查联动完成自动重启/摘流;发布流水线的冒烟验证也调用它。

23.6.2 监控三支柱

支柱 内容 工具
日志 结构化事件(第 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 章的性能预算在这里变成运维契约。

典型业务场景:轻订单上线全流程

  1. CI 落地:ci.yml 三板斧(build -warnaserror / test 全绿 / 依赖审计无高危)成为 PR 合并门禁;
  2. 容器化:多阶段 Dockerfile,镜像 <120MB,非 root 运行;
  3. 发布流水线:打 tag → 构建镜像 → 迁移脚本评审执行 → 部署到生产 → GET /health/ready 冒烟 → 通知团队;
  4. 可观测:OTel 指标接 Grafana(QPS/P95/错误率/GC),日志进 Seq,traceId 把用户报障直接带到日志现场;
  5. 演练回滚:故意发布一个 500 版本 → 告警触发 → 部署上一 tag 的镜像 → 5 分钟内恢复——回滚路径平时就验证,出了事才不是第一次。

至此贯穿项目完成最后的形态:分层架构 + EF Core 持久化 + 安全加固 + 弹性容错 + 流水线部署 + 可观测运行——一个可以真实托管的轻量生产系统。

常见误区

  1. 手动部署/手工改生产配置:不可复现、不可回滚;一切生产变更走流水线与配置中心。
  2. 机密进 yaml/镜像/日志:流水线配置文件是公开度很高的资产;Secrets 注入 + 密钥服务是唯一正道。
  3. 无回滚预案:每次发布前先想"失败怎么退";tag 化制品 + 蓝绿/滚动让回滚是按钮而不是事故现场抢救。
  4. 迁移与应用发布强耦合:先迁移后代码、迁移向后兼容,多实例滚动才安全。
  5. 只盯"活着",不盯"健康":进程在但错误率 100% 的系统不会自己报告;readiness + SLO 告警是第二双眼睛。
  6. CI 只跑构建:不跑测试不审计的流水线只是打包脚本,门禁价值为零。

最佳实践

  1. 质量门禁清单化:编译警告清零、测试全绿、依赖无高危、镜像扫描——全绿才可合并/发布。
  2. 环境=配置差异(第 13 章),同一镜像跑所有环境;禁止"测试环境特殊构建"。
  3. 发布节奏小步快跑:单次变更小、问题定位快、回滚成本低。
  4. 生产事故复盘模板:时间线 → 根因 → 影响面 → 修复 → 流程改进(改进落到流水线/代码,不是落到口头)。
  5. 监控覆盖三层:业务指标(下单成功率)、系统指标(P95/GC)、依赖健康(下游可用性)。

小结

  • CI 是质量门禁(构建/测试/审计),CD 是受控发布(迁移→部署→冒烟),tag 即版本即回滚锚点。
  • 多阶段 Dockerfile + 非 root + 环境变量配置 = 安全可移植的容器交付。
  • 部署策略按规模选(滚动/蓝绿/金丝雀);数据库迁移向后兼容、先行执行、可回滚。
  • 健康检查分存活与就绪;可观测三支柱(日志/指标/追踪)+ 症状型告警,让系统自己说话。

全书完。 你已经走完"从零到企业级"的完整路径:语言基础 → 工程实践 → 架构与运维。下一步建议:把轻订单继续做深(多租户、缓存一致性、消息队列),或选一个真实业务场景从零复刻一遍——架构能力只有在真实约束里才会长出来。附录 A、B、C 提供速查与项目蓝图。

上一章:异常处理与弹性设计 ← | README | 附录 A:版本速查 →

📄 附录 A:.NET 与 C# 版本演进速查 A-dotnet-csharp-versions.md

README | 附录 B:CLI 速查 →

A.1 .NET 运行时版本

版本 发布 支持类型 终止支持 关键变化
.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 用于尝鲜与短期项目。

A.2 C# 语言版本与常用特性

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 → 原始字符串 → 主构造函数 → 集合表达式。

A.3 生命周期查询

  • 官方支持策略:dotnet.microsoft.com/platform/support/policy/dotnet-core
  • 本机已装版本:dotnet --list-sdks / dotnet --list-runtimes
  • 查询 NuGet 包支持的 TFM(目标框架):nuget.org 包页面

README | 附录 B:CLI 速查 →

📄 附录 B:dotnet CLI 与常用工具速查 B-cli-commands-cheatsheet.md

附录 A ← | README | 附录 C:贯穿项目需求书 →

B.1 项目与解决方案

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

B.2 构建与运行

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

B.3 包管理

dotnet add package Serilog.AspNetCore     # 安装包
dotnet remove package Serilog.AspNetCore
dotnet list package                       # 列出依赖
dotnet list package --vulnerable --include-transitive   # 漏洞审计(含传递依赖)

B.4 测试

dotnet test                               # 跑全部测试
dotnet test --filter "FullyQualifiedName~OrderServiceTests"
dotnet test --collect:"XPlat Code Coverage"     # 收集覆盖率
dotnet tool install -g dotnet-reportgenerator-globaltool   # 覆盖率报告生成器

B.5 EF Core

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

B.6 配置与机密

dotnet user-secrets init                  # 初始化用户机密
dotnet user-secrets set "Jwt:SigningKey" "本机开发密钥"
dotnet user-secrets list / clear

B.7 诊断工具

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文件>                   # 分析转储

B.8 格式化与维护

dotnet format                             # 按 .editorconfig 格式化
dotnet format --verify-no-changes         # CI 中校验格式
dotnet nuget locals all --clear           # 清理 NuGet 缓存

B.9 Docker(部署相关)

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

附录 A ← | README | 附录 C:贯穿项目需求书 →

📄 附录 C:贯穿项目「轻订单」需求书与里程碑 C-capstone-project.md

附录 B ← | README

C.1 项目背景

「轻订单 LiteOrder」是一个面向小微商户(咖啡店、便利店、小餐饮)的订单管理系统:管理商品目录、受理订单、支持多种支付渠道、产出经营报表。它的定位贯穿本教程:

  • 业务复杂度适中:有状态机(订单生命周期)、金额计算(折扣/运费)、外部依赖(支付/汇率/通知)——足够真实,又不至于让业务本身喧宾夺主;
  • 演进式设计:从 40 行控制台程序起步,随章节知识自然生长为生产级系统,每一章的改造都有清晰动机("现状的痛点"→"本章的解法")。

C.2 功能需求(最终形态)

F1 商品管理

  • 商品:Id、名称、分类、价格(decimal)、库存、上下架状态
  • 创建/修改商品(Admin 角色);库存随订单扣减、随退款回补
  • 商品列表支持分类过滤、名称搜索、分页

F2 订单生命周期(状态机)

Created → Paid → Shipped → Completed
   │
   └→ Cancelled(仅 Created 可取消)
  • 非法流转必须被领域层拒绝(第 6/17 章),而非靠调用方自觉

F3 价格与优惠

  • 小计 = Σ(单价 × 数量)
  • VIP 折扣率、满减活动、免邮门槛可配置(第 13 章 OrderOptions)
  • 优惠叠加顺序明确固定:先折扣、后满减、最后运费

F4 支付

  • 多渠道:微信/支付宝(适配器 + 策略路由,第 19 章)
  • 支付回调幂等(渠道流水号唯一,第 22 章)

F5 通知(解耦示例)

  • 支付成功后:库存预占、短信通知、积分发放——领域事件订阅者(第 8/19 章)

F6 报表

  • 日报:总单数、支付单数、营收、客单价、热销 TOP5、品类分布(第 9 章 LINQ)

F7 非功能需求

  • 认证:JWT + 角色(Admin/Staff),资源级授权(第 21 章)
  • 持久化:EF Core + 关系数据库(第 18 章)
  • 弹性:外部调用超时/重试/熔断/降级(第 22 章)
  • 可观测:结构化日志(OrderNo 贯穿)、健康检查、核心指标(第 14/23 章)
  • 质量:核心规则单元测试全覆盖、API 集成测试、CI 门禁(第 15/23 章)

C.3 里程碑对照表

里程碑 章节 交付物 现状痛点(改造动机)
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 + 容器化 + 可观测 部署靠手、故障靠喊

里程碑编号与章节不一一对应(部分章节是纯改造/体检),以"痛点 → 解法"为准。

C.4 技术决策记录(ADR 风格示例)

学习时建议为关键决策留痕(写到项目 docs/ 里),示范:

ADR-001 数据库选型
  背景:学习阶段零部署成本,未来需平滑换 PostgreSQL
  决策:开发用 SQLite,EF Core 抽象保证可迁移;连接串配置化
  后果:接受 SQLite 并发限制;迁移到 PG 时仅需换 provider 包与连接串

ADR-002 状态机归属
  背景:订单状态流转规则曾被端点与服务各自校验,出现漏洞
  决策:流转规则收敛到领域实体(充血),TransitionTo 唯一入口
  后果:非法流转在领域层被强制拒绝;新增状态只改一处

C.5 超出教程范围的扩展方向

完成主线后可选挑战(也是简历级亮点):

  1. 多租户:商户隔离(行级租户过滤 + 请求上下文);
  2. 库存并发:高并发下单的库存超卖防治(乐观锁/原子扣减/队列削峰);
  3. 缓存一致性:商品缓存的主动失效与版本化;
  4. 消息队列:领域事件跨进程化(RabbitMQ/Kafka),对账与最终一致;
  5. 支付对账:定时对账任务(BackgroundService)与差错处理;
  6. 国际化:多币种(Money 值对象)与时区处理。

附录 B ← | README

📁 配套文件目录

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 仓库浏览 →