コードロード

エラー討伐

【Laravel】リポジトリパターンでデータ周りの要求変更に負けない設計パターンを取り入れる

こちらの本で勉強中なので、学習記録として。

リポジトリパターンとは

リポジトリパターンとは、ビジネスロジックからデータの保存や復元を別レイヤ(リポジトリ層)へ移し分離・隠蔽することで、コードのメンテナンス性やテストの容易性を高める実装パターン。

ビジネスロジックからデータストアに対して直接操作する処理を切り離し、何らかのデータ保管庫(リポジトリ)に対して、データの保存や復元を行う処理を抽象的に扱うオブジェクトを用意する。

対象サンプル

出版社テーブル(publishers)へのデータ操作を例とする。

Field Type Null Key Default Extra
id bigint unsigned NO PRI NULL auto_increment
name varchar(100) NO NULL
address text NO NULL
created_at timestamp YES NULL
updated_at timestamp YES NULL

出版社を新規に追加するWebAPIを作成していく。

エンドポイントは /api/publishers とする。

作成していくのは下記の3つのファイル。

  • データベースアクセスを受け持つEloquent(Publisher)
  • ビジネスロジックを受け持つサービスクラス(PublisherService)
  • リクエストを受けるコントローラクラス(PublisherAction)

実装

Publisherクラス

nameとaddressカラムのみ登録可能にする。

<?php
// app/DataProvider/Eloquent

namespace App\DataProvider\Eloquent;

use Illuminance\Database\Eloquent\Model;

class Publisher extends Model
{
    proteced $fillable = [
        'name',
        'address',
    ];
}

PublisherServiceクラス

existsメソッドで、引数nameで指定された名前と同じ出版社名がないかを確認し、もし同じ出版社名がすでに登録されていたら true を返す。

storeメソッドで、新たに登録して、シーケンス値(id)を返す。

<?php
// app/Services

namespace App\Services;

use App\DataProvider\Eloquent\Publisher;

class PublisherService
{
    public function exists(string $name): bool
    {
        $count = Publisher::whereName($name)->count();
        if ($count > 0) {
            return true;
        }
        return false;
    }

    public function store(string $name, string $address): int
    {
        $publisher = Publisher::create(
            [
                'name' => $name,
                'address' => $address,
            ]
        );
        return (int)$publisher->id;
    }
}

PublisherActionコントローラクラス

ユーザーからリクエストを受けて、nameで指定された名前と同じ出版社名が存在しないか確認する。同一出版社がすでに登録済みならなにも行わず、HTTPステータス200で返す。

登録されていない場合は、新規で登録してHTTPステータス201で返す。

<?php
// app/Http/Controllers

namespace App\Http\Controllers;

use App\Services\PublisherService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class PublisherAction
{
    private $publisher;

    public function __construct(PublisherService $publisher)
    {
        $this->publisher = $publisher;
    }

    public function create(Request $request)
    {
        if ($this->publisher->exists($request->name)) {
            return response('', Response::HTTP_OK);
        }

        $id = $this->publisher->store($request->name, $request->address);
        return response('', Response::HTTP_CREATED)
            ->header('Location', '/api/publishers/' . $id);
    }
}

最後に、エンドポンとを登録するために、 routes/api.php にルート追加。

Route::post('/publishers', [App\Http\Controllers\PublisherAction::class, 'create']);

リファクタリング

PublisherServiceクラスを確認すると、データの存在確認やデータ登録の処理は、EloquentであるPublisherクラスに依存してしまっている。

つまり、MySQLに接続できるEloquentを利用することが前提となっている。

データベースの代わりにモックを利用したり、Eloquent以外のデータ操作クラスを利用しようとすると、このサービスクラスを大幅に修正する必要が出てきてしまう。

そこで、ビジネスロジックから特定のデータベース操作を取り除いていく。

手順は下記のとおり。

  1. Repositoryを抽象化するインターフェースとEntityクラスを作成する
  2. データベース操作を担当するRepositoryクラスを作成する
  3. Serviceクラスはインターフェースを参照する
  4. インターフェースと具象クラスを紐づける

データ操作をServiceクラスから見た場合、Publisherオブジェクトは、同名出版社の存在確認と登録処理ができればいいため、この2つの処理を持つクラスを「リポジトリ」として新たに定義する。

同時に処理を抽象化し、これを表現したクラスをインターフェースとして作成する。

リポジトリインターフェース

インターフェースクラスなため、出版社名をキーにデータ取得を行うfindByNameと登録処理を行うstoreメソッド定義のみを行う。

<?php
// app/DataProvider/PublisherRepositoryInterface.php

namespace App\DataProvider;

use App\Domain\Entity\Publisher;

interface PublisherRepositoryInterface
{
    public function findByName(string $name): ?Publisher;

    public function store(Publisher $publisher): int;
}

PublisherのEntityクラス

<?php
// app/Domain/Entity/Publisher.php

namespace App\Domain\Entity;

class Publisher
{
    protected $id;
    protected $name;
    protected $address;

    public function __construct(?int $id, string $name, string $address)
    {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getAddress(): string
    {
        return $this->address;
    }
}

リポジトリインターフェースを実装した具象クラス

上記のインターフェースの実処理を行う具象クラス。

PublisherActionクラスで実行していたデータアクセス処理をこちらに移動。

<?php
// app/Domain/Repository/PublisherRepository.php

namespace App\Domain\Repository;

use App\DataProvider\PublisherRepositoryInterface;
use App\DataProvider\Eloquent\Publisher as EloquentPublisher;
use App\Domain\Entity\Publisher;

class PublisherRepository implements PublisherRepositoryInterface
{
    private $eloquentPublisher;

    public function __construct(EloquentPublisher $eloquentPublisher)
    {
        $this->eloquentPublisher = $eloquentPublisher;
    }

    public function findByName(string $name): ?Publisher
    {
        $record = $this->eloquentPublisher->whereName($name)->first();
        if ($record === null) {
            return null;
        }

        return new Publisher(
            $record->id,
            $record->name,
            $record->address,
        );
    }

    public function store(Publisher $publisher): int
    {
        $eloquent = $this->eloquentPublisher->newInstance();
        $eloquent->name = $publisher->getName();
        $eloquent->address = $publisher->getAddress();
        $eloquent->save();

        return (int)$eloquent->id;
    }
}

これで、データ操作の実処理はリポジトリクラスに移った。

PublisherServiceクラスでは、MySQLのデータアクセスクラスを直接利用していたが、抽象クラスであるPublisherRepositoryInterfaceをコンストラクタインジェクションで引数として渡す形式に置き換え可能なので、リファクタリングしていく。

<?php
// app/Services

namespace App\Services;

use App\DataProvider\PublisherRepositoryInterface;
use App\DataProvider\Eloquent\Publisher;

class PublisherService
{
    private $publisher;

    public function __construct(PublisherRepositoryInterface $publisher)
    {
        $this->publisher = $publisher;
    }

    public function exists(string $name): bool
    {
        if (!$this->publisher->findByName($name)) {
            return false;
        }

        return true;
    }

    public function store(string $name, string $address): int
    {
        return $this->publisher->store(new Publisher(null, $name, $address));
    }
}

こうすれば、

  • このサービスクラスは同じPublisherRepositoryInterfaceインターフェースを持つクラスであれば何でも動作することになる。
  • ユニットテストではモッククラスを利用可能。
  • 他のデータストアを利用することになっても、サービスクラスには変更を加えることなく差し替えが可能。
  • コントローラもこのサービスクラスを利用するので、データストア先の変更にも影響を受けない。

最後に、インターフェースと具象クラスの関連づけ(バインド)を行う。

サービスプロバイダクラスのregisterメソッドに記述する。

例ではデフォルトで用意されている App\Providers\AppServiceProvide クラスに登録するが、新たにサービスプロバイダを作成しても良い。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(
            \App\DataProvider\PublisherRepositoryInterface::class,
            \App\DataProvider\PublisherRepository::class,
        );
    }
}

もし、データストア先を変更する場合は、PublisherRepositoryInterfaceを持ったデータ操作クラスを新たに作成してバインド定義し直せば、ビジネスロジックを変更することなく、データ操作処理のみを差し替えることができる。

リポジトリパターンは各クラスを疎結合にできる反面、クラス数が増えるため、短期限定でしようするプログラムには不要かもしれない。

が、システムの要件や規模の拡張が見込まれるサービスでは、いいデザインパターン