Actix-web のハンドラメソッドをジェネリックにする
動機
テスト時にデータベースと接続する State
の部分を差し替え可能にしたい。
Rust で差し替え可能な実装を作る。ならばトレイトだ。
元の実装 [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(())
}
元の実装 [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
}
元の実装 [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
);
}
元の実装 [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)
}
適当な模式図
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 */ }
// ...
}
ハンドラをジェネリックにする
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
に宣言されているので、実装の中身に変化はない
ルーティングで困り始める
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>))
// ...
);
}
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
が登場する。
微妙。。。。
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 に渡す
ルータに型でなくファクトリメソッドを渡す
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 が必要
関数を引数に渡して、関数を返却する関数なので非常にわかりづらいがうまく型推論する方法が他に思いつかない。
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 に渡せば、テスト用のレポジトリが各ハンドラに渡る。