把 ASP.NET Core 10 和 Minimal APIs 放在一起看,最值得先弄清楚的问题不是“能不能少写几行代码”,而是:当模板默认生成的内容越来越多时,你怎么用最少的样板代码把 HTTP 接口稳定地立起来,并且后续还能按真实业务拆结构。Minimal APIs 是 ASP.NET Core 6 引入的接口开发方式,到 .NET 10 这个阶段已经不是实验功能,而是很多新项目实际采用的写法。这篇文章适合两类人:一类是从传统 Controller 项目切过来的老手,另一类是刚学 ASP.NET Core、想快速把接口跑通的新手。
我更建议把整个学习过程拆成几个阶段来走:先确认技术边界,再准备环境,然后从单条接口开始跑通,最后补上验证、日志、测试和发布。下面按这个顺序讲,代码都能直接在 .NET 10 环境下跑,但请留意:不同 Preview 版本的模板生成细节和依赖包可能不同,落地时以你本机 SDK 实际生成的代码为准。
1. Minimal APIs 解决的三个问题,以及它和 Controller 的边界
1.1 它解决的三个问题
Minimal APIs 的核心思路很简单:把一个 HTTP 接口当成一个“路由模板 + 处理函数”的组合,而不是必须先建 Controller 类、配置基类、填充 Action 方法,再处理各种继承关系。
它主要解决了三个实际问题。
第一,小型服务的启动成本。你只想暴露两三个接口给内部调用,或者做一个简单的消息转发、配置查询、文件上传接收服务,用 Controller 那套结构会觉得“大部分代码都不是业务本身”。Minimal APIs 允许你把一个接口浓缩成一行MapGet、MapPost。
第二,请求处理链路的理解和排错成本。传统 Controller 有 MVC 的模型绑定、过滤器、约定规则,很多行为是隐式发生的。Minimal APIs 的写法更直观:路由如何匹配、参数如何绑定、返回什么结果,基本都写在同一个委托里。出现问题时,你很容易顺着代码找到原因。
第三,学习曲线。对新手来说,先不用理解大批组件,就能在几分钟内看到一个返回 JSON 的接口。这不代表它只能做入门玩具,而是说明它把“最小可用路径”缩短了。
1.2 什么时候别硬上 Minimal APIs
不过,Minimal APIs 不是所有场景都必须选。
如果你的项目需要大量自定义约定、复杂过滤器链、统一基类逻辑,或者团队已经习惯了 Controller 的分层方式,那继续用 Controller 完全没有问题。Minimal APIs 也支持 Filter、依赖注入、日志等能力,但组织方式更自由,自由度高的另一面是:如果项目很大,你自己得负责把结构约定好。
我个人的判断标准是这样:
- 接口数量少、领域逻辑简单、结构边界清晰:优先 Minimal APIs。
- 接口很多,但每个接口都很薄,只是数据库的增删改查:Minimal APIs 配合 MapGroup 也很好用。
- 团队规模大,项目生命周期长,需要强统一规范:Controller 可能更省心,或者给 Minimal APIs 做额外封装。
- 项目里除了接口还要渲染页面、处理大量视图逻辑:别把 Minimal APIs 当 MVC 用,老老实实上 Controller。
这就像工具选择,不存在“谁替代谁”。Minimal APIs 是 ASP.NET Core 提供的另一种接口实现方式,理解它最好的办法是实际建一个项目,把各种请求都跑一遍。
2. 环境准备:安装 SDK、创建项目并读懂启动链路
2.1 SDK 安装与版本检查
题目既然写的是 ASP.NET Core 10,那安装的就是 .NET 10 SDK。SDK 支持 Windows、macOS、Linux,安装完成后先在命令行里确认目前的 SDK 状态:
dotnet --version如果显示的是10.x.x,说明 SDK 没问题;如果显示 8.0 或 9.0,说明当前命令行使用的是旧版本。这时先检查 PATH,或者直接安装 .NET 10 SDK 并重新打开终端。
更完整的检查方式是:
dotnet --list-sdks这个命令会把机器上所有 SDK 列出来。比如你同时装了 .NET 9 和 .NET 10,项目可以指定使用哪个版本。多数情况下,直接使用最新 SDK 就可以。
Visual Studio、VS Code、Rider 都可以开发,但命令行才是最能保证行为一致的路径。编辑器只是外壳,最终构建和运行还是靠 dotnet CLI,所以我建议先熟练命令行。
2.2 新建项目并看懂文件结构
创建 Minimal API 项目最简单的方式是用空模板:
dotnet new web -n DemoApi cd DemoApidotnet new web生成的内容非常少,默认就是一个 ASP.NET Core 空项目,Program.cs 里会有一段最基础的启动代码。你也可以用dotnet new webapi,但要注意:较新的模板版本默认可能会添加 OpenAPI 相关包和示例接口,不同 SDK 版本生成的结果不一定相同。
新建完项目后,先打开三个文件看一下:
- DemoApi.csproj:看目标框架,如果是 .NET 10 SDK,这里通常是
net10.0,还要看引用了哪些 NuGet 包。 - Program.cs:看入口代码长什么样。
- Properties/launchSettings.json:看开发环境用的端口、启动方式和环境变量。
很多新手一上来直接改代码,跑不起来才发现端口被占用,或者 HTTPS 证书有问题。更稳的顺序是:先创建一个空项目,直接执行dotnet run,确认浏览器能打开默认页面,再往里面加接口。这样后面报错时,你能把“项目本身的问题”和“自己代码的问题”分开。
3. 第一个接口:从 Hello 到包含路由参数的 GET 接口
3.1 最小可运行接口
把 Program.cs 里的内容替换成下面这段:
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", () => "Hello ASP.NET Core Minimal API"); app.Run();然后运行:
dotnet run命令行会输出监听地址,浏览器打开http://localhost:端口号/,页面上会显示Hello ASP.NET Core Minimal API。
这段代码背后做了几件事:
WebApplication.CreateBuilder创建宿主,读取配置、初始化日志。app.MapGet注册一个路由,把根路径/和后面的委托绑定起来。app.Run启动服务并开始监听 HTTP 请求。
这里不需要 Controller 类,不需要路由特性,也不需要额外建Startup.cs。框架会在请求到达时,把任务交给与路由匹配的委托。
3.2 添加路由参数和查询参数
如果只是返回固定字符串,Minimal APIs 的优势体现还不明显。真正好用的是路由参数和查询参数处理。
先加一个带路由参数的接口:
app.MapGet("/hello/{name}", (string name) => $"Hello {name}");再访问/hello/zhangsan,会返回Hello zhangsan。花括号里的name会从 URL 路径中自动提取,并绑定到委托参数。
查询参数写法也一样简单:
app.MapGet("/hello", (string name, int age) => $"Hello {name}, age {age}");访问/hello?name=lisi&age=25时,框架会从查询字符串里读取name和age。age声明为int,框架会自动做类型转换;如果传了无法转换的值,请求会返回 400。
这里有一个新手容易踩的小坑:委托参数如果既不是复杂类型,又没有显式标注[FromBody],框架会默认尝试从路由、查询参数、请求头等位置绑定。你写string name,它可能认为 name 来自查询参数或路由;你写一个自定义类,它才会当作请求体处理。所以参数叫什么、路径里有没有同名占位符,会直接影响绑定结果。
3.3 为什么先跑通单接口再谈封装
不少读者喜欢一上来就搭分层架构、建接口基类、写仓储模式。以我的经验,这种做法在一个 3 个接口的小服务里非常容易过度设计。Minimal APIs 的正确用法是:先用一个文件、一个接口跑通完整流程,确认数据流没问题,再按业务量决定是否拆文件、是否引入数据库。
如果单接口都还没验证过返回内容、状态码和日志,后面批量加接口时,你会在多个文件之间来回找问题。先把最简单的一条路走通,后面的封装才有意义。
4. POST 写入、请求体验证和状态码规范
4.1 请求体绑定
GET 接口只是读数据,真正进入业务场景后,你会需要 POST。先定义一个数据结构,我通常直接使用 C# 的record,简洁且不可变语义也够用:
public record Todo(int Id, string Title, bool IsCompleted = false);然后写一个内存版本的任务列表服务:
var todos = new List<Todo>(); var nextId = 1; app.MapPost("/todos", (Todo input) => { var item = new Todo(nextId++, input.Title, input.IsCompleted); todos.Add(item); return Results.Created($"/todos/{item.Id}", item); });客户端用 JSON 请求:
{ "title": "写一篇 Minimal API 教程", "isCompleted": false }框架会把 JSON 请求体反序列化成Todo对象。由于Todo是复杂类型,Minimal APIs 默认把它当作请求体,不需要额外标[FromBody]。
请求成功后会返回201 Created,响应头里带一个Location,指向刚创建资源的 URL。
4.2 状态码和手动校验
状态码不是随便写的。约定里通常这么区分:
200 OK:查询成功。201 Created:资源创建成功,POST 常用。204 No Content:更新成功或删除成功,不需要返回内容。400 Bad Request:客户端参数不合法。404 Not Found:请求的资源不存在。
所以,POST 接口最终应该写成带校验的形式:
app.MapPost("/todos", (Todo input) => { if (string.IsNullOrWhiteSpace(input.Title)) { return Results.ValidationProblem(new Dictionary<string, string[]> { ["Title"] = new[] { "标题不能为空" } }); } var item = new Todo(nextId++, input.Title, input.IsCompleted); todos.Add(item); return Results.Created($"/todos/{item.Id}", item); });这里要说明一个重要区别:MVC Controller 会自动帮你执行 DataAnnotations 校验,但 Minimal APIs 默认不会把[Required]之类的特性自动转换成 400 响应。你需要自己判断、手动返回Results.ValidationProblem,或者写一个通用的 Endpoint Filter 来做校验。
更新和删除接口也要遵循状态码语义:
app.MapGet("/todos/{id}", (int id) => { var item = todos.FirstOrDefault(t => t.Id == id); return item is null ? Results.NotFound() : Results.Ok(item); }); app.MapPut("/todos/{id}", (int id, Todo input) => { var item = todos.FirstOrDefault(t => t.Id == id); if (item is null) { return Results.NotFound(); } var updated = input with { Id = id }; todos.Remove(item); todos.Add(updated); return Results.NoContent(); }); app.MapDelete("/todos/{id}", (int id) => { var item = todos.FirstOrDefault(t => t.Id == id); if (item is null) { return Results.NotFound(); } todos.Remove(item); return Results.NoContent(); });你会发现这里大量使用了Results静态类。它的作用是明确告诉框架返回什么状态码、什么响应体。我更推荐优先用Results.Ok、Results.NotFound这类方法,而不是直接返回Todo对象,因为直接返回对象时框架会一律返回 200,你无法表达 404、400 这些语义。
5. 按业务组织接口:MapGroup、文件上传与端点拆分
5.1 MapGroup 减少重复前缀
真实项目不会只有三四个接口。当接口数量变多,路由前缀、鉴权规则、中间件会重复出现。Minimal APIs 里有一个很实用的 API:MapGroup。
假设所有 Todo 接口都有一个/api/todos前缀,你可以这样分组:
var todoApi = app.MapGroup("/api/todos"); todoApi.MapGet("/", () => ...); todoApi.MapGet("/{id:int}", (int id) => ...); todoApi.MapPost("/", (Todo input) => ...); todoApi.MapPut("/{id:int}", (int id, Todo input) => ...); todoApi.MapDelete("/{id:int}", (int id) => ...);这样做的好处不只是少写几个字符串,更重要的是可以在分组上统一添加 Filter 或中间件逻辑。比如给这个分组统一加日志、校验、权限判断,都不需要在每个接口上重复写。
路由约束也可以直接写在模板里,比如{id:int}会让框架只把整数路径当作合法匹配;如果传入非数字,会匹配不到这个接口。
5.2 文件上传
Minimal APIs 处理文件上传也很直接,IFormFile就是请求中的文件对象:
app.MapPost("/files", async (IFormFile file) => { if (file.Length == 0) { return Results.BadRequest("文件为空"); } var uploadDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); Directory.CreateDirectory(uploadDir); var filePath = Path.Combine(uploadDir, file.FileName); using var stream = File.Create(filePath); await file.CopyToAsync(stream); return Results.Ok(new { FileName = file.FileName, Length = file.Length }); });客户端测试时,表单要用multipart/form-data,字段名必须是file。这是很多人踩过的坑:请求本身没问题,但字段名对不上,绑定模型就是 null。
服务端这边,一定要先创建目录再写文件,否则第一次上传会因为目录不存在而报错。上传文件时不能直接信任用户提交的文件名,如果用于生产环境,最好生成随机文件名,或者做扩展名白名单校验。
5.3 把接口拆到独立文件
接口少的时候,全写在 Program.cs 里没问题。但一旦超过十几个,Program.cs 会变得很难维护。这时不需要把方案升级成 Controller,只要把分组逻辑抽到扩展方法里。
新建一个TodoEndpoints.cs:
public static class TodoEndpoints { public static RouteGroupBuilder MapTodoApi(this RouteGroupBuilder group) { group.MapGet("/", () => Results.Ok(new[] { "todo-1", "todo-2" })); group.MapGet("/{id:int}", (int id) => Results.Ok($"todo-{id}")); // 其他接口 return group; } }Program.cs 里只写一行:
app.MapGroup("/api/todos").MapTodoApi();这种组织方式让每个业务模块有独立的文件,但不需要引入 Controller 的整套继承体系。项目规模继续变大时,你还可以按业务分模块,每个模块一个静态类。
6. 加入日志、配置和数据库依赖
6.1 日志系统
日志是运行阶段最重要的观察手段。Minimal APIs 里的日志并没有被藏起来,你可以在委托里直接注入ILogger<T>。
app.MapGet("/todos", (ILogger<Program> logger) => { logger.LogInformation("开始查询 Todo 列表"); return Results.Ok(todos); });看到委托参数里出现ILogger<T>不要奇怪,Minimal APIs 支持简单的依赖注入:框架会根据参数类型,从服务容器里解析并传入实例。
还有一种做法是直接使用app.Logger,在 Program.cs 里获取当前应用的日志对象:
app.Logger.LogInformation("服务已启动");日志级别、输出格式都由配置文件决定。开发环境下,控制台就会输出信息。出现问题先看日志,不要一上来就改接口逻辑,这是我一直坚持的排查顺序。
6.2 配置读取
配置主要通过builder.Configuration读取。比如appsettings.json里有:
{ "AppOptions": { "AdminEmail": "admin@example.com" }, "ConnectionStrings": { "Default": "Data Source=app.db" } }在接口里可以这样读取:
app.MapGet("/config", (IConfiguration config) => { var email = config["AppOptions:AdminEmail"]; return Results.Ok(new { Email = email }); });注意读取路径里的冒号写法:AppOptions:AdminEmail。你还可以用强类型配置类,通过builder.Services.Configure<AppOptions>()注册到容器,但那需要额外定义类。对于小服务,直接读配置值通常够用。
6.3 EF Core 与依赖注入
当内存列表不再满足需求时,最常见的落地方案是 EF Core。先安装包:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite然后在 Program.cs 中注册 DbContext:
builder.Services.AddDbContext<TodoDb>(options => options.UseSqlite(builder.Configuration.GetConnectionString("Default")));数据库上下文定义:
public class TodoDb : DbContext { public TodoDb(DbContextOptions<TodoDb> options) : base(options) { } public DbSet<Todo> Todos => Set<Todo>(); }接口里直接注入TodoDb使用:
app.MapGet("/todos", async (TodoDb db) => await db.Todos.ToListAsync());EF Core 的 DbContext 在容器里默认是 Scoped 生命周期,也就是说每个请求会拿到一个新的 DbContext 实例,这正好适合在 Minimal API 里按请求注入。开发阶段数据库文件可以用EnsureCreated或 EF Core 迁移来创建,生产环境不要依赖EnsureCreated,建议使用迁移。
7. 异常处理、验证兜底与 OpenAPI 联调
7.1 异常处理
接口运行时抛出的异常,不会自动变成友好的 JSON 返回给客户端。如果不想让客户端看到一屏堆栈信息,可以用中间件统一定位异常。
全局异常处理在 Program.cs 里注册:
app.UseExceptionHandler(exceptionHandlerApp => { exceptionHandlerApp.Run(async context => { context.Response.StatusCode = StatusCodes.Status500InternalServerError; await context.Response.WriteAsJsonAsync(new { error = "服务内部错误" }); }); });这只是一个很基础的版本。生产环境你还应该把异常详情记录到日志,再决定要不要返回给客户端。这里最需要注意的是顺序:异常处理中间件要注册在业务接口之前,但异常处理器本身并不会捕获已经被app.Run之前处理掉的请求。
7.2 验证补强
Minimal APIs 不自动执行 DataAnnotations,所以验证逻辑需要主动补。简单做法是每个接口内部手动判断;复杂做法是写一个 Endpoint Filter,统一拦截请求体。
下面是一个简化示意:
app.MapPost("/todos", (Todo input) => { var item = new Todo(nextId++, input.Title, input.IsCompleted); todos.Add(item); return Results.Created($"/todos/{item.Id}", item); }).AddEndpointFilter(async (context, next) => { var todo = context.GetArgument<Todo>(0); if (string.IsNullOrWhiteSpace(todo.Title)) { return Results.ValidationProblem(new Dictionary<string, string[]> { ["Title"] = new[] { "标题不能为空" } }); } return await next(context); });Filter 的好处是验证逻辑可以复用。当多个接口都需要校验标题时,不用在每个方法里写重复代码。不过 Filter 的参数位置要小心,GetArgument<Todo>(0)表示取委托的第一个参数,参数顺序一变,这里的下标也要跟着变。
7.3 OpenAPI 文档联调
如果你希望接口能被前端或测试人员查看,Minimal APIs 也支持 OpenAPI。较新的 SDK 在dotnet new webapi模板中会默认带上 OpenAPI 支持,生成的内容可能是AddOpenApi()和MapOpenApi()这类新写法,也可能是传统 Swashbuckle 的AddSwaggerGen()和UseSwagger()。
两个写法不能混着用,也尽量不要凭记忆照抄。正确做法是:先打开 csproj,看里面引用了哪个 OpenAPI 包,再根据包的版本读取对应文档或项目模板生成的代码。
联调时的判断标准很简单:
- 接口能正常访问,返回正确 JSON。
- OpenAPI 文档能看到路由和请求参数结构。
- POST 请求能正常接收 JSON,响应状态码符合约定。
如果 OpenAPI 页面打不开,不要急着换包,先确认三件事:包里是否引用了正确的 OpenAPI 库、Program.cs 是否调用了对应的注册方法、访问的路由是不是文档实际暴露的openapi.json路径。
8. 集成测试、发布部署和常见报错排查
8.1 自动化集成测试
Minimal APIs 是接口项目,最适合的自动化验证方式是用WebApplicationFactory做内存级集成测试。它的作用是把一个配置好的Program类放进测试进程里,用真实的 HTTP 管道请求接口。
新建测试类库或 xUnit 项目,然后引用 API 项目。由于 Minimal API 的 Program 类使用顶级语句生成,默认不是公开类,测试项目可能访问不到。这时通常需要在 Program.cs 末尾加一行:
public partial class Program { }然后在测试里写:
public class TodoApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public TodoApiTests(WebApplicationFactory<Program> factory) { _client = factory.CreateClient(); } [Fact] public async Task Get_ReturnsSuccess() { var response = await _client.GetAsync("/todos"); response.EnsureSuccessStatusCode(); } }集成测试的价值在于,它验证的不只是某个方法,而是整个路由注册、中间件、绑定和序列化链路。接口数量越少,测试成本越低,这也是 Minimal APIs 适合小服务的另一个原因。
8.2 发布部署
代码本地验证完成后,发布并不复杂:
dotnet publish -c Release -o ./publish发布目录里就是可运行产物。生产环境通常有两种做法:一是直接把publish里的 DLL 配合 ASP.NET Core Runtime 部署在服务器;二是发布成自包含应用,服务器不需要安装运行时。
这里不要忽略几个点:
- 数据库连接字符串要放到环境变量或生产配置里,不要提交到代码库。
- 上传目录、日志目录在服务器上需要存在且进程有写入权限。
- 如果部署在 Nginx、IIS 等反向代理后面,端口监听、HTTPS 重定向、转发头中间件需要额外处理。
8.3 常见报错排查清单
最后按排查顺序列一下我实际遇到的问题。碰到报错先不要急着改业务代码,按这条链路走:
| 现象 | 先排查什么 | 再看什么 |
|---|---|---|
dotnet run后端口被占用 | launchSettings.json 里配置的端口 | 杀掉占用进程或换端口 |
| 访问接口返回 404 | 路由模板是否正确 | Program.cs 里有没有注册对应 Method |
| POST 请求返回 400 | JSON 字段名和 record 属性名是否一致 | 请求体是否为合法 JSON,Content-Type 是否application/json |
| 接口报空引用 | 依赖是否注册到容器 | 数据库文件路径或上传目录是否存在 |
| OpenAPI 文档打不开 | csproj 引用的是 OpenAPI 包还是 Swashbuckle | Program.cs 调用的是AddOpenApi还是AddSwaggerGen |
| HTTPS 证书问题 | 执行dotnet dev-certs https安装开发证书 | 浏览器是否信任本地证书 |
| 前端跨域报错 | Program.cs 是否启用 CORS | 跨域来源是否在白名单里 |
这组排查顺序的核心逻辑是:先看现象属于哪一层,再按“输入、环境、参数、工具本身”的顺序缩小范围。很多新手一看到 500 就以为是业务代码问题,但其实可能只是某个服务没注册、数据库目录权限不够、或者配置的连接字符串读到了空值。日志是你判断这一切的第一依据,而不是盲目加断点或到处改代码。
如果让我给一条最实用的建议,那就是:一开始不要追求把所有特性都用上。先做一个只有一个 GET 接口的最小项目,跑通、发布、能看到日志,再逐步加 POST、数据库和鉴权。Minimal APIs 的优势本来就是把复杂度向后推迟,你没必要在第一天就把所有复杂度接回来。