RustでRepositoryパターンを実装してみた備忘録
超絶怒涛のジュニアエンジニアです。勉強のログとして残しています。
そもそもなぜRepositoryパターンを勉強しようと思ったのか
未経験でエンジニアになった自分は今まで超絶簡単なことしかやってきていなかった
- 簡単なCRUD程度のお遊びアプリケーションしか作ったことない
- しかもLaravelを脳死で使っていたので、Controllerが肥大化してもお構いなし
そんなくそみたいな平穏?の日々は一瞬で崩れ去っていった
- Rustを用いたProjectへの参画が決定
- とりあえずコードを書いてみるが、今まで通り脳死でコーディングしていたら一瞬で負債が溜まってしまった
- 可読性・変更可用性・保守性全てが0のクソコードの出来上がり!!
- しかもテストが落ちまくってる
- 単体テストなのにDBのデータによってテスト結果が変わる→偽陽性が多すぎて開発どころじゃない
流石にやばいと思い、Clean Architectureやデザインパターン等のインプットを行った
自分のRepositoryパターンの解釈
ざっくり言うと、「エンティティがどのように保存されているのかをロジックが知る必要のない状態」を作るのがRepositoryパターンなのかなと考えています。
クソコード例
適当に作ったのでSyntax Errorや不適切なerror handlingがある部分は多めにみてください
pub struct UserUseCase {...}
impl UserUseCase {
// ユーザーを友達追加する
pub async fn get_friends(user:UserId, target_user:UserId)->Result<(),Error>{
let mut client = pool.get().await.unwrap();
// ユーザーIdが正当かどうかを判断する
client.query_one("SELECT id FROM users WHERE id = $1", &[&user]).await.map_err(|_|...)?;
// ユーザーがブロックされていないか判断する
let blocked_user = client.query("SELECT id FROM users WHERE id = $1", &[&target_user]).await.map_err(|_|...)?.map(|row|row.get(0).unwrap());
if matches!(user,blocked_user) {
return Err(...)
}
// 友達の数が上限を超えていないかチェックする
let current_friends_count = client.query_one(...).await.map_err(|_|...)?;
if current_friends_count >= MAX_FRIEND_COUNT{
return Err(...)
}
// DBに登録
client.execute(...).await.map_err(...)?;
Ok(())
}
}
このようなコードだと以下のような問題が発生する
問題点1️⃣ 変更に対して弱い
- 「なんかpostgresじゃなくてmysqlに変えたくね?」
- 「もはやNoSQLに変えたくなね?」
- 「Entityは変わらないけど、ちょっとテーブル構成変えたいね」
みたいな鶴の一声が発された時、ロジックに関係ないのにメソッドの方まで修正しなくてはいけなくなる
ここまで極端なことは少ないだろうが、ソフトウェアを開発しているのに変更可用性が低いのは致命的
問題点2️⃣ テストがしにくい
- データアクセスがロジックに内包されてしまっていると、データベースの状態も含めてテストしなければいけない
- テストの粒度が大きくなり、問題特定が遅くなる→テストが想定通りの価値を産まず、無駄なコストを使ってしまうことになる
改善案
「データアクセス」と「ロジック」の間に抽象を挟むように設計を変える。
#[async_trait]
pub trait UserRepositoryTrait {
async fn is_exist(&self,user:UserId)->Result<bool, Error>;
async fn find_block_users(&self,user:UserId)->Result<Vec<UserId>, Error>;
async fn get_current_frined_count(&self,user:UserId)->Result<i64, Error>;
...
}
pub struct UserRepository{...};
#[async_trait]
impl UserReopsitoryTrait for UserRepository{
async fn is_exist(&self,user:UserId)->Result<bool, Error>{
let mut client = pool.get().await.unwrap();
client.query_one("SELECT id FROM users WHERE id = $1", &[&user]).await.map_err(|_|...)
}
async fn find_block_users(&self,user:UserId)->Result<Vec<UserId>, Error>{
client.query("SELECT id FROM users WHERE id = $1", &[&target_user]).await.map_err(|_|...)?.map(|row|row.get(0).unwrap())
}
async fn get_current_frined_count(user:UserId)->Result<i64, Error>{
...
}
...
}
pub struct UserUseCase {
user_repository: Arc<dyn UserRepositoryTrait + Send + Sync>,
}
impl UserUseCase {
// ユーザーを友達追加する
pub async fn get_friends(&self,user:UserId, target_user:UserId)->Result<(),Error>{
// ユーザーIdが正当かどうかを判断する
self.user_repository.is_exist(user).await?;
let blocked_user = self.find_blocked_user(target).await?;
// ユーザーがブロックされていないか判断する
if matches!(user,blocked_user) {
return Err(...)
}
// 友達の数が上限を超えていないかチェックする
let current_friends_count = self.get_current_frined_count(user);
if current_friends_count >= MAX_FRIEND_COUNT{
return Err(...)
}
// DBに登録
user_repository.get_friend(user, target).await
}
}
これによって改善前の問題は以下のように改善される
「問題点1️⃣ 変更に対して弱い」の改善
- もしデータ保存の方法が変わっても、Repositoryのみ変更が加わり、ロジック部分のコードが変更されることはない
- そのため、影響箇所が少ないので速やかに変更できるようになる
「問題点2️⃣ テストがしにくい」の改善
- データアクセスのモックを使ってUserUseCase構造体をインスタンス化すれば、データベースに依存しないテストができる
テスト例(automockはasyncに対応していないので、冗長ではあるがこんな書き方になっている)
// preparing mock
#[automock]
pub trait UserMockValue {
fn is_exist(&self)->Result<bool, Error>;
fn find_block_users(&self)->Result<Vec<UserId>, Error>;
fn get_current_frined_count(&self)->Result<i64, Error>;
...
}
pub struct MockUserRespository{
pub inner: UserMockValue,
}
#[async_trait]
impl UserRepositoryTrait for MockUserRepository {
async fn is_exist(&self,user:UserId)->Result<bool, Error>{self.inner.is_exist()}
async fn find_block_users(&self,user:UserId)->Result<Vec<UserId>, Error>{self.inner.find_block_users()}
async fn get_current_frined_count(&self,user:UserId)->Result<i64, Error>{self.get_current_friend_count()}
}
// test
#[tokio::test]
async fn test_blocked_user_occurs_err() {
let mut value = UserMockValue::new();
// ブロックされているユーザーの値をモック
value.expect_is_exist().returning(||Ok(true));
value.expect_find_block_users().returning(|| vec![User,....] );
let uc = CarPoolUseCase {
cr: Arc::new(MockUserRepository { inner: valud }),
};
let result = uc.get_friends(...);
// ブロックされたいた時にエラーを返すのかを確かめる
assert(result.is_err())
}
学んだことまとめ
- Repositoryパターンはデータアクセスを抽象化するパターン
- Rustで実現する際にはtraitを使って実装することができる
- Repositoryパターンを用いることで、保守性や変更可用性が改善される