LoginSignup
106
111

More than 1 year has passed since last update.

Laravelで❝サービスクラス❞を排除したクリーンな設計

Posted at

はじめに

普段は FuelPHP で書かれたレガシーなアプリケーションの開発をおこなっているのですが、新規サービス立ち上げに伴い Laravel を導入することになり、四苦八苦しながらクリーンな設計を模索しました。

以下、本記事を読むにあたっての注意点です。

  • マルチページアプリケーションでMVCモデルの設計である

  • Eloquent Model の機能をフル活用するため、リポジトリパターンは諦めている

  • 多目的な❝サービスクラス❞がないだけで、サービスクラス的なものは存在する

❝サービスクラス❞の問題点

サービスクラスはその性質上、いろいろな場所から呼ぶことができるため、適切に責任を分離しておかないとなんでもかんでも詰め込まれてしまい、すぐに肥大化してしまいます。

そして、いろいろな場所から呼ばれているがために、一度肥大化してしまうと、それを適切なクラスに分離していくという作業はとてつもなく労力がかかります。

また、サービスクラスにきちんと制約を設けていないと、いつの間にかなんでもありのクラスになり(個人的には名前がいけないと思う)、1つのサービスクラスの中でリクエストの検証やバリデーション、DBへのアクセスなどあらゆることがおこなわれるようになってしまいます。
これはただ単にファットコントローラが丸々❝サービスクラス❞になっただけです。

そしてこの一見便利な❝サービスクラス❞はちょっとした機能追加でも新たに作られるようになり、似たような機能を持つ❝サービスクラス❞で溢れかえるという現象を招きます。
こうなってしまえば、ソース全体を把握することはほぼ不可能になり、以前の仕様のメソッドが使われたり、1行変更しただけで全く別の箇所がバグったりといったことが起こってしまいます。

❝サービスクラス❞を避けるための設計

こういった事態を避けるためには、各クラスが担う責任を適切に分離してあげる必要があります。
そして、新規実装者(や半年後の自分)が迷いなく実装できるように、各クラスを適切に配置して、ソース全体の統率をしっかり取る必要があります。

こういった課題を解決し、なおかつクラス同士の依存関係もしっかりと分離して、よりクリーンな設計を目指そうというのがクリーンアーキテクチャです。

しかし、私たちのチームにとってクリーンアーキテクチャはコスト(学習コスト、実装コスト)が高く、またクリーンアーキテクチャだとLaravelの便利な機能の一部が使えなかったりもするので、導入は断念しました。

そこで今回落ち着いた設計が、Laravelの機能をフル活用し、❝サービスクラス❞を排除して、各クラスの責任を分離したそれなりにクリーンな設計です。

Webアプリにおける責任

マルチページアプリケーションがリクエストをルーティングしてからHTMLを返すまでには、以下のような責任ごとがあると私は考えています。

  • リクエストの検証(URLやクエリパラメータ)

  • バリデーション(フォーム送信やXHRリクエスト)

  • DBのCRUD操作

  • 他サービスのAPIへのアクセス

  • DBやAPIから取得した各データをView表示用に整形する

  • 各処理の実行順序(全体の流れ)を決める

もっと細かく責任を分離すべきだという意見もあるかとは思いますが、今回はこちらをベースに進めさせていただきます。

設計の全体像

前置きが長くなってしまいましたが、ようやく本題です。
本記事用にコードは書き換えていますが、実装した環境は以下の通りです。

  • PHP 8.0.13

  • Laravel Framework 8.51.0

ディレクトリ構成は以下のようになっています。

app/
├─┬ Domain/
│ ├─┬ Item/
│ │ ├─┬ UseCases/
│ │ │ ├── FindAction.php
│ │ │ ├── StoreAction.php
│ │ │ ├── UpdateAction.php
│ │ │ └── DeleteAction.php
│ │ └── ItemEntity.php
│ └─┬ Shop/
│   ├─┬ UseCases/
│   │ └── FindAction.php
│   ├── ShopEntity.php
│   └── ShopRepositoryInterface.php
├─┬ Http/
│ ├─┬ Controllers/
│ │ └── ItemListController.php
│ ├─┬ Middleware/
│ │ └── EnsureShopIdIsValid.php
│ └─┬ Requests/
│   └── StoreItemRequest.php
├─┬ Infrastructure/
│ ├─┬ WebApi/
│ │ └── ShopRepository.php
│ └─┬ MockApi/
│   └── ShopRepository.php
├─┬ Helpers/
│ └── ImagePath.php
├─┬ Models/
│ └── Item.php
└── Providers/

DBやAPIから取得した各データをView表示用に整形する①

この設計の肝となるのが、app/Domain/配下のEntityです。

このクラスは独自に用意したもので、DBテーブルのレコード(Model)やAPIで取得するデータのリソースに対応しています。
Viewで動的なデータを表示するときはこのクラスのメソッドを呼び、またEntityの状態によって条件分岐をおこないたい場合もこのクラスのメソッドで真偽値を返すようにします。

すべてのデータをこのEntityに落とし込むことで、各コントローラやViewで使うEntityに関する共通のロジックを一元管理できます。
また配列とは異なり、クラスを定義することで型を明示できることも大きなメリットだと思います。

app/Domain/Item/ItemEntity.php
<?php

namespace App\Domain\Item;

use App\Models\Item;

class ItemEntity
{
    private int $id;
    private int $shop_id;
    private string $name;
    private int $fee;

    public static function convertModelIntoEntity(Item $model): self
    {
        return (new static)
            ->setId($model->id)
            ->setShopId($model->shop_id)
            ->setName($model->name)
            ->setFee($model->fee)
            ;
    }

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

    public function setId(int $id): self
    {
        $this->id = $id;
        return $this;
    }

    public function getShopId(): int
    {
        return $this->shop_id;
    }

    public function setShopId(int $shop_id): self
    {
        $this->shop_id = $shop_id;
        return $this;
    }

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

    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }

    public function getFee(): int
    {
        return $this->fee;
    }

    public function setFee(int $fee): self
    {
        $this->fee = $fee;
        return $this;
    }

    // 以下、getter/setter以外
    public function getFormattedFee(): string
    {
        return number_format($this->fee);
    }

    public function isHighClass(): bool
    {
        return $this->fee >= Item::MIN_HIGH_CLASS_FEE;
    }
}

このような感じで、主にgetter/setterで構成され、加えてView表示用のデータやEntityの状態の真偽値、プロパティの組み合わせで表現されるデータなどを返すメソッドからなります。

なぜModelではダメなのか

Eloquent Model をそのまま使わず、Viewに渡すデータはすべてEntityに変換するようにした理由はこちらです。

  • APIから取得したデータも同じように扱いたい

  • Modelクラスにはテーブルのリレーションなどの情報も定義する必要があり、また Eloquent Model を継承しているので、新たに表示用のロジックを追加するとなると責任過多になる

  • チームが Active Record の扱いに慣れていないので、ViewでModelを使うとN+1問題が多発する

ここで簡単に、N+1問題について例示します。
Eloquent Model の Collection(配列)を取得する際に、Eagerロードしておかないと下記のように1回(Itemsの取得)+ N回(各ループでのShopの取得)のクエリが実行されます。
ちなみに、Eagerロードをすると1回(Itemsの取得)+ 1回(Itemsのshop_idに紐づくShopsの取得)の計2回のクエリで済みます。

/**
 * Viewに渡される前にItemsの取得
 * $items = Item::all();
 */

@foreach ($items as $item)
<ul>
    <li>商品名: {{ $item->name }}</li>
    <li>値段: {{ $item->fee }}</li>
    <li>店舗名: {{ $item->shop->name }}</li> // ここで毎回select文が走る
</ul>
@endforeach

Eloquent Model を使うとこのような感じでいとも簡単にリレーション先のデータを取得できます。
この機能を使わないわけにはいかないので、もちろん今回の設計でも使用していますが、Viewでの使用は禁止しています。

理由はViewがDBの取得処理から遠いため、どのModelがEagerロードされているのかということがとても把握しづらいからです。
あげく仕様変更の際にViewをちょっといじるだけで追加のデータを容易に取得できるため、これを許してしまうとますますN+1問題が起こる可能性が高くなります。

このような理由により、ModelではなくEntityを実装しました。

リレーションをどう表現するか

上記のようなリレーションをEntityで表現するには、リレーション先のEntityをプロパティに追加して、getter/setterを定義してあげればよいです。

app/Domain/Item/ItemEntity.php
class ItemEntity
{
    private ShopEntity $shop; // :1 のリレーション
    private Collection $tags; // :N のリレーション(TagEntityのCollection)

    public function getShop(): ShopEntity
    {
        return $this->shop;
    }

    public function setShop(ShopEntity $shop): self
    {
        $this->shop = $shop;
        return $this;
    }

    public function getTags(): Collection
    {
        return $this->tags;
    }

    public function setTags(Collection $tags): self
    {
        $this->tags = $tags;
        return $this;
    }
}

もちろん、リレーションのプロパティを組み合わせたView表示用のメソッドの作成も可能です。
ここでリレーションが増えてくると、いちいちsetterを呼ぶのが面倒になるので、先述したconvertModelIntoEntity()が役に立ってきます。

Viewファイルでは以下のように使用します。

/**
 * Viewに渡される前にItemEntitiesの取得
 * $item_models = Item::with('shop')->get(); // Eagerロード
 * $item_entities = collect();
 * foreach ($item_models as $item_model) {
 *     $item_entity = ItemEntity::convertModelIntoEntity($item_model)
 *                             ->setShop(ShopEntity::convertModelIntoEntity($item_model->shop))
 *                             ;
 *     $item_entities->push($item_entity);
 * }
 */

@foreach ($item_entities as $item_entity)
<ul>
    <li>商品名: {{ $item_entity->getName() }}</li>
    <li>値段: {{ $item_entity->getFee() }}</li>
    <li>店舗名: {{ $item_entity->getShop()->getName() }}</li>
</ul>
@endforeach

DBのCRUD操作

DBへのアクセスはapp/Domain/配下のUseCases/に限定します。
データ取得のFindAction、新規作成のStoreAction、更新のUpdateAction、削除のDeleteActionから構成されます。
特にサービスの中核を担うEntityはいろいろな条件でアクセスされ、肥大化しやすいので、CRUDごとにファイルを作成するようにしています。

他にも、Laravelの通知機能を実装したNotifyActionや、後述するAPIデータの取得メソッド、場合によってはEntityに対するDBアクセスが伴わない操作なども、UseCases/に書いていきます。

簡単に言ってしまえば、ドメインロジックを扱うクラスの集まりです。
比較的制限は少なく、以下の内容さえ守られていれば問題ないです。

  • そのEntityに対する操作か

  • クラス名(Action)とメソッドの操作内容は一致しているか

以下、データ取得クラスであるFindActionの一例です。

app/Domain/Item/UseCases/FindAction.php
<?php

namespace App\Domain\Item\UseCases;

use App\Domain\Item\ItemEntity;
use App\Models\Item;
use Illuminate\Support\Collection;

class FindAction
{
    private Item $item_model_repository;

    public function __construct(Item $item_model_repository)
    {
        $this->item_model_repository = $item_model_repository;
    }

    public function findEntityById(int $id): ?ItemEntity
    {
        $model = $this->item_model_repository->find($id);
        if (is_null($model)) return null;

        return ItemEntity::convertModelIntoEntity($model);
    }

    public function findEntitiesByShopId(int $shop_id): Collection
    {
        $models = $this->item_model_repository->where('shop_id', $shop_id)->get();

        $entities = [];
        foreach ($models as $model)
        {
            $entities[] = ItemEntity::convertModelIntoEntity($model);
        }

        return collect($entities);
    }
}

EntityやそのCollectionを返すことがほとんどですが、Modelや真偽値などを返すことも許容しています。

また、取得するカラムを限定せずにEntityのプロパティに対応するものは毎回すべて取得するようにします。
こうすることで、データ取得メソッドでありがちなほぼ同じだけど微妙に取得するカラムが足らないので使いまわしができないといったことが起こらなくなります。
そして、引数を適切に設定して、なるべく柔軟なメソッドを作るようにすることも肥大化を避けるうえで大切なことです。

他サービスのAPIへのアクセス

AWS、LINEなどの外部サービスや自社の別サービスといったDB以外にリポジトリと考えられるものへのアクセスは、app/Infrastructure/配下でおこないます。

インターフェースを実装する形で、実運用で使用するクラスとテストで使用する通信が発生しないクラスをそれぞれ用意します。

基本的にテストにおいて、リポジトリ層はモックされている必要があるので、上記のようにWebApi/と対をなす形でMockApi/も実装しています。
しかし、DBアクセスに関しては、UseCases/が Eloquent Model に依存することを許容したため、テスト時はテスト用DBに接続して機能テストをおこなうようにしました。

ここでも、Entityベースで考えていきます。
今回は店舗のデータを自社の別サービスから取ってくる場合を想定しています。
まずインタフェースをapp/Domain/Shop/に作成します。

app/Domain/Shop/ShopRepositoryInterface.php
<?php

namespace App\Domain\Shop;

interface ShopRepositoryInterface
{
    public function find(int $id): ?ShopEntity;
}

このインタフェースをapp/Infrastructure/WebApi/app/Infrastructure/MockApi/の両方で実装します。

app/Infrastructure/WebApi/ShopRepository.php
<?php

namespace App\Infrastructure\WebApi;

use App\Domain\Shop\ {
    ShopEntity,
    ShopRepositoryInterface,
};

class ShopRepository implements ShopRepositoryInterface
{
    public function find(int $id): ?ShopEntity
    {
        // APIからデータを取ってきて、ShopEntityに変換して返す
        // MockApiの場合、ShopEntityを仮で作成して返す
    }
}

ここでデータアクセスはUseCases/に限定したいので、インタフェースをDIする形でapp/Domain/Shop/UseCases/を実装します。

app/Domain/Shop/UseCases/FindAction.php
<?php

namespace App\Domain\Shop\UseCases;

use App\Domain\Shop\ {
    ShopEntity,
    ShopRepositoryInterface,
};

class FindAction
{
    private ShopRepositoryInterface $shop_repository;

    public function __construct(ShopRepositoryInterface $shop_repository)
    {
        $this->shop_repository = $shop_repository;
    }

    public function findEntityById(int $id): ?ShopEntity
    {
        return $this->shop_repository->find($id);
    }
}

あとは、app/Providers/でサービスコンテナに登録するクラスを環境に応じて切り替えるようにすれば完了です。

ここまでで、すべてのデータアクセスを❝サービスクラス❞なしで実現することができました。
これまではすべて独自のクラスで対応してきましたが、これからはLaravelで用意されている機能を使って責任を分離していきます。

リクエストの検証

パスパラメータやクエリパラメータの正当性の確認や、ログイン状態の確認はapp/Http/Middleware/でおこないます。
ここでは、Laravelで用意されているミドルウェアという機能を使っていきます。

パスパラメータが数字であるかなどの簡単な確認はroutes/で済ませて、ミドルウェアでは存在するデータの確認やログイン状態の確認などをおこなって、適宜リダイレクトするようにします。

app/Http/Middleware/EnsureShopIdIsValid.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Domain\Shop\UseCases\FindAction;

class EnsureShopIdIsValid
{
    private FindAction $action;

    public function __construct(FindAction $action)
    {
        $this->action = $action;
    }

    public function handle(Request $request, Closure $next)
    {
        $input_id = $request->route('shop_id');
        $entity   = $this->action->findEntityById($input_id);

        // 存在しないshop_idの場合、404
        if (is_null($entity))
        {
            abort(404);
        }

        // $request->input('shop_entity')でアクセスできるようにする
        $request->merge(['shop_entity' => $entity]);
        return $next($request);
    }
}

ここでポイントなのが、リクエストの検証のために取得したデータ($entity)を、後続の処理で再度データアクセスすることなく使いまわしができるという点です。
こういったことが可能なため、クエリ数の増加を憂えることなく、どんどんミドルウェアを作成して責任を分離していくことができます。

バリデーション

フォーム送信やXHRリクエストのバリデーションはapp/Http/Requests/でおこないます。
こちらもLaravelの機能で、バリデーションエラー時は入力値とエラーメッセージを保持した状態でリクエスト前のページに自動でリダイレクトしてくれます。

app/Http/Requests/StoreItemRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreItemRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'name' => ['bail', 'required', 'string', 'max:50'],
            'fee'  => ['bail', 'required', 'integer', 'digits_between:0,10'],
        ];
    }

    public function messages()
    {
        return [
            'name.required'      => '商品名は必須です。',
            'name.max'           => '商品名は50文字以内で入力してください。',
            'fee.required'       => '金額は必須です。',
            'fee.integer'        => '金額は半角数字で入力してください。',
            'fee.digits_between' => '金額は10桁以内で入力してください。',
        ];
    }
}

このような感じで、バリデーションルールとエラーメッセージを書いていきます。
より複雑なバリデーションルールに関しては、app/Rules/にクラスを追加したり、withValidator()を実装したりすることで実現可能です。

バリデーションエラーを考慮したView

先述した通り、バリデーションエラー時は入力値とエラーメッセージがセッションに格納されて元のページへリダイレクトされます。
そのときに入力値はold()、エラーメッセージは$errorsで取得することができます。

// 第2引数で、デフォルト値の設定もできる
<input type="text" name="name" value="{{ old('name', $item_entity->getName()) }}">
@error('name')
    <p class="error">{{ $errors->first('name') }}</p>
@enderror

各処理の実行順序(全体の流れ)を決める

処理の流れを規定するのは、言わずもがなapp/Http/Controllers/、コントローラです。
これまで熱心に責任を分離していった甲斐あって、かなりスッキリしています。

app/Http/Controllers/ItemListController.php
<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

use App\Http\Requests\StoreItemRequest;
use App\Domain\Item\UseCases\ {
    FindAction,
    StoreAction,
};

class ItemListController extends Controller
{
    // Itemの一覧表示
    public function show(Request $request, FindAction $action)
    {
        $shop_entity   = $request->input('shop_entity'); // ミドルウェアの使いまわし
        $item_entities = $action->findEntitiesByShopId($shop_entity->getId());

        $data = [
            'shop_entity'   => $shop_entity,
            'item_entities' => $item_entities,
        ];

        return view('pages.item.list', $data);
    }

    // Itemの新規作成
    public function store(StoreItemRequest $request, StoreAction $action)
    {
        $validated_input = $request->validated(); // バリデーションの実行
        $shop_entity     = $request->input('shop_entity');

        $action->store($validated_input, $shop_entity->getId());

        return redirect()->route('item.list.show')->with('flash_message', '保存が完了しました');
    }
}

コントローラのポイントは、処理が複雑になった場合でもメソッド名を上から順に読んでいくだけでなにをしているかが簡単に把握できるようにするということです。

これは、処理を適切に関数に切り出していくということでもあり、またあらゆることを内包する❝サービスクラス❞を作らないということでもあります。
つまり、コントローラはメソッド間のデータ受け渡しだけを担って、また他のクラスではメソッド間のデータ受け渡しをおこなってはいけないということです。

DBやAPIから取得した各データをView表示用に整形する②

ここまで❝サービスクラス❞を作らずに進んで来ることができました。
また、最初に定義した責任がしっかりと分離できていることも確認できたのではないかと思います。
しかし、これだけではまだ対応できないことがあったので、❝サービスクラス❞なしでそれらにどう対応したかを最後にご紹介します。

汎用的な軽い処理は自作のヘルパ関数を定義する

app/Helpers/にヘルパ関数を作って、汎用的な軽い処理を置いていきます。
例えば、「与えられたimgパスを環境に応じたCloudFrontのURLにして返す」という処理です。
こういった処理はサービスクラスを作りたくなる対象かと思いますが、グッとこらえてヘルパ関数を作成します。

app/Helpers/ImagePath.php
<?php

function my_get_cloudfront_url(string $img_path): string
{
    return config('services.cloudfront.url').'/'.$img_path;
}

関数名の衝突が起きないように、関数名の先頭にmy_をつけるというルールを用意しました。
各ヘルパ関数は、app/Providers/で読み込んでいます。

これで残った汎用的な処理は大方潰せるかと思います。
あとはクエリパラメータによる並び替えや特殊な表示のためのデータ追加などページ固有の処理をどう書いていくかです。

ここでコントローラと1対1で対応するサービスクラスを許容するかかなり悩みましたが、この対応が必要なページは全体の1割にも満たなくて、下手にイレギュラーを増やすと新規実装者が間違えて❝サービスクラス❞を生んでしまいかねないと思い、少々強引ですが以下の2つの方法で対応しました。

Entityを抽出できるものに関しては、DBアクセスがなくてもUseCases/で処理する

例えば予約システムの場合、単純に予約を時間順で並べていくといったこと以外にも、チャート形式で表示するといった要件もありえると思います。

この場合、チャート形式で表示するといった特殊なロジックを、チャート用のUseCases/で実装して、チャートのEntityとして抽出することで対応します。

コントローラの private static メソッドで対応する

これまでのどれにも当てはまらない処理に関しては、コントローラに書きます。
これはコントローラの肥大化を招きかねないので、あまりいい方法ではないと思います。
なにかいい方法があれば是非教えていただきたいです。

private で static な理由は、コントローラ固有であることと状態を持っていない(持つ必要がない)処理であることを明示するためです。

さいごに

最後まで読んでいただきありがとうございます。
改善点やご指摘があれば、是非よろしくお願い致します。

参考

106
111
5

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
106
111