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

【Laravel】「『Controllerに入る』と思ったならッ! その時スデに(ほぼ)ビジネスロジックは終わっているんだッ!」という、DIコンテナのお話

More than 1 year has passed since last update.

発端





QiitadonでDIの話題が盛り上がっていた時に「LaravelのDIはつよい」みたいなことを書いたら一部反響があったので、その解説です。

はじめに

LaravelのDIコンテナ(サービスコンテナ)はめちゃ強力です。「DIコンテナとは何ぞや」という説明は良記事が大量に存在するので詳細を省きますが、超初心者向けに端折った説明をすると「クラスをnewするときに必要なインスタンスを外からブチ込んでくれる人1」みたいな感じです。

実際にコイツのヤバさをサンプルコードで確認してみましょう。

RequestFormを用意する

まず、検索リクエストを雑にバリデーションするSearchRequestFormクラスを用意します。
※とりあえず「ブログのポストを検索する」みたいなイメージです。

SearchFormRequest.php
namespace Acme\DIBlog\Application\Requests;
use Illuminate\Foundation\Http\FormRequest;

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

    public function rules()
    {
        return [
            'id'    => 'integer',
            'title' => 'max:50',
            'body'  => 'max:10000',
        ];
    }
}

Usecaseクラスを用意する

次に、実際のビジネスロジックを扱うSearchPostUsecaseクラスを用意します。「検索値があったらLike句追加して検索する」みたいな、よく見かけるアレですね。

SearchPostUsecase.php
namespace Acme\DIBlog\Application\Usecases;
use Acme\DIBlog\Infrastructure\Eloquents\Post;

class SearchPostUsecase
{
    public function __invoke($id = null, $title = null, $body = null)
    {
        $posts = new Post();
        $result = $posts->when(!is_null($id), function ($query) use ($id) {
            return $query->where('posts.id', 'like', "%{$id}%");
        })->when(!is_null($title), function ($query) use ($title) {
            return $query->where('posts.title', 'like', "%{$title}%");
        })->when(!is_null($body), function ($query) use ($body) {
            return $query->where('posts.body', 'like', "%{$body}%");
        })->get();
        return $result;
    }
}

__invoke()はマジックメソッドです。コイツが書かれたクラスのインスタンスは、$usecase($id, $title, $body)のように関数ライクな呼び出しが可能です。

ちなみにEloquentはこんな感じ。ほぼ何も書いてません。

Post.php
namespace Acme\DIBlog\Infrastructure\Eloquents;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $guarded = ['id'];
}

Controllerを用意する

本題のControllerです。なんと検索メソッドが1行で完結しています。
前述のFormRequestとUsecaseを使用していますが、newでインスタンスを作る処理が見当たりません。単に外部から引数として貰っているだけです。おやおや…?

PostController.php
namespace Acme\DIBlog\Application\Controllers;

use Acme\DIBlog\Application\Requests\SearchFormRequest;
use Acme\DIBlog\Application\Usecases\SearchPostUsecase;
use App\Http\Controllers\Controller;

class PostController extends Controller
{
    public function search(SearchFormRequest $request, SearchPostUsecase $Usecase)
    {
        return $usecase($request->id, $request->title, $request->body);
    }
}

Routerを眺める

念のためにRouterも確認してみましょう。こちらもFormRequestUsecaseを扱った形跡は見当たりません。単に「/post/searchを叩いたときはPostControllersearchメソッドを呼び出す」とだけ記述されています。

api.php
use Illuminate\Routing\Router;
use Acme\DIBlog\Application\Controllers\PostController;

/**
 * @var \Illuminate\Routing\Router $router
 */
$router->name('post.')->prefix('posts')->group(function (Router $router) {
    $router->name('search')->get('/search', PostController::class.'@search');
});

メイン処理のコードはこれでおしまいです。結局、Controllerで使用するクラスをインスタンス化する処理は一行も記述しませんでした。

実行してみる

ためしに適当なデータでAPIを叩いて見ましょう。
まず、次のような雑Seederでデータをブチ込みます。

postsTableSeeder.php
namespace Acme\DIBlog\Infrastructure\Seeds;

use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class postsTableSeeder extends Seeder
{
    public function run()
    {

        Model::unguard();

        DB::table('posts')->truncate();
        DB::table('posts')->insert([
            [
                'id'         => '1',
                'title'      => '初投稿です!',
                'body'       => 'はじめましてこんにちは!'
            ],
            [
                'id'         => '2',
                'title'      => '次の投稿です!!',
                'body'       => 'どうもよろしくおねがいします!!'
            ],
            [
                'id'         => '3',
                'title'      => 'またまたの投稿です!!!',
                'body'       => '僕の活躍に期待して下さいね!!!'
            ],
        ]);

        Model::reguard();

    }
}

APIを叩きます。今回は「Restlet Client」http://localhost:8080/api/posts/search?title=初投稿GETしてみました。すると…?

image.png

結果.json
[
    {
        "id": 1,
        "title": "初投稿です!",
        "body": "はじめましてこんにちは!"
    }
]

きちんとデータが検索できている!! しゅごい!!

どういうことなの…?

Laravelには「特定クラスのメソッド引数において、タイプヒント指定したクラスを自動的にインスタンス化してブチ込んでくれる」というステキ機能が付いています。Controllerはそのようなクラス群の一つというわけですね。
Controllerのように「様々なクラスをかき集めて処理を実現する」という役割を任されやすいクラスにおいて、「依存性解決の処理が肥大化して保守性が低下する」という光景は非常にありがちです。しかしLaravelなら、初心者が陥りがちな「Fat Controller」を自然に回避できる仕組みが最初から備わっているのです。便利ですね!!

もっと危険なサンプル

実はこの機能、「引数のクラス解決が多段式に作用する」という特長があります。すなわち、「『Controllerが必要とするクラス』が必要とするクラス」もまた、自動的に解決されるのです。

もう少し複雑なサンプルを作ってみましょう。先述したサンプルと同じBlog検索機能を、わざと多段式のクラス解決を使って実装してみます。今回はレイヤード・アーキテクチャっぽく「なんちゃって」層構造を持たせてみることにしました。2

クラスの挿入構造を図にすると、ざっくり次のような感じでしょうか。

この一連の挿入を、Controllerのメソッドインジェクションで一挙に終わらせます。

Controller

根元から遡るように見ていきましょう。まずはControllerです。
呼び出すUsecaseクラスを変更しましたが、相変わらずまったく仕事をしていません。

PostController.php
namespace Acme\DIBlog\Application\Controllers;

use Acme\DIBlog\Application\Requests\SearchFormRequest;
use Acme\DIBlog\Application\Usecases\AdvancedSearchPostUsecase;
use App\Http\Controllers\Controller;

class PostController extends Controller
{
    public function advancedSearch(SearchFormRequest $request, AdvancedSearchPostUsecase $usecase)
    {
        return $usecase($request->id, $request->title, $request->body);
    }
}

Usecase

続いて新しいUsecaseクラスです。以前は熱心にクエリを作っていたのに、Controllerの真似をして他所のクラス(SearchPostQueryService)に仕事を丸投げしています。しかも、丸投げ先のクラスは__construct経由で外部から貰っていて、自分で作ろうとすらしていません。つまりこのSearchPostQueryServiceが、UsecaseがLaravelから自動的に受け取るクラスになるわけです。

ちなみに、レイヤード・アーキテクチャではUsecaseのようなApplication層のクラスは「DBに関わる知識」を持たない方がよいとされます。つまり、「Controller」同様にこの状態は良い意味で「仕事をしていない」「依存性が少ない」状況と言えます。

AdvancedSearchPostUsecase.php
namespace Acme\DIBlog\Application\Usecases;
use Acme\DIBlog\Infrastructure\QueryService\SearchPostQueryService;

class AdvancedSearchPostUsecase
{
    protected $service;

    public function __construct(SearchPostQueryService $service)
    {
        $this->service = $service;
    }

    public function __invoke($id = null, $title = null, $body = null)
    {
        $result = call_user_func($this->service, $id, $title, $body);
        return $result;
    }
}

QueryService

仕事をブン投げられたSearchPostQueryServiceです。かつてUseCaseが担っていたクエリ生成をInfrastructure層に属する彼が一手に引き受け、結果をPostDTOというDomain層のクラスにマッピングして返却しています。(えらい…)

「クエリを作るならRepositoryじゃないの?」という意見もありそうですが、今回は永続化を考えるのが面倒なのでCQRS(Command/Query分離原則)を意識して、「読取専用の情報を取得(Query)」するクラスをQueryServiceとして位置付けてみました。

SearchPostQueryService.php
namespace Acme\DIBlog\Infrastructure\QueryService;

use Acme\DIBlog\Infrastructure\Eloquents\Post;
use Acme\DIBlog\Domain\Models\PostDTO;

class SearchPostQueryService
{
    protected $eloquent;

    public function __construct(Post $eloquent)
    {
        $this->eloquent = $eloquent;
    }

    public function __invoke($id = null, $title = null, $body = null)
    {
        $result =  $this->eloquent
                        ->when(!is_null($id), function ($query) use ($id) {
                            return $query->where('posts.id', 'like', "%{$id}%");
                        })->when(!is_null($title), function ($query) use ($title) {
                           return $query->where('posts.title', 'like', "%{$title}%");
                        })->when(!is_null($body), function ($query) use ($body) {
                            return $query->where('posts.body', 'like', "%{$body}%");
                        })->get()
                        ->map(function ($item) {
                            return new PostDTO($item->id, $item->title, $item->body);
                        });

        return $result;
    }

}

DTO

検索結果行を格納するDTO(Data Transfer Object)です。Controllerは最終的に当クラスのCollectionを戻り値として返却します。検索結果は基本読み取り専用なので、不要な状態変更を避けるためにsetter等は用意していません。いわゆる完全コンストラクタに近い形です。

なお、このクラスはLaravelのJsonableインターフェースをimplementsしているので、クラスからJsonに直接シリアライズ可能です。コレがないとControllerから直接Collectionを返却する際にJsonに変換することができず、取得値が空になっちゃうのでご注意下さい。本来ならJson化はDomain層の責務じゃありませんが、「なんちゃって」なので許してね…

PostDTO.php
namespace Acme\DIBlog\Domain\Models;
use Illuminate\Contracts\Support\Jsonable;

class PostDTO implements Jsonable
{
    protected $id;
    protected $title;
    protected $body;

    public function __construct($id, $title, $body)
    {
        $this->id = $id;
        $this->title = $title;
        $this->body = $body;
    }

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

    public function getTitle()
    {
        return $this->title;
    }

    public function getBody()
    {
        return $this->body;
    }

    public function toJson($options = 0)
    {
        $vars = [
            'id'   => $this->id,
            'title'=> $this->title,
            'body' => $this->body
        ];
        return json_encode($vars, $options);
    }
}

実行

再びAPIを叩いてみましょう。http://localhost:8080/api/posts/advanced_search?title=初投稿GETしてみました。(微妙にURLを変えているので注意)
image.png

advance.json
[
    {
        "id": 1,
        "title": "初投稿です!",
        "body": "はじめましてこんにちは!"
    }
]

同じ結果が取れていますね! やったー!

おしまい!

今回はタイプヒントによる挿入だけを扱いましたが、Laravelには他にも様々なサービスコンテナの使用法が存在します。色々試してみてくださいね!

2018/8/28 追記

GitHub上に当記事のコードを含むサンプルプロジェクト(Laravel-di-sample)を作成しました。
興味のある方は是非ご参考下さい。

ところで…

「明日中には記事が書き上がりそうだな!」とグッスリ寝たら、夜中に完全上位互換っぽい記事が上がっていて朝からうなだれる事件がありました…。(仕事が速い男になりたい人生だった…)

せっかくなのでこの場を借りて紹介します。クリーンアーキテクチャライクな設計で言えばこちらの方がずっと洗練されているのでぜひ一読を!
Laravel で Request, UseCase, Resource を使いコントロールフローをシンプルにする

また、今回のUsecaseパターンを使った設計は@shin1x1氏の「DDDパターンを活用した Laravelアプリケーション開発」に多大な影響を受けています(大分端折ってますが)。こちらも大変優れた資料なので、ご参考まで。

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
ユーザーは見つかりませんでした