原文地址:
https://rustasync.github.io/team/2018/10/16/tide-routing.html
本文将继续讨论Tide系列文章,概述一种路由和提取的可能设计,结合了Rocket、Actix和Gotham等框架中的一些最佳思想。
- 路由是框架如何从HTTP请求映射到端点,即处理请求的一段代码。
- 提取是端点从HTTP请求访问数据的方式。
这两个关注点通常是耦合的,因为提取策略决定了路由到的端点的签名。然而,正如我们将在本文中看到的,耦合可能非常松散。
这个帖子里没有什么是一成不变的!相反,这是一个可能的API方向的草图,用于开始讨论和协作。请把你的想法留在内部帖子上。
A simple example
我们将从这篇文章中构建在路由和提取系统之上的一个非常简单的示例“app”开始,然后我们将更详细地研究该系统。
The data
该 App 维护一个简单的内存消息列表:
#[derive(Serialize, Deserialize)]
struct Message {
contents: String,
author: Option<String>,
// etc...
}
/// A handle to an in-memory list of messages
#[derive(Clone)]
struct Database { /* ... */ }
impl Database {
/// Create a handle to an empty database
fn new() -> Database;
/// Add a new message, returning its ID
fn insert(&mut self, msg: Message) -> u64;
/// Attempt to look up a message by ID
fn get(&mut self, id: u64) -> Option<Message>;
/// Attempt to edit a message; returns `false`
/// if `id` is not found.
fn set(&mut self, id: u64, msg: Message) -> bool;
}
这个小API是作为更复杂后端的替代。主要的兴趣点是,数据库是数据库的句柄,这意味着它是克隆的(and uses an Arc under the hood)。稍后我们会看到为什么这很重要。
web API:目录(table of contents )
我们将构建一个简单的、基于json的web API,用于在这个内存数据库上操作。正如上一篇文章所述,我们将分两部分完成此工作:一个高级“目录表”显示如何将请求路由到端点,然后是低级端点定义。
应用程序的目录(table of contents )是通过builder API:指定的。
fn main() {
// The endpoints will receive a handle to the app state, i.e. a `Database` instance
let mut app = App::new(Database::new());
app.at("/message").post(new_message);
app.at("/message/{}").get(get_message);
app.at("/message/{}").put(set_message);
app.serve();
}
在/message上发布消息将创建一个新消息,而/message/{}允许检索和编辑现有消息。{}段匹配任何单个URL段(不包含分隔符)。我们将看到如何提取匹配的数据。
web API:端点实现
为了完成这个应用程序,我们需要实现传递给目录的端点函数。
新增(Insertion)
让我们从new_message端点开始:
async fn new_message(mut db: AppState<Database>, msg: Json<Message>) -> Display<usize> {
db.insert(msg.0)
}
首先,我们使用async fn来编写端点。该特性目前在Nightly可用,允许您轻松地编写futures-based的代码。函数签名等价于:
fn new_message(mut db: AppState<Database>, msg: Json<Message>) -> impl Future<Output = Display(usize)>
每个端点签名都有相同的形式:
- 零或多个参数,每个参数实现提取器特性。提取器实现说明如何从请求中提取参数数据。通常,提取器只是带有公共字段的包装结构。
- 可以转换为响应的异步返回值(通过IntoResponse特性)
对于new_message,我们使用两个提取器:一个获取应用程序状态的句柄(数据库),另一个提取主体(作为json编码的消息)。
在函数体中,我们可以直接使用参数。提取器包装器类型实现了Deref和DerefMut,当我们需要所有权时,我们可以使用.0来提取内部对象:
db.insert(msg.0)
最后,返回插入消息的标识符Display(u64)值。与Json类似,Display类型是一个装饰器,它表示将给定的值序列化为普通的HTTP 200,并通过Display trait格式化生成主体。特别地,它提供了以下impl:
impl<T: fmt::Display> IntoResponse for Display<T> { ... }
更新(Updates)
接下来,我们将查看更新现有消息:
async fn set_message(mut db: AppState<Database>, id: Path<usize>, msg: Json<Message>) -> Result<(), NotFound> {
if db.set(id.0, msg.0) {
Ok(())
} else {
Err(NotFound)
}
}
这里的基本设置与new_message非常相似。但是,对于这个端点,我们需要从URL中提取{}参数。我们使用路径提取器表示相应的URL段应该解析为一个usize值。否则,函数的参数和主体就很容易解释了。
一个重要的细节:结果返回值将通过其包含的类型的序列化序列化为响应。()和NotFound都序列化为空体响应,但是前者生成200个响应代码,而后者生成404。
注意,这种构造返回类型的方法只是一个例子。在实践中,您可能会使用更复杂的序列化方法来定制应用程序错误类型。
检索/查询(Retrieval)
async fn get_message(mut db: AppState<Database>, id: Path<usize>) -> Result<Json<Message>, NotFound> {
if let Some(msg) = db.get(id.0) {
Ok(Json(msg))
} else {
Err(NotFound)
}
}
这里唯一的问题是,我们在成功案例中使用了Json标记,这表明我们想返回一个200响应,其主体是通过Json序列化的消息
更深入的研究
遍历示例应用程序已经介绍了许多相关的api,但是现在值得回过头来看看更完整的图景,以及与现有的Rust web框架的原理和关系。
设计目标
这里概述了API设计的几个核心目标:
- 使理解url如何映射到代码变得非常简单。我们通过在路由和其他关注点(包括提取)之间进行严格的分离,并通过限制路由的表达能力来实现这一点。
- 使提取和响应系列化符合人机工程学。我们通过利用双方的特质系统来做到这一点。
- 避免宏和核心代码生成;更喜欢简单的“plain Rust”api。虽然宏可能非常强大,但它们也可能模糊框架的底层机制,并在出错时导致难以理解的错误。虽然这不是一个硬约束,但在只使用“plain Rust”的情况下实现上述两个目标是非常好的。
- 为中间件和配置提供一个干净的机制。稍后我们将看到所提议的API如何非常适合于可扩展性和自定义。
该设计从Rocket、Gotham和Actix-Web中汲取了大量的灵感,并加入了一些自己的新花样。让我们开始吧!
Routing
对于路由,为了达到清晰的目标,我们遵循以下原则:
- 通过“table of contents”方法将路由分离出来,从而更容易看到整个应用程序结构。
- 路由匹配无“后撤”;使用匹配的特异性。特别是,添加路由的顺序没有影响,并且不能有两个相同的路由。
- 仅通过URL和HTTP方法驱动端点选择。请求的其他方面可以影响中间件和端点的行为,但不能影响在成功的情况下使用哪个端点。因此,例如,中间件可以执行身份验证并避免在失败时调用端点,但是它通过显式地选择提供响应的独立方式来实现这一点,而不是依赖于路由器中的“回退”。
路由系统的核心是几种数据类型:
/// An application, which houses application state and other top-level concerns.
pub struct App<AppData> { .. }
/// Configures routing within an application. Routers can be nested.
pub struct Router<AppData> { .. }
/// Configures the responses for an application for a particular URL match.
pub struct Resource<AppData> { .. }
/// Embeds a typemap for providing hierarchical configuration of extractors, middleware, and more.
pub struct Config { .. }
应用程序级和路由器api很简单:
impl<AppData> App<AppData> {
pub fn new(app_data: AppData) -> App<AppData>;
/// Access the top-level router for the app.
pub fn router(&mut self) -> &mut Router<AppData>;
/// Access the top-level configuration.
pub fn config(&mut self) -> &mut Config;
/// Convenience API to add routes directly at the top level.
pub fn at(&mut self, path: &str) -> &mut Resource<AppData>;
/// Start up a server instance.
pub fn serve(&mut self);
}
impl<AppData> Router<AppData> {
/// Configure the router.
pub fn config(&mut self) -> &mut Config;
/// Add a route.
pub fn at(&mut self, path: &str) -> &mut Resource<AppData>;
}
路由的语法非常简单:URL包含0个或多个{}段,可能以*段结尾(用于匹配URL的任意“rest”)。将{}段钩入路径提取器;端点中的每个参数按顺序提取一个这样的Path<T>段。
为了提供最大的清晰度,路由器只允许两条路由重叠,如果其中一条路由比另一条更具体;最具体的路线是首选的。因此,例如,以下路径可以共存:
"/users/{}"
"/users/{}/help"
"/users/new"
"/users/new/help"
而/users/new或/users/new/help上的请求将分别使用最后两条路由。
更一般地说:路由可以共享一个相同的前缀,但是在某些情况下,要么必须有不重叠的段(例如两个不同的固定字符串),要么恰好其中一个路由必须有一个{}或*段,而另一个路由必须有一个固定的字符串。
一旦一个路由被给出,你得到一个资源的句柄,它允许挂载端点或使用嵌套在该URL的路由器:
impl<AppData> Resource<AppData> {
pub fn get<T>(&mut self, endpoint: impl Endpoint<AppData, T>) -> &mut Config;
pub fn put<T>(&mut self, endpoint: impl Endpoint<AppData, T>) -> &mut Config;
pub fn post<T>(&mut self, endpoint: impl Endpoint<AppData, T>) -> &mut Config;
pub fn delete<T>(&mut self, endpoint: impl Endpoint<AppData, T>) -> &mut Config;
pub fn nest(&mut self, impl FnOnce(&mut Router));
pub fn config(&mut self) -> &mut Config;
}
如果端点中的{}或*段的数量与对应的路径和Glob提取器不匹配,资源构建器API将在端点注册时出现panic。因此,这种不匹配需要在服务器运行之前被发现。
这些methods 大多返回Config的句柄,这使得在路由或端点级别调整Config成为可能。这篇文章不会详细讨论配置API,但其思想是,配置与中间件一样,以分层的方式沿着内容路由表应用。因此, app-level configuration提供了一个全局缺省值,然后可以在路由(甚至路由的一部分)和端点的每一步进行调整。
Endpoints
路由在端点处终止,端点是响应函数的异步请求:
pub trait Endpoint<AppData, Kind> {
type Fut: Future<Output = Response> + Send + 'static;
fn call(&self, state: AppData, req: Request, config: &Config) -> Self::Fut;
}
这里的请求和响应类型是http crates 中的warppers;我们不会在这里详细讨论API的这一部分,因为大多数最终用户不会直接使用这些类型。
除了请求的所有权之外,还向 endpoint 提供应用程序状态的句柄和对配置的引用。
注意,endpoint 特征有一个在特征体中没有使用的Kind参数。这个附加参数使重载挂载api以处理各种函数签名成为可能。特别是,这里有一些提供的实现片段:
/// A marker struct to avoid overlap
struct Ty<T>(T);
// An endpoint implementation for *zero* extractors.
impl<T, AppData, Fut> Endpoint<AppData, Ty<Fut>> for T
where
T: Fn() -> Fut,
F: Future,
F::Output: IntoResponse,
// ...
// An endpoint implementation for *one* extractor.
impl<T, AppData, Fut, T0> Endpoint<AppData, (Ty<T0>, Ty<Fut>)> for T
where
T: Fn(T0) -> Fut,
T0: Extractor<AppData>,
Fut: Future,
Fut::Output: IntoResponse,
// ...
// An endpoint implementation for *two* extractors.
impl<T, AppData, Fut, T0, T1> Endpoint<AppData, (Ty<T0>, Ty<T1>, Ty<Fut>)> for T
where
T: Fn(T0, T1) -> Fut,
T0: Extractor<AppData>,
T1: Extractor<AppData>,
Fut: Future,
Fut::Output: IntoResponse,
// ...
// and so on...
综上所述,从用户的角度来看,“端点”是任何异步函数,其中每个参数类型实现Extractor,返回类型实现IntoResponse。这就是我们如何在不使用宏或代码生成的情况下,提供与Rocket中类似的端点体验(其设置类似)。
Extraction (提取)
提取器的工作原理与许多其他Rust 框架相似。它们是异步函数,从应用程序状态、配置和请求中提取数据:
pub trait Extractor<AppData>: Sized {
type Error: IntoResponse;
type Fut: Future<Output = Result<Self, Self::Error>> + Send + 'static;
fn extract(state: AppData, config: &Config, req: &mut Request) -> Self::Fut;
}
注意,提取器可能会因为错误而失败。不像在某些框架中,这会导致重路由,这个API的工作方式更像Actix-Web:错误本身必须直接转换为响应,并且不执行进一步的路由。与Actix-Web一样,您可以使用Config对象定制提取器的参数,包括它产生的错误类型。
就像Actix-Web和其他框架一样,我们可以提供一组预构建的提取器:
// These all implement `Extractor`:
pub struct Json<T>(pub T);
pub struct AppState<T>(pub T);
pub struct Path<T>(pub T);
pub struct Glob<T>(pub T);
pub struct Query<T>(pub T);
与Actix-Web和Gotham不同,如果想在URL中提取多个{}匹配项,可以使用多个路径参数,而不是在路径中使用元组。
Serialization (序列化)
最后,端点必须返回可转换为响应的数据:
pub trait IntoResponse: Sized {
type Body: BufStream + Send + 'static;
fn into_response(self) -> http::Response<Self::Body>;
}
这里的主体类型允许流化响应主体。否则,该设置将与Rocket工作相同。
What’s next?
一般来说,这个API似乎采用了现有Rust框架的一些最吸引人的方面,并以一种相当精简的方式对它们进行了整合。虽然这篇文章省略了很多细节,但希望它能提供足够的概略,引发有益的讨论。我渴望得到关于这里所列出的任何方面的反馈。
如果反馈通常是积极的,下面有几个步骤可以并行进行:
-
虽然我已经对这些api的类型系统方面进行了原型化,但是还没有一个可用的实现。如果我们想朝这个方向前进,我很乐意与大家合作让它运作起来!
-
除了这里列出的api之外,我还考虑了中间件和配置api。所以,再一次假设我们想朝这个方向前进,下一篇文章会详细说明这些想法。
期待听到你的想法!
译文结束。
网友评论