6.2 Web API 控制器与请求响应处理 一行 ApiController 特性换来四项管线行为:绑定源自动推断、自动 400 验证响应、ProblemDetails 错误体、推断路由要求。动作返回的普通对象经内容协商器按 Accept 头选格式序列化——这一站是接口通道与页面通道最大的分叉点。 设计原则立好,本节回到管线看实现。观察哨蹲在接口动作的进出两侧:进侧看请求体怎么变成对象(与 3.3 节的阶梯规则有何不同),出侧看返回对象怎么变成字节(协商器、格式化器、406)。这里的每个机制都能做小实验验证——本节照例给可跑的代码。 ApiController 特性加了什么工 四项行为逐条对应:路由方面,控制器级 Route 必填(接口不走约定路由);
一行 ApiController 特性换来四项管线行为:绑定源自动推断、自动 400 验证响应、ProblemDetails 错误体、推断路由要求。动作返回的普通对象经内容协商器按 Accept 头选格式序列化——这一站是接口通道与页面通道最大的分叉点。
设计原则立好,本节回到管线看实现。观察哨蹲在接口动作的进出两侧:进侧看请求体怎么变成对象(与 3.3 节的阶梯规则有何不同),出侧看返回对象怎么变成字节(协商器、格式化器、406)。这里的每个机制都能做小实验验证——本节照例给可跑的代码。
[ApiController] // 这行是接口通道的开关 [Route("api/products")] public class ProductsApiController : ControllerBase { private readonly ShopContext _db; public ProductsApiController(ShopContext db) => _db = db; [HttpGet("{id:int}")] public async Task<ActionResult<ProductView>> Get(int id) { var p = await _db.Products.FindAsync(id); return p is null ? NotFound() : Ok(p.ToView()); } [HttpPost] public async Task<ActionResult<ProductView>> Create(ProductCreateInput input) // 没写 FromBody——ApiController 自动推断复杂类型从请求体绑定 { var p = await _db.Products.AddAsync(input.ToEntity()); await _db.SaveChangesAsync(); return CreatedAtAction(nameof(Get), new { id = p.Entity.Id }, p.Entity.ToView()); } }
四项行为逐条对应:路由方面,控制器级 Route 必填(接口不走约定路由);绑定方面,复杂类型自动 FromBody、简单类型自动从路由与查询串取(3.3 节的 From 特性推断自动化了);验证方面,ModelState 无效时管线自动返回 400 加 ProblemDetails 标准错误体,动作体里再也不用写那段检查样板;错误方面,未处理异常在生产环境转成不带堆栈细节的标准错误响应,不泄露内部结构。
验证失败时管线直接拦截,动作根本不执行。用命令行看真实响应:
# 提交一个缺字段的非法请求体 curl -X POST http://localhost:5180/api/products -H "Content-Type: application/json" -d "{}" # 响应(400,标准 ProblemDetails 结构): # { # "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", # "title": "One or more validation errors occurred.", # "status": 400, # "traceId": "0HN7GK1R2:00000001", # "errors": { # "Name": ["商品名必填"] # } # }
错误体带 traceId,与日志作用域(2.3 节)能对上号——客户端报障时给这个标识,服务端日志一查一个准。这是"接口可运营"的细节:错误不只是拒绝,还是可追踪的拒绝。
动作返回普通对象(或包着对象的 Ok)时,管线在出站前插入协商站:读 Accept 头,遍历格式化器列表,选出双方都支持的格式。默认只配置 JSON 格式化器,所以 Accept 要 XML 也只会得到 JSON——协商站没到 406 那一步,因为框架默认选择"退回 JSON"而不是拒绝。

背景:同事把列表接口写成 return db.Products;——能跑,返回的 JSON 也正确。评审时他问:既然能跑,为什么要改?
操作:两个实验。实验一,看 SQL 时机:断点显示动作返回后、序列化开始时 SQL 才发出——延迟执行(5.3 节)在序列化阶段被触发,异常会发生在管线更深处,错误栈更难定位。实验二,看耦合:实体新增成本价字段(内部字段),接口没有一行改动,下一次发布后成本价出现在了所有客户端的响应里。
// 改造:投影成 DTO,收网点回到动作内 [HttpGet] public async Task<ActionResult<IList<ProductView>>> List(int page = 1, int size = 20) { var views = await _db.Products .OrderByDescending(p => p.Id) .Skip((page - 1) * size).Take(size) // 分页在库端完成 .Select(p => new ProductView { Id = p.Id, Name = p.Name }) // 只暴露三字段 .ToListAsync(); // 动作内收网:SQL 时机明确 return Ok(views); }
结果:SQL 在动作内发出(日志时间戳紧贴断点),新字段不再自动泄漏,响应体积从每行十几字段降到三个。解读:接口层的边界由 DTO 划定——实体是数据层的形状,视图对象是契约的形状,两者同步演进但不共用。变式:映射手写或用映射库都可,关键是"契约层存在";分页接口配总数响应头(自定义头带总条数),客户端能算页数。
不是必需,但是好的契约。Consumes 声明接什么(Content-Type 为 JSON 的请求体),Produces 声明给什么。声明之后:错误请求在进入动作前就被 415 拒绝(不支持的媒体类型),文档生成器读取它们产出准确契约——接口的"使用说明书"从代码里长出来。
⚠️ 常见坑:在接口动作里返回 View() 或依赖 ViewBag。接口通道没有视图引擎这一站,这类代码抛运行时异常。通道选错时越早报错越好——编译期不查这个,评审要查。
💡 关键直觉:ApiController 与内容协商是"接口通道的制服"——穿上它,管线对你按接口的规矩办事:验证自动 400、错误标准化、输出协商化。规矩越多,代码越少,契约越硬。