基于dotnet的后端学习
基础 WebApplication
一个基础的 Web 后端程序如下所示,大体上可以分为三个阶段。
- 创建并配置 WebApplicationBuilder 阶段
- 构造并配置 WebApplication 阶段
- 运行 WebApplication 阶段
public class Program
{
public static void Main(string[] args)
{
// 创建 WebApplicationBuilder
var builder = WebApplication.CreateBuilder(args);
// 构造 WebApplication
var app = builder.Build();
// 对 WebApplication 进行端点映射
app.MapGet(
pattern: "/",
handler: () => "Hello World!"
);
// 启动 WebApplication
app.Run();
}
}
为什么构造 WebApplication 需要分为 创建 WebApplicationBuilder,然后再构造 WebApplication 两步?
因为这两个对象对应应用生命周期的两个性质完全不同的阶段
WebApplicationBuilder 是筹备阶段,这个阶段你可以准备进行 「登记注册」,读取系统配置,配置日志都是这个阶段需要做的内容。
WebApplication 是运行阶段,将之前登记的东西创建为真正的实体。
Build() 方法实际上做了三件事
- 根据 WebApplicationBuilder 中登记的服务清单,构建出真正的 DI(依赖注入)容器。
- 容器从此冻结,运行阶段不可再注册新服务。
- 注意:此时只构建了容器(一个"知道如何创建服务"的工厂),服务实例并未创建——
- 实例是在第一次被索取时才延迟创建的。
- 创建并配置好 Web 服务器(Kestrel)
- 把所有配置、日志、环境信息整合成一个完整的、可以处理请求的应用
为什么必须冻结?不能在 app 上继续注册服务吗?
- 线程安全。 运行阶段你的服务会同时处理成百上千个并发请求,DI 容器会被大量线程同时读取。如果容器随时能被修改,就需要昂贵的锁机制。"构建一次、之后只读"让运行期完全无锁,这是性能基础。
- 可校验。 Build() 时框架可以一次性检查依赖关系:A 依赖 B、B 依赖 C,链条是否完整、有没有循环依赖。如果允许随时注册,这种校验就没法做了。
- 概念清晰。 "准备材料"和"开火做菜"是两个阶段,混在一起的项目(比如运行到一半改配置、动态加服务)是无数线上事故的来源。框架用类型系统强制你分开:builder 上根本没有 MapGet 方法,app 上根本没有 AddSingleton 方法——你在哪个阶段就只能干哪个阶段的事,编译器帮你守门。
服务器是如何知道要如何处理不同的 Web 请求的?
核心是「路由表 + 路由模板匹配」。每次调用 MapGet 等方法,都会在应用内部的一张路由表(端点列表)里登记一条记录:HTTP 方法 + 路由模板 + 处理函数。
一个请求到达后的流程:
- Kestrel 接收请求(事件驱动,基于 epoll,空闲时不消耗 CPU)
- 请求进入中间件管线,路由中间件取出请求的 HTTP 方法和 URL
- 将 URL 按
/分段,与路由表中的「路由模板」逐段做模式匹配,同时把 {id} 这样的模板段提取为参数 - 找到匹配的端点后,框架做「模型绑定」:把路由参数、查询字符串、请求体(JSON) 自动转换为处理函数需要的 C# 参数
- 执行处理函数,把返回值自动序列化(对象 → JSON)写回响应
- 没有任何模板匹配时,返回 404
MapGet 是什么?还有别的类似的方法吗
MapGet 注册一个响应 HTTP GET 请求的端点。HTTP 的每个方法都有对应的 Map 方法:
| 方法 | 对应 HTTP 方法 | 典型用途 |
|---|---|---|
MapGet |
GET | 查询数据(不修改状态) |
MapPost |
POST | 创建数据 |
MapPut |
PUT | 整体更新数据 |
MapPatch |
PATCH | 局部更新数据 |
MapDelete |
DELETE | 删除数据 |
MapMethods |
自定义 | 指定任意 HTTP 方法 |
Map |
所有方法 | 不区分方法(较少用) |
同一 URL 的不同方法可以映射到不同处理函数,如 MapGet("/api/params", ...) 和 MapPost("/api/params", ...) 可以共存。
成为 CRUD BOY
public class Program
{
public static void Main(string[] args)
{
var db = new Dictionary<string, string>()
{
["MaxLevel"] = "60",
["ServerName"] = "CN-1"
};
// 创建 WebApplicationBuilder
var builder = WebApplication.CreateBuilder(args);
// 构造 WebApplication
var app = builder.Build();
// 对 WebApplication 进行端点映射
var apiGroup = app.MapGroup("/api");
apiGroup.MapGet(
pattern: "/params",
handler: () => db.Select(kv => new ParamItem(kv.Key, kv.Value))
);
apiGroup.MapGet(
pattern: "/params/{key}",
handler: (string key) =>
{
if (db.TryGetValue(key, out var value))
{
return Results.Ok(new ParamItem(key, value));
}
return Results.NotFound();
}
);
apiGroup.MapPost(
pattern: "/params",
handler: (ParamItem item) =>
{
if (db.ContainsKey(item.key))
{
return Results.Conflict($"Param {item.key} exist");
}
db[item.key] = item.value;
return Results.Created($"/api/params/{item.key}", item);
}
);
apiGroup.MapPut(
pattern: "/params/{key}",
handler: (string key, ParamItem item) =>
{
if (db.ContainsKey(key) is false)
{
return Results.NotFound();
}
db[item.key] = item.value;
return Results.Ok(new ParamItem(key, item.value));
}
);
apiGroup.MapDelete(
pattern: "/params/{key}",
handler: (string key) =>
{
if (db.ContainsKey(key) is false)
{
return Results.NotFound();
}
db.Remove(key);
return Results.NoContent();
}
);
// 启动 WebApplication
app.Run();
}
record ParamItem(string key, string value);
}
依赖注入 Server
接口
/// <summary>
/// 参数存储的抽象接口。
/// 只描述"能做什么",不关心"怎么实现"——内存、数据库、Redis 都可以是背后的实现。
/// </summary>
public interface IParamStore
{
/// <summary>获取全部参数</summary>
IReadOnlyCollection<ParamItem> GetAll();
/// <summary>尝试获取单个参数,存在返回 true 并通过 item 传出</summary>
bool TryGet(string key, out ParamItem? item);
/// <summary>新增参数,key 已存在时返回 false</summary>
bool Add(string key, string value);
/// <summary>更新参数,key 不存在时返回 false</summary>
bool Update(string key, string value);
/// <summary>删除参数,key 不存在时返回 false</summary>
bool Remove(string key);
}
接口实现
using System.Collections.Concurrent;
namespace ParamStore;
/// <summary>
/// 基于 ConcurrentDictionary 的内存实现。
/// 注意:这个类不知道 HTTP 的存在——它只负责存取数据。
/// 这样的好处:将来换成数据库实现时,调用方无感知。
/// </summary>
public class MemoryParamStore : IParamStore
{
// ConcurrentDictionary:线程安全字典,支持并发读写
private readonly ConcurrentDictionary<string, string> _store = new()
{
["MaxLevel"] = "60",
["ServerName"] = "CN-1"
};
public IReadOnlyCollection<ParamItem> GetAll()
{
return _store
.Select(kv => new ParamItem(kv.Key, kv.Value))
.ToList();
}
public bool TryGet(string key, out ParamItem? item)
{
if (_store.TryGetValue(key, out var value))
{
item = new ParamItem(key, value);
return true;
}
item = null;
return false;
}
public bool Add(string key, string value)
{
// TryAdd:key 不存在时才添加,原子操作,天然处理了并发冲突
return _store.TryAdd(key, value);
}
public bool Update(string key, string value)
{
// key 存在才允许更新(保持和改造前一致的语义:不存在的 key 返回 404)
if (!_store.ContainsKey(key))
{
return false;
}
_store[key] = value;
return true;
}
public bool Remove(string key)
{
// TryRemove:原子地"存在则删除",不存在返回 false
return _store.TryRemove(key, out _);
}
}
主逻辑
using ParamStore;
var builder = WebApplication.CreateBuilder(args);
// 注册服务:声明"谁要 IParamStore,就给它 MemoryParamStore,全应用共享一个实例"
builder.Services.AddSingleton<IParamStore, MemoryParamStore>();
var app = builder.Build();
// 注意每个端点的第一个参数 IParamStore store——
// 你没有 new,也没有传参,是容器在请求到来时自动注入的
app.MapGet("/api/params", (IParamStore store) =>
{
return store.GetAll();
});
app.MapGet("/api/params/{key}", (string key, IParamStore store) =>
{
if (store.TryGet(key, out var item))
{
return Results.Ok(item);
}
return Results.NotFound();
});
app.MapPost("/api/params", (ParamItem item, IParamStore store) =>
{
if (!store.Add(item.Key, item.Value))
{
return Results.Conflict($"参数 {item.Key} 已存在");
}
return Results.Created($"/api/params/{item.Key}", item);
});
app.MapPut("/api/params/{key}", (string key, ParamItem item, IParamStore store) =>
{
if (!store.Update(key, item.Value))
{
return Results.NotFound();
}
return Results.Ok(new ParamItem(key, item.Value));
});
app.MapDelete("/api/params/{key}", (string key, IParamStore store) =>
{
if (!store.Remove(key))
{
return Results.NotFound();
}
return Results.NoContent();
});
app.Run();
HTTP 概念模型
HTTP 方法(动词)的语义
| 方法 | 语义 | 幂等性 | 你的身体语言直觉 |
|---|---|---|---|
GET |
读取,不能有副作用 | 幂等 | "给我看看" |
POST |
创建/触发动作 | 不幂等 | "帮我办件事" |
PUT |
整体替换 | 幂等 | "把这个换成那个" |
PATCH |
局部修改 | 不保证 | "改一下这个字段" |
DELETE |
删除 | 幂等 | "干掉它" |
幂等(Idempotent): 同一个请求执行一次和执行 N 次,效果相同。
网络是不可靠的。客户端发出请求后超时了,它不知道服务器到底执行了没有——如果接口是幂等的,客户端可以放心重试
如果不幂等(比如 POST 创建订单),重试就可能创建两单。
注意一个常见的认知错误:
GET 重复执行当然幂等,但 DELETE 也是幂等的——删一次和删十次,最终状态都是"不存在"(第二次返回 404,但状态没变)。
幂等看的是服务器状态,不是返回值。
状态码的分类直觉
- 1xx:信息,几乎见不到
- 2xx:成功。200 通用成功 / 201 创建成功 / 204 成功但无返回体
- 3xx:重定向。301 永久搬家 / 302 临时跳转 / 304 缓存有效不用重传
- 4xx:客户端的错。400 请求格式错 / 401 没登录 / 403 登录了但没权限 / 404 不存在 / 409 冲突 / 422 语义错误
- 5xx:服务器的错。500 代码炸了 / 502 网关上游挂了 / 503 服务不可用
401 和 403 的区别
- 401 是"我不知道你是谁"(未认证)
- 403 是"我知道你是谁,但你没资格"(未授权)。
服务端开发的职业素养:出错时返回正确的 4xx 而不是 500。500 意味着"我代码有 bug",用它来表达"你参数传错了"是业余的表现。
Header 语义
Header 是"关于这次请求的元信息"。按用途分四组理解:
GET /api/params HTTP/1.1
Host: localhost:5077 ← 路由信息:找哪个站点
Authorization: Bearer eyJhbG... ← 身份:我是谁
Accept: application/json ← 协商:我想要什么格式
Content-Type: application/json ← 描述:我发的 body 是什么格式
Cookie: sessionId=abc123 ← 状态:我之前来过的凭证
响应里重点认识这些:
HTTP/1.1 201 Created
Content-Type: application/json ← body 的格式
Location: /api/params/DropRate ← 新资源的地址(配合 201)
Cache-Control: max-age=3600 ← 缓存策略
Set-Cookie: sessionId=abc123 ← 要求客户端存一个状态
配置接入
配置分层
配置不是单一文件,而是多个配置来源按优先级叠加的结果,高优先级来源覆盖低优先级的同名配置项:
appsettings.json (最低优先级:通用默认值)
< appsettings.{环境}.json (如 Development / Production)
< 环境变量
< 命令行参数 (最高优先级)
为什么这么设计:同一份代码,开发环境连本地库、生产环境连线上库——配置外置,代码不变。容器化部署时用环境变量注入配置,正好和这个优先级规则无缝对接。
配置的层级结构
appsettings.json 中用嵌套 JSON 表达层级:
{
"ParamStore": {
"MaxKeyLength": 50,
"WelcomeMessage": "ParamStore Dev"
}
}
代码中访问时用冒号 : 连接层级路径:"ParamStore:MaxKeyLength"。
读取配置的三种方式
1. 简单读取(适合少量、散装配置)
// 在 builder 阶段即可读取(配置属于筹备阶段的资源)
var maxKeyLength = builder.Configuration.GetValue<int>("ParamStore:MaxKeyLength");
2. 强类型绑定 + DI 注册(真实项目的标准做法)
// 定义与配置节对应的类
public class ParamStoreOptions
{
public const string SectionName = "ParamStore";
public int MaxKeyLength { get; set; } = 50; // 默认值兜底
}
// 注册到 DI 容器
builder.Services.Configure<ParamStoreOptions>(
builder.Configuration.GetSection(ParamStoreOptions.SectionName));
// 端点/服务中注入使用
app.MapGet("/demo", (IOptions<ParamStoreOptions> options) =>
options.Value.MaxKeyLength);
3. 直接绑定为对象(不经过 DI 时)
var options = builder.Configuration
.GetSection(ParamStoreOptions.SectionName)
.Get<ParamStoreOptions>();
环境变量覆盖规则
环境变量的优先级高于 appsettings.json
层级分隔符:JSON 用冒号 : 表达层级,但在 bash 等 shell 中变量名不允许包含冒号,且各平台对冒号的支持不一致;因此约定统一使用双下划线 __ 作为环境变量的层级分隔符,配置系统读取时自动将其转换为层级关系:
# 覆盖 ParamStore:MaxKeyLength
# 这样的写法,环境变量只覆盖当前命令,避免配置泄露污染到其他进程
ParamStore__MaxKeyLength=100 dotnet run
- 这一约定是 Docker / Kubernetes 传配置的标准方式
环境(Environment)机制
- 应用通过环境变量
ASPNETCORE_ENVIRONMENT知道自己处于什么环境 (Development / Production / 自定义) dotnet run默认 Development;发布部署时通常设为 Productionappsettings.{环境}.json只在对应环境下加载并覆盖通用配置- 代码中可判断环境做差异化配置:
if (app.Environment.IsDevelopment()) { /* 开发环境专用逻辑 */ }
典型应用:开发环境显示详细异常堆栈(方便调试),生产环境返回统一模糊错误(不泄露内部实现)。
安全红线
- 密钥绝不写进 appsettings.json 并提交到 git
- 生产密钥走环境变量或专门的密钥管理服务
- 开发期敏感配置可用
dotnet user-secrets(存在用户目录,不进仓库)
使用案例
配置文件
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ParamStore": {
"MaxKeyLength": 50,
"Params": {
"MaxLevel": "60",
"ServerName": "CN-1"
}
}
}
项目代码
using Microsoft.Extensions.Options;
namespace Nas;
public class Program
{
public static void Main(string[] args)
{
// 创建 WebApplicationBuilder
var builder = WebApplication.CreateBuilder(args);
// 配置系统:注册强类型配置
// 将 ParamStore 配置节绑定到 ParamStoreOptions,注册进 DI 容器
builder.Services.Configure<ParamStoreOptions>(
builder.Configuration.GetSection(ParamStoreOptions.SectionName)
);
// 配置系统: 用配置初始化数据
// 筹备阶段直接读配置(此时 DI 容器还没建,用 Get<T>() 手动绑定)
var option = builder.Configuration
.GetSection(ParamStoreOptions.SectionName)
.Get<ParamStoreOptions>() ?? new();
// 初始数据来自配置文件,不再硬编码
var db = new Dictionary<string, string>(option.Params);
// 构造 WebApplication
var app = builder.Build();
// 对 WebApplication 进行端点映射
var apiGroup = app.MapGroup("/api");
apiGroup.MapGet(
pattern: "/params",
handler: () => db.Select(kv => new ParamItem(kv.Key, kv.Value))
);
apiGroup.MapGet(
pattern: "/params/{key}",
handler: (string key) =>
{
if (db.TryGetValue(key, out var value))
{
return Results.Ok(new ParamItem(key, value));
}
return Results.NotFound();
}
);
apiGroup.MapPost(
pattern: "/params",
handler: (ParamItem item, IOptions<ParamStoreOptions> opts) =>
{
if (item.key.Length > opts.Value.MaxKeyLength)
{
return Results.BadRequest($"Key Length must less {opts.Value.MaxKeyLength}");
}
if (db.ContainsKey(item.key))
{
return Results.Conflict($"Param {item.key} exist");
}
db[item.key] = item.value;
return Results.Created($"/api/params/{item.key}", item);
}
);
apiGroup.MapPut(
pattern: "/params/{key}",
handler: (string key, ParamItem item, IOptions<ParamStoreOptions> opts) =>
{
if (item.key.Length > opts.Value.MaxKeyLength)
{
return Results.BadRequest($"Key Length must less {opts.Value.MaxKeyLength}");
}
if (db.ContainsKey(key) is false)
{
return Results.NotFound();
}
db[item.key] = item.value;
return Results.Ok(new ParamItem(key, item.value));
}
);
apiGroup.MapDelete(
pattern: "/params/{key}",
handler: (string key) =>
{
if (db.ContainsKey(key) is false)
{
return Results.NotFound();
}
db.Remove(key);
return Results.NoContent();
}
);
// 启动 WebApplication
app.Run();
}
record ParamItem(string key, string value);
}
public class ParamStoreOptions
{
public const string SectionName = "ParamStore";
public int MaxKeyLength { get; set; } = 50;
public Dictionary<string, string> Params { get; set; } = [];
}
handler 是怎么实现的自动解析参数
Q:handler 的参数是如何被自动填充的?为什么
IOptions<T>这样的参数不需要手动传值就能拿到实例?A:这不是魔法,而是"启动时反射分析 + 请求时按规则绑定"两步机制。
第一步:启动时"解剖" handler
调用 MapGet/MapPost(...) 注册端点的那一刻,框架通过反射读取 handler 的参数列表,为每个参数确定绑定来源规则,然后预生成真正的请求处理函数。
apiGroup.MapPost(
pattern: "/params",
handler: (ParamItem item, IOptions<ParamStoreOptions> opts) => { ... }
);
框架的分析过程:
参数 item (ParamItem):
- 名字与路由模板不匹配 → 不是路由参数
- 询问 DI 容器:"ParamItem 注册过吗?" → 没有
- 是复杂类型 → 规则:从请求体 JSON 反序列化
参数 opts (IOptions<ParamStoreOptions>):
- 名字与路由模板不匹配
- 询问 DI 容器:"IOptions<ParamStoreOptions> 注册过吗?" → 有!
(builder.Services.Configure<ParamStoreOptions>() 完成了注册)
- 规则:从 DI 容器解析
关键动作:框架构建端点时会拿每个参数的类型询问容器"这个类型你认识吗"
(内部使用
IServiceProviderIsService查询接口):
- 认识 → 走 DI 注入
- 不认识且是复杂类型 → 视为请求体
- 名字匹配路由模板 → 路由参数
- 名字不匹配且是简单类型 → 查询字符串
第二步:请求时按预设规则填充
1. 读请求体 → JSON 反序列化 → new ParamItem(...) → 填入 item
2. 从本次请求的服务作用域 → GetService<IOptions<ParamStoreOptions>>() → 填入 opts
3. 调用业务 lambda:handler(item, opts)
第 2 步就是 DI 容器在工作:Configure<ParamStoreOptions>() 除了绑定配置,
还自动把 IOptions<ParamStoreOptions> 这个包装类型注册进了容器,
注入后通过 .Value 取到真正的配置对象。
结论:DI 不是框架的特权功能,而是所有端点共享的通用机制。
自己注册的
IParamStore和框架注册的IOptions<T>走完全相同的路径。
参数来源规则总表
框架按以下顺序判断每个参数的来源:
| 参数特征 | 来源 |
|---|---|
名字匹配路由模板(如 {key} 对应 string key) |
URL 路径段 |
类型在 DI 容器注册过(如 IOptions<T>、IParamStore) |
DI 容器 |
复杂类型且容器未注册(如 ParamItem) |
请求体 JSON |
| 简单类型且不在路由模板中 | 查询字符串(?foo=1) |
特殊类型 HttpContext / HttpRequest / CancellationToken |
框架直接提供 |
显式标注:规则推断不符预期时
多数情况框架推断正确,但可用特性(Attribute)明确指定来源:
handler: (
[FromRoute] string key, // 强制从路由取
[FromQuery] int page, // 强制从查询字符串取
[FromBody] ParamItem item, // 强制从请求体取
[FromServices] IParamStore store, // 强制从 DI 容器取
[FromHeader] string xRequestId // 从请求头取
)
必须显式标注的两种典型情况:
- 简单类型在路由与查询字符串之间有歧义
- 想让代码意图更明确(
[FromServices]在团队项目里常写,一眼看出"此参数是注入的")
中间件管线
概念模型 中间件是请求到达端点之前、响应离开服务器之前要经过的流水线。每个中间件可以做三件事:
- 处理请求(记下来、改一改)
- 决定是否传给下一个(还是直接"短路"返回)
- 处理响应(下一个返回后再加工)
请求 → [日志中间件] → [异常处理] → [鉴权] → [路由→端点]
响应 ← [日志中间件] ← [异常处理] ← [鉴权] ← [端点返回]
经典比喻是洋葱:请求一层层往里钻,响应再一层层穿出来,每一层都能看到"进去"和"出来"两个方向。
中间件注册于 WebAplicationBuilder 调用 Build() 之后, WebApplication 注册端点服务之前。
案例:内联 Lambda 中间件
写一个简单的中间件,将每一个请求的日志都打印出来
// 构造 WebApplication
var app = builder.Build();
app.Use(async (context, next) =>
{
var start = DateTime.UtcNow;
await next();
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
Console.WriteLine(
$"{context.Request.Method} {context.Request.Path} " +
$"{context.Response.StatusCode} {elapsed:F1}ms"
);
});
// 对 WebApplication 进行端点映射
var apiGroup = app.MapGroup("/api");
跑起来之后,就会输出类似于以下内容的日志
GET /api/params 200 5.5ms
案例:独立类中间件
将在主函数里写死的 lambda 中间件替换为 RequestLoggingMiddleware 独立类。
using Microsoft.Extensions.Options;
namespace Nas;
public class Program
{
public static void Main(string[] args)
{
// 创建 WebApplicationBuilder
var builder = WebApplication.CreateBuilder(args);
// 配置系统:注册强类型配置
// 将 ParamStore 配置节绑定到 ParamStoreOptions,注册进 DI 容器
builder.Services.Configure<ParamStoreOptions>(
builder.Configuration.GetSection(ParamStoreOptions.SectionName)
);
// 配置系统: 用配置初始化数据
// 筹备阶段直接读配置(此时 DI 容器还没建,用 Get<T>() 手动绑定)
var option = builder.Configuration
.GetSection(ParamStoreOptions.SectionName)
.Get<ParamStoreOptions>() ?? new();
// 初始数据来自配置文件,不再硬编码
var db = new Dictionary<string, string>(option.Params);
// 构造 WebApplication
var app = builder.Build();
app.UseMiddleware<RequestLoggingMiddleware>();
// 对 WebApplication 进行端点映射
var apiGroup = app.MapGroup("/api");
apiGroup.MapGet(
pattern: "/params",
handler: () => db.Select(kv => new ParamItem(kv.Key, kv.Value))
);
apiGroup.MapGet(
pattern: "/params/{key}",
handler: (string key) =>
{
if (db.TryGetValue(key, out var value))
{
return Results.Ok(new ParamItem(key, value));
}
return Results.NotFound();
}
);
apiGroup.MapPost(
pattern: "/params",
handler: (ParamItem item, IOptions<ParamStoreOptions> opts) =>
{
if (item.key.Length > opts.Value.MaxKeyLength)
{
return Results.BadRequest($"Key Length must less {opts.Value.MaxKeyLength}");
}
if (db.ContainsKey(item.key))
{
return Results.Conflict($"Param {item.key} exist");
}
db[item.key] = item.value;
return Results.Created($"/api/params/{item.key}", item);
}
);
apiGroup.MapPut(
pattern: "/params/{key}",
handler: (string key, ParamItem item, IOptions<ParamStoreOptions> opts) =>
{
if (item.key.Length > opts.Value.MaxKeyLength)
{
return Results.BadRequest($"Key Length must less {opts.Value.MaxKeyLength}");
}
if (db.ContainsKey(key) is false)
{
return Results.NotFound();
}
db[item.key] = item.value;
return Results.Ok(new ParamItem(key, item.value));
}
);
apiGroup.MapDelete(
pattern: "/params/{key}",
handler: (string key) =>
{
if (db.ContainsKey(key) is false)
{
return Results.NotFound();
}
db.Remove(key);
return Results.NoContent();
}
);
// 启动 WebApplication
app.Run();
}
record ParamItem(string key, string value);
}
public class ParamStoreOptions
{
public const string SectionName = "ParamStore";
public int MaxKeyLength { get; set; } = 50;
public Dictionary<string, string> Params { get; set; } = [];
}
public class RequestLoggingMiddleware(RequestDelegate next)
{
private readonly RequestDelegate _next = next;
public async Task InvokeAsync(HttpContext context)
{
var start = DateTime.UtcNow;
await _next(context);
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
Console.WriteLine(
$"{context.Request.Method} {context.Request.Path} " +
$"{context.Response.StatusCode} {elapsed:F1}ms"
);
}
}
Q: 框架如何知道该调用中间件的哪个方法?中间件的执行顺序由什么决定?
A: 靠约定(Convention)被识别,靠注册顺序确定位置, 在启动时被组装成一条
RequestDelegate委托链。
第一步: 约定识别——框架如何找到 InvokeAsync
中间件类不需要实现任何接口。UseMiddleware<T> 用反射检查约定:
- 构造函数:第一个参数必须是
RequestDelegate(接收"里层"),其余参数从 DI 容器解析 - 方法:必须有名为
Invoke或InvokeAsync的方法,第一个参数是HttpContext,返回Task
符合约定即可使用,不符合则启动时报错(尽早暴露问题)。
这种"不写接口、靠命名和签名约定"的风格,与"配置类属性名对应配置键" 是同一种设计哲学(约定优于配置)。
核心类型: RequestDelegate
"请求处理函数"在 ASP.NET Core 中有统一的类型定义:
// 本质:接收 HttpContext,返回 Task 的函数
public delegate Task RequestDelegate(HttpContext context);
整个管线就是一条 RequestDelegate 组成的链。所有中间件和端点执行,最终都被包装成这个类型串起来。
第二步:启动时组装——注册列表 → 嵌套调用链
每次调用 app.Use(...) / app.UseMiddleware<T>(),框架往一个有序列表追加组件。
Build() 时一次性组装(反向包裹):
已注册组件:[RequestLoggingMiddleware] → [路由/端点执行]
step1: 最内层 = 端点执行的 RequestDelegate
step2: 创建 RequestLoggingMiddleware,
把 step1 的委托作为 next 传入其构造函数
step3: 将其 InvokeAsync 包装成 RequestDelegate,成为新的链头
最终链头:RequestLoggingMiddleware.InvokeAsync
└─ _next ──→ 端点执行
"谁是 next"在启动时确定:构造函数收到哪个 RequestDelegate,取决于注册时排在谁前面。
这就是"注册顺序 = 管线顺序"的原因——顺序在组装那一刻被固化进委托链。
第三步:请求时执行——纯委托调用,零反射
Kestrel 收到请求 → 构造 HttpContext
→ 调用链头委托(InvokeAsync)
→ await _next(context) → 调用下一个委托
→ ... → 端点执行
全程是委托调委托,反射只发生在启动组装那一次,因此中间件的运行时开销极小。
一句话总结
中间件靠约定被识别,靠注册顺序确定位置,启动时组装成 RequestDelegate 委托链; 请求到来时委托顺链逐个调用,
await _next(context)就是"交给下一棒"的那一下。
错误处理
异常处理
在管线最前面加全局兜底,让未捕获的异常变成干净的 500 而不是裸堆栈。
异常处理每个应用只需要做一次即可。
// 放在所有中间件最前面(最外层)
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new
{
code = "INTERNAL_ERROR",
message = "服务器内部错误,请稍后再试"
});
});
});
模型校验
在 Web 框架的语境里,"模型(Model)" 就是指"承载数据的那个 C# 类。所以"模型校验"翻译成大白话就是"对数据类做检查"
模型校验简单来说就是为一些参数添加属性,方便框架分析参数是否合规。
模型校验管线也只需要写一遍,之后每个需要校验的模型,一行接入
// 现在的 ParamItem
apiGroup.MapPost("/params", ...).AddEndpointFilter<ValidationFilter<ParamItem>>();
// 将来加了用户模型
apiGroup.MapPost("/users", ...).AddEndpointFilter<ValidationFilter<UserItem>>();
// 将来加了订单模型
apiGroup.MapPost("/orders", ...).AddEndpointFilter<ValidationFilter<OrderItem>>();
第 1 步:给模型加校验规则
using System.ComponentModel.DataAnnotations;
// 特性写在 property: 前缀后面(record 的位置参数语法要求)
record ParamItem(
[property: Required(ErrorMessage = "Key 不能为空")]
[property: StringLength(50, MinimumLength = 1, ErrorMessage = "Key 长度须为 1~50")]
string Key,
[property: Required(ErrorMessage = "Value 不能为空")]
[property: StringLength(500, ErrorMessage = "Value 最长 500")]
string Value
);
第 2 步:写一个可复用的校验过滤器
using System.ComponentModel.DataAnnotations;
namespace Nas;
/// <summary>
/// 端点过滤器:对 handler 参数中指定类型的模型执行 DataAnnotations 校验。
/// 校验失败返回 400 + 结构化错误详情,handler 不会被执行。
/// </summary>
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// 从 handler 参数里找到要校验的模型
var model = context.Arguments.OfType<T>().FirstOrDefault();
if (model is null)
{
// 没找到就放行(防御性写法)
return await next(context);
}
// 执行校验
// TryValidateObject 用反射读取 ParamItem 上标的 [Required]、[StringLength] 特性,逐条检查
// 把违反的规则收集进 results
// 整体结果通过返回值 valid 告诉你。
var results = new List<ValidationResult>();
var valid = Validator.TryValidateObject(
model, // 要校验的对象
new ValidationContext(model), // 校验上下文
results, // 收集到的错误会塞进这个列表
validateAllProperties: true); // 校验所有属性
if (!valid)
{
// 按字段分组错误信息
var errors = results
.GroupBy(r => r.MemberNames.FirstOrDefault() ?? "")
.ToDictionary(
g => g.Key,
g => g.Select(r => r.ErrorMessage!).ToArray());
// 400 + RFC 标准错误格式
return Results.ValidationProblem(errors);
}
// 校验通过,放行执行 handler
return await next(context);
}
}
第 3 步:挂到需要校验的端点上
apiGroup.MapPost(
pattern: "/params",
handler: (ParamItem item, ...) => { ... })
.AddEndpointFilter<ValidationFilter<ParamItem>>();
apiGroup.MapPut(
pattern: "/params/{key}",
handler: (string key, ParamItem item, ...) => { ... })
.AddEndpointFilter<ValidationFilter<ParamItem>>();
预期返回(ValidationProblem 自动生成的标准格式):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Key": ["Key 不能为空", "Key 长度须为 1~50"]
}
}
TryValidateObject 内部做了什么
Validator.TryValidateObject(model, ...)
│
├─ 反射读取 model 的类型(ParamItem)
├─ 遍历每个属性,找出所有继承自 ValidationAttribute 的特性
│ Key 属性上: [Required], [StringLength(50, MinimumLength = 1)]
│ Value 属性上: [Required], [StringLength(500)]
├─ 逐个调用每个特性的 IsValid(属性值) 方法
│ Required.IsValid("") → false,记录 "Key 不能为空"
│ StringLength.IsValid("") → false,记录 "Key 长度须为 1~50"
└─ 汇总:所有特性都通过 → 返回 true;有失败 → false + 错误列表
Required, StringLength, Range, RegularExpression 这些特性都继承自同一个基类 ValidationAttribute, 各自实现了 IsValid 方法。
Validator 做的就是"找出它们、挨个调用、收集结果"——你写的声明式特性,到这一刻变成了实实在在的方法调用。
Session & JWT
Session 方案
Session 实现原理
- 客户端提交用户名密码
- 服务器验证通过后,在服务端存储(内存/Redis)创建一条会话记录: sessionId(随机字符串)→ { userId, role, 登录时间, ... }
- 通过 Set-Cookie 响应头把 sessionId 发给浏览器
- 浏览器之后每次请求自动携带 Cookie: sessionId=xxx
- 服务器拿 sessionId 去存储里查出会话数据 → 知道"你是谁、能干什么"
核心特征:状态保存在服务端,客户端手里只有一个无意义的随机 id,本身不含任何信息。
使用场景
- 传统的、服务器渲染的单体网站(管理后台、内部系统)
- 客户端只有浏览器的应用
- 对"实时踢人、即时改权限"要求高的系统
- 团队规模小、不想引入复杂度的项目
优势
- 完全可控:删除会话立即踢人,改权限立即生效
- 实现简单,所有 Web 框架内置支持
- 客户端零逻辑(浏览器自动处理 Cookie)
- 会话数据在服务端,可以存任意多的信息,不受大小限制
局限性
- 扩展性差:多实例部署时,所有服务器必须共享会话存储(通常是 Redis),Redis 成为关键依赖和潜在单点
- 每次请求都要查存储:比纯计算签名多一次网络/存储开销
- 移动端不友好:Cookie 是浏览器机制,App 使用要手动模拟
- 跨域麻烦:前后端分离部署在不同域名时,Cookie 的 SameSite/CORS 配置复杂且易出安全问题
- 无法跨系统:你的 session 存在你家服务器,第三方系统无法识别
JWT(Json Web Token) 方案
- 客户端提交用户名密码
- 服务器验证通过后,把身份信息打包并签名,生成令牌: - Header.Payload.Signature - Payload 内容(示例):{ "userId": 42, "role": "admin", "exp": 1723000000 } - Signature = HMAC_SHA256(Header + "." + Payload, 服务器密钥)
- 令牌发给客户端,客户端自行保存(localStorage / 内存 / App 本地)
- 之后每次请求,客户端手动在 Header 里携带: - Authorization: Bearer eyJhbGci...
- 服务器收到后只做一件事:用密钥验签 - 签名对 → 内容没被篡改 → Payload 里的 userId/role 可信 - 全程不查任何存储
核心特征:状态自包含在令牌里,服务端零存储。防伪靠签名——Payload 谁都能看能改,但没有密钥就伪造不出匹配的签名。
使用场景
- 前后端分离的 SPA + API 架构
- 手机 App / 桌面客户端 / 游戏客户端的后端接口
- 微服务架构(服务间互相认证)
- 第三方登录(OAuth 2.0 / OpenID Connect 的 ID Token 就是 JWT)
- 需要跨系统、跨组织传递身份的场景
优势
- 无状态,天然水平扩展:任意多实例,各自验签,零共享存储
- 验签纯计算,无存储查询:性能开销极小
- 跨域、跨平台无障碍:走 Authorization 头,不依赖 Cookie
- 跨系统互认:谁拿到都能验证(配合非对称签名时,验签方甚至不需要密钥,只需公钥)
局限性
- 发出就收不回:令牌在过期前一直有效,踢人、降权有延迟(需靠短过期/黑名单/版本号等补偿手段)
- Payload 不保密:只是 Base64 编码,任何人可解开查看,绝不能放密码、敏感数据
- 令牌体积较大:每次请求都带,比 sessionId 费流量
- 密钥是命门:密钥泄漏 = 攻击者可伪造任意身份,且验签方无法察觉
- 依赖实现正确:必须用它成熟库验签,历史上 alg 混淆等漏洞都出自不规范实现
- 权限信息会过期:令牌里的 role 是签发时刻的快照,不反映后续变更
业界主流方案是怎么做的
现实里的成熟系统几乎不用"纯 Session"或"纯 JWT",而是按场景分层组合,几种典型架构:
一、传统单体网站(内部系统、管理后台)
纯 Session + Cookie,常把 session 存 Redis 以防重启丢失。简单可靠,不折腾。这类系统至今占企业应用的很大比例,没有过时。
二、现代 Web / App 后端(最主流的形态)
短寿命 Access Token(JWT)+ Refresh Token:
- Access Token 活 15~30 分钟,装用户身份和角色,验签放行,零查库
- Refresh Token 活数天,存服务端,专门用于换新令牌——换发时查库拿最新权限,这提供了权限刷新的时机
- 封禁踢人:删 Refresh Token + Redis 黑名单(或权限版本号),旧令牌最多再活十几分钟
- 敏感操作(转账、删数据):不管令牌写什么,实时查库确认
这是当前互联网公司的标准答案,用"可控的小延迟"换"无状态的扩展性"。
三、第三方登录体系(OAuth 2.0 / OIDC)
跨系统场景别无选择,全部是签名令牌(JWT 为标准格式)。你用的每一个"微信登录""Google 登录"背后都是它。
四、游戏行业
令牌 + 长连接的分段组合:
- 登录阶段:账号密码 → 登录服签发 ticket/token(JWT 或类似的签名令牌),无状态、好扩容
- 会话阶段:客户端拿 ticket 连游戏服,建立长连接后,这条连接就是可信上下文——权限数据加载到游戏服内存,变更时服务器主动通过连接推送或踢人,即时生效
- 本质:用令牌解决"分布式身份认证",用长连接解决"实时控制",各管一段
共同的设计哲学
- 令牌/会话只负责回答"你是谁",而"你此刻能干什么"由服务端按需实时裁决。
- 信任分级:低风险操作信令牌快照,高风险操作查实时数据。
JWT 权限实时性问题:以飞书团队管理为例
问题:类似飞书的团队管理场景,撤销某人的管理员权限、给组员下发某个应用的 数据查看权限时,要求立即生效。JWT 的权限延迟在这种场景下是否致命?
核心结论:这类细粒度权限根本不在 JWT 里,因此"JWT 延迟致命"在真实架构中 不存在。JWT 的延迟问题是被架构设计消灭的,而不是 JWT 自己解决的。
关键认知:认证与授权是两件事
- 认证(Authentication):你是谁?→ JWT 负责
- 授权(Authorization):你能干什么?→ 权限系统负责,实时裁决
飞书这类 SaaS 的登录令牌里通常只有:
{
"userId": "u_12345",
"tenantId": "org_678",
"exp": 1723000000
}
即:身份 + 所属组织 + 过期时间。不包含"是不是管理员""能看哪个应用的数据"。
细粒度权限不能进令牌的两个原因:
- 变更频繁:管理员权限说撤就撤,放进令牌就是制造延迟问题
- 粒度太细:一个用户在不同应用/文档/群里有成百上千条权限,令牌装不下
权限校验的实际流程
请求:"u_12345 想查看应用 A 的数据"
↓
验签(JWT):确认他确实是 u_12345 ← 无状态,微秒级
↓
权限查询:u_12345 对应用 A 有查看权限吗? ← 查权限系统,实时数据
↓
有 → 返回数据 / 无 → 403
撤权那一刻改的是权限系统里的数据,下一个请求拿到的就是最新结果,实时生效,与 JWT 无关。
性能问题:缓存 + 主动失效
每个请求都查权限数据库不现实,工业级做法是:
- 权限数据缓存到 Redis / 服务本地内存(读多写少,命中率极高)
- 权限变更时:写数据库 + 广播失效消息(消息队列 / Redis PubSub)
- 所有服务收到消息后丢弃旧缓存
- 下一个请求缓存未命中 → 回源数据库拿新权限
效果:正常读请求微秒级;权限变更后失效消息传播是毫秒级,用户体感实时。
("实时查库"策略的工业形态:读时走缓存,写时广播失效)
JWT 权限延迟的精确边界
JWT 里适合放的权限只有一类:低频变更、粗粒度、延迟可容忍的角色信息。
例如"付费会员"(到期后多用 15 分钟无所谓)、"普通玩家而非 GM"(极少变更)。
高频变更、细粒度、延迟不可容忍的权限永远不进令牌,由服务端权限系统实时裁决。
设计原则
- 认证做薄,授权做实。
- 令牌只回答"你是谁"(尽量精简,只放 userId),
- "你此刻能干什么"由服务端权限系统按需实时裁决。
从 SessionId 到 JWT:认证体系的肢解与重组
核心洞察:SessionId 方案在大业务场景下太重了。工业界并没有"用 JWT 取代 Session",而是发现 Session 承担的多重职责可以拆分得更细,于是用不同的专门 方案把 SessionId "肢解"了:
- 身份认证 → JWT(无状态、零 I/O)
- 细粒度授权 → 权限系统(缓存加速、实时裁决)
- 令牌续命与回收 → Refresh Token(服务端可删)
- 紧急封禁 → 黑名单 / 版本号(Redis)
JWT 体系下的注销与踢人
前提:纯 JWT 服务端零存储,"注销"概念本身不存在,需要补偿机制。
用户主动注销
- 本质是客户端动作:删除本地保存的令牌
- 服务端真正做的:删除 Refresh Token → Access Token 到期后自然死亡。无法续期(残余窗口 = Access Token 剩余寿命,分钟级)
- 更高要求:注销时把用户 ID 写入 Redis 黑名单,立即拒杀
被踢出企业(三道防线)
- 授权层实时拒绝(毫秒级):权限系统中"用户属于该企业"的记录被删,缓存失效广播。令牌仍有效(身份真实),但所有企业资源权限查询返回 403
- Refresh Token 删除(分钟级):Access Token 自然过期,身份令牌彻底作废
- 黑名单(极端情况):连个人资源都要立即切断时,验签环节直接拒绝
设计美感:被踢出企业(授权问题)反而比主动注销(认证问题)更容易实时—— 权限从令牌里剥离得越干净,实时控制能力越强。
JWT 的真实分工:认证做薄,授权做实
真实架构中:
验签(JWT)→ 确认身份,零 I/O,纯 CPU 计算
权限校验 → 查权限系统,有 I/O
JWT 消灭的只是"身份解析"的 I/O,不是所有状态。
到了细粒度授权层面,JWT 方案与 Session 方案收敛了——都需要服务端状态、缓存和失效同步。
JWT 架构仍然保留的优势
- 存储查询不在身份的必经之路上 - Session 查存储是获取身份的唯一途径(存储挂 = 全站瘫痪) - JWT 身份获取零 I/O,权限系统挂了还有降级空间,且大量只需"是不是本人"的接口永远零查询
- 认证与授权独立伸缩:验签是纯 CPU 负载,授权是 I/O 负载,分开后各自扩容、各自优化、故障隔离
- 跨系统能力:同一令牌可被多个独立服务/组织验证,无需共享 session 存储——Session 跨组织做不到,是结构性差异
权限系统的性能瓶颈处理
权限数据特征:读极多、写极少(缓存命中率 99%+)。
多级缓存,读不落地:
请求 → 服务本地内存缓存(纳秒级)
→ Redis 集群(微秒级)
→ 数据库(极少,缓存全 miss 才到)
- 按租户(tenantId)分片:天然隔离,热点只影响单个分片
- 粗粒度校验前置到网关:不进业务服务
分布式数据同步(一致性)处理
思路:接受短暂不一致,把窗口压到毫秒级,为零容忍场景留后门。
- 写时广播失效(主手段,负责"快"): 权限变更 → 写数据库(唯一事实源)→ PubSub/MQ 广播失效消息 → 各级缓存立即丢弃旧值 → 下次查询回源
- 短 TTL 兜底(安全网,负责"不丢"): 缓存设 30~60 秒 TTL,即使广播消息丢失,最坏一分钟内自动回源
- 版本号机制:缓存附带权限版本号,变更 +1,校验时发现版本落后强制回源,可通过对账发现丢失的消息
- 高危操作读穿透:财务、删库等零容忍场景跳过缓存直读主库
总结
"无状态"从来不是目的,而是手段。JWT 消灭的是"身份解析"的 I/O,而不是所有状态。
授权状态、撤销能力、踢人能力——这些应该留在服务端,问题只是怎么让它们又快又一致。
选 JWT 还是 Session,本质不是"存不存状态",而是"状态存什么、谁来读、怎么缓存、故障时怎么降级"。
SQL 基础
不使用 MYSQL,因为我讨厌 MYSQL。
安装 Pgsql
# 包管理器安装
sudo apt install -y postgresql
# 检查 systemd 服务是否启动
sudo systemctl status postgresql
运行 psql
安装 PostgreSQL 时,安装包的安装脚本自动创建了一个名为 postgres 的 Linux 系统用户(数据库的数据目录、进程都归它管)。
这不是 PostgreSQL 的怪癖,而是 Linux 服务的标准做法。
nginx、redis、mysql 安装后都会创建各自的专用系统用户。原则叫最小权限,「服务不以 root 运行,而是跑在一个"除了自己的数据目录什么都碰不了"的受限用户下。」
基于上面的认知,当我们想要使用 postgresql 的时候,就需要先切换到其对应的系统账户,然后再进行操作。
# 登录数据库
# sudo -u 用户名 程序名: 以 postgres 这个【Linux 用户】身份执行 psql
sudo -u postgres psql
创建练习用的库和账号
-- 创建名为 paramstore 的数据库
CREATE DATABASE paramstore;
-- 创建数据库账号 paramadmin 并填写其密码 dev123456
--
-- 刚创建的角色除了"能登录"以外,什么权限都没有
-- 它能看到 paramstore 这个库存在,但连进去、建表、读写数据全都无权。
CREATE USER paramadmin WITH PASSWORD 'dev123456';
-- 为 paramstore 用户授予 paramstore 数据库全部的权限
GRANT ALL PRIVILEGES ON DATABASE paramstore TO paramadmin;
-- 切换数据库
-- \c 中的 c 可以理解为 connect
\c paramstore
-- 授予 paramadmin 在 paramstore 数据库中的 schema 权限
GRANT ALL ON SCHEMA public TO paramadmin;
-- 为什么还要单独再授予一个 schema 权限?
-- 因为 PostgreSQL 的权限是分层的
-- 服务器 → 数据库(DATABASE) → schema → 表(TABLE)
-- GRANT ON DATABASE 只给了"进楼"的权利
-- PostgreSQL 15 之后,默认的 public schema(所有表实际存放的楼层)不再自动开放,所以还要单独给"进楼层"的权限
-- 否则 paramadmin 连得上库却建不了表。
使用创建的账号连接数据库
# -h host (不填写的时候默认连接主机)
# -U user (不填写 psql 默认连一个和用户名同名的库)
# -d database
psql -h localhost -U paramadmin -d paramstore
建表
CREATE TABLE params (
id SERIAL PRIMARY KEY, -- 自增主键
key VARCHAR(50) NOT NULL UNIQUE,
value VARCHAR(500) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
数据库增删改查
-- INSERT 插入单条数据
INSERT INTO params (key, value) VALUES ('MaxLevel', '60');
-- INSERT插入多条
INSERT INTO params (key, value) VALUES
('DropRate', '0.05'),
('MaxPartySize', '4');
-- SELECT 查
SELECT * FROM params; -- 全表
SELECT key, value FROM params; -- 指定列
SELECT * FROM params WHERE key = 'MaxLevel'; -- 条件
SELECT * FROM params WHERE key LIKE 'Max%'; -- 模糊匹配
SELECT * FROM params ORDER BY key; -- 排序
SELECT * FROM params LIMIT 2 OFFSET 1; -- 分页
-- 安全习惯: UPDATE/DELETE 之前,先用同样的 WHERE 条件跑一次 SELECT
-- 确认命中的是你想改的行,再执行写操作。
-- UPDATE 更
UPDATE params SET value = '70', updated_at = now() WHERE key = 'MaxLevel';
-- DELETE 删
DELETE FROM params WHERE key = 'DropRate';
多表查询
SELECT p.key, p.value AS current_value, h.old_value, h.changed_at
FROM params p
JOIN param_history h ON h.param_id = p.id;
key | current_value | old_value | changed_at
----------+---------------+-----------+----------------------------
MaxLevel | 70 | 50 | 2026-08-09 20:04:04.281156
MaxLevel | 70 | 55 | 2026-08-09 20:04:04.281156
(2 rows)
理解 JOIN 的直觉:JOIN ... ON 就是"把右边表的行,按条件贴到左边表的行旁边",结果是一张临时的宽表。LEFT JOIN / INNER JOIN 的区别(没匹配上时是保留还是丢弃左行)
表设计基础
主键(PRIMARY KEY):每行的唯一标识。规则:永不重复、永不为空、最好永不变化。
SERIAL(自增整数)是最省心的选择;业务字段当主键(如用手机号)通常是坏主意——
业务字段会变,主键不该变。
外键(FOREIGN KEY / REFERENCES):表之间的关联 + 数据库级的完整性保护。
有了 REFERENCES params(id),数据库会拒绝插入指向不存在行的数据,
也会阻止你删掉还有历史记录的参数(默认行为)。
常用类型直觉:
| 类型 | 用途 | 备注 |
|---|---|---|
SERIAL / BIGSERIAL |
自增主键 | |
VARCHAR(n) |
有长度上限的字符串 | |
TEXT |
不限长字符串 | PG 里性能和 VARCHAR 相当 |
INT / BIGINT |
整数 | |
NUMERIC(p,s) |
精确小数 | 金额必须用它,绝不用 FLOAT |
BOOLEAN |
布尔 | |
TIMESTAMP / TIMESTAMPTZ |
时间 | 生产建议 TIMESTAMPTZ(带时区) |
JSONB |
半结构化 JSON | PG 特色,可索引可查询 |
范式直觉:一份数据只在一个地方存一份
反例:把 ServerName 同时存在 params 表和 param_history 表里,改了一处忘了另一处就是数据不一致的源头。
需要冗余时(性能原因),要清楚地知道自己在打破规则、并想好谁来保持一致。
数据库索引
-- 创建索引
CREATE INDEX idx_params_key ON params(key);
-- 查看表上的索引
\d params
索引不是免费的
- 占空间:每个索引是一份额外的有序数据
- 拖慢写入:INSERT/UPDATE 时除了写表还要维护索引
- 不是永远被使用:模糊查询
LIKE '%abc'(前缀通配)用不上普通索引;表很小时数据库会判断"全表扫更快"而不用索引
经验法则:为"经常出现在 WHERE / JOIN / ORDER BY 里的列"建索引,其余不建。
索引设计是"按查询需求反推"的,不是建表时拍脑袋。
EXPLAIN:让数据库告诉你它打算怎么查
-- 不执行,只看计划
EXPLAIN SELECT * FROM params WHERE key = 'MaxLevel';
-- 真的执行并给出实际耗时(更准)
EXPLAIN ANALYZE SELECT * FROM params WHERE key = 'MaxLevel';
输出里重点看两个词:
Seq Scan(全表扫描):大表上出现它通常是警报Index Scan/Index Only Scan:用上索引了
EF Core
EF Core 是一种ORM(对象关系映射)框架:让开发者可以用 C# 类操作数据库,不用手写 SQL。
C# 世界 数据库世界
Param 类 ←→ params 表
context.Params ←→ SELECT * FROM params
context.Add(p) ←→ INSERT INTO ...
SaveChanges() ←→ 把累积的变更一次性提交
EF Cpre 的 三个核心概念
Entity实体:和表对应的 C# 类DbContext数据库上下文:一次"工作单元",管连接、追踪变更、生成 SQLDbSet<T>:一张表的入口,增删改查都通过它
EF Core 的连接架构
你的代码(PostgresParamStore)
↓ 调用
DbContext(EF Core:翻译 LINQ → SQL、追踪实体状态)
↓ 交给
Npgsql 驱动(EF Core 的 PostgreSQL "插头")
↓ 打开
NpgsqlConnection(真正的 TCP 连接)→ PostgreSQL
关键认知:DbContext 本身不等于数据库连接。它更像一个"工作主管"——平时手里拿着计划(你写的 LINQ),只在真正需要执行时(ToListAsync、SaveChangesAsync 被调用的瞬间)才从楼下借一条 TCP 连接,用完立刻还。
连接池——为什么"随用随借"不浪费
你可能马上想到:每个请求都开关 TCP 连接,不是很贵吗?对,所以 Npgsql 底层有连接池:
应用启动后,池子里预建几条空闲 TCP 连接
↓
DbContext 需要执行 SQL → 从池里"借"一条(毫秒级)
↓
执行完 → 归还池中(连接不断开,留给下一个人用)
物理连接被反复复用,真正昂贵的"建立 TCP + 认证握手"只发生寥寥几次。这就是为什么"每个请求 new 一个 DbContext"是廉价且正确的。贵的东西(连接)在池里躺着,便宜的东西(DbContext 对象)用完即弃。
DbContext 到底是什么(理解 Scoped 的钥匙)
DbContext 的真正身份是工作单元(Unit of Work):它在内存里维护一块"本次工作的现场":
DbContext 内部状态:
├── 变更追踪器:你查出来的每个实体,它都盯着(哪个字段被改了)
├── 待办清单:Add/Remove 了哪些实体,还没写库
├── 身份映射:同一条记录查两次,给你同一个对象
└── 当前事务(如果有的话)
SaveChangesAsync() 做的事就是:把这块"现场"里累积的所有变更,翻译成 SQL,包成一个事务,一次性提交。
这块"现场"是高度有状态、且只属于"当前这一次工作"的——这就是生命周期问题的根源。
为什么 Scoped 对、Singleton 错
把 DbContext 设成 Singleton = 全应用共享同一块工作现场。四个灾难:
- 线程不安全(最致命): DbContext 内部状态不是并发设计的。两个请求同时操作同一个上下文——一个在遍历实体、一个在修改追踪器——轻则数据错乱,重则直接抛异常。而 Scoped 保证每个请求拿到自己的上下文,天然隔离。
- 未提交变更会串请求: 请求 A 改了实体但还没 SaveChanges 就返回了;请求 B 拿到同一个上下文调用 SaveChanges——把 A 的半成品变更一起提交了。数据完整性直接崩坏。
- 内存无限增长: 变更追踪器只增不减:跑一个月,上下文里追踪着几百万个实体对象,内存爆炸。(Scoped 上下文请求结束就销毁,追踪器随之清空。)
- 事务边界失控: 一个请求开启的事务还没结束,另一个请求的操作挤进同一个上下文——两个毫不相干的业务操作被绑进同一个事务,一个失败连累另一个回滚。 Transient 为什么也不对? 同一请求内多处注入拿到不同上下文:你在 A 处查出的实体,B 处的上下文不认识(没追踪),更新时状态错乱;而且无法保证同一请求内的多个操作在同一个事务里。
定义实体和 DbContext
// 实体:和 params 表对应
public class Param
{
public int Id { get; set; }
public required string Key { get; set; }
public required string Value { get; set; }
public DateTime UpdatedAt { get; set; }
}
// DbContext:数据库会话
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Param> Params => Set<Param>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Param>(e =>
{
e.ToTable("params");
e.Property(p => p.Key).HasColumnName("key").HasMaxLength(50);
e.HasIndex(p => p.Key).IsUnique();
e.Property(p => p.Value).HasColumnName("value").HasMaxLength(500);
e.Property(p => p.UpdatedAt).HasColumnName("updated_at");
});
}
}
连接字符串放进配置
{
"ConnectionStrings": {
"ParamStore": "Host=localhost;Username=paramadmin;Password=你的密码;Database=paramstore"
}
}
注册 DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("ParamStore"))
);
AddDbContext 默认把 DbContext 注册为 Scoped
- 同一请求内多处注入拿到同一个上下文 → 一个请求内的操作在同一个事务语义里
- 不同请求之间隔离 → 未提交的变更绝不串请求
- 不能是 Singleton(全应用共享一个上下文会事务混乱、线程不安全)
Migration:用代码管理表结构
dotnet ef migrations add InitialCreate # 根据实体生成迁移脚本
dotnet ef database update # 把迁移应用到数据库(建表)
Migration 是版本化的表结构变更脚本:每次改实体类,生成一个新迁移,数据库里有一张 __EFMigrationsHistory 表记录已应用到哪个版本。
核心认知:表结构的变更历史从此和代码一起进 git,可审查、可回滚、团队可同步——再也不用口头通知"我加了列,你们手动改下库"。
实现 PostgresParamStore
public class PostgresParamStore(AppDbContext db) : IParamStore
{
public async Task<IReadOnlyCollection<ParamItem>> GetAllAsync()
=> await db.Params
.Select(p => new ParamItem(p.Key, p.Value))
.ToListAsync();
public async Task<ParamItem?> FindAsync(string key)
=> await db.Params
.Where(p => p.Key == key)
.Select(p => new ParamItem(p.Key, p.Value))
.FirstOrDefaultAsync();
public async Task<bool> AddAsync(string key, string value)
{
if (await db.Params.AnyAsync(p => p.Key == key)) return false;
db.Params.Add(new Param { Key = key, Value = value, UpdatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
return true;
}
// Update / Remove 同理……
}
EF Core 是如何实现数据库迁移的
Migration 的底层机制拆开来是"一次对比、一次生成、一次执行、一张账本"四步。
第一步:对比——"模型现在长什么样 vs 上次长什么样"
EF Core 在你的项目里维护着一个关键文件:模型快照(ModelSnapshot)。它是一份"上次迁移时,实体模型长什么样"的完整描述(也是自动生成的 C# 代码)。
你执行 dotnet ef migrations add AddHistory 时:
当前实体类(你刚改的 C# 类)
↓ 对比
ModelSnapshot(上次的模型快照)
↓ 算出差异
"ParamHistory 表是新增的、Param 表多了个 UpdatedAt 列……"
第二步:生成——差异变成一份迁移文件
差异被翻译成一份新的 C# 文件(比如 20260810_AddHistory.cs):
public partial class AddHistory : Migration
{
protected override void Up(MigrationBuilder mb) // 升级:怎么改
{
mb.CreateTable("param_history", ...);
mb.AddColumn<DateTime>("updated_at", "params", ...);
}
protected override void Down(MigrationBuilder mb) // 回滚:怎么改回去
{
mb.DropTable("param_history");
mb.DropColumn("updated_at", "params");
}
}
注意两点:Up/Down 成对存在(所以迁移可逆);文件里是数据库无关的抽象操作(CreateTable、AddColumn),不是 SQL——这是它能跨数据库的原因。同时快照更新为最新模型,为下一次对比做准备。
第三步:执行——抽象操作翻译成具体 SQL
dotnet ef database update 时:
迁移文件里的抽象操作(数据库无关)
↓ 交给数据库提供程序(Npgsql)
翻译成 PostgreSQL 方言的 DDL:
CREATE TABLE param_history (...);
ALTER TABLE params ADD COLUMN updated_at timestamp;
↓ 按顺序逐条执行
PostgreSQL
如果底层是 SQL Server,同一个迁移文件会被翻译成 T-SQL 方言——迁移代码写一次,方言由提供程序适配。
第四步:账本——__EFMigrationsHistory 表
数据库里有一张 EF Core 自动维护的表:
__EFMigrationsHistory
┌──────────────────────────┬─────────────────┐
│ MigrationId │ ProductVersion │
├──────────────────────────┼─────────────────┤
│ 20260801_InitialCreate │ 8.0.x │
│ 20260810_AddHistory │ 8.0.x │
└──────────────────────────┴─────────────────┘
每次 database update 的流程:查账本 → 找出"已生成但未执行"的迁移 → 按顺序执行 → 把 ID 记进账本。这就是"幂等"的来源——你在开发机执行过一次,部署到生产机再执行,它只会补上新迁移,不会重复建表。
EF Core 的工程纪律
- 表结构变更只走 Migration 一条路。 管理员手动改库 = 制造三方不一致。紧急情况必须手动改(比如线上 hotfix),改完后立刻补一个等效的迁移(哪怕实体没变化,生成空迁移再手动补上对应操作),把快照和账本对齐。
- 手动改错了怎么办。 EF 不管漂移,所以修复也靠人:要么手动把库改回去,要么用 dotnet ef migrations script 生成"从账本状态到最新模型"的 SQL,人工审查修正后执行。
- 想主动发现漂移,有工具。 dotnet ef migrations has-pending-model-changes(.NET 8+)可以检查"实体模型和快照是否一致";数据库和模型的一致性检查,严肃项目会引入专门的 schema 对比工具或在 CI 里跑验证。你现在知道有这回事即可。
EF Core 的迁移是单向记账:只按迁移文件的记录往前走,从不回头看真实数据库长什么样,更不会"纠正"它。
手动改库不会被还原,但会造成模型、快照、真实库的三方漂移。
漂移不会立刻爆炸,它会在最意想不到的时刻、以最迷惑的方式爆炸。
所以纪律只有一条:结构变更,全部走 Migration。