URL 路由深入
大约 15 分钟约 4418 字
URL 路由深入
简介
ASP.NET Core 路由系统是整个请求管道的入口调度器,负责将入站 HTTP 请求的 URL 映射到对应的端点处理器(Endpoint)。深入理解路由模板语法、约束机制、链接生成、端点路由架构以及路由匹配的优先级规则,是设计 RESTful API 和构建可维护 Web 应用的基础能力。路由不仅决定"谁来处理请求",还影响 URL 生成的正确性、中间件的选择性执行以及 API 的整体可发现性。
特点
端点路由架构
请求处理流程
HTTP 请求进入
│
▼
┌─────────────────────┐
│ UseRouting() │ 1. 匹配路由,设置 Endpoint
│ ───────────────── │ 从所有注册的端点中找到最佳匹配
│ Route → Endpoint │ 将结果存入 HttpContext.Features
└────────┬────────────┘
│
┌────▼─────┐
│ UseCors │ 2. CORS 中间件(读取终结点元数据)
│ UseAuth │ 3. 认证中间件
│ UseAuthz │ 4. 授权中间件
└────┬─────┘
│
┌────▼────────────┐
│ UseEndpoints() │ 5. 执行匹配到的 Endpoint 委托
│ ─────────────── │ 调用实际的处理器(Controller / Minimal API)
│ Execute Handler │
└─────────────────┘核心概念:Endpoint
// Endpoint 是端点路由的核心抽象
// 每个注册的路由都会生成一个 Endpoint 对象,包含:
// - RequestDelegate: 实际处理请求的委托
// - RoutePattern: 路由模板(如 "api/users/{id}")
// - Metadata: 元数据集合(用于 CORS、Auth 等中间件读取)
// - DisplayName: 显示名称(用于调试和日志)
// 可以在中间件中访问当前匹配的端点
app.Use(async (context, next) =>
{
var endpoint = context.GetEndpoint();
if (endpoint != null)
{
Console.WriteLine($"匹配到端点: {endpoint.DisplayName}");
// 读取元数据
var routeValues = context.Request.RouteValues;
Console.WriteLine($"Controller: {routeValues["controller"]}");
Console.WriteLine($"Action: {routeValues["action"]}");
}
else
{
Console.WriteLine("未匹配到任何端点");
}
await next();
});路由模板深入
参数类型与语法
var app = builder.Build();
// 1. 基本参数 — 不带约束,匹配除 / 外的所有字符
app.MapGet("/users/{name}", (string name) => $"User: {name}");
// GET /users/alice → "User: alice"
// GET /users/bob/jones → 404(不能包含 /)
// 2. 带约束的参数 — 冒号后跟约束名
app.MapGet("/users/{id:int}", (int id) => $"User ID: {id}");
// GET /users/42 → "User ID: 42"
// GET /users/abc → 404(int 约束失败)
// 3. 可选参数 — 参数后加问号
app.MapGet("/products/{id:int?}", (int? id) =>
id.HasValue ? $"Product: {id.Value}" : "All products");
// GET /products/42 → "Product: 42"
// GET /products → "All products"
// 4. 默认值 — 赋予参数默认值
app.MapGet("/orders/{status:alpha=pending}", (string status) =>
$"Orders with status: {status}");
// GET /orders/shipped → "Orders with status: shipped"
// GET /orders → "Orders with status: pending"
// 5. Catch-All 参数 — 双星号匹配剩余路径
app.MapGet("/files/{**path}", (string path) => $"File path: {path}");
// GET /files/docs/readme.txt → "File path: docs/readme.txt"
// GET /files/src/utils/helper.js → "File path: src/utils/helper.js"
// 6. Catch-All 非贪婪匹配(.NET 8+)
app.MapGet("/docs/{**page:nonfile}", (string page) => $"Page: {page}");
// GET /docs/api/routing → "Page: api/routing"
// GET /docs/readme.md → 尝试静态文件而非路由匹配内置路由约束一览
// 类型约束
app.MapGet("/int/{id:int}", (int id) => ""); // 整数
app.MapGet("/bool/{val:bool}", (bool val) => ""); // 布尔值
app.MapGet("/datetime/{date:datetime}", (DateTime dt) => ""); // 日期时间
app.MapGet("/decimal/{price:decimal}", (decimal p) => ""); // 小数
app.MapGet("/double/{val:double}", (double v) => ""); // 双精度浮点
app.MapGet("/float/{val:float}", (float v) => ""); // 单精度浮点
app.MapGet("/guid/{id:guid}", (Guid id) => ""); // GUID
// 长度约束
app.MapGet("/minlen/{name:minlength(3)}", (string name) => ""); // 最小长度
app.MapGet("/maxlen/{name:maxlength(20)}", (string name) => ""); // 最大长度
app.MapGet("/len/{name:length(3,20)}", (string name) => ""); // 长度范围
// 范围约束
app.MapGet("/range/{id:int:range(1,1000)}", (int id) => ""); // 整数范围
app.MapGet("/min/{id:int:min(1)}", (int id) => ""); // 最小值
app.MapGet("/max/{id:int:max(100)}", (int id) => ""); // 最大值
// 正则约束
app.MapGet("/slug/{slug:regex(^[a-z0-9-]+$)}", (string slug) => "");
// 只匹配小写字母、数字和连字符
// 多约束组合 — 用冒号分隔多个约束
app.MapGet("/items/{id:int:range(1,1000)}", (int id) => $"Item {id}");
// 同时满足:整数 + 范围 1-1000
// Alpha 约束 — 只匹配字母
app.MapGet("/categories/{name:alpha}", (string name) => $"Category: {name}");
// GET /categories/electronics → "Category: electronics"
// GET /categories/123 → 404自定义路由约束
// 自定义约束 1:偶数约束
public class EvenNumberConstraint : IRouteConstraint
{
public bool Match(
HttpContext? httpContext,
IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value))
return false;
return int.TryParse(value?.ToString(), out var number) && number % 2 == 0;
}
}
// 自定义约束 2:语言代码约束(如 zh-CN, en-US)
public class LanguageCodeConstraint : IRouteConstraint
{
private static readonly HashSet<string> SupportedLanguages = new(StringComparer.OrdinalIgnoreCase)
{
"zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR", "fr-FR", "de-DE"
};
public bool Match(
HttpContext? httpContext,
IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value))
return false;
return SupportedLanguages.Contains(value?.ToString() ?? "");
}
}
// 自定义约束 3:Slug 格式约束(使用 ParameterTransformRouteConstraint 基类)
public class SlugConstraint : IRouteConstraint
{
private static readonly Regex SlugRegex = new(@"^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled);
public bool Match(
HttpContext? httpContext,
IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value))
return false;
return SlugRegex.IsMatch(value?.ToString() ?? "");
}
}
// 注册自定义约束
builder.Services.AddRouting(options =>
{
options.ConstraintMap.Add("even", typeof(EvenNumberConstraint));
options.ConstraintMap.Add("lang", typeof(LanguageCodeConstraint));
options.ConstraintMap.Add("slug", typeof(SlugConstraint));
});
// 使用自定义约束
app.MapGet("/api/{lang:lang}/products", (string lang) => $"Products in {lang}");
app.MapGet("/blog/{slug:slug}", (string slug) => $"Blog: {slug}");
app.MapGet("/pages/{id:even}", (int id) => $"Page {id} (even)");路由匹配优先级
优先级规则详解
匹配优先级(从高到低):
1. 字面量路由(无参数)
GET /api/health ← 最高优先级
2. 带约束的参数路由
GET /api/users/{id:int} ← 约束参数
3. 无约束的参数路由
GET /api/users/{name} ← 无约束参数
4. Catch-All 参数路由
GET /api/{**path} ← 最低优先级
5. 多个同优先级路由 → 按注册顺序匹配(先注册优先)路由冲突示例与解决
// ❌ 容易冲突的路由定义
app.MapGet("/api/users/active", () => "Active users"); // 字面量
app.MapGet("/api/users/{id:int}", (int id) => $"User {id}"); // 参数
// 这两个不会冲突,因为字面量优先级更高
// ❌ 真正的冲突
app.MapGet("/api/products/{category}", (string cat) => $"Category: {cat}");
app.MapGet("/api/products/{id:int}", (int id) => $"Product: {id}");
// GET /api/products/123 → 匹配第一个(顺序优先),因为 "123" 也满足 string
// ✅ 正确做法:让约束更具体,或调整注册顺序
app.MapGet("/api/products/{id:int}", (int id) => $"Product: {id}"); // 先注册约束路由
app.MapGet("/api/products/{category}", (string cat) => $"Category: {cat}"); // 后注册
// ✅ 更好的做法:使用不同的 URL 结构避免歧义
app.MapGet("/api/products/{id:int}", (int id) => $"Product: {id}");
app.MapGet("/api/products/category/{category}", (string cat) => $"Category: {cat}");链接生成深入
LinkGenerator 服务
// LinkGenerator 是 .NET 8 推荐的 URL 生成方式
// 比旧版的 UrlHelper 更灵活,可在任何地方注入使用
// 方式 1:在 Minimal API 中使用
app.MapGet("/users/{id:int}", (int id) => $"User: {id}")
.WithName("GetUserById");
app.MapGet("/links", (LinkGenerator linker, HttpContext ctx) =>
{
// 通过名称生成路径
var userUrl = linker.GetPathByName("GetUserById", new { id = 42 });
// → "/users/42"
// 生成绝对 URL
var absoluteUrl = linker.GetUriByName(ctx, "GetUserById", new { id = 42 });
// → "https://localhost:5000/users/42"
// 通过路由值生成路径
var pathByValues = linker.GetPathByValues("users/{id:int}", new { id = 42 });
// → "/users/42"
return Results.Ok(new { UserUrl = userUrl, AbsoluteUrl = absoluteUrl });
});
// 方式 2:在服务层中使用(不依赖 HttpContext)
public class NotificationService
{
private readonly LinkGenerator _linkGenerator;
public NotificationService(LinkGenerator linkGenerator)
{
_linkGenerator = linkGenerator;
}
public string GenerateResetPasswordLink(string scheme, string host, string token)
{
return _linkGenerator.GetUriByName(
"ResetPassword",
new { token },
new { scheme, host });
}
}
// 方式 3:在 Controller 中使用
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult Get(int id) => Ok(new { Id = id });
[HttpPost]
public IActionResult Create(ProductDto dto)
{
var id = 42; // 模拟创建后的 ID
var location = Url.Action(nameof(Get), new { id });
// → "/api/Products/42"
return Created(location, new { Id = id });
}
}链接生成的高级用法
// 1. 带查询参数的链接生成
app.MapGet("/search", (string q, int page = 1) => $"Search: {q}, Page: {page}")
.WithName("Search");
app.MapGet("/generate-search-link", (LinkGenerator linker) =>
{
// LinkGenerator 不直接支持查询参数,需要手动拼接
var basePath = linker.GetPathByName("Search");
var query = HttpUtility.ParseQueryString("");
query["q"] = "ASP.NET Core";
query["page"] = "2";
var fullUrl = $"{basePath}?{query}";
return Results.Ok(fullUrl);
});
// 2. 路由组内的链接生成
var apiGroup = app.MapGroup("/api/v1");
apiGroup.MapGet("/users/{id:int}", (int id) => $"User {id}")
.WithName("V1_GetUser");
apiGroup.MapGet("/orders/{orderId:int}", (int orderId) =>
{
// 在路由组内也可以通过名称引用其他端点
return Results.Ok();
});
// 3. 参数变换器(URL 友好化)
builder.Services.AddRouting(options =>
{
options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer);
});
// 将标题转换为 URL slug
// "ASP.NET Core Routing" → "asp-net-core-routing"
public class SlugifyParameterTransformer : IOutboundParameterTransformer
{
public string? TransformOutbound(object? value)
{
if (value == null) return null;
return Regex.Replace(value.ToString()!, @"[^a-zA-Z0-9]+", "-")
.Trim('-')
.ToLowerInvariant();
}
}
// 在 Controller 路由中使用参数变换器
[Route("api/blog/{slug:slugify}")]
public class BlogController : ControllerBase
{
[HttpGet("{title:slugify}")]
public IActionResult GetPost(string title) => Ok();
}路由组(MapGroup)
基础用法
// 路由组可以批量添加公共前缀,减少重复代码
var api = app.MapGroup("/api")
.WithOpenApi(); // 组级别的 OpenAPI 元数据
var v1 = api.MapGroup("/v1")
.WithTags("v1");
// 用户相关端点
var users = v1.MapGroup("/users");
users.MapGet("/", () => "All users");
users.MapGet("/{id:int}", (int id) => $"User {id}");
users.MapPost("/", (UserDto dto) => $"Created: {dto.Name}");
users.MapPut("/{id:int}", (int id, UserDto dto) => $"Updated: {id}");
users.MapDelete("/{id:int}", (int id) => $"Deleted: {id}");
// 订单相关端点
var orders = v1.MapGroup("/orders");
orders.MapGet("/", () => "All orders");
orders.MapGet("/{id:int}", (int id) => $"Order {id}");
orders.MapPost("/", (OrderDto dto) => $"Created order");
// v2 API 可以有不同的实现
var v2 = api.MapGroup("/v2").WithTags("v2");
v2.MapGet("/users/{id:int}", (int id) => $"User {id} (v2)");路由组与中间件
// 路由组可以添加组级别的中间件和元数据
var adminGroup = app.MapGroup("/api/admin")
.RequireAuthorization("AdminPolicy") // 组级别授权
.AddEndpointFilter(async (context, next) =>
{
// 组级别过滤器:记录管理员操作日志
var userId = context.HttpContext.User.FindFirst("sub")?.Value;
Console.WriteLine($"[Admin API] User {userId} accessing {context.HttpContext.Request.Path}");
var result = await next(context);
Console.WriteLine($"[Admin API] Response status: {result.StatusCode}");
return result;
});
adminGroup.MapGet("/dashboard", () => "Admin Dashboard");
adminGroup.MapGet("/users", () => "Admin User Management");
adminGroup.MapPost("/config", (ConfigDto dto) => $"Config updated");
// 带限流的路由组
var publicApi = app.MapGroup("/api/public")
.RequireRateLimiting("PublicRateLimit")
.CacheOutput("PublicCache");
publicApi.MapGet("/products", () => "Products list");
publicApi.MapGet("/categories", () => "Categories list");控制器路由深入
属性路由
[ApiController]
[Route("api/v1/[controller]")]
public class UsersController : ControllerBase
{
// GET api/v1/users
[HttpGet]
public async Task<ActionResult<IEnumerable<UserDto>>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
// 分页查询
return Ok(Array.Empty<UserDto>());
}
// GET api/v1/users/42
[HttpGet("{id:int:min(1)}")]
public async Task<ActionResult<UserDto>> GetById(int id)
{
return Ok(new UserDto { Id = id, Name = "Test" });
}
// GET api/v1/users/42/orders
[HttpGet("{userId:int}/orders")]
public async Task<ActionResult<IEnumerable<OrderDto>>> GetUserOrders(int userId)
{
return Ok(Array.Empty<OrderDto>());
}
// POST api/v1/users
[HttpPost]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status201Created)]
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserDto dto)
{
var user = new UserDto { Id = 1, Name = dto.Name };
return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
}
// PUT api/v1/users/42
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateUserDto dto)
{
return NoContent();
}
// DELETE api/v1/users/42
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
return NoContent();
}
// GET api/v1/users/search?query=alice
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<UserDto>>> Search(
[FromQuery] string query,
[FromQuery] string? sortBy = null,
[FromQuery] SortDirection sortDir = SortDirection.Ascending)
{
return Ok(Array.Empty<UserDto>());
}
}路由特性选择
// [Route] 属性支持占位符和约束
[Route("api/{version:regex(^v[12]$)}/[controller]")]
public class FlexibleVersionController : ControllerBase
{
// GET /api/v1/FlexibleVersion 或 /api/v2/FlexibleVersion
}
// 多路由(一个 Action 支持多个 URL)
[HttpGet]
[Route("/api/users")]
[Route("/api/v1/users")] // 同时支持两个路径
public IActionResult GetAllUsers() => Ok();
// 路由前缀与 Token 替换
[Route("api/[controller]/[action]")]
public class ReportController : ControllerBase
{
// GET /api/Report/DailySales
public IActionResult DailySales() => Ok();
// GET /api/Report/MonthlySales
public IActionResult MonthlySales() => Ok();
}路由调试与排错
路由诊断中间件
// 开发环境路由调试中间件
if (builder.Environment.IsDevelopment())
{
app.Use(async (context, next) =>
{
var endpoint = context.GetEndpoint();
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation(
"请求路径: {Method} {Path}",
context.Request.Method,
context.Request.Path);
if (endpoint != null)
{
logger.LogInformation(
"匹配端点: {DisplayName}, 路由模式: {RoutePattern}",
endpoint.DisplayName,
endpoint.Metadata.GetMetadata<Route>()?.RoutePattern?.RawText ?? "N/A");
// 显示所有路由值
foreach (var (key, value) in context.Request.RouteValues)
{
logger.LogInformation(" 路由值: {Key} = {Value}", key, value);
}
}
else
{
logger.LogWarning("未匹配到任何端点,返回 404");
}
await next();
});
}
// 列出所有注册的端点(启动时)
app.Lifetime.ApplicationStarted.Register(() =>
{
var dataSource = app.Services.GetRequiredService<EndpointDataSource>();
foreach (var endpoint in dataSource.Endpoints)
{
var route = endpoint.Metadata.GetMetadata<Route>();
if (route != null)
{
Console.WriteLine($" {route.RoutePattern.RawText,-40} → {endpoint.DisplayName}");
}
}
});常见路由问题排查
// 问题 1:路由返回 404,但端点确实存在
// 原因 A:HTTP 方法不匹配
// 注册了 app.MapGet,但客户端发送 POST 请求
// 解决:确认 HTTP 方法一致
// 原因 B:参数约束不满足
// GET /users/abc 匹配 /users/{id:int},但 "abc" 不是整数
// 解决:检查参数值是否满足约束
// 原因 C:UseRouting 或 UseEndpoints 缺失
// 没有 app.UseRouting() 或 app.UseEndpoints()
// 解决:确保管道配置完整
// 问题 2:多个路由匹配同一个请求
// 原因:路由模板歧义,两个路由都能匹配
// 解决:增加约束使路由更具体,或调整注册顺序
// 问题 3:链接生成返回 null
// 原因:端点名称不匹配,或缺少必要路由值
// 解决:确保 .WithName() 与 LinkGenerator 引用一致
// 问题 4:Catch-All 路由吞噬所有请求
// 原因:/{**path} 匹配了所有路径,导致静态文件和 API 都被拦截
// 解决:将 catch-all 路由放在最后,或使用更具体的模式性能考量
路由匹配性能
// .NET 8 路由匹配使用基于树的算法,性能极高
// 但仍需注意以下优化点:
// 1. 避免过多路由端点
// 数千个端点时,匹配时间会线性增长
// 对于动态路由,考虑使用单一端点 + 内部分发
// 2. 复杂正则约束影响性能
// ❌ 避免:每个请求都执行复杂正则
app.MapGet("/products/{id:regex(^[A-Z]{2}-\\d{4,6}$)}", (string id) => $"Product {id}");
// ✅ 更好的做法:使用简单约束 + 业务层验证
app.MapGet("/products/{id:maxlength(10)}", (string id) =>
{
if (!Regex.IsMatch(id, @"^[A-Z]{2}-\d{4,6}$"))
return Results.BadRequest("Invalid product ID format");
return Results.Ok($"Product {id}");
});
// 3. 路由组的性能优势
// 路由组不仅减少代码重复,还能提高匹配效率
// 因为框架可以按组进行前缀匹配,提前剪枝
// 4. 避免运行时动态添加路由
// 启动后添加的路由需要重建路由树
// 如果需要动态路由,使用 catch-all + 内部分发
app.MapGet("/api/{**path}", async (HttpContext ctx, string path) =>
{
// 内部分发逻辑
return Results.Ok($"Dynamic: {path}");
});优点
缺点
总结
URL 路由深入要点:端点路由将请求匹配和端点执行分离为 UseRouting 和 UseEndpoints 两个阶段。路由模板使用 {param:constraint} 语法定义参数和约束,内置支持 int、bool、datetime、guid、minlength、maxlength、range、regex 等约束,可通过 IRouteConstraint 扩展自定义约束。路由匹配优先级:字面量 > 约束参数 > 无约束参数 > catch-all。LinkGenerator 是推荐的 URL 生成方式,可在任何服务中注入使用。MapGroup 批量添加前缀、中间件和元数据,减少重复代码。路由数量较多时应注意性能,避免复杂的正则约束和运行时动态路由。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
- 框架能力的真正重点是它在请求链路中的位置和对上下游的影响。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
- 画清执行顺序、入参来源、失败返回和日志记录点。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
- 知道 API 名称,却不知道它应该放在请求链路的哪个位置。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
- 继续补齐协议选型、网关治理、端点可观测性和契约演进策略。
适用场景
- 当你准备把《URL 路由深入》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《URL 路由深入》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《URL 路由深入》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《URL 路由深入》最大的收益和代价分别是什么?
