3.1 路由:请求找到终点站


文档摘要

3.1 路由:请求找到终点站 路由是端点路由中间件的一次模式匹配:注册期收集全应用的端点与模板,请求期拿 URL 逐个比对,选出唯一终点并填充路由参数。理解匹配优先级与两阶段工作方式,是排查"404 与进错动作"两类故障的钥匙。 上一章留了个尾巴:UseRouting 在请求上贴好"候选终点",UseEndpoints 负责执行。本节观察哨就蹲在这两站之间,看一段 URL 如何被拆解、比对、最终锁定某个控制器动作。这是业务管线的第一站,也是后面三节的入口——没有路由选中的动作,绑定器、视图引擎都无从谈起。 路由在管线里的位置 先建一个 MVC 项目作实验台,观察默认路由长什么样: 模板项目里能看到两类路由写法并存。

3.1 路由:请求找到终点站

路由是端点路由中间件的一次模式匹配:注册期收集全应用的端点与模板,请求期拿 URL 逐个比对,选出唯一终点并填充路由参数。理解匹配优先级与两阶段工作方式,是排查"404 与进错动作"两类故障的钥匙。

上一章留了个尾巴:UseRouting 在请求上贴好"候选终点",UseEndpoints 负责执行。本节观察哨就蹲在这两站之间,看一段 URL 如何被拆解、比对、最终锁定某个控制器动作。这是业务管线的第一站,也是后面三节的入口——没有路由选中的动作,绑定器、视图引擎都无从谈起。

路由在管线里的位置

先建一个 MVC 项目作实验台,观察默认路由长什么样:

# 用 mvc 模板创建项目,含控制器与视图脚手架 dotnet new mvc -n RouteLab cd RouteLab dotnet run # 输出示例: # info: Microsoft.Hosting.Lifetime[14] # Now listening on: http://localhost:5177

模板项目里能看到两类路由写法并存。约定路由在中间件层声明一次,所有控制器自动套用:

// 约定路由:一条模板管全应用的默认风格 app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); // 模板解读:第一段映射控制器名,第二段映射动作名,第三段可选参数 id // 访问 /Products/Details/5 → ProductsController.Details,id=5 // 访问 / → HomeController.Index(默认值兜底)

特性路由写在控制器和方法上,模板跟着代码走:

// 特性路由:模板贴在动作上,URL 与代码强关联 [Route("shop")] public class ProductsController : Controller { [HttpGet("items/{id:int}")] // 约束 id 必须是整数 public IActionResult Details(int id) => Content($"商品 {id}"); [HttpGet("items/latest")] // 字面段优先级高于参数段 public IActionResult Latest() => Content("最新商品"); } // GET /shop/items/42 → Details,id=42 // GET /shop/items/abc → 404,int 约束拒绝匹配 // GET /shop/items/latest → Latest,不会进 Details

两类写法的取舍很实际:约定路由适合"一个控制器一套页面"的传统站点,URL 结构统一;特性路由适合 Web API 与 URL 需要精细控制的场景,改一个动作的路径不必动全局配置。现代项目常见组合是页面用 Razor Pages(内部也是特性路由)、接口用特性路由,约定路由只留给遗留风格。

两阶段工作模型

端点路由把"找终点"拆成两个阶段,这个拆分解释了很多看似奇怪的行为。注册期,应用启动时框架扫描所有控制器,把每个动作连同它的模板、约束、顺序值登记成一张端点表——这就是为什么路由信息必须能在编译期确定。请求期,路由中间件拿 URL 与端点表比对,生成候选集再按优先级择一,把选中结果贴进 HttpContext 的 Endpoint 属性;后续的 MVC 中间件只消费这个结果。两阶段的好处是职责清晰:路由只管"选谁",执行交给各自的端点执行器——MVC 动作、Razor Pages、健康检查端点可以共存于同一张表。

图 3.1-1 路由匹配的决策流程

图 3.1-1 路由匹配的决策流程

案例:一个"时灵时不灵"的 404

背景:同事负责的商品站升级后出现怪事:访问商品详情页时灵时不灵,直接访问某些商品正常,另一些 404。代码没改过路由,压力全落在他头上。

操作:观察哨分两步排查。第一步在管线最前加一段日志中间件,把每个请求的路径与最终状态码打出来:

// 排查中间件:注册在路由之前,记录路径与最终结果 app.Use(async (ctx, next) => { await next(); Console.WriteLine($"[路由哨] {ctx.Request.Path} -> {ctx.Response.StatusCode}"); });

第二步收集 404 样本,发现规律:数字编号正常,带字母的编号(如 A102)全部 404。翻端点登记,详情动作的模板带着 int 约束——items/{id:int}。商品编号系统升级后混入了字母前缀,约束把这类 URL 全部挡在门外。

结果:把约束从 int 换成正则或直接去掉约束、在动作内自行解析校验,问题消失:

// 修复:放宽约束,把格式校验挪进动作内部 [HttpGet("items/{code:length(1,12)}")] // 只约束长度,格式自检 public IActionResult Details(string code) { if (!ProductCode.TryParse(code, out var id)) // 自行解析编号 return NotFound(); // 解析失败明确 404 return Content($"商品 {id}"); } // GET /shop/items/A102 → 商品 102(编号规则升级后依旧可用)

解读:约束的语义是"不满足就不匹配",而非"满足才执行"。URL 模式与业务编码规则耦合过紧,业务规则一变路由就碎。这条经验值得上墙:路由约束只放与资源寻址相关的规则(数字、长度、固定前缀),业务格式校验留给动作内部。

变式:如果站点确实需要同时支持新旧两套编号路径,可以并存两个特性路由指向同一动作,或加一条重定向规则把旧格式转发到新格式——用管线思维想,就是给终点站多修一条进站岔道。

优先级规则速查

多个模板都能匹配时,判定不靠运气,靠一张确定性的优先级表:

优先级要素 规则 示例
字面段数量 字面段越多越优先 items latest 优于 items 参数段
约束数量 带约束的模板优先于无约束 id int 优于 id
参数默认值 依赖默认值的候选排后 无默认值模板优先
段长度 更具体的模板优先 多段模板优于单段通配
注册顺序 以上全平手时,后注册的特性路由优先 兜底路由放最后

⚠️ 常见坑:约约路由与特性路由混用时,特性路由优先级整体高于约定路由——很多"为什么走了那个动作"的疑惑,答案都是目标控制器里混进了一个 Route 特性。另一个坑是大小写:路由匹配不区分控制器与动作名大小写,但参数值原样保留。

设计路由方案的检查清单

给中等规模应用设计路由,我习惯过一遍这份清单:资源名用名词复数还是单数,全站统一;层级不超过三层,过深说明资源划分有问题;需要被收藏、被分享的页面用稳定路径,别把易变参数放进路径段;版本号放路径还是请求头,接口项目在第一天就要定;所有约束问一句"业务规则变了它会不会碎"。清单过完,路由方案基本不会在后期推倒重来。

本节要点回顾

  • 两阶段模型:注册期收集端点表,请求期匹配择一,路由只管选、不管执行;
  • 两类写法:约定路由适合统一风格的页面站,特性路由适合精细控制的接口;
  • 优先级是确定性的:字面段、约束、默认值、段长度依次判定,平手才看注册顺序;
  • 约束语义:不满足即不匹配,业务格式校验别压在路由约束上;
  • 404 排查法:先加哨站日志收集样本,再看样本与模板约束的交集。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U