路由
本页由英文自动翻译。 阅读原文
The Router 是构建服务器的第一步。它负责容纳 Route 对象,这些对象是将 URL 及其方法映射到服务器执行的操作的端点。每个操作负责接收请求并向客户端返回响应。
路由是路径表达式(“路径模式”)与其可监听的 HTTP 方法的配对。当向服务器发出请求时,服务器会尝试找到匹配该请求的路由,然后调用该路由的操作并将产生的响应返回给客户端。
在 Sisk 中定义路由有多种方式:可以是静态的、动态的或自动扫描的,亦可通过属性定义,或直接在 Router 对象中定义。
Router mainRouter = new Router();
// maps the GET / route into the following action
mainRouter.MapGet("/", request => {
return new HttpResponse("Hello, world!");
});要了解路由能够做什么,需要先了解请求能够做什么。一个 HttpRequest 将包含你所需的一切。Sisk 还提供了一些额外特性,以加快整体开发速度。
对于服务器接收到的每个操作,都会调用类型为 RouteAction 的委托。该委托包含一个参数,持有一个 HttpRequest,其中包含关于服务器收到的请求的所有必要信息。该委托返回的对象必须是 HttpResponse 或通过 implicit response types 映射到它的对象。
Matching routes #
当 HTTP 服务器收到请求时,Sisk 会搜索满足请求路径表达式的路由。该表达式始终在路由和请求路径之间进行测试,不考虑查询字符串。
第一个路径和方法匹配请求的路由会处理该请求。当没有路由匹配该请求时,返回 Router.NotFoundErrorHandler 的响应给客户端。当路径模式匹配但 HTTP 方法不匹配时,返回 Router.MethodNotAllowedErrorHandler 的响应给客户端。路由的测试顺序在 matching precedence 中描述。
Sisk 会检查静态路由之间是否可能出现冲突。当服务器启动时,Sisk 会查找路径模式和方法重叠的静态路由,若发现则抛出异常。在同一次检查中,若有两个或更多路由共享相同的 Route.Name,Sisk 也会抛出 ArgumentException。这两项检查受 Router.CheckForRouteCollisions 控制,默认启用。
Matching precedence #
每个路由要么是 静态 要么是 动态,由 Route.IsStatic 指示:
- Static routes 严格遵循其路径模式,例如
/users/<id>。使用Map*方法、Route实例和路由属性创建的路由都是静态的。 - Dynamic routes 以自己的方式解释路径,可能匹配无法用路径模式描述的路径。RegexRoute、PrefixRoute(用于 file servers)以及 custom matching routes 属于动态路由。
当服务器启动时,路由器会重新排序其路由,使所有静态路由在所有动态路由之前进行测试。每个组内部保持注册顺序。这意味着静态路由始终优先于动态路由,无论它们的定义顺序如何:
// dynamic: serves any GET path from the "wwwroot" directory
mainRouter.MapFileSystem("/", "wwwroot");
// static: handles GET /api/status, even though it was defined after the file server
mainRouter.MapGet("/api/status", request => new HttpResponse("ok"));在上面的示例中,文件服务器充当了所有未被静态路由处理的 GET 请求的回退。
两个组在验证和转换方式上也有所不同:
| 静态路由 | 动态路由 | |
|---|---|---|
| 匹配顺序 | 首先,按注册顺序 | 在所有静态路由之后,按注册顺序 |
| 路由冲突检查 | 是 | 否 |
| Router.Prefix 是否应用 | 是 | 否 |
警告
动态路由不会进行冲突检查,因为 Sisk 无法知道它们匹配哪些路径。当两个动态路由可以匹配同一请求时,先定义的路由获胜。例如,先映射到 / 的文件服务器会使随后映射到 /static 的文件服务器不可达。
重要
在 1.7 之前的版本中,路由仅按注册顺序测试,正则路由可能会优先于之后定义的静态路由。如果你的应用依赖正则路由处理同样被静态路由匹配的路径,请将该逻辑移至静态路由或缩小静态路径模式的范围。
Creating routes using path patterns #
对于新应用,推荐使用 Map* 方法。它们在调用位置即可看到 HTTP 方法,并且符合当前的 Router API。旧的 SetRoute 方法仍作为兼容包装存在,但新示例应使用 Map、MapGet、MapPost、MapPut、MapDelete、MapPatch、MapAny、MapOptions 或 MapHead。
// Map* methods are the usual way to define method-specific routes.
mainRouter.MapGet("/hey/<name>", (request) =>
{
string name = request.RouteParameters["name"].GetString();
return new HttpResponse($"Hello, {name}");
});
mainRouter.MapPost("/form", (request) =>
{
var formData = request.GetFormContent();
return new HttpResponse(); // empty 200 ok
});
// Map can also receive a Route instance when you need route options.
mainRouter.Map(Route.Get("/image.png", (request) =>
{
var imageStream = File.OpenRead("image.png");
return new HttpResponse()
{
// the StreamContent inner
// stream is disposed after sending
// the response.
Content = new StreamContent(imageStream)
};
}));
// multiple parameters
mainRouter.MapGet("/hey/<name>/surname/<surname>", (request) =>
{
string name = request.RouteParameters["name"].GetString();
string surname = request.RouteParameters["surname"].GetString();
return new HttpResponse($"Hello, {name} {surname}!");
});RouteParameters 属性包含了收到请求的路径变量的所有信息。
服务器接收到的每个路径在执行路径模式测试之前都会被规范化,规则如下:
- 所有空段都会从路径中移除,例如:
////foo//bar会变为/foo/bar。 - 路径匹配 区分大小写,除非将 Router.MatchRoutesIgnoreCase 设置为
true。
Query 和 RouteParameters 属性返回一个 StringValueCollection 对象,其中每个索引属性返回一个非空的 StringValue,可用作选项/单子,将其原始值转换为受管理的对象。
下面的示例读取路由参数 “id”,并从中获取一个 Guid。如果参数不是有效的 Guid,会抛出异常;如果服务器未处理 Router.CallbackErrorHandler,则返回 500 错误给客户端。
mainRouter.MapGet("/user/<id>", (request) =>
{
Guid id = request.RouteParameters["id"].GetGuid();
return new HttpResponse($"User id: {id}");
});注意
路径的尾部 / 在请求路径和路由路径中都会被忽略,也就是说,如果你尝试访问定义为 /index/page 的路由,同样可以使用 /index/page/ 进行访问。
你也可以通过启用 HttpServerConfiguration.ForceTrailingSlash 强制 URL 以 / 结尾。
Creating routes using class instances #
你也可以使用属性 RouteAttribute 通过反射动态定义路由。这样,类的实例中实现了该属性的方法将在目标路由器中定义路由。
要将方法定义为路由,必须使用 RouteAttribute 标记,例如该属性本身或 RouteGetAttribute。方法可以是 static、实例、public 或 private。希望从对象映射实例和静态路由方法时使用 MapInstance。希望仅从类型映射静态路由方法时使用 MapType。
public class MyController
{
// will match GET /
[RouteGet]
HttpResponse Index(HttpRequest request)
{
HttpResponse res = new HttpResponse();
res.Content = new StringContent("Index!");
return res;
}
// static methods works too
[RouteGet("/hello")]
static HttpResponse Hello(HttpRequest request)
{
HttpResponse res = new HttpResponse();
res.Content = new StringContent("Hello world!");
return res;
}
}下面的代码会将 MyController 的 Index 与 Hello 方法都定义为路由,因为两者都被标记为路由,并且提供了类的实例,而不是类型。如果提供的是类型,则仅会定义静态方法。
var myController = new MyController();
mainRouter.MapInstance(myController);若只想映射类型中的静态路由方法,使用:
mainRouter.MapType<MyController>();自 Sisk 0.16 版本起,可启用 AutoScan,自动搜索实现了 RouterModule 的用户自定义类并将其自动关联到路由器。AOT 编译不支持此功能。
mainRouter.AutoScanModules<ApiController>();上述指令会搜索所有实现了 ApiController 的类型,但 不包括该类型本身。两个可选参数指示方法搜索这些类型的方式。第一个参数暗示搜索的程序集,第二个参数指示类型的定义方式。
Regex routes #
如果不想使用默认的 HTTP 路径匹配方式,可以使用 RegexRoute 类将路径解释为正则表达式来定义路由。正则路由属于 dynamic routes。
mainRouter.Map(new RegexRoute(RouteMethod.Get, @"\/[a-z]+\/", request =>
{
return new HttpResponse("hello, world");
}));你还可以将正则模式中的捕获组写入 HttpRequest.RouteParameters:
public class MyController
{
[RegexRoute(RouteMethod.Get, @"/uploads/(?<filename>.*\.(jpeg|jpg|png))")]
static HttpResponse RegexRoute(HttpRequest request)
{
string filename = request.RouteParameters["filename"].GetString();
return new HttpResponse().WithContent($"Acessing file {filename}");
}
}注意
正则表达式不会自动锚定:例如 /uploads 也会匹配 /files/uploads/a。使用 ^ 和 $ 可匹配完整路径。
重要
自 Sisk 1.7 起,Route.UseRegex 属性不再可设置。该属性已废弃,仅用于报告路由是否为 RegexRoute。路由属性中的 UseRegex(如 [Route(RouteMethod.Get, "...", UseRegex = true)])以及 [RegexRoute] 属性仍然有效。
要迁移使用 Route.UseRegex 创建的正则路由,请改为使用 RegexRoute:
// before 1.7
mainRouter.Map(new Route(RouteMethod.Get, @"^/items/(?<id>\d+)$", action) { UseRegex = true });
// 1.7 and later
mainRouter.Map(new RegexRoute(RouteMethod.Get, @"^/items/(?<id>\d+)$", action));Prefix routes #
PrefixRoute 匹配其路径以及其下的所有嵌套路径,比较完整的路径段。它是 Router.MapFileSystem 使用的路由。前缀路由属于 dynamic routes。
// matches /docs, /docs/ and /docs/any/nested/path, but not /docsfoo
mainRouter.Map(new PrefixRoute(RouteMethod.Get, "/docs", null, request =>
{
return new HttpResponse($"Requested {request.Path}");
}, null));Custom route matching #
通过继承 Route 并重写 Route.Match 可以创建自定义匹配规则。该方法接收请求路径和正在匹配该路由的路由器,并返回一个包含结果以及可选捕获路由参数的 RouteMatch。
路由器仅在 Match 成功后才会验证 HTTP 方法,因此 Match 应仅测试路径。这保证了 405 Method Not Allowed 和 OPTIONS 响应在自定义路由中仍然有效。
// matches any path ending with the given file extension, such as /reports/2024.csv
public sealed class ExtensionRoute : Route
{
public ExtensionRoute(string extension, RouteAction action)
: base(RouteMethod.Get, extension, action)
{
}
// this route does not follow a path pattern
public override bool IsStatic => false;
// the path is not a path pattern, so it cannot be redirected to its trailing-slash form
public override bool AllowRewrites => false;
public override RouteMatch Match(string requestPath, Router router)
{
StringComparison comparison = router.MatchRoutesIgnoreCase
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (!requestPath.EndsWith(Path, comparison))
return RouteMatch.NotMatched;
var parameters = new NameValueCollection
{
["file"] = requestPath[..^Path.Length]
};
return new RouteMatch(true, parameters);
}
}mainRouter.Map(new ExtensionRoute(".csv", request =>
{
string file = request.RouteParameters["file"].GetString();
return new HttpResponse($"Exporting {file} as CSV");
}));在重写 Match 时,请注意:
- 当路由不严格遵循路径模式时,覆盖 Route.IsStatic 并返回
false。否则,路由器在检查冲突和应用 Router.Prefix 时会将其Path当作路径模式,可能导致错误的冲突或无效路径。 - 当路由路径无法通过 HttpServerConfiguration.ForceTrailingSlash 重定向到其尾部斜杠形式时,覆盖 Route.AllowRewrites 并返回
false。 Match会在每个到达该路由的请求上被调用,可能会被多个线程并发调用。路由不匹配时应避免分配,并确保任何缓存状态是线程安全的。对不成功的匹配返回 RouteMatch.NotMatched。- 通过
router参数遵循 Router.MatchRoutesIgnoreCase。
Prefixing routes #
可以使用 RoutePrefix 属性为类或模块中的所有路由添加前缀,并将前缀设为字符串。
下面的示例展示了 BREAD 架构(Browse, Read, Edit, Add and Delete):
[RoutePrefix("/api/users")]
public class UsersController
{
// GET /api/users
[RouteGet]
public async Task<HttpResponse> Browse()
{
...
}
// GET /api/users/<id>
[RouteGet("/<id>")]
public async Task<HttpResponse> Read()
{
...
}
// PATCH /api/users/<id>
[RoutePatch("/<id>")]
public async Task<HttpResponse> Edit()
{
...
}
// POST /api/users
[RoutePost]
public async Task<HttpResponse> Add()
{
...
}
// DELETE /api/users/<id>
[RouteDelete("/<id>")]
public async Task<HttpResponse> Delete()
{
...
}
}在上述示例中,省略了 HttpResponse 参数,转而通过全局上下文 HttpContext.Current 使用。后续章节将进一步说明。
Routes without request parameter #
路由可以在没有 HttpRequest 参数的情况下定义,并仍然能够在请求上下文中获取请求及其组件。下面考虑一个抽象类 ControllerBase,它作为 API 所有控制器的基础,并提供 Request 属性以获取当前的 [HttpRequest]。
public abstract class ControllerBase
{
// gets the request from the current thread
public HttpRequest Request { get => HttpContext.Current.Request; }
// the line below, when called, gets the database from the current HTTP session,
// or creates a new one if it doesn't exist
public DbContext Database { get => HttpContext.Current.RequestBag.GetOrAdd<DbContext>(); }
}所有子类均可在不使用请求参数的情况下使用路由语法:
[RoutePrefix("/api/users")]
public class UsersController : ControllerBase
{
[RoutePost]
public async Task<HttpResponse> Create()
{
// reads the JSON data from the current request
UserCreationDto? user = await Request.GetJsonContentAsync<UserCreationDto>();
...
Database.Users.Add(user);
return new HttpResponse(201);
}
}更多关于当前上下文和依赖注入的细节,请参阅 dependency injection 教程。
Any method routes #
你可以定义仅通过路径匹配而忽略 HTTP 方法的路由。这在需要在路由回调内部进行方法验证时非常有用。
// will match / on any HTTP method
mainRouter.MapAny("/", callbackFunction);RouteMethod.Any 包含 GET、POST、PUT、PATCH、DELETE、QUERY、HEAD 和 OPTIONS。
QUERY method routes #
Sisk 支持 HTTP QUERY 方法,这是一种安全且幂等的方式,将查询放在请求体中。使用 Router.MapQuery、Route.Query、RouteMethod.Query 或 RouteQuery 属性来定义这些路由:
mainRouter.MapQuery("/search", request =>
{
string filter = request.Body;
return new HttpResponse($"Searching for {filter}");
});public class SearchController
{
[RouteQuery("/products")]
static HttpResponse SearchProducts(HttpRequest request)
{
return new HttpResponse($"Searching for {request.Body}");
}
}Accept-Query 头部用于声明在 QUERY 请求中接受的媒体类型,可通过 HttpHeaderCollection.AcceptQuery 和 HttpKnownHeaderNames.AcceptQuery 访问。
Any path routes #
任意路径路由会测试 HTTP 服务器收到的任何路径,前提是路由方法也被测试。如果路由方法是 RouteMethod.Any 且路由在路径表达式中使用了 Route.AnyPath,则该路由会监听所有来自 HTTP 服务器的请求,且不能再定义其他路由。
// the following route will match all POST requests
mainRouter.Map(RouteMethod.Post, Route.AnyPath, callbackFunction);Ignore case route matching #
默认情况下,路由与请求的匹配是区分大小写的。若要忽略大小写,请启用此选项:
mainRouter.MatchRoutesIgnoreCase = true;这同样会为正则路由启用 RegexOptions.IgnoreCase,并为前缀路由启用不区分大小写的比较。
Not Found (404) callback handler #
你可以为未匹配到任何已知路由的请求创建自定义回调。
mainRouter.NotFoundErrorHandler = () =>
{
return new HttpResponse(404)
{
// Since v0.14
Content = new HtmlContent("<h1>Not found</h1>")
// older versions
Content = new StringContent("<h1>Not found</h1>", Encoding.UTF8, "text/html")
};
};Method not allowed (405) callback handler #
你也可以为路径匹配但方法不匹配的请求创建自定义回调。
mainRouter.MethodNotAllowedErrorHandler = (context) =>
{
return new HttpResponse(405)
{
Content = new StringContent($"Method not allowed for this route.")
};
};Error Handling #
在请求生命周期内(从预执行请求处理程序、通过路由操作、到后执行请求处理程序和数值处理程序)可能会抛出异常。这些异常由以下机制管理:
- 如果 HttpServerConfiguration.ThrowExceptions 为
true,异常会正常抛出,Sisk 不会捕获,且如果未捕获异常,HTTP 服务器可能会中断。 - 如果 HttpServerConfiguration.ThrowExceptions 为
false,异常会被 Sisk 捕获并处理。随后,如果已定义Router.CallbackErrorHandler,它会使用捕获的异常和请求上下文被调用,并且 不会 转发到标准错误输出。如果未定义Router.CallbackErrorHandler,异常会转发到标准错误输出,客户端将收到 HTTP 500 错误响应。如果未定义标准错误输出,错误将被静默忽略。
注意:在 Router.CallbackErrorHandler 中,你可以设置错误日志、访问日志、两者或都不记录的模式,并修改默认的日志写入行为:
router.CallbackErrorHandler = (ex, ctx) =>
{
ctx.LogMode = LogOutput.Both; // override log mode to log the error in both access and error logs
}Internal error handler #
路由回调在服务器执行期间可能抛出错误。如果未正确处理,HTTP 服务器的整体功能可能会被终止。路由器提供了一个回调,用于在路由回调失败时防止服务中断。
此方法仅在 ThrowExceptions 设置为 false 时可达。
mainRouter.CallbackErrorHandler = (ex, context) =>
{
return new HttpResponse(500)
{
Content = new StringContent($"Error: {ex.Message}")
};
};