大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
构建 REST API:Minimal API 与 Controller
这一课你将能做到
- 用 Minimal API 和 Controller 两种形态实现同一组「提交代码」接口,并说出选型依据
- 完成模型绑定、参数校验、DTO 转换、分页返回的完整链路
- 用
IExceptionHandler实现全局异常到 ProblemDetails 的统一映射(对应@ControllerAdvice)
为什么要学
API 层是后端的第一道门面:绑定参数、校验输入、调用业务、映射状态码、描述文档。Spring 生态里这套由 @RestController + @Valid + @ControllerAdvice + Springdoc 组成;ASP.NET Core 8 把同样的能力拆成两套门面——Minimal API(轻、代码即路由)和 Controller(重约定、适合大团队),共享绑定/校验/文档底座。学完能按团队规模选形态,而不是背「哪个新用哪个」。
先建立图像
一次 POST 提交代码请求的完整旅程:
POST /api/submissions {"problemId":1,"language":"cpp","code":"int main(){}"}
│
├─ 路由匹配 MapPost / [HttpPost] 决定谁接单
├─ 模型绑定 JSON → SubmitDto(对应 @RequestBody)
├─ 模型校验 [Required] / @NotNull 不通过 → 自动 400
├─ 业务执行 注入的 JudgeService 干活(@Autowired)
└─ 结果映射 201/400/404 + JSON 体(ResponseEntity 语义)
跟着做
步骤 1:Minimal API 版提交接口
public record SubmitDto(long ProblemId, string Language, string Code);
public record SubmissionView(long Id, string Status, DateTime CreatedAt);
var api = app.MapGroup("/api/submissions").WithTags("提交");
api.MapPost("/", async (SubmitDto dto, OjDbContext db, IJudgeQueue queue) =>
{
var problem = await db.Problems.FindAsync(dto.ProblemId);
if (problem is null) return Results.NotFound();
var submission = new Submission
{
ProblemId = dto.ProblemId, Language = dto.Language,
Status = "PENDING", CreatedAt = DateTime.UtcNow,
};
db.Submissions.Add(submission);
await db.SaveChangesAsync();
await queue.EnqueueAsync(submission.Id); // 入判题队列(第 9 课)
return Results.Created($"/api/submissions/{submission.Id}",
new SubmissionView(submission.Id, "PENDING", submission.CreatedAt));
});
Results.Created(locationUrl, body) 返回 201 + Location 头,等价 ResponseEntity.created(uri).body(dto)。返回 IResult 而非裸对象:状态码显式可控。
步骤 2:等价 Controller 版
[ApiController]
[Route("api/submissions")]
public class SubmissionsController(OjDbContext db, IJudgeQueue queue) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<SubmissionView>> Submit(SubmitDto dto)
{
var problem = await db.Problems.FindAsync(dto.ProblemId);
if (problem is null) return NotFound();
var submission = new Submission { ProblemId = dto.ProblemId, Language = dto.Language, Status = "PENDING" };
db.Submissions.Add(submission);
await db.SaveChangesAsync();
await queue.EnqueueAsync(submission.Id);
return Created($"/api/submissions/{submission.Id}",
new SubmissionView(submission.Id, "PENDING", submission.CreatedAt));
}
}
// Program.cs:AddControllers() + MapControllers()
步骤 3:校验(DataAnnotations,对应 Bean Validation)
using System.ComponentModel.DataAnnotations;
public record SubmitDto(
[Range(1, long.MaxValue, ErrorMessage = "题目不存在")] long ProblemId,
[Required, RegularExpression("^(cpp|java|python)$")] string Language,
[Required, MaxLength(65536)] string Code);
[ApiController] 下校验失败自动返回 400 + ProblemDetails,方法体不会进——与 @Valid 抛 MethodArgumentNotValidException 的行为一致。Minimal API 需 AddValidation 过滤器或手动检查 ModelState。
步骤 4:分页查询接口(顺便看查询参数绑定)
api.MapGet("/", async (OjDbContext db, string? user, string? status,
int page = 1, int size = 20, CancellationToken ct = default) =>
{
var query = db.Submissions.AsNoTracking();
if (user is not null) query = query.Where(s => s.UserName == user); // ≙ Specification
if (status is not null) query = query.Where(s => s.Status == status);
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * size).Take(size) // ≙ PageRequest.of(page, size)
.Select(s => new SubmissionView(s.Id, s.Status, s.CreatedAt))
.ToListAsync(ct);
return Results.Ok(new { items, total, page, size });
});
方法签名的 string? user、int page = 1 自动从查询字符串绑定,等价 @RequestParam(required=false)。
步骤 5:全局异常处理(.NET 8 IExceptionHandler ≙ @ControllerAdvice)
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
logger.LogError(ex, "未处理异常: {Path}", ctx.Request.Path);
var (status, title) = ex switch
{
KeyNotFoundException => (StatusCodes.Status404NotFound, "资源不存在"),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "无权限"),
_ => (StatusCodes.Status500InternalServerError, "服务器内部错误"),
};
await Results.Problem(title: title, statusCode: status)
.ExecuteAsync(ctx);
return true; // true = 已处理,不再向上抛
}
}
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
所有未捕获异常统一变成 RFC 7807 application/problem+json,Spring 侧对应 @RestControllerAdvice + @ExceptionHandler。
步骤 6:OpenAPI 文档(对应 Springdoc/Swagger)
dotnet add package Swashbuckle.AspNetCore
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // /swagger
Minimal API 与 Controller 的接口都会进文档,WithSummary("...") 补充描述,配合 XML 注释。
讲清楚要点
- 形态选型是团队决策:单文件服务、原型选 Minimal API(启动快、样板少);接口多、多人协作选 Controller。两者可共存——新接口用 Minimal,存量留 Controller。
- DTO 与实体隔离是铁律:绝不能把 EF 实体直接当出入参(字段泄漏、循环引用、overposting)。用 record 定义 DTO,投影查询直接生成,等价 Java 的 DTO + MapStruct。
- 状态码要语义化:201+Location 表创建、204 表无返回体、409 表冲突、400 表校验失败。
Results覆盖全部语义,别一律 200。 MapGroup是 Minimal API 的模块化手段:分组统一加前缀、Tag、鉴权、限流,等价 Controller 类级注解的聚合。
你可能会懵的地方
-
Minimal API 怎么做「前置校验/日志」这类切面? Endpoint Filter(
AddEndpointFilter),作用于路由命中后的端点,对应 Action Filter / Spring Interceptor:public class TimingFilter : IEndpointFilter { public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next) { var sw = Stopwatch.StartNew(); var result = await next(ctx); Console.WriteLine($"耗时 {sw.ElapsedMilliseconds}ms"); return result; } } -
CancellationToken 为什么到处出现? 客户端断开/超时通过
HttpContext.RequestAborted自动注入到参数,传给 EF 异步方法能省掉无效计算——等价 Spring MVC 的请求超时中断,但更显式。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 接口直接返回 EF 实体 | 循环引用、字段泄漏、overposting | record DTO + 投影查询 |
| 所有接口都返回 200 + 自定义 code 字段 | 丢掉 HTTP 语义,网关/监控无法识别错误 | 用状态码 + ProblemDetails |
| 校验写在业务方法里逐个 if | 重复、遗漏、不可声明 | DataAnnotations / FluentValidation 统一声明 |
| 全局异常处理器里返回堆栈给前端 | 泄漏内部信息 | 生产环境只返回 title + traceId,日志里留堆栈 |
卡点急救
- POST 报 415:Content-Type 没给
application/json,或客户端发了表单。确认 header 与绑定来源特性。 - 校验不生效:Controller 忘了
[ApiController];Minimal API 需注册校验过滤器;record 的required成员构造失败先于校验,改用 DataAnnotations。 - Swagger 页面 404:确认对应环境执行了
UseSwagger/UseSwaggerUI(模板常只在 Development 注册),生产要开就移出条件块并加访问控制。
面试常问
-
Minimal API 和 Controller 怎么选?底层差异是什么? 底层共用 Endpoint Routing、模型绑定、DI、过滤器管线;差异在门面:Minimal API 用 lambda 注册,无反射扫描,冷启动更优;Controller 提供类级约定与过滤器生态。选型看规模与团队约定,性能差异已不是决定因素。
-
ProblemDetails 是什么,为什么推荐? RFC 7807 标准化的错误响应格式(type/title/status/detail/traceId),前端、网关、监控用统一契约读错误。.NET 8 通过
AddProblemDetails+IExceptionHandler全链路自动生成,对应 Spring 6 的 ProblemDetail。 -
如何防止 overposting(恶意绑定额外字段)? 入参用窄 DTO 只声明可写字段,实体绝不出现在绑定签名里;必要时
[BindNever]排除敏感属性。与 Java DTO 隔离原则一致。
自测三问
Results.Created的两个参数各是什么语义?- 请求里的
page参数绑定失败(传了 "abc")会发生什么? IExceptionHandler返回 true/false 分别代表什么?
小结一句话
API 层的核心资产是「绑定 → 校验 → 状态码 → 文档」四件套:Minimal API 与 Controller 只是门面之争,把 @Valid/@ControllerAdvice/ProblemDetails 心智平移即可。
延伸
- 官方文档:Minimal API
- 下一课:缓存与查询优化——Redis 分布式缓存、缓存三大经典问题,对照 Spring Cache。