1.4 嵌套路由

2022-10-17 • 更新于 2022-10-20

准备项目

在工作空间的 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_serveractix_web 外,暂时不太清楚还有什么模块的日志可以设置并将日志打印出来。

看向官方源码的项目结构,可以复刻一下其项目结构。在 src 下创建子目录 bin,并将 main.rs 移动到 bin 目录下。

添加样例结构体

在 src 目录下创建两个文件,分别为 lib.rs 和 common.rs。

lib.rs 中的内容为,将 common 模块添加到项目中。

pub mod common;

看向官方源码,由于需要给之后新建的结构体标记序列化,所以需要在 Cargo.toml 中添加 serde 的依赖。同时,还添加了 serdederive 功能。

serde = { version = "^1.0", features = ["derive"] }

这里的版本号前多了一个 ^ 字符,我也是很困惑。在 rust 的 The Cargo Book中有说明,看大致意思应该是和 1.0 差不多。

官方源码中,在 common.rs 里声明了两个结构体,分别是 ProductPart。结构体中的内容除了字段名没什么区别。并且都使用 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 中,将 partsproducts 模块添加进去。

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::Queryweb::Jsonweb::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 的回应。

actix-web学习笔记Rustactix-web学习笔记

学少何

不求上进的社畜……

1.5 静态文件

1.3 状态