路由
本页由英文自动翻译。 阅读原文
The Router 是构建服务器的第一步。它负责保存 Route 对象,这些对象是将 URL 及其方法映射到服务器执行的操作的端点。每个操作负责接收请求并向客户端返回响应。
路由是路径表达式(“路径模式”)与它们可以监听的 HTTP 方法的配对。当向服务器发出请求时,服务器会尝试找到匹配该请求的路由,然后调用该路由的操作并将产生的响应返回给客户端。
在 Sisk 中定义路由有多种方式:可以是静态的、动态的或自动扫描的,使用属性定义,或直接在 Router 对象中定义。
Router mainRouter = new Router();
// 将 GET / 路由映射到以下操作
mainRouter.MapGet("/", request => {
return new HttpResponse("Hello, world!");
});要了解路由能够做什么,需要先了解请求能够做什么。一个 HttpRequest 包含了你所需的一切。Sisk 还提供了一些额外功能,以加快整体开发。
对于服务器接收到的每个操作,都会调用类型为 RouteAction 的委托。该委托包含一个参数,持有一个包含所有关于服务器接收的请求的必要信息的 HttpRequest。该委托返回的对象必须是 HttpResponse 或通过 implicit response types 映射到它的对象。
匹配路由 #
当 HTTP 服务器收到请求时,Sisk 会搜索满足请求路径表达式的路由。该表达式始终在路由和请求路径之间进行测试,不考虑查询字符串。
此测试没有优先级,并且仅针对单一路由。当没有路由与该请求匹配时,返回 Router.NotFoundErrorHandler 响应给客户端。当路径模式匹配但 HTTP 方法不匹配时,返回 Router.MethodNotAllowedErrorHandler 响应给客户端。
Sisk 会检查路由冲突的可能性以避免这些问题。定义路由时,Sisk 会查找可能与正在定义的路由冲突的路由。此测试包括检查路径和路由设置接受的方法。
使用路径模式创建路由 #
对于新应用,优先使用 Map* 方法。它们在调用点保持 HTTP 方法可见,并匹配当前的 Router API。较旧的 SetRoute 方法仍作为兼容包装存在,但新示例应使用 Map、MapGet、MapPost、MapPut、MapDelete、MapPatch、MapAny、MapOptions 或 MapHead。
// Map* 方法是定义特定 HTTP 方法路由的常用方式。
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(); // 空的 200 OK
});
// 当需要路由选项时,Map 也可以接收 Route 实例。
mainRouter.Map(Route.Get("/image.png", (request) =>
{
var imageStream = File.OpenRead("image.png");
return new HttpResponse()
{
// StreamContent 内部
// 流在发送后会被释放
// 响应。
Content = new StreamContent(imageStream)
};
}));
// 多个参数
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,可用作 option/monad 将其原始值转换为受管理的对象。
下面的示例读取路由参数 “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 以 / 结尾。
使用类实例创建路由 #
你也可以使用属性 RouteAttribute 通过反射动态定义路由。这样,类的实例中实现了该属性的方法将在目标路由器中定义其路由。
要将方法定义为路由,必须使用 RouteAttribute 标记,例如该属性本身或 RouteGetAttribute。方法可以是 static、实例、public 或 private。需要从对象映射实例和静态路由方法时使用 MapInstance。只想从类型映射静态路由方法时使用 MapType。
public class MyController
{
// 将匹配 GET /
[RouteGet]
HttpResponse Index(HttpRequest request)
{
HttpResponse res = new HttpResponse();
res.Content = new StringContent("Index!");
return res;
}
// 静态方法也适用
[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 的类型,但不包括该类型本身。两个可选参数指示该方法如何搜索这些类型。第一个参数表示搜索这些类型的程序集,第二个参数指示这些类型的定义方式。
正则路由 #
如果不想使用默认的 HTTP 路径匹配方法,可以将路由标记为使用正则表达式解释。
Route indexRoute = new RegexRoute(RouteMethod.Get, @"\/[a-z]+\/", IndexPage);
mainRouter.Map(indexRoute);或使用 RegexRoute 类:
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}");
}
}前缀路由 #
你可以使用 RoutePrefix 属性为类或模块中的所有路由添加前缀,并将前缀设为字符串。
下面的示例使用 BREAD 架构(Browse、Read、Edit、Add 和 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 使用。更多内容请参见下节。
没有请求参数的路由 #
路由可以在没有 HttpRequest 参数的情况下定义,并仍然能够在请求上下文中获取请求及其组件。这里考虑一个抽象类 ControllerBase,它作为 API 所有控制器的基础,并提供 Request 属性以获取当前的 [HttpRequest]。
public abstract class ControllerBase
{
// 从当前线程获取请求
public HttpRequest Request { get => HttpContext.Current.Request; }
// 以下代码在调用时,从当前 HTTP 会话获取数据库,若不存在则创建一个新的
public DbContext Database { get => HttpContext.Current.RequestBag.GetOrAdd<DbContext>(); }
}并让所有子类能够在不传入请求参数的情况下使用路由语法:
[RoutePrefix("/api/users")]
public class UsersController : ControllerBase
{
[RoutePost]
public async Task<HttpResponse> Create()
{
// 从当前请求读取 JSON 数据
UserCreationDto? user = await Request.GetJsonContentAsync<UserCreationDto>();
...
Database.Users.Add(user);
return new HttpResponse(201);
}
}更多关于当前上下文和依赖注入的细节,请参见 dependency injection 教程。
任意方法路由 #
你可以定义仅通过路径匹配而跳过 HTTP 方法的路由。这在路由回调内部进行方法验证时非常有用。
// 将匹配任意 HTTP 方法的 /
mainRouter.MapAny("/", callbackFunction);任意路径路由 #
任意路径路由会测试 HTTP 服务器收到的任何路径,前提是路由方法也被测试。如果路由方法是 RouteMethod.Any 且路由在路径表达式中使用了 Route.AnyPath,则该路由将监听 HTTP 服务器的所有请求,且不能再定义其他路由。
// 以下路由将匹配所有 POST 请求
mainRouter.Map(RouteMethod.Post, Route.AnyPath, callbackFunction);忽略大小写的路由匹配 #
默认情况下,路由与请求的解释是区分大小写的。要使其忽略大小写,请启用此选项:
mainRouter.MatchRoutesIgnoreCase = true;这也会为使用正则匹配的路由启用 RegexOptions.IgnoreCase 选项。
未找到 (404) 回调处理程序 #
你可以为请求未匹配到任何已知路由时创建自定义回调。
mainRouter.NotFoundErrorHandler = () =>
{
return new HttpResponse(404)
{
// 自 v0.14 起
Content = new HtmlContent("<h1>Not found</h1>")
// 旧版本
Content = new StringContent("<h1>Not found</h1>", Encoding.UTF8, "text/html")
};
};方法不允许 (405) 回调处理程序 #
你也可以为请求匹配路径但不匹配方法时创建自定义回调。
mainRouter.MethodNotAllowedErrorHandler = (context) =>
{
return new HttpResponse(405)
{
Content = new StringContent($"Method not allowed for this route.")
};
};错误处理 #
在请求生命周期内(从前置执行请求处理程序、通过路由操作、到后置执行请求处理程序和数值处理程序)可能会抛出异常。这些异常由以下机制管理:
- 如果 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; // 覆盖日志模式,使错误同时记录在访问日志和错误日志中
}内部错误处理程序 #
路由回调在服务器执行期间可能抛出错误。如果未正确处理,HTTP 服务器的整体功能可能会被终止。路由器提供了一个回调,用于在路由回调失败时防止服务中断。
此方法仅在 ThrowExceptions 设置为 false 时可达。
mainRouter.CallbackErrorHandler = (ex, context) =>
{
return new HttpResponse(500)
{
Content = new StringContent($"Error: {ex.Message}")
};
};