把一台玩具机器人拆开,发现核心控制代码只有不到80行——这是我在某个模拟项目里把服务端从传统控制器改成 ASP.NET Core 极简 API 之后的真实体验。不要误会,我不是说控制器写法不行,而是在大量内部工具、微服务、原型验证甚至小规模生产接口的场景里,Minimal API 带来的"少即是多"体验会让整个开发过程轻快得多。尤其到了 .NET 10 时代,极简 API 已经不再是早期那个简陋的实验品,它在路由、绑定、校验、OpenAPI、AOT 发布等环节都已经相当成熟,完全值得认真对待。
这篇内容我会从一个真正动手写过项目的人的角度,把 ASP.NET Core 极简 API 从环境准备、第一个接口、路由与绑定、依赖注入、数据访问,一直到发布部署和避坑心得完整串一遍。无论你是刚从别的语言切过来,还是在老项目里一直用 Controller,这篇文章都能帮你快速建立一套可落地的极简 API 开发手感。全程没有花架子,全部是实际能跑、能抄、能改的干货。
1. 为什么我现在更愿意用极简 API:适用边界与核心取舍
1.1 极简 API 到底解决了什么问题
传统 ASP.NET Core 开发里,哪怕只是做一个返回一句话的接口,通常也要经历"建控制器类、加路由特性、声明动作方法、构造服务、注入依赖"这一整套流程。对于小型项目来说,这套流程本身没有错,但当项目里只有三五个接口,或者你只是想快速验证一个想法时,这些仪式感就变成了负担。
极简 API 的出现把整个链路压缩成两件事:注册服务、映射路由。你不需要单独建类,不需要理解一堆管道和中间件的组合方式,只需要在 Program.cs 里用几行代码把一个 lambda 或者局部函数交给 URL 路由去执行。这种模式最直接的价值是"心智负担低"——你打开文件,从上往下读,看到的几乎就是一个接口从请求到响应的完整形状。
我最早试着用极简 API 是在一个数据迁移工具里,当时的场景是要暴露三四个临时接口给测试脚本调用。用传统控制器需要建目录、建类、注册路由前缀,而用极简 API,我只需要在 Program.cs 末尾追加几个 app.MapGet 和 app.MapPost,改代码只改一个文件。这种"单文件即接口"的开发体验一开始会让人怀疑是不是太简单了,但它确实就是为这种场景而生的。
1.2 什么场景该用它,什么场景该继续用控制器
虽然我推荐大家在合适的场景拥抱极简 API,但它不是万能的替代品。我的经验里,下面这些场景特别适合极简 API:
- 微服务或模块化服务中的独立小服务,每个服务只负责少数几个领域动作
- 内部管理后台、运维工具、数据修复脚本的 HTTP 暴露层
- 快速原型、Demo、教学演示、竞赛作品中的后端
- 需要极高启动速度和低内存占用的无服务器函数或 AOT 发布场景
而下面这些场景,我更倾向于继续用传统控制器:
- 大型业务系统里按领域划分的复杂接口层,需要大量继承、过滤器、统一基类的场景
- 团队整体已经建立了基于控制器的代码规范和脚手架,迁移成本大于收益
- 接口数量多且高度相似,依赖控制器路由的某些约定式发现机制
这里多说一句:极简 API 的"极简"指的是写法的精简,而不是能力的降级。.NET 8 之后极简 API 已经支持了很多时候被误以为只能由 Controller 提供的功能,例如依赖注入、授权、CORS、模型校验、结果类型化、OpenAPI 文档等。能力并没有弱化,弱化的只是组织和仪式感。
1.3 项目结构的两种常见组织习惯
即使使用极简 API,我们也可以在一定程度上保留模块化结构。我见过两种比较靠谱的组织方式:
第一种是"单文件起步,膨胀后拆分"。刚开始所有映射都写在 Program.cs 里,当接口数量超过五六个且彼此之间开始有重复逻辑时,再把路由映射移动到专门的静态类中,比如定义 ApiEndpoints 静态类,里面包含扩展方法 MapXxxApi(this WebApplication app)。这样 Program.cs 始终只有一行调用,而具体映射被拆到不同文件。
第二种是"按模块组织极简接口"。每个领域模块有一个 EndpointDefinitions 类,类内部定义该模块需要注册的服务和端点。这种方式特别适合在团队里推行,因为它既保留了极简 API 对单个接口的轻量表达,又不会把 Program.cs 撑成几百行的"面条文件"。
我在实际项目中比较偏向第二种,因为它降低了多人协作时的冲突概率,也让路由的查找路径更清晰。不过如果你只是做个人项目或者一次性脚本,第一种完全够用。
2. 环境准备与第一个 API 落地:.NET 10 SDK 与项目脚手架
2.1 安装 SDK 与验证环境
做 ASP.NET Core 极简 API 之前,最重要的前提就是装好 .NET 10 SDK。这里不建议只装运行时,因为只要你想执行 dotnet new、dotnet build、dotnet run 这些命令,就必须有完整的 SDK。
安装完之后,在终端执行:
dotnet --list-sdks正常会出现类似10.0.100或更高版本的 SDK 信息。如果看不到 .NET 10 相关的版本,就说明安装有问题,需要检查环境变量 PATH 以及安装包对应的系统架构(x64 与 arm64 不要选错)。
有些人在 Linux 环境下会习惯用包管理器安装,但我其实更推荐去官方下载页拿安装脚本或者在容器里直接使用mcr.microsoft.com/dotnet/sdk:10.0镜像,因为这样可以保证 SDK 版本与后续部署环境尽量一致,也避免系统包管理器源里没有最新版本的问题。
2.2 创建项目的三种方式
创建极简 API 项目的方式主要有三种,我分别说下各自的特点。
方式一,使用空模板:
dotnet new web -n TodoApi cd TodoApi dotnet run这是最干净的方式。项目文件里只有几行基础的描述,Program.cs 里默认只有:
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", () => "Hello World!"); app.Run();直接在浏览器访问http://localhost:5000/(具体端口以 Kestrel 输出为准),就会看到 Hello World 字符串。这个模板与"极简 API"概念最对照,它不引入任何额外的 MVC 相关依赖。
方式二,使用 Web API 模板取消控制器引用:
dotnet new webapi -n TodoApi这个模板默认会带上 OpenAPI 支持,并把 Program.cs 里的映射改成了:
app.MapGet("/weatherforecast", () => ...);如果不想用控制器,直接删除天气相关的类并在 Program.cs 里写自己的极简接口即可。
方式三,在已有解决方案中单独添加项目:
dotnet new web -n MyApi -o src/MyApi dotnet sln MySolution.sln add src/MyApi/MyApi.csproj这种方式适合团队仓库,一开始就把项目放进 sln 便于管理与统一构建。
2.3 Program.cs 拆解:每一行在做什么
新人第一次看到极简 API 的 Program.cs 时,可能会觉得代码少到不知道它怎么跑起来的。这里我逐行拆一遍,你就明白背后的流程了:
var builder = WebApplication.CreateBuilder(args); // 创建宿主构建器 var app = builder.Build(); // 构建应用,此时管道已经组合完成 app.MapGet("/", () => "Hello World!"); // 注册一个 GET 路由端点 app.Run(); // 启动进程并监听请求CreateBuilder方法内部做的事情远比表面看起来多:它读取环境变量、配置文件,注册默认日志、Kestrel 服务、诊断中间件、路由服务等。Build方法则把这些注册过的服务组装成最终的应用管道。MapGet是在路由管道上挂一个"当收到 GET 请求且路径匹配时执行接下来的代码"的节点。Run是启动那个真正监听的服务器。
从 .NET 6 到 .NET 10,顶层语句和隐式 using 让这些代码越来越简洁。WebApplication.CreateBuilder(args)这个入口始终是核心,弄清楚它,后面再加服务、加中间件时就完全不慌了。
3. 路由、模型绑定与返回值:高频场景逐项拆解
3.1 路由参数的几种写法与约束
极简 API 的路由参数语法和控制器里相似,都是在大括号里声明占位符:
app.MapGet("/users/{id}", (int id) => Results.Ok($"用户编号: {id}"));这里{id}会被自动尝试解析为 int,如果客户端传入了非数字内容,请求会在路由匹配阶段直接返回 400。这种自动类型转换看起来方便,但有一个注意点:它是依赖路由约束解析的,也就是说,如果参数数量过多、涉及复杂类型,最好在 lambda 参数里显式标注类型,避免被当成字符串绑定。
如果你希望对参数做更精细的限制,可以给路由参数加约束:
app.MapGet("/users/{id:min(1)}", (int id) => Results.Ok(id)); app.MapGet("/files/{name:regex(^[a-z0-9\\.]+$)}", (string name) => Results.File(...));min(1)表示最小值为 1,regex(...)给出正则约束。路由约束的好处是校验发生在路由匹配阶段,不会走进你的业务代码,等于给接口加了一道免费的前置防线。
3.2 Query 字符串与 Body 绑定的选择
参数从 URL 查询字符串来,直接放在 lambda 参数里即可:
app.MapGet("/search", (string keyword, int pageIndex, int pageSize) => { return Results.Ok($"搜索: {keyword}, 页码: {pageIndex}, 每页: {pageSize}"); });请求/search?keyword=dotnet&pageIndex=1&pageSize=20时,三个参数会被自动绑定。对于可空参数,可以声明为int?或者给默认值:
app.MapGet("/search", (string keyword, int? pageIndex, int? pageSize) => { int currentPage = pageIndex ?? 1; int size = pageSize ?? 20; return Results.Ok(new { keyword, page = currentPage, size }); });POST 请求中如果需要读取 JSON 请求体,则需要用 FromBody 显式指定(虽然在新版本里未标注也能自动判断,但推荐显式标注以增强可读性):
app.MapPost("/users", (UserDto user) => Results.Created($"/users/{user.Id}", user));绑定规则有一条经验你最好记住:极简 API 的类型绑定遵循"最佳猜测"原则——简单类型默认从路由或查询字符串取,复杂类型默认从 JSON Body 取。当同一个接口里既有简单类型又有复杂类型时,也完全没问题,框架会自动分配。
下面用表格总结三种常用绑定来源:
| 绑定来源 | 写法示例 | 适用场景 |
|---|---|---|
| 路由参数 | /users/{id},lambda 参数int id | 资源的唯一标识、层级关系 |
| 查询字符串 | /search?keyword=xxx,lambda 参数string keyword | 过滤、分页、排序条件 |
| JSON 请求体 | lambda 参数UserDto user | 新增、更新等需要提交结构化数据的请求 |
3.3 返回类型:IResult 系列与状态码
极简 API 最推荐的返回方式是实现 IResult 接口的对象。Results.Ok、Results.Created、Results.NoContent、Results.BadRequest这些静态工厂方法返回的都是 IResult。
app.MapPost("/users", (UserDto user) => { if (string.IsNullOrWhiteSpace(user.Name)) { return Results.BadRequest(new { error = "Name 不能为空" }); } var saved = SaveUser(user); return Results.Created($"/users/{saved.Id}", saved); });有人可能会问,直接返回 DTO 对象行不行?行,极简 API 会默认把它序列化并返回 200,但这会带来两个问题:一是无法精细控制状态码,二是当项目启用 OpenAPI 时,直接返回 DTO 虽然还能推理出类型,但如果你想返回 201 这类状态码,最好还是用 IResult。对于大多数生产接口,我建议统一使用 Results 工厂方法,这样每个端点返回什么状态码一目了然。
对于可能有多种返回状态的接口,可以使用强类型结果:
app.MapGet("/users/{id}", (int id) => { var user = FindUser(id); if (user is null) { return Results.NotFound(); } return Results.Ok(user); });强类型结果的好处是 lambda 表达式的返回类型被推断为Results<Ok<User>, NotFound>,在 OpenAPI 生成时能够准确反映可能的响应类型,前端生成的 TypeScript 客户端也会更精确。
4. 依赖注入与配置系统的轻量连招
4.1 注册服务与作用域的理解
极简 API 同样完整支持依赖注入。你需要先注册服务,然后在 lambda 参数里声明接口参数:
builder.Services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>(); builder.Services.AddScoped<IUserRepository, InMemoryUserRepository>(); app.MapGet("/users/{id}", (int id, IUserRepository repo, IDateTimeProvider clock) => { var user = repo.GetById(id); if (user == null) return Results.NotFound(); return Results.Ok(new { user, currentTime = clock.Now }); });是不是很简单?极简 API 会自动把 lambda 中的接口参数解析为对应服务实例,前提是你已经在 builder.Services 里注册过。这里有一类很隐蔽的坑:lambda 参数如果包含一个没有注册的复杂类型,运行时不会在启动阶段报错,而是在请求执行阶段才抛出 InvalidOperationException。所以我建议在写完映射后,至少跑一次包含该端点的请求,确认参数都能被正确解析。
关于生命周期,简单说下:
- Singleton 适合无状态服务或者需要全局唯一的服务,例如配置对象、连接工厂
- Scoped 适合"每个请求一个实例"的服务,例如数据库上下文
- Transient 适合轻量、无状态且可能被多次创建的服务
如果拿不准用什么,先想清楚一个问题:这个服务内部会不会保存跨请求状态?如果不保存,Transient 或者 Singleton 都可以;如果保存且需要请求隔离,用 Scoped。
4.2 读取配置文件的常见姿势
极简 API 项目默认会加载 appsettings.json,以及环境变量。读取配置最简单的方式是直接通过 builder.Configuration:
var connectionString = builder.Configuration.GetConnectionString("Default"); var appName = builder.Configuration["AppSettings:AppName"];如果希望把一组配置映射为强类型对象,可以这样做:
public sealed class JwtOptions { public string Issuer { get; set; } = string.Empty; public string Audience { get; set; } = string.Empty; public string Key { get; set; } = string.Empty; } var jwtSection = builder.Configuration.GetSection("Jwt"); builder.Services.Configure<JwtOptions>(jwtSection);然后在端点里通过注入IOptions<JwtOptions>来读取。这个模式的优点在于配置结构一改,编译阶段就能发现哪些地方引用了缺失字段。
我个人建议不要把连接字符串、密钥等敏感信息放进 appsettings.json,而是使用用户机密或环境变量。本地开发时用用户机密,部署时用环境变量,这样代码和配置分离,安全性也更好。
4.3 中间件:自定义请求日志与异常处理
极简 API 的中间件机制和传统 ASP.NET Core 完全一致。你可以在 app.Build() 之后、映射路由之前插入自定义中间件:
app.Use(async (context, next) => { var stopwatch = Stopwatch.StartNew(); await next(); stopwatch.Stop(); Console.WriteLine($"{context.Request.Method} {context.Request.Path} - {stopwatch.ElapsedMilliseconds}ms"); });也可以用简洁的委托来按路径分流。很多时候,一个简单的全局异常处理中间件就够了:
app.Use(async (context, next) => { try { await next(); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsJsonAsync(new { error = ex.Message }); } });这个中间件能把所有未处理异常统一转成 JSON 错误响应,避免返回 ASP.NET Core 默认的 HTML 错误页。对于面向移动端或者前后端分离的项目,这种统一错误格式非常有必要。
5. 接入关系型数据:EF Core + SQLite 的完整链路
5.1 为什么先用 SQLite 起步
对于入门教程,我强烈建议数据访问层先用 SQLite。原因很简单:零安装、单文件、开箱即用。你不用先部署一个 PostgreSQL 或 SQL Server 实例,各种连接串、权限、网络问题统统都先不用管,全部精力放在理解数据访问链路上。
等熟悉了整套流程,再切换到真正的数据库服务其实只需改动三处:连接串、数据库提供程序包、少量兼容性代码。
5.2 添加 EF Core 相关包
在项目目录下执行:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Design注意Microsoft.EntityFrameworkCore.Design是为了使用dotnet ef迁移命令,如果你不使用迁移,直接调用EnsureCreated或者手工建表,也可以不装。但做正式项目还是推荐迁移,因为迁移能记录数据库结构变化,团队协作时也更安全。
5.3 定义实体与上下文
定义一个简单的实体类:
public sealed class TodoItem { public int Id { get; set; } public string Title { get; set; } = string.Empty; public bool IsCompleted { get; set; } public DateTimeOffset CreatedAt { get; set; } }再定义一个 DbContext:
public sealed class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<TodoItem> TodoItems => Set<TodoItem>(); }然后在 Program.cs 注册服务:
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(builder.Configuration.GetConnectionString("Default")));appsettings.json 里的连接字符串可以写成:
{ "ConnectionStrings": { "Default": "Data Source=todo.db" } }5.4 创建数据库并完成一组 CRUD 接口
执行迁移命令:
dotnet ef migrations add InitialCreate dotnet ef database update如果不想用迁移,也可以先注册上下文,然后在启动时手动调用:
using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); db.Database.EnsureCreated();但请记住,EnsureCreated 不会帮你处理后续的表结构变更,只适合原型阶段。
下面是完整的 CRUD 端点集合:
app.MapGet("/todos", async (AppDbContext db) => { var items = await db.TodoItems .AsNoTracking() .OrderByDescending(t => t.CreatedAt) .ToListAsync(); return Results.Ok(items); }); app.MapGet("/todos/{id}", async (int id, AppDbContext db) => { var item = await db.TodoItems.FindAsync(id); return item is null ? Results.NotFound() : Results.Ok(item); }); app.MapPost("/todos", async (TodoItem input, AppDbContext db) => { input.CreatedAt = DateTimeOffset.UtcNow; db.TodoItems.Add(input); await db.SaveChangesAsync(); return Results.Created($"/todos/{input.Id}", input); }); app.MapPut("/todos/{id}", async (int id, TodoItem input, AppDbContext db) => { var item = await db.TodoItems.FindAsync(id); if (item is null) return Results.NotFound(); item.Title = input.Title; item.IsCompleted = input.IsCompleted; await db.SaveChangesAsync(); return Results.NoContent(); }); app.MapDelete("/todos/{id}", async (int id, AppDbContext db) => { var item = await db.TodoItems.FindAsync(id); if (item is null) return Results.NotFound(); db.TodoItems.Remove(item); await db.SaveChangesAsync(); return Results.NoContent(); });这里有一点一定要提醒:lambda 参数与 DbContext 的依赖注入是配合得很好的。你只需要在参数列表里声明AppDbContext db,框架就会从请求作用域里解析出一个 Scoped 上下文实例,整个请求生命周期里复用它,不需要手动释放。EF Core 的上下文不是线程安全的,所以不要在同一作用域里并发使用同一个上下文实例,极简 API 的自然依赖注入机制在这点上已经帮你规避了大量风险。
6. 发布、容器化与本地联调的踩坑记录
6.1 本地启动时的端口配置
用dotnet run启动时,Kestrel 监听的端口由 launchSettings.json 里的applicationUrl决定。很多新人发现改了某个端口但不生效,通常就是因为这个文件被 IDE 缓存住了。
我推荐的本地调试技巧是直接使用命令行参数覆盖:
dotnet run --urls "http://localhost:5200"这样最直接,不会跟 launchSettings.json 里的配置纠缠。容器环境里则通常依赖环境变量ASPNETCORE_URLS或者--urls参数来指定监听地址,比如容器里要暴露给宿主机访问,一般就要监听到http://0.0.0.0:8080。
6.2 Dockerfile 的最小可用写法
下面是一份可以拿去直接改的 Dockerfile:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY ["MyApi/MyApi.csproj", "MyApi/"] RUN dotnet restore "MyApi/MyApi.csproj" COPY . . RUN dotnet publish "MyApi/MyApi.csproj" -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app EXPOSE 80 COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "MyApi.dll"]这里有几个细节需要解释一下:
- 第一段用 sdk 镜像做构建,第二段用 aspnet 镜像做运行,这是标准的"构建环境与运行环境分离"手法
EXPOSE 80只是声明容器内监听的端口,真正映射到宿主机端口要靠-p 8080:80- 如果镜像里忘记设置
ASPNETCORE_URLS=http://0.0.0.0:80,容器内 Kestrel 默认可能只监听 localhost,导致宿主机访问不到。建议在 final 阶段显式设置:
ENV ASPNETCORE_URLS=http://0.0.0.0:80这个坑我刚开始也踩过,Kestrel 默认绑定 localhost 的特点在容器环境非常容易被忽略。
6.3 HTTPS 重定向与反向代理的冲突
极简 API 模板默认包含app.UseHttpsRedirection(),这个中间件在本地开发时没问题,但如果你把服务部署到 Nginx 或云负载均衡后面,情况就变了。
当外部请求到达 Nginx 后由 HTTPS 转发给内部 HTTP 服务时,如果没有正确配置转发头,服务端看到的是 HTTP 请求,于是 UseHttpsRedirection 就会将请求 307 重定向到 HTTPS 地址。客户端拿着这个 HTTPS 地址再次访问时,如果中间没有任何 HTTPS 服务,就会出现"反复重定向却怎么都打不开"的现象。
解决思路有两个:
- 在容器或反向代理环境中,明确告诉中间件已经处理了 HTTPS 终止:配置
ForwardedHeaders中间件并只信任代理来源 - 或者干脆在非生产环境移除 UseHttpsRedirection,只保留 HTTPS 终结在网关层
对于入门项目,我的建议是:如果部署环境简单,不加反向代理,那 UseHttpsRedirection 可以保留;一旦引入 Nginx、Caddy 或者云负载均衡,就要把 HTTPS 交给上游,应用层不再自己处理这个重定向。
7. 我在实际项目中记住的几件事
7.1 别把极简 API 写成"巨型单文件"
Program.cs 里塞几十个路由映射是很容易发生的事情,尤其当接口逻辑简单时,你会觉得写一个文件很爽。但等到需要改一个接口的校验逻辑、或者想给某组接口统一加权限时,你就会开始后悔了。
我在项目里形成的习惯是:每个领域模块一个静态类,比如TodoApiEndpoints、UserApiEndpoints,类里提供MapTodoApi(this WebApplication app)这样的扩展方法。Program.cs 里只保留app.MapTodoApi();和app.MapUserApi();两行,具体实现都在各自文件里。这个方法很简单,但确实避免了极简 API 项目最常见的"文件膨胀"问题。
具体写法可以参考:
public static class TodoApiEndpoints { public static IEndpointRouteBuilder MapTodoApi(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/todos", async (AppDbContext db) => ...); endpoints.MapGet("/todos/{id}", async (int id, AppDbContext db) => ...); endpoints.MapPost("/todos", async (TodoItem input, AppDbContext db) => ...); return endpoints; } }Program.cs 里调用:
app.MapTodoApi();这样每个文件的职责清晰,多人协作时也能减少冲突。甚至还可以配合路由前缀按模块分组:
var group = app.MapGroup("/api/todos"); group.MapGet("/", ...); group.MapGet("/{id}", ...);这样 URL 层级也更规范。
7.2 校验别全交给模型绑定
极简 API 默认不会像 MVC 那样自动执行 DataAnnotations 模型校验。也就是说,如果你在 TodoItem 的 Title 属性上写了[Required],POST 接口收到的 body 缺少 Title 时,并不会自动返回 400,而是会把一个 Title 为 null 的对象传进你的 lambda。这一点跟 Controller + [ApiController] 的行为完全不同,是很多从 MVC 转过来的人最容易忽略的坑。
解决方案有两种。第一种是自己在端点开头手动校验:
app.MapPost("/todos", (TodoItem input, AppDbContext db) => { if (string.IsNullOrWhiteSpace(input.Title)) return Results.BadRequest(new { error = "Title 不能为空" }); ... });第二种是引入 Minimal API 的校验机制(AddValidation或手动调用IValidatableObject),但这样做会引入一定复杂度。对于大多数入门项目,手动校验反而更直观。真正复杂到需要统一校验模型时,再做一层过滤器也不迟,不要一开始就上重装备。
7.3 使用路由组统一认证与 CORS
假设你要给/api/admin下所有接口加上管理员认证,传统思维是在每个端点上写属性或单独判断。极简 API 里只要用一次 MapGroup 就能统一搞定:
var adminGroup = app.MapGroup("/api/admin") .RequireAuthorization("AdminPolicy"); adminGroup.MapGet("/stats", (AppDbContext db) => ...); adminGroup.MapGet("/logs", (AppDbContext db) => ...);这样该组内所有端点都会自动应用认证,不需要逐个端点标注。同样的道理适用于 CORS、限流、输出缓存等中间件。MapGroup 的返回值依然是 IEndpointRouteBuilder,可以继续链式调用,也可以传给其他方法继续注册端点。
这种"把一组端点的公共行为抽到组级配置"的思路,是极简 API 在高阶场景里最顺手的组织方式。入门阶段能早点学会,后面写生产接口时能少走很多弯路。
7.4 日志和链路追踪值得一开始就养成习惯
极简 API 默认已经集成了 ILogger 日志。在端点代码里注入 ILogger 是最便宜也最关键的观测手段:
app.MapGet("/todos/{id}", async (int id, AppDbContext db, ILogger<TodoApiEndpoints> logger) => { logger.LogInformation("正在查询待办项,Id={Id}", id); ... });我自己在项目里体会最深的其实是"结构化日志不是查错误的时候才去想的东西",而是写代码的时候就顺手留下可检索的关键字段。比如上面这个记录,查询出现问题时,日志里就能直接按 Id 定位到是哪条数据出了问题。如果你一开始不做,等线上出问题再补日志,成本和风险都会大不少。
在 .NET 10 的极简 API 体系里,这些都不会增加额外复杂度,只需要你在写端点时多传递一个参数。但这个习惯的价值会随着项目规模增长逐渐放大。