3
6

More than 3 years have passed since last update.

Laravelでクリーンアーキテクチャに近づけてみる

Posted at

目次

  • はじめに
  • バージョン
  • もととなるシンプルなCRUD
  • クリーンにしてみる
    • 準備
    • packagesディレクトリの全体像
    • Entities
    • Gateways
    • Use Cases(とControllers)
  • テストを書いてみる

はじめに

Laravelを使ってクリーンアーキテクチャっぽいことをやってみました。
"クリーンアーキテクチャっぽくしつつLaravelの機能(主にEloquent)を使う"
ということを試してみました。
自分の理解がまだ浅いこともあって、"クリーン" かどうかと聞かれると正直微妙なところですが、MVCフレームワークの恩恵を受けつつクリーンアーキテクチャを採用してみる一つの妥協点としてはアリなんじゃないかと思います。

実際のコードはこちら
https://github.com/koyablue/laravel_clean_architecture

バージョン

PHP 7.4.4
Laravel 6.18.40

もととなるシンプルなCRUD

  • ユーザーがいて、そのユーザーがメモの作成/編集/削除ができる
  • 作成したメモの一覧と詳細が表示できる

というシンプルなCRUDで試してみます。
例として以下のようなものを想像してください。

controller
app/Http/Controllers/MemoController.php

<?php

namespace App\Http\Controllers;

use App\Models\Memo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class MemoController extends Controller
{
    /**
     * 一覧
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     */
    public function index(){
        $user = Auth::user();
        $memos = $user->memos;
        return view('index', compact('memos'));
    }

    /**
     * 詳細表示
     * @param $memoId
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     */
    public function show($memoId){
        $memo = Memo::find($memoId);
        return view('detail', compact('memo'));
    }

    /**
     * 新規作成
     * @param Request $request
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     */
    public function create(Request $request){
        $user = Auth::user();
        $input = $request->get('content');
        $memo = new Memo();
        $user->Memos()->save(
            $memo->fill(['content' => $input]));
        return redirect(route('index'));
    }

    /**
     * 編集画面
     * @param $memoId
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     */
    public function edit($memoId){
        $memo = Memo::find($memoId);
        return view('edit', compact('memo'));
    }

    /**
     * 更新
     * @param Request $request
     * @param $memoId
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     */
    public function update(Request $request, $memoId){
        $memo = Memo::find($memoId);
        $memo->fill(['content' => $request->get('content')])->save();
        return redirect(route('index'));
    }

    /**
     * 削除
     * @param $memoId
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     */
    public function delete($memoId){
        $memo = Memo::find($memoId);
        $memo->delete();
        return redirect(route('index'));
    }
}


クリーンにしてみる

  • Laravelの機能(Eloquent, FormRequestなど)をある程度使った上で、なるべくクリーンアーキテクチャに近づけてみる

ということを心がけながら、上記のCRUDをクリーンアーキテクチャっぽくしてゆきましょう。
ということで、あの図を貼っておきます。
CleanArchitecture.jpg

準備

スクリーンショット 2020-09-27 19.03.45.png
appとおなじ階層にpackagesというディレクトリを作成します。今回はこの中でいろいろ細かく区切って実装してゆくことにしました。
ディレクトリを追加したら、ちゃんとLaravelに読み込んでもらえるようcomposer.jsonも編集しておきます。

composer.json
"autoload": {
        "psr-4": {
            "App\\": "app/"
        },
        "classmap": [
            "database/seeds",
            "database/factories",
            "packages" <-これを追加
        ]
    }

事前準備はこれで終わりです。

packagesディレクトリの全体像

ちなみに完成後のpackagesディレクトリは、以下のようになります
スクリーンショット 2020-09-27 20.38.44.png
このディレクトリの置き方がいいのか悪いのかちょっと自信がありませんが、とりあえず今回は、こんな感じの配置なんだなーと思っていただければ。

Entities

Entityが何かを調べたり考えたりしていたら正直いつまでたっても先に進まない気がしたので、とりあえず
「アプリケーションの中で一番重要そうなモデル(達)」
というふうに捉えて実装しようと思います。(ちなみにこちらの記事がとてもわかりやすかったです。https://nrslib.com/clean-ddd-entity/)

packages/Domain/Domain
というディレクトリをEntities用に用意します。
今回のサンプルは

  • ユーザーがいて、そのユーザーがメモの作成/編集/削除ができる
  • 作成したメモの一覧と詳細が表示できる

というものでした。
"ユーザー" と "メモ"が重要っぽく見えますので、Entityとして必要なのはユーザーとメモと考えるのが妥当かなと思います。(本当はたぶんもっとしっかり整理/洗い出しを行わないといけないんですが...)

今回メモのCRUDに焦点を当てているので、ユーザーは一旦置いておいて、メモのドメインモデルのみ作成します。

packages/Domain/Domain/Memo/Memo.php
<?php

namespace packages\Domain\Domain\Memo;

class Memo
{
    private int $id;
    private int $userId;
    private string $content;
    private \DateTime $createdAt;

    /**
     * Memo constructor.
     * @param int $id
     * @param int $userId
     * @param string $content
     * @param \DateTime $createdAt
     */
    public function __construct(int $id, int $userId, string $content, \DateTime $createdAt)
    {
        $this->id = $id;
        $this->userId = $userId;
        $this->content = $content;
        $this->createdAt = $createdAt;
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return int
     */
    public function getUserId(): int
    {
        return $this->userId;
    }

    /**
     * @return string
     */
    public function getContent(): string
    {
        return $this->content;
    }

    /**
     * @return \DateTime
     */
    public function getCreatedAt(): \DateTime
    {
        return $this->createdAt;
    }
}

Gateways

DBとやりとりする部分です。

  • packages/Domain/Domain/Memo/MemoRepositoryInterface
  • packages/Infrastructure/Memo/MemoRepository
  • packages/UseCase/Memo/QueryService/MemoQueryServiceInterface
  • packages/Infrastructure/Memo/MemoQueryService

の4つをGatewaysとして作成しました。
永続化系(Repository)と参照系(QueryService)の処理を分けて、それぞれにinterfaceと実装クラスがあるというような感じです。

Eloquentをどこで使うかを考えた結果、この層で使うことにしました。
ただし、あくまでも処理の途中で使用するだけで、返す値はドメインモデルか、xxDtoと名付けた専用モデルにするよう心がけました。

MemoRepositoryInterface
packages/Domain/Domain/Memo/MemoRepositoryInterface.php
<?php
namespace packages\Domain\Domain\Memo;

interface MemoRepositoryInterface
{
    public function save(Memo $memo): Memo;

    public function update(int $memoId, string $content): Memo;

    public function delete(int $memoId);
}

MemoRepository
packages/Infrastructure/Memo/MemoRepository.php
<?php

namespace packages\Infrastructure\Memo;

use packages\Domain\Domain\Memo\Memo;
use packages\Domain\Domain\Memo\MemoRepositoryInterface;
use App\Models\Memo as EloqMemo;

class MemoRepository implements MemoRepositoryInterface
{
    /**
     * @param Memo $memo
     * @return Memo
     */
    public function save(Memo $memo): Memo
    {
        $eloqMemo = new EloqMemo();

        $eloqMemo->fill(
            [
                'user_id' => $memo->getUserId(),
                'content' => $memo->getContent()
            ])->save();

        return new Memo($eloqMemo->id, $eloqMemo->user_id, $eloqMemo->content, $eloqMemo->created_at);
    }

    /**
     * @param int $memoId
     * @param string $content
     * @return Memo
     */
    public function update(int $memoId, string $content): Memo
    {
        $eloqMemo = EloqMemo::find($memoId);
        $eloqMemo->fill(['content' => $content])->save();
        return new Memo($eloqMemo->id, $eloqMemo->user_id, $eloqMemo->content, $eloqMemo->created_at);
    }

    /**
     * @param int $memoId
     */
    public function delete(int $memoId)
    {
        $eloqMemo = EloqMemo::find($memoId);
        $eloqMemo->delete();
    }
}

MemoQueryServiceInterface
packages/UseCase/Memo/QueryService/MemoQueryServiceInterface.php
<?php

namespace packages\UseCase\Memo\QueryService;

use packages\Domain\Domain\Memo\Memo;
use packages\UseCase\Memo\Dto\MemoDetailDto;
use packages\UseCase\Memo\Dto\MemoEditDto;

interface MemoQueryServiceInterface
{
    public function fetchUsersMemo(int $userId): array;

    public function getMemoDetail(int $memoId): MemoDetailDto;

    public function getEditTarget(int $memoId): MemoEditDto;

    public function findById(int $memoId): Memo;
}
MemoQueryService
packages/Infrastructure/Memo/MemoQueryService.php
<?php

namespace packages\Infrastructure\Memo;

use packages\Domain\Domain\Memo\Memo;
use packages\UseCase\Memo\Dto\MemoDetailDto;
use packages\UseCase\Memo\Dto\MemoEditDto;
use packages\UseCase\Memo\Dto\UsersMemoDto;
use packages\UseCase\Memo\QueryService\MemoQueryServiceInterface;
use App\Models\Memo as EloqMemo;

class MemoQueryService implements MemoQueryServiceInterface
{
    /**
     * @param int $userId
     * @return array
     */
    public function fetchUsersMemo($userId): array
    {
        $eloqMemoList = EloqMemo::where('user_id', $userId)->get()->all();
        $usersMemoDtoList = array_map(function ($eloqMemo){
            return new UsersMemoDto($eloqMemo->id, $eloqMemo->content, $eloqMemo->created_at);
        }, $eloqMemoList);

        return $usersMemoDtoList;
    }

    /**
     * @param int $memoId
     * @return MemoDetailDto
     */
    public function getMemoDetail(int $memoId): MemoDetailDto
    {
        $eloqMemoModel = EloqMemo::find($memoId);
        return new MemoDetailDto($eloqMemoModel->id, $eloqMemoModel->content, $eloqMemoModel->created_at);
    }

    /**
     * @param int $memoId
     * @return MemoEditDto
     */
    public function getEditTarget(int $memoId): MemoEditDto
    {
        $eloqMemoModel = EloqMemo::find($memoId);
        return new MemoEditDto($eloqMemoModel->id, $eloqMemoModel->content);
    }

    /**
     * @param int $memoId
     * @return Memo
     */
    public function findById(int $memoId): Memo
    {
        $eloqMemoModel = EloqMemo::find($memoId);
        $memo = new Memo($eloqMemoModel->id, $eloqMemoModel->user_id, $eloqMemoModel->content,
            $eloqMemoModel->created_at);

        return $memo;
    }
}


Use Cases(とControllers)

この層はControllerからの呼び出しと合わせて説明します。

Use Casesは、アプリケーションができることを表す部分です。
アプリケーションができることというのは、今回でいうと

  • メモの作成
  • メモの更新
  • メモの削除
  • メモの一覧表示
    etc...
    みたいなことを意味します。
    対象となる部分が多いので、永続化処理と参照処理をそれぞれ一つずつ紹介します。他の部分を確認されたい場合は、上に貼ったGitHubのリンクからコードをご覧になってください。

create

メモの新規作成処理です。
引数として渡ってきたパラメーターの値からMemoモデルを作成し、MemoRepositoryのsaveメソッドに渡します。
以下がinterfaceと実装クラスです。

packages/UseCase/Memo/Create/MemoCreateUseCaseInterface.php
<?php

namespace packages\UseCase\Memo\Create;

use packages\Domain\Domain\Memo\Memo;

interface MemoCreateUseCaseInterface
{
    public function create(MemoCreateRequest $request): Memo;
}

packages/Domain/Application/Memo/MemoCreateInteractor.php
<?php

namespace packages\Domain\Application\Memo;

use Carbon\Carbon;
use packages\Domain\Domain\Memo\Memo;
use packages\Domain\Domain\Memo\MemoRepositoryInterface;
use packages\UseCase\Memo\Create\MemoCreateRequest;
use packages\UseCase\Memo\Create\MemoCreateUseCaseInterface;

class MemoCreateInteractor implements MemoCreateUseCaseInterface
{
    private MemoRepositoryInterface $memoRepository;

    /**
     * MemoCreateInteractor constructor.
     * @param MemoRepositoryInterface $memoRepository
     */
    public function __construct(MemoRepositoryInterface $memoRepository)
    {
        $this->memoRepository = $memoRepository;
    }

    /**
     * @param MemoCreateRequest $request
     * @return Memo
     */
    public function create(MemoCreateRequest $request): Memo
    {
        $memo = new Memo(mt_rand(), $request->getUserId(), $request->getContent(), Carbon::now());
        return $this->memoRepository->save($memo);
    }
}

MemoRepositoryのsaveメソッドでは、以下のようにEloquentのモデルを新規作成しDBに保存->Memoモデルを返すという処理を行っています。

MemoRepository
    /**
     * @param Memo $memo
     * @return Memo
     */
    public function save(Memo $memo): Memo
    {
        $eloqMemo = new EloqMemo();

        $eloqMemo->fill(
            [
                'user_id' => $memo->getUserId(),
                'content' => $memo->getContent()
            ])->save();

        return new Memo($eloqMemo->id, $eloqMemo->user_id, $eloqMemo->content, $eloqMemo->created_at);
    }

MemoControllerからは以下のように呼び出しています
バリデーションはFormRequestを使いました

MemoController
    /**
     * 新規作成
     * @param MemoCreateFormRequest $request
     * @param MemoCreateUseCaseInterface $interactor
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     */
    public function create(MemoCreateFormRequest $request, MemoCreateUseCaseInterface $interactor)
    {
        $userId = Auth::user()->id;
        $content = $request->get('content');
        $memoCreateRequest = new MemoCreateRequest($userId, $content);
        $interactor->create($memoCreateRequest);
        return redirect(route('index'));
    }

updateとdeleteも基本的には同じ処理です。渡されたパラメーターからMemoモデルを作成し、Repositoryで永続化処理を行います。
ただ、deleteについては戻り値がありません。何を返すのがベストか正直わかりませんでした。(どうやら戻り値がないのがクリーンとされているらしいです。図らずもクリーンになってしまったのかもしれません)


index

作成したメモの一覧表示です。
パラメーターの値をMemoQueryServiceのfetchUsersMemoに渡して、DBからデータを取得する処理を依頼します。

packages/UseCase/Memo/MemoIndexUseCaseInterface.php
<?php

namespace packages\UseCase\Memo\Index;

interface MemoIndexUseCaseInterface
{
    public function getMemoList(MemoIndexRequest $request): array;
}

MemoQueryServiceのfetchUsersMemoメソッドでは、検索結果の値からUsersMemoDtoというモデルを作成し、その配列を返します。

MemoQueryService
    /**
     * @param int $userId
     * @return array
     */
    public function fetchUsersMemo($userId): array
    {
        $eloqMemoList = EloqMemo::where('user_id', $userId)->get()->all();
        $usersMemoDtoList = array_map(function ($eloqMemo){
            return new UsersMemoDto($eloqMemo->id, $eloqMemo->content, $eloqMemo->created_at);
        }, $eloqMemoList);

        return $usersMemoDtoList;
    }
packages/Domain/Application/Memo/MemoIndexInteractor.php
<?php

namespace packages\Domain\Application\Memo;

use packages\UseCase\Memo\Index\MemoIndexRequest;
use packages\UseCase\Memo\Index\MemoIndexUseCaseInterface;
use packages\UseCase\Memo\QueryService\MemoQueryServiceInterface;

class MemoIndexInteractor implements MemoIndexUseCaseInterface
{
    private MemoQueryServiceInterface $memoQueryService;

    /**
     * MemoIndexInteractor constructor.
     * @param MemoQueryServiceInterface $memoQueryService
     */
    public function __construct(MemoQueryServiceInterface $memoQueryService)
    {
        $this->memoQueryService = $memoQueryService;
    }

    /**
     * @param MemoIndexRequest $request
     * @return array
     */
    public function getMemoList(MemoIndexRequest $request): array
    {
        return $this->memoQueryService->fetchUsersMemo($request->getUserId());
    }
}

UsersMemoDtoは以下のようになっています

UsersMemoDto
<?php

namespace packages\UseCase\Memo\Dto;

class UsersMemoDto
{
    private int $memoId;
    private string $content;
    private \DateTime $createdAt;

    /**
     * UsersMemoDto constructor.
     * @param int $memoId
     * @param string $content
     * @param \DateTime $createdAt
     */
    public function __construct(int $memoId, string $content, \DateTime $createdAt)
    {
        $this->memoId = $memoId;
        $this->content = $content;
        $this->createdAt = $createdAt;
    }

    /**
     * @return int
     */
    public function getMemoId()
    {
        return $this->memoId;
    }

    /**
     * @return string
     */
    public function getContent()
    {
        return $this->content;
    }

    /**
     * @return \DateTime
     */
    public function getCreatedAt()
    {
        return $this->createdAt;
    }
}

MemoControllerのindexは以下の通りです。

MemoController
    /**
     * 一覧
     * @param MemoIndexUseCaseInterface $interactor
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     */
    public function index(MemoIndexUseCaseInterface $interactor)
    {
        $userId = Auth::user()->id;

        $memoIndexRequest = new MemoIndexRequest($userId);
        $usersMemoDtoList = $interactor->getMemoList($memoIndexRequest);

        $memoViewModels = array_map(function ($usersMemo){
            return new MemoViewModel($usersMemo->getMemoId(), $usersMemo->getContent());
        }, $usersMemoDtoList);
        $MemoIndexViewModel = new MemoIndexViewModel($memoViewModels);

        return view('index', compact('MemoIndexViewModel'));
    }

返ってきたUsersMemoDtoの配列からMemoViewModelというモデルの配列を作成し、その配列を引数に持たせてMemoIndexViewModelというモデルを生成します。
イメージとしては

//このMemoはEloquentのMemo
Memo::where('user_id', $userId)->get();

で返ってくるCollection => MemoIndexViewModel
そのCollectionの中身のMemoモデル => MemoViewModel
みたいな感じです。

MemoViewModelとMemoIndexViewModelの内容はこのようになっています。

app/Models/MemoViewModel.php
<?php

namespace App\Models;

class MemoViewModel
{
    public int $id;
    public string $content;
    public $createdAt;

    /**
     * MemoViewModel constructor.
     * @param int $id
     * @param string $content
     * @param \DateTime|null $createdAt
     */
    public function __construct(int $id, string $content, \DateTime $createdAt = null)
    {
        $this->id = $id;
        $this->content = $content;
        $this->createdAt = $createdAt;
    }
}

app/Models/Memo/MemoIndexViewModel.php
<?php

namespace App\Models;

class MemoIndexViewModel
{
    public array $memos;

    /**
     * MemoIndexViewModel constructor.
     * @param MemoViewModel[] $memos
     */
    public function __construct(array $memos)
    {
        $this->memos = $memos;
    }

}

viewではEloquentのモデルと同じように展開できます

resources/views/index.blade.php
<table class="table table-hover">
    <tbody>
        @if(!empty($MemoIndexViewModel->memos))
            @foreach($MemoIndexViewModel->memos as $memo)
                <tr>
                    <td>{{$memo->content}}</td>
                    <td>
                        <button type="button" class="btn btn-outline-primary">
                            <a href="{{route('show', ['memoId' => $memo->id])}}">show</a>
                        </button>
                    </td>
                    <td>
                        <button type="button" class="btn btn-outline-primary">
                            <a href="{{route('edit', ['memoId' => $memo->id])}}">edit</a>
                        </button>
                    </td>
                    <td>
                        <form method="POST" action="{{route('delete', ['memoId' => $memo->id])}}">
                            @csrf
                            <button type="submit" class="btn btn-outline-danger">delete</button>
                        </form>
                    </td>
                </tr>
            @endforeach
        @endif
    </tbody>
</table>

テストを書いてみる

メモ作成機能のテスト

//TestBaseでユーザーを作成しています

<?php

namespace Tests\Unit;

use packages\Domain\Application\Memo\MemoCreateInteractor;
use packages\Infrastructure\Memo\MemoRepository;
use packages\UseCase\Memo\Create\MemoCreateRequest;
use Tests\Base\TestBase;

class MemoCreateInteractorTest extends TestBase
{
    public function testMemoCreate()
    {
        $userId = $this->user->id;
        $str = null;
        for ($i = 0; $i < 10; $i++){
            $str .= chr(mt_rand(97, 122));
        }
        $content = $str;
        $repository = new MemoRepository();
        $memoCreateRequest = new MemoCreateRequest($userId, $content);
        $interactor = new MemoCreateInteractor($repository);
        $createdMemo = $interactor->create($memoCreateRequest);

        var_dump($createdMemo);

        $this->assertNotNull($createdMemo);
        $this->assertNotNull($createdMemo->getId());
        $this->assertEquals($userId, $createdMemo->getUserId());
        $this->assertEquals($str, $createdMemo->getContent());
        $this->assertNotNull($createdMemo->getCreatedAt());

    }
}

やってみた所感

今回サンプルを作成してみて思ったこと

  • QueryServiceはそのまま呼び出してもいいのでは
  • 参照系の処理の結果を、専用のDTOに詰めるのか、ドメインモデルに詰めるのか、場合によってどうするのが適切なのかをちゃんと考えないとかなり不便になりそう。
  • ↑も含めて、機械的に同じような実装をしていると詰みそう。しっかり意識しながら実装する必要があるので、そういうクリーンさは体感できたかも(と言いつつ依存の方向とかは正直あまり意識できていない...)
  • viewで使うためのview modelが無限に増殖しそう
  • 共通化できそうな処理をある程度共通化してしまっていいのか悩む。なんとなく、あんまり共通化しすぎない方が変更に強そう

参考にした記事一覧

多いので別記事にまとめました。
https://qiita.com/koyablue/items/e0e8d66803bef789b6bc

3
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
6