4. 控制器与路由:Controller 基类、属性路由、路由约束、路由数据转换
路由这东西,说白了就是URL和代码之间的“翻译官”。你想想看,用户访问 /api/users/123,系统怎么知道该调用哪个方法?这就是路由干的事。我在项目中见过太多因为路由配置混乱导致的404问题,嗯,今天咱们就把这块彻底理清楚。
4.1 Controller 基类:到底该继承谁?
ASP.NET Core 提供了两个基类:Controller 和 ControllerBase。很多人上来就继承 Controller,其实不一定对。
核心区别:
ControllerBase:轻量级,只包含 Web API 需要的核心功能(模型绑定、验证、状态码等)Controller:继承自ControllerBase,额外增加了对视图(View)的支持
我个人习惯是:纯 Web API 项目一律用 ControllerBase。为什么?因为不需要视图支持,多继承一层反而多了不必要的依赖。我曾经接手过一个项目,所有控制器都继承 Controller,结果单元测试时还得 mock 一堆视图相关的对象,纯粹给自己找麻烦。
// 推荐写法:纯 API 控制器
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
// 业务逻辑...
return Ok(user);
}
}
小技巧:记得加上 [ApiController] 特性。它会自动启用模型验证、自动返回 400 错误等,省掉不少样板代码。
4.2 属性路由:把路由写在代码里
传统路由(比如 MVC 5 时代的集中式路由)把所有规则写在一个地方,项目大了以后简直灾难。属性路由就好多了——路由规则直接写在控制器或方法上,一目了然。
我建议你养成习惯:能用属性路由就别用传统路由。特别是团队协作时,每个人负责的模块路由规则写在对应代码里,改起来不会互相影响。
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
// GET api/products
[HttpGet]
public IActionResult GetAll() { ... }
// GET api/products/5
[HttpGet("{id}")]
public IActionResult GetById(int id) { ... }
// GET api/products/category/electronics
[HttpGet("category/{categoryName}")]
public IActionResult GetByCategory(string categoryName) { ... }
}
注意看第三个方法:[HttpGet("category/{categoryName}")]。它会在控制器基路由 api/products 后面追加路径,最终变成 api/products/category/electronics。这种组合方式非常灵活。
避坑指南:我曾经遇到过一个 bug——两个路由模板冲突了。比如 [HttpGet("{id}")] 和 [HttpGet("category/{categoryName}")],当请求 /api/products/123 时,系统会尝试匹配第一个;但如果请求 /api/products/category,它也会尝试匹配第一个(把 "category" 当成 id 参数)。结果就是 404 或者类型转换错误。解决办法:要么用路由约束,要么调整路径结构。
4.3 路由约束:让路由更“聪明”
路由约束说白了就是给参数加个“过滤器”。比如 {id:int} 表示 id 必须是整数,{name:alpha} 表示 name 只能是字母。
为什么要用约束?我举个例子:假设你有两个方法——
[HttpGet("{id:int}")]
public IActionResult GetById(int id) { ... }
[HttpGet("{name}")]
public IActionResult GetByName(string name) { ... }
当请求 /api/products/123 时,系统会匹配第一个(因为 123 是整数)。当请求 /api/products/laptop 时,系统会匹配第二个。没有约束的话,两个方法都可能匹配,系统只能靠参数类型硬猜,容易出错。
常用的路由约束有这些:
| 约束 | 示例 | 说明 |
|---|---|---|
int |
{id:int} |
必须是整数 |
bool |
{active:bool} |
必须是 true/false |
datetime |
{date:datetime} |
必须是有效日期 |
decimal |
{price:decimal} |
必须是小数 |
guid |
{id:guid} |
必须是 GUID 格式 |
minlength(n) |
{name:minlength(3)} |
字符串最小长度 |
maxlength(n) |
{name:maxlength(20)} |
字符串最大长度 |
regex(表达式) |
{code:regex(^[A-Z]{{3}}$)} |
正则表达式匹配 |
个人经验:我习惯在 id 参数上总是加 :int 约束。因为用户可能输入 /api/users/abc,没有约束的话系统会尝试把 "abc" 转成 int,然后抛异常。加了约束,直接返回 404,更优雅。
4.4 路由数据转换:参数是怎么“变”成对象的?
路由数据转换,就是 URL 里的字符串怎么变成 C# 里的强类型参数。这个过程叫模型绑定(Model Binding),但路由层面的转换更基础——它只处理路由模板里的占位符。
举个例子:
[HttpGet("orders/{year:int}/{month:int}/{day:int}")]
public IActionResult GetOrdersByDate(int year, int month, int day)
{
var date = new DateTime(year, month, day);
// 查询该日期的订单...
}
当请求 /api/orders/2024/3/15 时,路由系统会自动把 "2024"、"3"、"15" 分别赋值给 year、month、day 参数。这里有个细节:参数名必须和占位符名称一致,否则绑定不上。
如果你想把参数名和占位符名称分开,可以用 [FromRoute] 特性:
[HttpGet("orders/{year:int}/{month:int}/{day:int}")]
public IActionResult GetOrdersByDate(
[FromRoute(Name = "year")] int y,
[FromRoute(Name = "month")] int m,
[FromRoute(Name = "day")] int d)
{
var date = new DateTime(y, m, d);
// ...
}
嗯,这种写法虽然灵活,但我个人觉得可读性差了点。除非有特殊需求(比如参数名和占位符冲突),否则保持名称一致更清爽。
关于复杂类型:路由参数只能绑定简单类型(int、string、DateTime 等)。如果你想把整个对象从 URL 里解析出来,得用查询字符串或者请求体。比如 /api/orders?year=2024&month=3&day=15,然后方法参数用一个 DateFilter 类接收——但这属于模型绑定的范畴,不是路由数据转换了。
4.5 综合示例:一个完整的控制器
最后,咱们看一个实际项目中可能会用到的控制器。把今天讲的知识点串起来:
[ApiController]
[Route("api/v{version:apiVersion}/inventory")]
public class InventoryController : ControllerBase
{
// GET api/v1/inventory?category=electronics&page=1
[HttpGet]
public IActionResult GetItems(
[FromQuery] string category,
[FromQuery] int page = 1)
{
// 分页查询逻辑...
return Ok(items);
}
// GET api/v1/inventory/5
[HttpGet("{id:int:min(1)}")]
public IActionResult GetById(int id)
{
// 根据 ID 查询...
return Ok(item);
}
// GET api/v1/inventory/sku/ABC-123
[HttpGet("sku/{sku:regex(^[A-Z]{{3}}-\\d{{3}}$)}")]
public IActionResult GetBySku(string sku)
{
// SKU 格式:三个大写字母 + 短横 + 三个数字
// 比如 ABC-123
return Ok(item);
}
// POST api/v1/inventory
[HttpPost]
public IActionResult CreateItem([FromBody] InventoryItem item)
{
// 创建新库存项...
return CreatedAtAction(nameof(GetById), new { id = item.Id }, item);
}
}
这个例子涵盖了:
- 版本号路由(
v{version:apiVersion}) - 整数约束 + 最小值约束(
{id:int:min(1)}) - 正则表达式约束(
{sku:regex(...)}) - 查询字符串绑定(
[FromQuery]) - 请求体绑定(
[FromBody])
最后提醒一句:路由设计一定要有“可读性”。我曾经见过一个项目,路由全是 /api/{a}/{b}/{c} 这种,参数名毫无意义。结果半年后没人能看懂哪个参数对应什么。路由是 API 的门面,花点心思设计,后面能省很多沟通成本。
好了,控制器和路由这块就聊到这儿。下一章咱们深入聊聊模型绑定和验证——那才是真正让 API 变得“健壮”的关键。