准备项目
在工作空间的 Cargo.toml 中添加行 ch01/nested-routing
。添加后,内容如下所示:
[workspace]
members = [
"ch01/helloworld",
"ch01/error-handling",
"ch01/state",
"ch01/nested-routing"
]
创建对应的项目之后,按照之前的方式编辑好好项目的 Cargo.toml 和 main.rs 文件内容。
添加日志中间件
这次的嵌套路由,代码会比较复杂,所以先从简单的入手。那么最容易的首先是添加 Logger
中间件。
在 Cargo.toml 中,添加对 env_logger
的依赖:
[dependencies]
actix-web = "4.2.1"
env_logger = "0.9"
然后在主函数中初始化 env_logger
,同时添加 Logger
中间件。
use actix_web::{HttpServer, App, middleware};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_server=info,actix_web=info");
env_logger::init();
HttpServer::new(|| {
App::new()
.wrap(middleware::Logger::default())
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
如果此时尝试运行项目,可以看到控制台上打印出来的日志比之前多了。而多出来的日志的标记为
actix_server
,由此可以猜测在std::env::set_var()
的 value 中填入的actix_server=info
项,表示将actix_server
模块的日志打印出来。除了actix_server
和actix_web
外,暂时不太清楚还有什么模块的日志可以设置并将日志打印出来。
看向官方源码的项目结构,可以复刻一下其项目结构。在 src 下创建子目录 bin,并将 main.rs 移动到 bin 目录下。
添加样例结构体
在 src 目录下创建两个文件,分别为 lib.rs 和 common.rs。
lib.rs 中的内容为,将 common 模块添加到项目中。
pub mod common;
看向官方源码,由于需要给之后新建的结构体标记序列化,所以需要在 Cargo.toml 中添加 serde
的依赖。同时,还添加了 serde
的 derive
功能。
serde = { version = "^1.0", features = ["derive"] }
这里的版本号前多了一个
^
字符,我也是很困惑。在 rust 的 The Cargo Book中有说明,看大致意思应该是和1.0
差不多。
官方源码中,在 common.rs 里声明了两个结构体,分别是 Product
和 Part
。结构体中的内容除了字段名没什么区别。并且都使用 serde
中的宏,标记两个结构体都是可序列化和反序列化的。
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct Product {
id: Option<i64>,
product_type: Option<String>,
name: Option<String>,
}
#[derive(Deserialize, Serialize)]
pub struct Part {
id: Option<i64>,
part_type: Option<String>,
name: Option<String>,
}
添加配置函数
回到主函数,看到在 App::new()
之后,执行了 config
函数。config
函数的传入参数为,参数为 &mut ServiceConfig
的函数句柄。那么按照源码的方式,在 src 下创建文件 app_config.rs,并把 app_config
模块添加到项目中。
(lib.rs)
pub mod common;
pub mod app_config;
在 app_config.rs 中添加如下代码(其他的操作由于没有实现,所以暂时只添加路径)。
use actix_web::web;
pub fn config_app(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/products")
.service(web::resource(""))
.service(
web::scope("/{product_id}")
.service(web::resource(""))
.service(
web::scope("/parts")
.service(web::resource(""))
.service(web::resource("/{part_id}"))
)
)
);
}
简单的分析一下。在上面的代码中,首先以 /products
路径创建了一个域。这个域是什么呢?通过官方主页的教程中的描述看,域是用来更好的组织路由的,并且可以让域内的路由共享一个根目录。同时可以通过域,来实现嵌套路由。
根据我个人的理解,相当于将 /xx/yy/zz
拆解成 xx
域,xx
域下的 yy
域,yy
域下的 zz
域。可以让路径的配置更加清晰,通过分层级的域,来更好的控制路由的路径。
那么配置嵌套路由的时候,/{part_id}
和 /{products_id}
这两个路径是什么呢?这是匹配路径或者说可变路径,在程序中可以通过调用特定函数来获取到匹配路径的值。具体可以看向官方的说明。
上面的代码编写好后,配置的嵌套路径结构大致如下所示。
然后回到 main.rs,将编写好的配置函数添加到 App
的配置中:
use actix_web::{HttpServer, App, middleware};
// add use mod
use nested_routing::app_config::config_app;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_server=info,actix_web=info");
env_logger::init();
HttpServer::new(|| {
App::new()
// add function
.configure(config_app)
.wrap(middleware::Logger::default())
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
添加路由
在 src 下添加子目录 handlers,并创建文件 mod.rs、parts.rs和products.rs。在 mod.rs 中,将 parts
和 products
模块添加进去。
pub mod parts;
pub mod products;
在 lib.rs 中,将 handlers
模块添加到项目中。
pub mod common;
pub mod app_config;
// add here
pub mod handlers;
通过上面配置路径的时候,可以发现 Products
的层级比 Parts
高,所以先来试着编写 Products.rs 中的内容。
use actix_web::{web, HttpResponse, Error};
use crate::common::{Part, Product};
pub async fn get_products(_query: web::Query<Option<Part>>) -> Result<HttpResponse, Error> {
Ok(HttpResponse::Ok().finish())
}
pub async fn add_product(_new_product: web::Json<Product>) -> Result<HttpResponse, Error> {
Ok(HttpResponse::Ok().finish())
}
pub async fn get_product_detail(_id: web::Path<String>) -> Result<HttpResponse, Error> {
Ok(HttpResponse::Ok().finish())
}
pub async fn remove_product(_id: web::Path<String>) -> Result<HttpResponse, Error> {
Ok(HttpResponse::Ok().finish())
}
看官方源码,发现 parts.rs 里的代码和 products.rs 中的几乎一直,只是将函数名中的 product
换成了 part
。目前传入这些函数的参数,并不了解其作用,但是可以发现 web::Query
、web::Json
、web::Path
都是提取器。
在官方网站上对提取器有介绍。这些提取器都实现了 FromRequest
,所以可以作为请求路由句柄的参数。
编写好 parts.rs 和 products.rs 后,回到 app_config.rs,为 /products
域的根目录配置了两个路由,get_products
对应 get 方法,add_product
对应 post 方法。
use actix_web::web;
use crate::handlers::products;
pub fn config_app(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/products")
.service(
web::resource("")
// add here
.route(web::get().to(products::get_products))
.route(web::post().to(products::add_product))
)
.service(...)
);
}
然后为 /products/{product_id}/
添加路由,对应 get 和 delete 指令。
pub fn config_app(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/products")
.service(..)
.service(
web::scope("/{product_id}")
.service(
web::resource("")
.route(web::get().to(products::get_product_detail))
.route(web::delete().to(products::remove_product))
)
.service(..)
)
);
}
对于 /products/{product_id}/parts/
,添加的路由对应指令为 get 和 post。
pub fn config_app(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/products")
.service(..)
.service(
web::scope("/{product_id}")
.service(..)
.service(
web::scope("/parts")
.service(
web::resource("")
.route(web::get().to(parts::get_parts))
.route(web::post().to(parts::add_part))
)
.service(web::resource("/{part_id}"))
)
)
);
}
对于 /products/{product_id}/parts/{part_id}
,添加的路由对应指令为 get 和 delete。
pub fn config_app(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/products")
.service(..)
.service(
web::scope("/{product_id}")
.service(..)
.service(
web::scope("/parts")
.service(..)
.service(
web::resource("/{part_id}")
.route(web::get().to(parts::get_part_detail))
.route(web::delete().to(parts::remove_part))
)
)
)
);
}
至此,路由都添加完毕了。
学到的东西
应用的配置,可以调用 App.configure
并传入对应句柄来操作。句柄对应函数的传入参数应该为 &mut web::ServiceConfig
,这样就可以将配置应用的内容放到其他地方。
通过 web::scope()
可以配置域,而域是可以包含域的,从而可以实现嵌套路由。如果要想让域包含域,需要给 web::scope().service()
传入下一个 web::scope()
。同时 web::scope().service()
中也可以传入 web::resource()
来结束嵌套,路由则可以通过 web::resource().route()
配置。而且可以链式调用来为不同的 Http 指令配置路由。
另外是在编写路由的函数的时候,传入参数类型和返回值和之前有一点点区别,出现了之前没有见过的类型,即提取器。每一个提取器都有其对应的作用。同时也要注意到返回值 Err<T>
的泛型,填入的是 actix_web::Error
,这是因为返回的错误类型需要实现 ResponseError
。
注意到这一次编写完代码后,没有进行运行测试。
额外补充(可跳过)
看向官方源码中的 products.rs 文件,发现有一个测试模块。这一次不再次照搬编写了,直接复制到自己项目的 products.rs 中。
#[cfg(test)]
mod tests {
use actix_web::{
dev::Service,
http::{header, StatusCode},
test, App,
};
use crate::app_config::config_app;
#[actix_web::test]
async fn test_add_product() {
let app = test::init_service(App::new().configure(config_app)).await;
let payload = r#"{"id":12345,"product_type":"fancy","name":"test"}"#.as_bytes();
let req = test::TestRequest::post()
.uri("/products")
.insert_header((header::CONTENT_TYPE, "application/json"))
.set_payload(payload)
.to_request();
let resp = app.call(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}
测试模块首先是在函数句柄上,声明了宏 #[actix_web::test]
,表明这个函数用于测试。actix-web 中也有提供用于测试的内容。这个 test::init_service
是手动初始化了应用服务。同时也是通过之前编写的配置函数,对应用进行了相应的配置。
之后通过 actix_web::test
模块,生成了一个 post 请求,内容格式为 json 数据,传输的内容对应着之前创建的 Product
结构体。然后通过 app.call()
将请求发送给应用,并获取回应。最后没有判断回应的内容,只是判断了一下回应的状态代码,因为之前配置路由时,返回的都是空内容但是状态码为 OK
的回应。