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