Laravelでのロジック管理のしやすいコードのまとめ方
インターン生の望月です。
今回は、このようなまとめ方をすると(小規模なアプリケーションは)管理しやすいかなというメモ書きのようなものになります。
0. 概要
データベースにアクセスするModelの部分に用意するメソッドは、リレーションメソッドと対応するテーブルのレコードのみで処理できるビジネスロジックのメソッドだけにしましょう。
ロジックを凝集させるUseCaseのようなファイルのコードは、機能ごとに直列に並べましょう。
1. MVCモデルについて
今回はシンプルなMVCモデルの構成を基に説明します。
まずは、MVCモデルについてChatGPTに質問してみました。
MVCモデルでのデータ取得の流れ
Controllerで、ユーザーからの入力を基に、Model経由でデータベースからデータを取得し、計算したり、加工する工程を経て、必要なデータをViewに渡します。このままでは、ControllerやModelが肥大化しすぎるという問題があるので、複数のテーブルのレコードを必要とするビジネスロジックの部分を全てUseCaseという形で分離します。
※ModelからDBのデータを取得する流れについて
今回はいい機会だったので、Modelを呼び出してからどうやってDBのデータを取得してるのかを調べてみました。
Modelでのデータ取得の流れ
①ModelクラスがConnectionインスタンスを呼び出す
②ConnectionインスタンスとDB間の通信を確立する
③Query/Builderインスタンスを呼び出す
④Eloquent/BuilderがQuery/Builderでwrapする
⑤生成したクエリをConnectionインスタンスに渡す
⑥クエリをDBで実行
⑦クエリの結果をDBから取得
⑧取得した結果をEloqunent/Builderインスタンスに渡す
⑨Eloquent/Builderインスタンス内のメソッドを使って、ModelインスタンスかCollectionインスタンスを生成する(この生成されたインスタンスが普段取得してるデータ)
※⑨での生成されるインスタンスの違いについて
確実に一つのレコードのみしか取得しないことが分かっている場合(firstメソッド、単一キーを指定したfindメソッド)にはModelインスタンスが生成される。
複数のレコードを取得する可能性がある場合(getメソッド、複数キーを指定したfindメソッド、pluckメソッド)にはCollectionインスタンスが生成される。
※Builderインスタンスについて
このインスタンス内でSQLクエリを発行するが、いわゆる1クエリにつき一つのBuilderインスタンスが生成される。
このような処理を行なってデータを取得しているようです。(基本的に意識することのない部分ではありますが...)
特にBuilderインスタンスのところを知ることで、N+1問題がDBとLaravelの両者にとって良くないことがわかりますね。
2. ロジックについて
今回は、ControllerからModel、データベースまでの部分での構成に着目します。
基本的なMVCモデルでのControllerの役割としては、データアクセスロジック->ビジネスロジック->プレゼンテーションロジックの処理を行います。または、ビジネスロジックをModelで定義し、Controllerでメソッドを呼び出すという形をとっている場合もあります。これらのロジックの中で、特にビジネスロジックは肥大化しやすいです。
←全てControllerにまとめた場合のイメージ
ビジネスロジックをModelにまとめた場合のイメージ→
※データアクセスロジックやビジネスロジック、プレゼンテーションロジックについてもChatGPTにまとめてもらいました。
そこで、ControllerやModelが背負う役割を分散させます。
本記事では、先ほど、特に肥大化しやすいといったビジネスロジックを分解していきます。
3. ロジックの区別の仕方
ビジネスロジックを分解するにしても、区別の基準が必要になります。
今回の基準としては、Modelの中にあるメソッドは、そのModelに対応するDBのテーブルのレコードだけで実行が可能かどうかで考えます。
構成したい関係
4. 役割の分散の仕方
まず最初に今回行いたい移行後の形はこうなります。
UseCaseを導入して、実現したい形
それぞれの実現したい形の例を用意してみました。
class ~~Controller extends Controller
{
public function example()
{
//Auth
$user = Auth::user();
//validation
$validation_data = $request['query_parameter'];
//try-catch + UseCase
try{
$data = $useCase->exec()
} catch (\Exception $e) {
Log::error('error message: ' . $e);
}
//return view()
return view('blade', compact($data))
}
}
class ~~UseCase
{
public function exec()
{
//fetch data
$model_data = ~~Model::with('relation')
->where()
︙
->get()
if(~~~) {
return ~~;
//またはthrow new ~~Exception;
}
//ビジネスロジック
$useCase_data = $model_data->relation->・・・
if(~~~) {
return $useCase_data;
}
︙
︙
︙
return ~~~;
}
}
class ~~Model extends Model
{
︙
//リレーションメソッド
public function relation()
{
︙
}
︙
//対応するテーブルのレコードで実行可能なビジネスロジック
//※ビジネスロジックをアクセサとして定義する場合は、EagerLoadingするようにしてください。
public function get{Service}Attribute()
{
︙
}
︙
}
このような形でまとめられるように分類していきましょう。
4-1. Controllerにビジネスロジックをまとめてる場合
まずは、Controllerで実際に行っている処理をもう少し具体的にまとめてみます。
今回はUserモデルとそれに関するControllerを用意しました。
class Book extends Model
{
︙
protected fillable = [
'title',
'description',
'last_borrower',
'classification',
︙
]
︙
︙
}
class User extends Model
{
︙
protected $fillable = [
︙
︙
'last_name',
'first_name',
︙
]
︙
︙
}
class BorrowBookController extends Controller
{
public function __invoke(
BorrowBookRequest $request
) {
$user = Auth::guard('user')->user();
$validated_data = $request->validated();
$book_id = $validated_data['book_id'];
$book = Book::find($book_id);
$book->last_borrower = $user->last_name . " " . $user->first_name;
$book->save();
return view('user.borrowed', compact($book));
}
}
今回変更したい部分はデータの処理の部分になります。
このControllerでは、取得した本を最後に借りた人として、利用者のフルネームを登録するという処理を行なっています。
これをさらに分解すると、利用者のフルネームを取得する、特定の本を最後に借りた人に登録する、という処理に分けることができます。
今回の例の場合は、前者の処理をgetFullNameメソッドとしてUserModelに残し、後者の処理をborrowBookメソッドとしてUseCaseに移します。
class User extends Model
{
︙
protected $fillable = [
︙
︙
'last_name',
'first_name',
︙
]
︙
+ public function getFullName(): string
+ {
+ return $this->last_name . " " . $this->first_name;
+ }
︙
}
class BorrowBookController extends Controller
{
public function __invoke(
- BorrowBookRequest $request
+ BorrowBookRequest $request,
+ BorrowBookUseCase $useCase
) {
$user = Auth::guard('user')->user();
$validated_data = $request->validated();
$book_id = $validated_data['book_id'];
- $book = Book::find($book_id);
- $book->last_borrower = $user->last_name . " " . $user->first_name;
- $book->save();
+ $borrowed_book = $useCase->exec($user, $book_id);
- return view('user.borrowed', compact($book));
+ return view('user.borrowed', compact($borrowed_book));
}
}
+class BorrowBookUseCase
+{
+ public function exec(User $user, int $book_id)
+ {
+ $book = Book::find($book_id);
+ $book->last_borrower = $user->getFullName();
+ $book->save();
+
+ return $book;
+ }
+}
4-2. Modelにビジネスロジックをまとめている場合
続いて、Modelにビジネスロジックごとにメソッドを作成している場合をまとめてみます。
class User extends Model
{
︙
protected $fillable = [
︙
︙
'last_name',
'first_name',
︙
]
︙
public function getFullName(): string
{
return $this->last_name . " " . $this->first_name;
}
public function borrowBook($book_id): Book
{
$book = Book::find($book_id);
$book->last_borrower = $this->getFullName();
$book->save();
return $book;
}
︙
}
class BorrowBookController extends Controller
{
public function __invoke(
BorrowBookRequest $request
) {
$user = Auth::guard('user')->user();
$validated_data = $request->validated();
$book_id = $validated_data['book_id'];
$borrowed_book = $user->borrowBook($book_id);
return view('user.borrowed', compact($borrowed_book));
}
}
このようなModelとControllerがあったとします。
UserモデルのgetFullNameメソッドとborrowBookメソッドを見比べてみてください。
getFullNameメソッドでは、単純なデータの処理のみを行っているのに対して、borrowBookメソッドはUserモデルと直接結びつかないデータベースのテーブルから自分(User)以外のモデルを介して、レコードを取得しています。
getFullNameメソッドでは、Userテーブルに含まれているレコードのみが必要な処理になっているのですが、borrowBookメソッドでは、Userテーブル以外のテーブルに含まれているレコードの情報がないと実行できない処理になっています。
この場合は、UseCaseにborrowBookメソッド移植してみましょう。
class User extends Model
{
︙
protected $fillable = [
︙
︙
'last_name',
'first_name',
︙
]
︙
public function getFullName(): string
{
return $this->last_name . " " . $this->first_name;
}
- public function borrowBook($book_id): int
- {
- $book = Book::find($book_id);
-
- $book->last_borrower = $this->getFullName();
- $book->save();
-
- return $book_id;
- }
︙
}
class BorrowBookController extends Controller
{
public function __invoke(
- BorrowBookRequest $request
+ BorrowBookRequest $request,
+ BorrowBookUseCase $useCase
) {
$user = Auth::guard('user')->user();
$validated_data = $request->validated();
$book_id = $validated_data['book_id'];
- $borrowed_book = $user->borrowBook($book_id);
+ $borrowed_book = $useCase->exec($user, $book_id);
return view('user.borrowed', compact($borrowed_book));
}
}
+class BorrowBookUseCase
+{
+ public function exec(User $user, int $book_id)
+ {
+ $book = Book::find($book_id);
+ $book->last_borrower = $user->getFullName();
+ $book->save();
+
+ return $book;
+ }
+}
このようにして、移動させました。機能ごとに異なるUseCaseに移すことで、Userモデルにはリレーションメソッドや対応するテーブルのレコードだけで処理できるメソッドだけが残り、Modelの肥大化が抑えられます。
これで、Modelにあった、データアクセスロジックの部分やビジネスロジックの部分をUseCaseに移動させることができました。
5. コンポーネントの考え方について
ここまでで、複数のテーブルのレコードを必要とするメソッドをそれぞれ異なるUseCaseに移動させることができました。
ただ、移動させたメソッドの中には、とても長いメソッドだったり、ModelとControllerの内容を合わせたら複雑になるメソッドだったりと、UseCaseの内容が分かりづらくなるものもあると思います。
そこで、UseCaseで複雑な処理を行うサービスに関しては、細かい機能ごとに直列に並べるようにすることをおすすめします。
このようにまとめると、機能の変更やどのような処理を行っているのかが分かりやすくなります。
こんな感じでやるといいかも?
UseCaseでは、一番最初でwithメソッドやloadメソッドを使用して、必要となるデータを一括で取得するようにしましょう。ただ、条件によっては、特定のデータの値によって、以降の機能部分の処理をしなくても良い場合があります。このような場合には、まとめてデータを取得しきるのではなく、条件に当てはまるもののみリレーションメソッドのEagerLoadingをして、それ以外は極力、early returnやthrow Exceptionするようにしてください。
必要なデータが取得できたら、ビジネスロジックをまとめていきましょう。
ここで、重要なのは、簡単なビジネスロジックから順番に独立させて処理をするように記述することです。
これができていないと、機能の拡張やリファクタリングをするときに苦労することになります。
イメージ的には、プリンターのカートリッジのような形に並べたい、という考え方です。
プリンターのカートリッジ
プリンターのカートリッジは色ごとに入れる場所が決まっています。複数の色を1箇所にセットするわけではありません。
コードも同様です。複数の機能を1箇所でまとめずに、機能ごとにまとめて並べるようにしましょう。
6. 最後に
本記事では、ModelとControllerの肥大化を抑えるために、機能ごとに1つのUseCaseという形に役割を分散させました。役割を分散させることで、エラーが起きたときの修正やリファクタリング、アーキテクチャの変更がしやすくなります。
この記事を読んで、利用者と開発者にやさしいアプリケーションの開発に役立てていただければ幸いです。
Discussion