Open13

Actix-web のハンドラメソッドをジェネリックにする

koko_ukoko_u

動機

テスト時にデータベースと接続する State の部分を差し替え可能にしたい。

Rust で差し替え可能な実装を作る。ならばトレイトだ。

koko_ukoko_u

元の実装 [main]

関係するところだけ切り出すと、こう。

#[actix_web::main]
async fn main() -> AppResult<()> {

    // create state
    let app_data = web::Data::new(AppState::new()?);

    HttpServer::new(move || {
        App::new()
            .app_data(app_data.clone()) // pass state
            .configure(tasks) // configure routing
    })
    .bind(addrs)
    .change_context(AppError)?
    .run()
    .await
    .change_context(AppError)?;

    Ok(())
}
koko_ukoko_u

元の実装 [state]

pub struct AppState {
    pub db: db::DbState,
}

impl AppState {
    pub fn new() -> AppResult<Self> {
        let database_url = dotenv::var("DATABASE_URL").change_context(AppError)?;
        let db = db::DbState::init(&database_url)?;

        Ok(Self { db })
    }
}
#[derive(Debug)]
pub struct DbState(PgPool);

impl DbState {
    /// setup database
    pub fn init(database_url: &str) -> AppResult<Self> { /* snip */ }
    /// find tasks
    pub async fn get_filtered_tasks(&self, filter: &TaskFilter) -> AppResult<Vec<TaskModel>> { /* snip */}
    // ... other methods
}
koko_ukoko_u

元の実装 [route]

pub fn tasks(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/tasks")
            .route("", web::get().to(task_list_handler))
            .route("", web::post().to(create_task_handler))
            .route("/new", web::get().to(new_task_form_handler))
            // ... other routing
    );
}
koko_ukoko_u

元の実装 [handler]

// GET /tasks
pub async fn task_list_handler(
    app_data: web::Data<AppState>,
    query: web::Query<TaskFilter>,
) -> Result<impl Responder, AppResponseError> {
    let task_filter = query.into_inner();

    let tasks = app_data.db.get_filtered_tasks(&task_filter).await?;
   
    let task_list = TaskList {
        task_filter,
        tasks: tasks.into_iter().map(From::from).collect(),
    };

    Ok(task_list)
}
koko_ukoko_u

AppState が保持する DB をトレイトに差し替える

async-trait を使えば、ややこしいことを気にせず、DbState 構造体が「リポジトリ」としてふるまう何かにできる

#[async_trait]
pub trait DbRepository {
    /// get task using filter conditions
    async fn get_filtered_tasks(&self, filter: &TaskFilter) -> AppResult<Vec<TaskModel>>;

    /// get task by id
    async fn get_task_by_id(&self, id: i64) -> AppResult<Option<TaskModel>>;

    // ...
#[async_trait]
impl DbRepository for DbState {
    async fn get_filtered_tasks(&self, filter: &TaskFilter) -> AppResult<Vec<TaskModel>> { /* snip */ }
    async fn get_task_by_id(&self, id: i64) -> AppResult<Option<TaskModel>> { /* snip */ }

    // ...
}
koko_ukoko_u

ハンドラをジェネリックにする

AppState を レポジトリ的なナニカを保持する状態管理の構造体にして

pub struct AppState<Repo> {
    pub repo: Repo,
}

ハンドラメソッドも、レポジトリ的なナニカを保持するステートを受け取るとする

// GET /tasks
pub async fn task_list_handler<Repo>(
    app_data: web::Data<AppState<Repo>>,
    query: web::Query<TaskFilter>,
) -> Result<impl Responder, AppResponseError> 
where
    Repo: DbRepository
{
    let task_filter = query.into_inner();

    let tasks = app_data.db.get_filtered_tasks(&task_filter).await?;
   
    let task_list = TaskList {
        task_filter,
        tasks: tasks.into_iter().map(From::from).collect(),
    };

    Ok(task_list)
}

必要なメソッドは DbRepository に宣言されているので、実装の中身に変化はない

koko_ukoko_u

ルーティングで困り始める

pub fn tasks(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/tasks")
            .route("", web::get().to(task_list_handler))
            .route("", web::post().to(create_task_handler))
            .route("/new", web::get().to(new_task_form_handler))
            // ...
    );
}

ルーティングの設定がこのようになっていて、web::get().to() にハンドラを渡すのでここもジェネリックにしなければならん。とりあえずこう。

pub fn tasks<Repo>(cfg: &mut web::ServiceConfig)
where
    Repo: DbRepository
{
    cfg.service(
        web::scope("/tasks")
            .route("", web::get().to(task_list_handler::<Repo>))
            .route("", web::post().to(create_task_handler::<Repo>))
            .route("/new", web::get().to(new_task_form_handler::<Repo>))
            // ...
    );
}
koko_ukoko_u

main で微妙な気持ちになる

tasks がジェネリックなメソッドになったので、レポジトリの具体的な型をわたさなければならない。

#[actix_web::main]
async fn main() -> AppResult<()> {

    // create state
    let app_data = web::Data::new(AppState::new()?);

    HttpServer::new(move || {
        App::new()
            .app_data(app_data.clone())
            .configure(tasks::<DbState>)   // <- コレ
    })
    .bind(addrs)
    .change_context(AppError)?
    .run()
    .await
    .change_context(AppError)?;

    Ok(())
}

最初の実装では、main のいるバイナリクレートからは AppState だけが見えていて、その内部に DbState がいることは隠ぺいされていたのに、ここで突如 DbState が登場する。

微妙。。。。

koko_ukoko_u

AppState のファクトリメソッドを用意する

AppState を素朴に new するのをやめて、ファクトリメソッドを用意する

pub fn create_app_state() -> AppResult<AppState<impl DbRepository>> {
    let database_url = dotenv::var("DATABASE_URL").change_context(AppError)?;
    let db = db::DbState::init(&database_url)?;
    Ok(AppState { repo: db })
}

戻り値を impl Trait にすることで、返却される AppState の実際に保持しているレポジトリは隠したままにする。

テストの時には、同じシグネーチャで

fn create_test_state() -> AppResult<AppState<impl DbRepository>> { /* test implementation */ }

を用意して、これを actix_web::App に渡す

koko_ukoko_u

ルータに型でなくファクトリメソッドを渡す

pub fn tasks<Repo>(cfg: &mut web::ServiceConfig)
where
    Repo: DbRepository
{
    cfg.service(
        web::scope("/tasks")
            .route("", web::get().to(task_list_handler::<Repo>))
            .route("", web::post().to(create_task_handler::<Repo>))
            .route("/new", web::get().to(new_task_form_handler::<Repo>))
            // ...
    );
}

とすると、使うときにジェネリックパラメータを明示的にわたさなければならない。
これをやめて、create_app_state の返却する型から推論してもらうように変更する

pub fn tasks<F, Repo>(_: F) -> impl FnOnce(&mut web::ServiceConfig)
where
    F: FnOnce() -> AppResult<AppState<Repo>>,
    Repo: DbRepository + 'static,
{
    move |cfg: &mut web::ServiceConfig| {
        cfg.service(
            web::scope("/tasks")
                .route("", web::get().to(task_list_handler::<Repo>))
                .route("", web::post().to(create_task_handler::<Repo>))
                .route("/new", web::get().to(new_task_form_handler::<Repo>))
                // ... other routing
        );
    }
}
  • もともとの ルーティングの設定を行っていた関数を戻り値で返却する高階関数とする
  • 引数は create_app_state を渡して型が合致する関数とする
  • ジェネリックパラメータ Repo がクロージャに渡されるので、ライフタイム 'static が必要

関数を引数に渡して、関数を返却する関数なので非常にわかりづらいがうまく型推論する方法が他に思いつかない。

koko_ukoko_u

main からの呼び方

#[actix_web::main]
async fn main() -> AppResult<()> {

    // create state
    let app_state = create_app_state()?;  // <- factory method
    let app_data = web::Data::new(app_state);

    HttpServer::new(move || {
        App::new()
            .app_data(app_data.clone())
            .configure(tasks(create_app_state))   // <- pass factory method
    })
    .bind(addrs)
    .change_context(AppError)?
    .run()
    .await
    .change_context(AppError)?;

    Ok(())
}

メイン関数で tasks にファクトリメソッド create_app_state を渡すことで、その戻り値がこの場合は DbState を保持するステートであることがコンパイラにはわかる。

結果として、tasks の側で、ハンドラメソッドに渡すべき Repo の具体的な型がわかる。
テスト用のファクトリメソッドを作って tasks に渡せば、テスト用のレポジトリが各ハンドラに渡る。