rustで作るテストしやすいactix-webアプリケーション
業務でまだrustでWebアプリケーションを運用してみたことはないが、そこそこの規模のものを作るとするとどんな感じだろう、ということでisucon11の本戦の問題を題材に素振りをしてみた。
いくつかの記事をみながら真似しながらやってみたりしてみたが、実際にテストを書いてみようとするとcompileを通せなくなったりライフサイクルがでてきて難しくなったが試行錯誤してみた感じこの構成ならそこそこうまくいくんじゃないかなーという感じにできた。
isuconというパフォーマンスチューニングコンテストの初期実装を書き換えている関係であえて効率が悪いクエリなどを実行しているので見る上ではそういう面には注意してください。
crate構成
https://blog-dry.com/entry/2021/12/26/002649 を参考にレイヤーごとにcrateを分割している。
crateを分割することで、例えばhttp-core
のコード内ではinfra
のコードの呼び出しをすることができないといった制約を作ることができる。こうすることでactix-webを触る部分のコードではDBに依存しないでテストするようなことができる構成にできる。
binaryになるようなmain.rsを持つようなcrateは*-app
のような命名にする。
このcrate内でinfra内のAPIと紐づけるように構成する。
サービスを運用しているとバッチ処理などを開発することになることがあるが、そういう場合には例えば、batch-app
のようなcrateを切ればよいというイメージ。
ここの層のコードは初期化とinfra
層と紐づける以外にはなるべく薄く保つのが理想的。
http-core
ではactix-webのハンドラと入力、出力のデータ構造の型などを持つ。(つまりhttp関連の処理は基本的にここに書く)
http-core
ではcore
にあるservices関連のクラスを利用して処理を書いていく。詳しくは後述する。
infra
では、DBへ実際にアクセスする処理や、外部サービスへのHTTPリクエスト、オブジェクトストレージへの保存処理といったものを扱う。今回の題材ではオブジェクトストレージへの保存ではなく、ローカルにファイルを保存しているのでそれは置き換える想定で、infra-storage-file
といったcrateを切って実装してある。
http-app
から直接infra-storage-file
を使うようにしていないのは例えば、ローカルのファイル実装になっているものをS3といったオブジェクトストレージを使う実装に移行するようなケースで、infra-storage-s3
といったものを実装したときに、infra-storage-file
とinfra-storage-s3
の並行運用をして全ファイルが置き換わったらinfra-storage-file
を用済みにするという感じになるんじゃないかなーと想定している。
ただレイヤをあまりたくさん必要以上に実装するとボイラープレートの実装も多くなってしまうので、基本的にはinfra
内に実装していくでよいんじゃないかと思っている。
core
ではドメインモデルなどの処理を書く。 sqlxなどには依存しない形で実現するのが理想的ではあるが、色々やってみた限りでは依存させないように作りつつテストできるようにするのは難しかった。
DBレイヤの実装とテスト
今回のアプリケーションではO/R Mapperは使わずsqlx を利用して生SQLで書いている。
DBに関する処理はXXXRepositoryといった命名にしてそこに処理を書いていく。
1クエリに対して1メソッド用意していき、あるテーブルに関しての処理はあるRepositoryにまとまっていく感じにする。
一部抜粋すると、core層でのリポジトリの定義は以下のような感じ になる。
#[cfg_attr(any(test, feature = "test"), mockall::automock)]
#[async_trait]
pub trait AnnouncementRepository {
...
async fn find_by_id(&self, conn: &mut sqlx.MysqlConnection, id: &AnnouncementID) -> Result<Announcement>;
}
pub trait HaveAnnouncementRepository {
type Repo: Sync + AnnouncementRepository;
fn announcement_repo(&self) -> &Self::Repo;
}
また、infra側の実装では次のようになる。
#[derive(Clone)]
pub struct AnnouncementRepositoryInfra {}
#[async_trait]
impl AnnouncementRepository for AnnouncementRepositoryInfra {
...
async fn find_by_id(&self, conn: &mut sqlx.MysqlConneciton, id: &AnnouncementID) -> Result<Announcement> {
let announcement = sqlx::query_as!(
Announcement,
r"
SELECT
id as `id:AnnouncementID`,
course_id as `course_id:CourseID`,
title,
message
FROM `announcements` WHERE `id` = ?
",
id
)
.fetch_one(conn)
.await?;
Ok(announcement)
}
}
1SQLにたいして1関数用意することで1クエリずつテストできるような構成にできる。
sqlxのマクロを利用することでコンパイル時にクエリがエラーになるかのチェックをすることはできるが実際にDBにデータを入れて実行できるようにしておくと安心感がある。
sqlx.XXXConnection
をRepositoryのフィールドにした方がcoreでDBに依存した部分が表出しないのでよさそうに思えるが、トランザクションなどの処理を考えたときにフィールドで扱うのは難しいといった問題があって結局引数になるので引数として渡す形にした。
sqlx.XXXPool
を引数にしていないのはテスト時でトランザクションを活用してデータの初期化、ロックなどを解決するためである。
これを利用したテストは例えば次のように書く。
#[tokio::test]
async fn success() {
let db_pool = get_test_db_conn().await.unwrap();
let mut tx = db_pool.begin().await.unwrap();
sqlx::query!("SET foreign_key_checks=0")
.execute(&mut tx)
.await
.unwrap();
let announcement: Announcement = Faker.fake();
sqlx::query!(
"INSERT INTO announcements (id, course_id, title, message) VALUES (?,?,?,?)",
&announcement.id,
&announcement.course_id,
&announcement.title,
&announcement.message,
)
.execute(&mut tx)
.await
.unwrap();
let repo = AnnouncementRepositoryInfra {};
let result = repo.find_by_id(&mut tx, &announcement.id).await.unwrap();
assert_eq!(result, announcement);
}
トランザクションを貼ってテストを実行し、commitを実行しないことでロールバックすることでデータを消して他のテストの結果に影響を与えなくすることができる。注意が必要なのは、テストに利用するIDなどでこれがテスト間で被っていたりすると並列に実行したときにデッドロックを起こしてテストが稀に失敗してしまうので、明示的に指定したい値以外はfake を用いてランダム生成してしまうのがよさそうだった。
サービス層の実装
トランザクションについてはサービス部分で処理する形になる。
coreでは次のような実装になる。
#[cfg_attr(any(test, feature = "test"), mockall::automock)]
#[async_trait]
pub trait UserService: Sync {
async fn find_by_code(&self, code: &UserCode) -> Result<Option<User>>;
...
}
pub trait HaveUserService {
type Service: UserService;
fn user_service(&self) -> &Self::Service;
}
#[async_trait]
pub trait UserServiceImpl: Sync + HaveDBPool + HaveUserRepository {
async fn find_by_code(&self, code: &UserCode) -> Result<Option<User>> {
let pool = self.get_db_pool();
let mut conn = pool.acquire().await?;
let result = self.user_repo().find_by_code(&mut conn, &code).await?;
Ok(result)
}
}
#[async_trait]
impl<S: UserServiceImpl> UserService for S {
async fn find_by_code(&self, code: &UserCode) -> Result<Option<User>> {
UserServiceImpl::find_by_code(self, code).await
}
}
http-core
などサービスを利用するところでは、UserService
といったインターフェースtraitに依存させる。infra
レイヤなどに関しては、impl traitを実装させる。
最後にあるgeneriticsの実装により両者を組み合わせて利用することができる。
インターフェースtraitとimpl traitを分けておくのはテストを実装する上でも重要なポイントで、こうすることで、UserServiceの定義は複雑にならず、mockallでモックを作っても複雑になりすぎてクラッシュしてしまうといったことを避けることができる。
infraでは次のように実装をする。
#[derive(Clone)]
pub struct UserServiceInfra {
db_pool: Arc<DBPool>,
user_repo: UserRepositoryInfra,
}
impl UserServiceInfra {
pub fn new(db_pool: Arc<DBPool>) -> Self {
Self {
db_pool,
user_repo: UserRepositoryInfra {},
}
}
}
impl UserServiceImpl for UserServiceInfra {}
impl HaveDBPool for UserServiceInfra {
fn get_db_pool(&self) -> &DBPool {
&self.db_pool
}
}
impl HaveUserRepository for UserServiceInfra {
type Repo = UserRepositoryInfra;
fn user_repo(&self) -> &Self::Repo {
&self.user_repo
}
}
core層のテストについては適当にHaveDBpool/HaveUserRepositoryを持ったクラスを実装することでmockallを利用してテストを書くことができる。
actix-webハンドラの実装
こうして作ったServiceを利用してhttpの処理は次のように書くことができる。
pub async fn add_announcement<Service: HaveAnnouncementService>(
service: web::Data<Service>,
req: web::Json<AddAnnouncementRequest>,
) -> ResponseResult<HttpResponse> {
let announcement = Announcement {
id: req.id.clone(),
course_id: req.course_id.clone(),
title: req.title.clone(),
message: req.message.clone(),
};
let result = service.announcement_service().create(&announcement).await;
return match result {
Ok(_) => Ok(HttpResponse::Created().finish()),
Err(e) => match e {
Error::AnnouncementDuplicate => Err(AnnouncementConflict),
Error::CourseNotFound => Err(CourseNotFound),
_ => Err(e.into()),
},
};
}
geneticsとして定義することでcoreのみに依存した形で記述することができる。
ルーティング部分では次のように登録する
pub fn get_announcement_routes<Service: ServiceManager + 'static>() -> Scope {
web::scope("/announcements")
.route("", web::get().to(get_announcement_list::<Service>))
.service(
web::resource("")
.guard(actix_web::guard::Post())
.to(add_announcement::<Service>),
)
}
http-app
では次のようにして呼びだす。
let announcements_api = get_announcement_routes::<ServiceManagerInfra>();
actix_web::App::new()
.app_data(web::Data::new(service))
.service(
web::scope("/api")
.service(announcements_api),
)
これを次のようにactix-webのunit testでテストすることができる。
#[actix_web::test]
async fn success_case() {
let mut service = MockServiceManager::new();
service
.announcement_service
.expect_create()
.returning(|_| Ok(()));
let _req = TestRequest::with_uri("/announcements").to_http_request();
let result = add_announcement(
Data::new(service),
Json(AddAnnouncementRequest {
id: AnnouncementID::new("".to_string()),
course_id: CourseID::new("".to_string()),
title: "".to_string(),
message: "".to_string(),
}),
)
.await
.unwrap();
assert_eq!(result.status(), StatusCode::CREATED);
}
全ての箇所にテストが必要かというとrustの場合わりとコンパイルでケアレスミスはチェックできるのでがりがり全部に書く必要はないのかなと思いつつも、運用上バグなどを作ってしまったときにテストが書けるようになっていると安心度が高いのではないのかなと思います。
最後に
実際のリポジトリではもっとたくさんの処理/テストが実装してあるので(まだ未実装のものもありますが)、参考になれば幸いです。
Discussion