Help us understand the problem. What is going on with this article?

Laravel5のアーキテクチャから学ぶより良いクラス設計

More than 3 years have passed since last update.

はじめに

ウェブアプリケーションフレームワークのクラス構成にはさまざまなバリエーションがありますが、どれも様々なデザインパターンを駆使し、素晴らしいクラス構成になっています。
今回、じっくりフレームワークのソースコードを読むことで、少しでもいいクラス設計について学べるといいなぁと思い、このような企画を思いつきました。
PHP には様々なウェブアプリケーションフレームワークがあり、それぞれに特徴がありますが、今回は、近年突出して注目されている Laravel を取り上げます (いずれ他のフレームワークでも試してみたいです)。

環境

PHP 5.6.9
Laravel 5.2

やったこと

  1. Eloquent (Active Record) と DBファサード (Query Builder) の使い分け、ついでに Repository について
  2. Dependency Injection と Service Container (Service Locator) の使い分け
  3. Middleware と Controller の関係

1. Eloquent (Active Record) と DBファサード (Query Builder) の使い分け、ついでに Repository について

1.1. Eloquent

主なクラスのパス

  • vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php -> ORMの抽象基底クラス
  • vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php -> QueryBuilder のラッパークラス

Eloquent は Active Record パターンを元にした、ORマッパーです。
RDBMS上のテーブルと対になり、SQLの SELECT, UPDATE, INSERT, DELETE をラップした操作を提供するクラスです。

例)

User.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
}

Active Record は、Martin Fowler によって定義づけられたデザインパターンです。

P of EAA: Active Record

Fowler の定義では、「テーブルまたはビューの行をラップするオブジェクト」とありますが、なぜか近年のウェブアプリケーションフレームワークで扱われる Active Record は、Laravel Eloquent や Ruby on Rails の ActiveRecord がそうであるように (Eloquent は ActiveRecord にインスパイアされた?)、Query Builder (後述) を使って、コレクション (行の集合) に対する操作も行えます。

元々の ActiveRecord

$user = new User();
$user.name = 'John';
$user.age = 20;
$user.save();

Eloquent

$users = User::where('age', '>=', 20)->get();
foreach ($users as $user) {
    $user->accepted = true;
    $user->save();
}

Eloquent の特徴

  1. アクセサーとミューテーター
  2. Query Builder を内包した ORM
  3. クエリスコープ
  4. ソフトデリート (論理削除)

1. アクセサーとミューテーター

User.php
<?php

namespace App;

class User extends Model
{
    public function getFullNameAttribute()
    {
        return $this->last_name . ' ' . $this->first_name;
    }

    public function setFirstNameAttribute($firstName)
    {
        $this->attributes['first_name'] = ucfirst($firstName);
    }
}

というエンティティクラスがあるとき、以下のように、クライアントクラスからはパブリックプロパティにアクセスするような形で、内部的にはミューテーターメソッド setFirstNameAttribute を呼び出しています。

HomeController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

use App\Http\Requests;
use App\User;

class HomeController extends Controller
{
    public function index()
    {
        $user = User::find(1);
        $user->first_name = 'john'; // 小文字で入れても先頭は大文字で登録される
        $user->last_name = 'Doe';
        $user->save();

        return view('home')->with('user', $user);
    }
}

また、ビューからオブジェクトのフィールドにアクセスする場合も同様に、内部的にはアクセサーメソッド getNameAttribute が呼び出されます。

home.blade.php
Name: {{ $user->full_name }}

内部で保持している値の形式とビューで表示する値の形式が異なるときや、複数のフィールドを組み合せてひとつの意味ある値にしたいとき (上の例のような) など、役に立ちます。

詳しくは、Model.php の __get, __set マジックメソッドのコードを追ってみてください。

2. Query Builder を内包した ORM

後述する DB ファサード とは別に、Eloquent はクエリービルダーを内包しています (厳密に言うと内包という表現は正しくなくて、内部で処理を Builder クラスへ委譲しています)。

例)

// ユーザーと同じドメインのメールアドレスを持つユーザーを取得する
$users = User::where('email', 'LIKE', '%' . substr($user->email, strpos($user->email, '@')))->get();

これは、以下のようにメソッド呼び出しが行われます。

Model::__callStatic -> Model::__call -> Eloquent\Builder::where -> Query\Builder::where

便利な半面、SQLライクな処理がモデル (エンティティ) の外に露出するため、凝集度が低くなりがちです。

それをある程度解決するために、次の「クエリスコープ」という仕組みを利用できます。

3. クエリスコープ

上の例を、「同じ組織の」ユーザーというコンテキストで集合を得るようにすると、以下のようになります。

$users = $user->isInSameOrganization()->get();

こうすることで、仮に「メールアドレスのドメインが同じ」≠「同じ組織」となった場合でも、呼び出し元は変更せずにモデル側だけを変更すれば済むようになります。

モデル側のコードは、以下のようになります。

User.php
    public function scopeIsInSameOrganization()
    {
        return $this->where('email', 'LIKE', '%' . substr($this->email, strpos($this->email, '@')));
    }

scope + メソッド名 でメソッドを定義すると、絞り込む条件をモデル内に隠蔽できます。

仮に organization_id というカラムが追加になったとしても、モデル側を

User.php
    public function scopeIsInSameOrganization()
    {
        return $this->where('organization_id', $this->organization_id);
    }

のように変更すれば、呼び出し元は変更せずに済む、というわけです。

4. ソフトデリート (論理削除)

Eloquent は論理削除の仕組みも提供しており、各モデルで実装する必要はありません。

SoftDeletes トレイト を use するのと、論理削除用のフィールド (デフォルトでは deleted_at) を追加するだけです。

User.php
class User extends Model
{
    use SoftDeletes;

    protected $dates = ['deleted_at'];

    // 省略
}

こうすると、

$users = User::all();

と呼び出したとき、

select * from `users` where deleted_at is null;

というクエリが実行されるようになります。

超絶便利ですね。

1.2. DB ファサード

DB ファサードは前述の Eloquent に内包された Query Builder とは別の Query Builder です (ややこしい)。

Laravel には他にもたくさんの Facade パターンを使ったクラスが用意されていて、いずれも後述の Service Container と結びついて Laravel アーキテクチャの根幹をなす要素のひとつになっているので、別の機会に取り上げたいと思います。

基本的には、テーブルとエンティティクラスが対になるように作っていくのですが、集計クエリなど、Eloquent で対応しきれない場合に使用します。

使い方はこんなかんじです。

$users = DB::table('users')
    ->join('contracts', 'contracts.user_id', '=', 'users.id')
    ->select('users.id', DB::raw('count(*) as contract_count'))
    ->orderBy('users.id')
    ->groupBy('users.id')
    ->having(DB::raw('count(*)'), '>', $count)
    ->get()
;

まぁ、個人的には、複雑なクエリーは生SQLで記述した方が読みやすいと思っているので、

$sql =<<<EOQ;
SELECT u.id, count(*) as contract_count
  FROM users
 INNER JOIN contracts c
    ON c.user_id = u.id
 GROUP BY u.id
HAVING count(*) > ?
 ORDER BY u.id
EOQ;

$users = DB::select($sql, [$count]);

みたいに書くと思います。

生SQLも書けますよ、というご紹介でした。

ちなみに、DB ファサードを使って取得する場合、戻り値は stdClass の配列になります。

1.3. リポジトリ

Repository パターン

Repository パターンは、Laravel では採用されていないので、完全に余談ですが、DDD (ドメイン駆動設計) 的にクラス設計するなら、Repository パターンを検討してもいいかもしれません (Symfony2/3 にはありますね)。

エンティティの集合を取得する責務をすべてリポジトリに与えます。

UserRepository.php
<?php
namespace App\Repository;

class UserRepository
{
    public function findInSameOrganization(User $user)
    {
        return User::where('organization_id', $user->organization_id)->get();
    }
}

DDD本 (「ドメイン駆動設計」エリック・エバンス著) における Repository パターンの特徴は以下のようなものです。

リポジトリは、特定の型のオブジェクトを、すべて概念上の集合(通常は、それを模したもの)として表現する。

(p150)

リポジトリには、次に挙げるものを含め、数多くの利点がある。
● クライアントに対して、永続化されたオブジェクトを取得し、そのライフサイクルを管理するためのシンプルなモデルを与える
● アプリケーションとドメインの設計を、永続化技術や複数のデータベース戦略、さらには複数のデータソースからも分離する
● オブジェクトアクセスに関する設計上の決定を伝える。
● テストで使用するために、ダミーの実装で置き換えるのが容易になる(インメモリコレクションを使用するのが一般的)。

(p151)

私が特に重視すべきと思っているのは、後半のふたつです。

設計上の意図を表現するためにメソッドを作る利点は、スコープクエリの項でも書いたように、ある条件を持つ集合が、概念上の条件 (「同じ組織に属する」) と実装上の条件 (「同一のメールアドレスドメインを持つ」) が異なる場合でも、その違いを吸収することができます。

また、最後の点、テストでの利点も明らかにあって、単体テストで ORM をモック化するよりも、Repository をモック化する方がずっと簡単ですし、ドメイン層のオブジェクト (例えば Service) の関心事 (上の例では、"InSameOrganization" の部分) がメソッドになっているので、テストを書く際に、オブジェクトの集合がどのような条件で選択されたものかを意識しやすくなります。

Eloquent の場合、スコープクエリを使えばこうした設計上の意図を明確にできますので、大抵の場合 Repository クラスは不要かもしれません。後述する Dependency Injection を使えば、テストにおけるモック化もラクにできます。

参考までに、以前書いた記事も載せておきます。

DDDにおけるレイヤードアーキテクチャを適用したLaravel Eloquentの使い方 - Qiita

1.4. 使い分け

基本的には Eloquent のみで事足りるのではないかと思っていますが、モデルクラスは原則的にテーブルと対になっているため、集約 (Aggregate) パターンを使って、リレーションを管理したい場合、自由度が高すぎてメンテナンスしにくいコードになっていく可能性があります。
集約ルートオブジェクトのみに Repository クラスを作って、データの取得の手段を制限することで、適切なモデル設計を維持していくことができれば、保守性を高くしていけるのかな、とも思います。

2. Dependency Injection と Service Container (Service Locator) の使い分け

Dependency Injection と Service Locator の違いはこちらの記事を参照ください。

Inversion of Control コンテナと Dependency Injection パターン

2.1. Dependency Injection

Laravel ではコンストラクタインジェクションが使えます。

ParkingLot という Eloquent なエンティティクラスがあるとき、コントローラークラスでは以下のようにコンストラクタでインスタンスを受け取ることができます。

HomeController.php
<?php

namespace App\Http\Controllers;

use App\ParkingLot;

class HomeController extends Controller
{
    public function __construct(ParkingLot $parkingLot)
    {
        $this->parkingLot = $parkingLot;
    }

    public function index()
    {
        $parkingLots = $this->parkingLot->all();

        return view('home')->with('parkingLots', $parkingLots);
    }
}

View はこうなっています。

home.blade.php
@foreach ($parkingLots as $parkingLot)
Name: {{ $parkingLot->name }}
@endforeach

テストは下記のようになります。

HomeControllerTest.php
<?php

use App\ParkingLot;

class HomeControllerTest extends TestCase
{
    /**
     * /home
     *
     * @return void
     */
    public function testIndex()
    {
        $parkingLots = [];
        $parkingLots[0] = new \stdClass;
        $parkingLots[0]->name = 'A駐車場';

        $mockParkingLot = Mockery::mock(ParkingLot::class);
        $mockParkingLot->shouldReceive('all')->once()->andReturn($parkingLots);
        $this->app->instance(ParkingLot::class, $mockParkingLot);

        $this->visit('/home')
             ->see('Name: A駐車場');
    }
}

この仕組を実現するのに、Container というクラスを使っています。

この辺でやってます。↓
https://github.com/illuminate/container/blob/5.2/Container.php#L725

vendor/laravel/framework/src/Illuminate/Container/Container.php

Container.php
        $dependencies = $constructor->getParameters();
        // Once we have all the constructor's parameters we can create each of the
        // dependency instances and then use the reflection instances to make a
        // new instance of this class, injecting the created dependencies in.
        $parameters = $this->keyParametersByArgument(
            $dependencies, $parameters
        );

        $instances = $this->getDependencies(
            $dependencies, $parameters
        );

        array_pop($this->buildStack);

        return $reflector->newInstanceArgs($instances);
    }

リフレクションからコンストラクタとパラメータリストを取得して、依存オブジェクトをインスタンス化しています。

ちなみに、メソッドインジェクション(?) もできて、アクションメソッド (Router から呼ばれるメソッド) でも依存オブジェクトを注入することができます。

HomeController.php
    /**
    * @param ParkingLot $parkingLot
    */
    public function index(ParkingLot $parkingLot)
    {
        $parkingLots = $parkingLot->all();

        return view('home')->with('parkingLots', $parkingLots);
    }

こちらは、RouteDependencyResolverTrait というトレイトを使って、依存オブジェクトをパラメータに渡しています。

この辺でやってます。↓
https://github.com/illuminate/routing/blob/5.2/RouteDependencyResolverTrait.php#L52

vendor/laravel/framework/src/Illuminate/Routing/RouteDependencyResolverTrait.php

2.2. Service Container

もうひとつ、ServiceProvider に登録して、サービスコンテナから呼び出す方法があります。

AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\ParkingLot;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton(ParkingLot::class, function () {
            return new ParkingLot();
        });
    }
}

クライアントクラスからは以下のように呼び出します。

HomeController.php
    public function index()
    {
        $parkingLot = app(ParkingLot::class);
        $parkingLots = $parkingLot->all();

        return view('home')->with('parkingLots', $parkingLots);
    }

この方法の利点は、サービスの初期化時にパラメータを渡せるところや、コンストラクタインジェクションでは、依存するサービスの数が多くなると引数が長くなって可読性が下がってしまうのを解消できる、とかでしょうか。

ServiceProvider を独自に作成できるので、関連するサービスをひとつの ServiceProvider でまとめて登録するのがよさそうです。

ServiceProvider の登録は Application クラスで行われます。

実は、Application クラスは前述の Container クラスを継承していて、自身がサービスコンテナになっています。

興味深いのは、ServiceProvider クラスには、サービス登録用の register メソッドの他に、サービス起動後の初期化処理を行う boot メソッドが用意されていて (php artisan make:provider で作成するとスケルトンに含まれます)、他のサービスとの関連付けなどができるようになっている点です。

あるサービス A が別のサービス B に依存していて、いずれも ServiceProvider によってコンテナに登録されるとき、A の register 時点で B が登録されているとは限らないので、すべてのサービスが登録された後に呼ばれる boot メソッド内で関連付けを行うようにします。

実例は、DatabaseServiceProvider などのコードを見てみてください。

https://github.com/illuminate/database/blob/5.2/DatabaseServiceProvider.php

vendor/laravel/framework/src/Illuminate/Database/DatabaseServiceProvider.php

2.3. 使い分け

コンストラクタインジェクションやメソッドインジェクションを使えば、依存オブジェクトを引数として表現できるので、できるだけこちらを使った方がいいと思います。
サービスコンテナは便利な反面、どこからでもアクセスできてしまうので、気をつけないと依存オブジェクトがどんどん増えてしまう恐れがあります。
サービスコンテナを使う場合でも、依存オブジェクトが多くなってきたな、と思ったら、集約 (Aggregate) パターンを新たに作るなどして、依存関係を減らすように努めると、設計も洗練されて行くんじゃないかと思います。

3. Middleware と Controller の関係

3.1. Middleware

Middleware も Laravel の特徴のひとつで、ルーティングの際のフィルターとして使われます。

例えば、認証や認可、CSRFトークンのチェックなどです。

ここでは、メンテナンスモードを簡単に実現する CheckForMaintenanceMode クラスを見てみましょう。

https://github.com/laravel/framework/blob/5.2/src/Illuminate/Foundation/Http/Middleware/CheckForMaintenanceMode.php

vendor/laravel/framework/src/Illuminate/Http/Middleware/CheckResponseForModifications.php

CheckForMaintenanceMode.php
    public function handle($request, Closure $next)
    {
        if ($this->app->isDownForMaintenance()) {
            throw new HttpException(503);
        }

        return $next($request);
    }

Closure である $next の実行位置が重要で、上のように、$next の前に処理をするなら Before フィルター、後なら After フィルターの役目をします (After フィルターの使い所が思い付かないのでだれか教えてください)。

ちなみに After フィルターの書き方はこうです。

AfterMiddleware.php
    public function handle($request, Closure $next)
    {
        $response = $next($request);

        // 何らかの処理

        return $response;
    }

閑話休題。

CheckForMaintenanceMode Middleware に戻って、Application::isDownForMaintenance を見てみます。

Application.php
    public function isDownForMaintenance()
    {
        return file_exists($this->storagePath().'/framework/down');
    }

/storages/framework/down というファイルを置くとメンテナンスモードに切り替わり、デフォルトでは "Be right back." というメッセージが表示されます (これは resources/views/errors/503.blade.php を書き換えれば変更できます)。

このデフォルトの実装だと、ある一定の期間だけメンテナンスモードにしたい、といったときに融通が利かないので (cron で touch -> rm するって方法はありますが、できればアプリケーション内で完結させたいですね)、新たに CheckForMaintenanceMode.php を作りましょう。

CheckForMaintenanceMode.php
<?php

namespace App\Http\Middleware;

use Closure;
use Symfony\Component\HttpKernel\Exception\HttpException;
use DB;

class CheckForMaintenanceMode
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ($this->isDownForMaintenance()) {
            throw new HttpException(503);
        }

        return $next($request);
    }

    protected function isDownForMaintenance()
    {
        $now = date('Y-m-d H:i:s');
        $maintenance = DB::table('maintenance')
            ->where('start_at', '<=', $now)
            ->where('end_at', '>', $now)
            ->first()
        ;

        return !empty($maintenance);
    }
}

単純なクエリなので、今回は DB ファサードを使ってみました。
もっと複雑な条件が必要なら、Maintenance クラスを作ってもいいかもしれません。

このクラスを作成してから、app/Kernel.php の以下の箇所を書き換えれば、メンテナンスモードに切り替えるロジックを上書きできます。

Kernel.php
    protected $middleware = [
        \App\Http\Middleware\CheckForMaintenanceMode::class,
    ];

簡単ですね。

この辺で各 Middleware の handle メソッドを呼び出しています。

https://github.com/illuminate/routing/blob/5.2/Router.php#L711

vendor/laravel/framework/src/Illuminate/Routing/Router.php

https://github.com/illuminate/pipeline/blob/5.2/Pipeline.php#L111

vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php

他にも、Kernel.php の中を見ると、$middlewareGroups$routeMiddleware といったプロパティがあるので、どのような動作をするのか調べてみると面白いかもしれません。

まとめ

以上、たった3つではありますが、Laravel 5 の内部を覗いて、その素晴らしい設計を堪能できたので、時間はかかりましたがやってよかったと思っています。

  • Eloquent は強力な ORM です、大抵の場合は DB ファサードや Repository は必要ないでしょう。ただし、Repository パターンを導入すれば、設計の意図をコードで表現しやすくなりますので、検討してみましょう。
  • DI を使うとクラスの依存関係を明確にできます。依存関係が増えてしまった場合、サービスコンテナを使いたくなるかもしれませんが、依存関係が曖昧になるので、サービスの構成を見直すなどして、依存関係を少なく保つようにした方がいいかもしれません。
  • Middleware は複雑な仕組みですが、アプリケーション側の利便性はとても高いです。複雑性をどうやって隠蔽するか、という見本のようなつくりなので、参考にしたいです (できるかな…)。

Happy Laravel coding!

nunulk
PHP, Laravel, オブジェクト指向プログラミング, デザインパターン, リファクタリング, 関数プログラミング, etc.
http://nunulk.hatenablog.com
phper-oop
ペチオブはオブジェクト指向ワーキンググループです。様々なエンジニアの方に参加頂いております。
https://phper-oop.connpass.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした