基础 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 上继续注册服务吗?

  1. 线程安全。 运行阶段你的服务会同时处理成百上千个并发请求,DI 容器会被大量线程同时读取。如果容器随时能被修改,就需要昂贵的锁机制。"构建一次、之后只读"让运行期完全无锁,这是性能基础。
  2. 可校验。 Build() 时框架可以一次性检查依赖关系:A 依赖 B、B 依赖 C,链条是否完整、有没有循环依赖。如果允许随时注册,这种校验就没法做了。
  3. 概念清晰。 "准备材料"和"开火做菜"是两个阶段,混在一起的项目(比如运行到一半改配置、动态加服务)是无数线上事故的来源。框架用类型系统强制你分开: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;发布部署时通常设为 Production
  • appsettings.{环境}.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    // 从请求头取
)

必须显式标注的两种典型情况:

  1. 简单类型在路由与查询字符串之间有歧义
  2. 想让代码意图更明确([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 容器解析
  • 方法:必须有名为 InvokeInvokeAsync 的方法,第一个参数是 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 实现原理

  1. 客户端提交用户名密码
  2. 服务器验证通过后,在服务端存储(内存/Redis)创建一条会话记录: sessionId(随机字符串)→ { userId, role, 登录时间, ... }
  3. 通过 Set-Cookie 响应头把 sessionId 发给浏览器
  4. 浏览器之后每次请求自动携带 Cookie: sessionId=xxx
  5. 服务器拿 sessionId 去存储里查出会话数据 → 知道"你是谁、能干什么"

核心特征:状态保存在服务端,客户端手里只有一个无意义的随机 id,本身不含任何信息。

使用场景

  • 传统的、服务器渲染的单体网站(管理后台、内部系统)
  • 客户端只有浏览器的应用
  • 对"实时踢人、即时改权限"要求高的系统
  • 团队规模小、不想引入复杂度的项目

优势

  • 完全可控:删除会话立即踢人,改权限立即生效
  • 实现简单,所有 Web 框架内置支持
  • 客户端零逻辑(浏览器自动处理 Cookie)
  • 会话数据在服务端,可以存任意多的信息,不受大小限制

局限性

  • 扩展性差:多实例部署时,所有服务器必须共享会话存储(通常是 Redis),Redis 成为关键依赖和潜在单点
  • 每次请求都要查存储:比纯计算签名多一次网络/存储开销
  • 移动端不友好:Cookie 是浏览器机制,App 使用要手动模拟
  • 跨域麻烦:前后端分离部署在不同域名时,Cookie 的 SameSite/CORS 配置复杂且易出安全问题
  • 无法跨系统:你的 session 存在你家服务器,第三方系统无法识别

JWT(Json Web Token) 方案

  1. 客户端提交用户名密码
  2. 服务器验证通过后,把身份信息打包并签名,生成令牌: - Header.Payload.Signature - Payload 内容(示例):{ "userId": 42, "role": "admin", "exp": 1723000000 } - Signature = HMAC_SHA256(Header + "." + Payload, 服务器密钥)
  3. 令牌发给客户端,客户端自行保存(localStorage / 内存 / App 本地)
  4. 之后每次请求,客户端手动在 Header 里携带: - Authorization: Bearer eyJhbGci...
  5. 服务器收到后只做一件事:用密钥验签 - 签名对 → 内容没被篡改 → 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 黑名单,立即拒杀

被踢出企业(三道防线)

  1. 授权层实时拒绝(毫秒级):权限系统中"用户属于该企业"的记录被删,缓存失效广播。令牌仍有效(身份真实),但所有企业资源权限查询返回 403
  2. Refresh Token 删除(分钟级):Access Token 自然过期,身份令牌彻底作废
  3. 黑名单(极端情况):连个人资源都要立即切断时,验签环节直接拒绝

设计美感:被踢出企业(授权问题)反而比主动注销(认证问题)更容易实时—— 权限从令牌里剥离得越干净,实时控制能力越强。

JWT 的真实分工:认证做薄,授权做实

真实架构中:

验签(JWT)→ 确认身份,零 I/O,纯 CPU 计算
权限校验   → 查权限系统,有 I/O

JWT 消灭的只是"身份解析"的 I/O,不是所有状态。

到了细粒度授权层面,JWT 方案与 Session 方案收敛了——都需要服务端状态、缓存和失效同步。

JWT 架构仍然保留的优势

  1. 存储查询不在身份的必经之路上 - Session 查存储是获取身份的唯一途径(存储挂 = 全站瘫痪) - JWT 身份获取零 I/O,权限系统挂了还有降级空间,且大量只需"是不是本人"的接口永远零查询
  2. 认证与授权独立伸缩:验签是纯 CPU 负载,授权是 I/O 负载,分开后各自扩容、各自优化、故障隔离
  3. 跨系统能力:同一令牌可被多个独立服务/组织验证,无需共享 session 存储——Session 跨组织做不到,是结构性差异

权限系统的性能瓶颈处理

权限数据特征:读极多、写极少(缓存命中率 99%+)。

多级缓存,读不落地

请求 → 服务本地内存缓存(纳秒级)
     → Redis 集群(微秒级)
     → 数据库(极少,缓存全 miss 才到)
  • 按租户(tenantId)分片:天然隔离,热点只影响单个分片
  • 粗粒度校验前置到网关:不进业务服务

分布式数据同步(一致性)处理

思路:接受短暂不一致,把窗口压到毫秒级,为零容忍场景留后门。

  1. 写时广播失效(主手段,负责"快"): 权限变更 → 写数据库(唯一事实源)→ PubSub/MQ 广播失效消息 → 各级缓存立即丢弃旧值 → 下次查询回源
  2. 短 TTL 兜底(安全网,负责"不丢"): 缓存设 30~60 秒 TTL,即使广播消息丢失,最坏一分钟内自动回源
  3. 版本号机制:缓存附带权限版本号,变更 +1,校验时发现版本落后强制回源,可通过对账发现丢失的消息
  4. 高危操作读穿透:财务、删库等零容忍场景跳过缓存直读主库

总结

"无状态"从来不是目的,而是手段。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 数据库上下文:一次"工作单元",管连接、追踪变更、生成 SQL
  • DbSet<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 = 全应用共享同一块工作现场。四个灾难:

  1. 线程不安全(最致命): DbContext 内部状态不是并发设计的。两个请求同时操作同一个上下文——一个在遍历实体、一个在修改追踪器——轻则数据错乱,重则直接抛异常。而 Scoped 保证每个请求拿到自己的上下文,天然隔离。
  2. 未提交变更会串请求: 请求 A 改了实体但还没 SaveChanges 就返回了;请求 B 拿到同一个上下文调用 SaveChanges——把 A 的半成品变更一起提交了。数据完整性直接崩坏。
  3. 内存无限增长: 变更追踪器只增不减:跑一个月,上下文里追踪着几百万个实体对象,内存爆炸。(Scoped 上下文请求结束就销毁,追踪器随之清空。)
  4. 事务边界失控: 一个请求开启的事务还没结束,另一个请求的操作挤进同一个上下文——两个毫不相干的业务操作被绑进同一个事务,一个失败连累另一个回滚。 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 的工程纪律

  1. 表结构变更只走 Migration 一条路。 管理员手动改库 = 制造三方不一致。紧急情况必须手动改(比如线上 hotfix),改完后立刻补一个等效的迁移(哪怕实体没变化,生成空迁移再手动补上对应操作),把快照和账本对齐。
  2. 手动改错了怎么办。 EF 不管漂移,所以修复也靠人:要么手动把库改回去,要么用 dotnet ef migrations script 生成"从账本状态到最新模型"的 SQL,人工审查修正后执行。
  3. 想主动发现漂移,有工具。 dotnet ef migrations has-pending-model-changes(.NET 8+)可以检查"实体模型和快照是否一致";数据库和模型的一致性检查,严肃项目会引入专门的 schema 对比工具或在 CI 里跑验证。你现在知道有这回事即可。

EF Core 的迁移是单向记账:只按迁移文件的记录往前走,从不回头看真实数据库长什么样,更不会"纠正"它。

手动改库不会被还原,但会造成模型、快照、真实库的三方漂移。

漂移不会立刻爆炸,它会在最意想不到的时刻、以最迷惑的方式爆炸。

所以纪律只有一条:结构变更,全部走 Migration。