4. 控制器与路由:Controller 基类、属性路由、路由约束、路由数据转换

路由这东西,说白了就是URL和代码之间的“翻译官”。你想想看,用户访问 /api/users/123,系统怎么知道该调用哪个方法?这就是路由干的事。我在项目中见过太多因为路由配置混乱导致的404问题,嗯,今天咱们就把这块彻底理清楚。

4.1 Controller 基类:到底该继承谁?

ASP.NET Core 提供了两个基类:ControllerControllerBase。很多人上来就继承 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 变得“健壮”的关键。