🍣

Actix Web でのスコープ切り分けに困った

2024/01/01に公開

動機

Actix-WebにはCookieの管理方式として、Cookie(つまりメモリ内?) or Redis で管理する方式が用意されているが、.envの値に応じて切り分けしたかった。

下部に 全コード記載

困りポイント

  • Cookieセッションの場合は素直に actix_session:: storage::CookieSessionStore を使えば良い
  • Redisセッションの場合で素直に actix_session:: storage::RedisSessionStore を使おうとすると以下のハマりポイントに遭遇する
    1. Redisへの接続の確立は最初の1回だけにしたいものの、接続確立はasync関数である
    2. async関数は App::new().configure(ここ) の引数に取れない
    3. よって別途宣言しておく必要がある

CookieとRedisとで場合分けをすると同じコードを2回書く必要があり、テストコードにおいてもこれは同じ → 避けたい

詳細

main関数

main関数
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    std::env::set_var("RUST_LOG", "actix_web=info");
    env_logger::init();
    HttpServer::new(app!()).bind(("0.0.0.0", 3000))?.run().await
}

以下の理由でapp本体はマクロにしている

  • 主にテストコードから何度も呼ぶ
  • Redisの場合にスコープ外(?)に別途宣言が必要
    何かこのあたりの理屈はよく分かってないが、HttpServer::new(app!())で宣言してもHttpServerの外で宣言したような動きをしてくれる

マクロ部分

macro部分
#[macro_export]
macro_rules! app {
    () => {{
        let store: Option<actix_session::storage::RedisSessionStore> =
            actix_session::storage::RedisSessionStore::new(&*$crate::SESSION_HOST)
                .await
                .ok();
        move || {
            App::new().configure(|sc| {
                $crate::cfg(sc, store.clone());
            })
        }
    }};
}
  • グローバル定数はcrate::定数名で呼べるようにしてる (once_cellで)
  • SESSION_HOSTはredisの場合のみ存在する前提 (実際呼んでみて確かめてるので行儀が悪いかも)
  • HttpServerの外で宣言が必要なstoreをマクロにすることで、テストなどの呼び出し部分でRedis, Cookieの判定を行わずに済んだ

サービス定義部分

サービスというより、ルーティングか。

サービス定義部分
pub fn cfg(cfg: &mut web::ServiceConfig, store: Option<RedisSessionStore>) {
    let cors = crate::modules::cors::main();
    let pool = helpers::db::establish_connection();
    let tera = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap();

    // 1. 共通して書きたかった部分
    let scope = web::scope("")
        .app_data(web::Data::new(pool))
        .app_data(web::Data::new(tera))
        .service(
            web::scope("/api")
                .wrap(modules::middlewares::authenticator::Authenticator)
        )
        .service(routes::public::get)
        .wrap(modules::middlewares::parse_config::ParseConfig)
        .wrap(IdentityMiddleware::default())
        .wrap(cors);
	
    // 2. セッション方式を判定
    if &*crate::SESSION_TYPE == "redis" || store.is_none() {
        let scope = scope.wrap(
            SessionMiddleware::builder(
                store.expect("redis host not found"),
                crate::SESSION_KEY.clone(),
            )
            .cookie_same_site(cookie::SameSite::Strict)
            .build(),
        );
	// 3. ここからは冗長なコード
        let scope = scope
            .wrap(modules::middlewares::not_found_handler::new())
            .wrap(modules::middlewares::remove_trailing_slash::RemoveTrailingSlash)
            .wrap(modules::middlewares::access_log::Logging);
        cfg.service(scope);
    } else {
        let scope = scope.wrap(SessionMiddleware::new(
            CookieSessionStore::default(),
            crate::SESSION_KEY.clone(),
        ));
        // 3. ここからは冗長なコード
        let scope = scope
            .wrap(modules::middlewares::not_found_handler::new())
            .wrap(modules::middlewares::remove_trailing_slash::RemoveTrailingSlash)
            .wrap(modules::middlewares::access_log::Logging);
        cfg.service(scope);
    }
}
  • Actix-Webでは cfg.service(scope.service()).service() みたいにスコープを設定できるが、サービスを分けると毎回ミドルウェアをwrapする必要があるので、基底サービスを作ってそこに色々乗せている

actix_web::scope (やdieselのクエリビルダー)は追加していくたびに型が変化するので、抽象化が面倒だったりするのだが、scopeを単に宣言するだけでは型を明示しなくても許してくれるのでこういった実装になった
セッションをラップする段階で完全に型が変更されるので、3. ここからは冗長なコード 以降は共通化できない

(実のところこの実装でなぜ許されてるのか全く分かっていない 実装者しゅごい)

テストコードの方

これは認証を行ってトークンを返す部分をエミュレートした部分

テストコード
    /// Login and get token
    pub async fn auth(test_id: &String) -> (HeaderName, String) {
        let app = test::init_service(crate::app!()()).await; // ← これ!!

        let req = test::TestRequest::post()
            .insert_header(ContentType::json())
            .set_json(json!({
                "username": format!("TEST_USER:{test_id}"),
                "password": "qweqwe"
            }))
            .uri("/api/users/sign_in")
            .to_request();

        let resp = test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let mut session_id = String::new();
        for (k, v) in resp.headers() {
            if let Ok(inner) = v.to_str() {
                let key = k.as_str();
                if key == "set-cookie" {
                    session_id = inner.to_string();
                }
            }
        }
        let session_id = actix_web::cookie::Cookie::from_str(&session_id).unwrap();
        (
            actix_web::http::header::COOKIE,
            format!("{}={}", session_id.name(), session_id.value()),
        )
    }

全コード

関係ない部分も多いがActix-Webで何か作る際に参考になるかもしれないので、main.rsのみ全コードを書いておく (本当はutoipaのApiDocや各種エンドポイントもあるが省略してる)

main.rs
use crate::helpers::consts::*;
use actix_files::Files;
use actix_identity::IdentityMiddleware;
use actix_session::{
    storage::{CookieSessionStore, RedisSessionStore},
    SessionMiddleware,
};
use actix_web::*;
use config::Config;
use diesel_async::{
    pooled_connection::deadpool::Pool,
    pooled_connection::AsyncDieselConnectionManager as ConnectionManager, AsyncPgConnection as DB,
};
use dotenv::dotenv;
use tera::Tera;

extern crate diesel;

mod config;
#[macro_use]
mod helpers;
mod models;
mod modules;
mod routes;
mod schema;
mod tests;

pub type RawDB = diesel::pg::Pg;
pub type DbPool = Pool<DB>;
pub type DbConn = deadpool::managed::Object<ConnectionManager<DB>>;
pub type Result<T> = std::result::Result<T, crate::helpers::errors::Error>;

pub fn cfg(cfg: &mut web::ServiceConfig, store: Option<RedisSessionStore>) {
    let cors = crate::modules::cors::main();
    let pool = helpers::db::establish_connection();
    let tera = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap();

    let scope = web::scope("")
        .app_data(web::Data::new(pool))
        .app_data(web::Data::new(tera))
        .service(
            web::scope("/api")
                .wrap(modules::middlewares::authenticator::Authenticator)
        )
        .service(routes::public::get)
        .wrap(modules::middlewares::parse_config::ParseConfig)
        .wrap(IdentityMiddleware::default())
        .wrap(cors);

    if &*crate::SESSION_TYPE == "redis" || store.is_none() {
        let scope = scope.wrap(
            SessionMiddleware::builder(
                store.expect("redis host not found"),
                crate::SESSION_KEY.clone(),
            )
            .cookie_same_site(cookie::SameSite::Strict)
            .build(),
        );
        let scope = scope
            .wrap(modules::middlewares::not_found_handler::new())
            .wrap(modules::middlewares::remove_trailing_slash::RemoveTrailingSlash)
            .wrap(modules::middlewares::access_log::Logging);
        cfg.service(scope);
    } else {
        let scope = scope.wrap(SessionMiddleware::new(
            CookieSessionStore::default(),
            crate::SESSION_KEY.clone(),
        ));
        let scope = scope
            .wrap(modules::middlewares::not_found_handler::new())
            .wrap(modules::middlewares::remove_trailing_slash::RemoveTrailingSlash)
            .wrap(modules::middlewares::access_log::Logging);
        cfg.service(scope);
    }
}

#[macro_export]
macro_rules! app {
    () => {{
        let store: Option<actix_session::storage::RedisSessionStore> =
            actix_session::storage::RedisSessionStore::new(&*$crate::SESSION_HOST)
                .await
                .ok();
        move || {
            App::new().configure(|sc| {
                $crate::cfg(sc, store.clone());
            })
        }
    }};
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    std::env::set_var("RUST_LOG", "actix_web=info");
    env_logger::init();
    HttpServer::new(app!()).bind(("0.0.0.0", 3000))?.run().await
}

Discussion