発端
QiitadonでDIの話題が盛り上がっていた時に「LaravelのDIはつよい」みたいなことを書いたら一部反響があったので、その解説です。
はじめに
LaravelのDIコンテナ(サービスコンテナ)はめちゃ強力です。「DIコンテナとは何ぞや」という説明は良記事が大量に存在するので詳細を省きますが、超初心者向けに端折った説明をすると「クラスをnewするときに必要なインスタンスを外からブチ込んでくれる人[1]」みたいな感じです。
[1]:かなり雑な説明。真面目に書くなら「クラスに関わる依存性の取り扱いを責務とするフレームワークの総称」という方が適切ですが、まあ初心者はよくわからんと思うので手を動かして勘を掴んだ方がよいかと思う次第。
実際にコイツのヤバさをサンプルコードで確認してみましょう。
RequestFormを用意する
まず、検索リクエストを雑にバリデーションするSearchRequestForm
クラスを用意します。
※とりあえず「ブログのポストを検索する」みたいなイメージです。
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句追加して検索する」みたいな、よく見かけるアレですね。
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はこんな感じ。ほぼ何も書いてません。
namespace Acme\DIBlog\Infrastructure\Eloquents;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $guarded = ['id'];
}
Controllerを用意する
本題のControllerです。なんと検索メソッドが1行で完結しています。
前述のFormRequestとUsecaseを使用していますが、new
でインスタンスを作る処理が見当たりません。単に外部から引数として貰っているだけです。おやおや…?
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も確認してみましょう。こちらもFormRequest
とUsecase
を扱った形跡は見当たりません。単に「/post/search
を叩いたときはPostController
のsearch
メソッドを呼び出す」とだけ記述されています。
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でデータをブチ込みます。
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
してみました。すると…?
[
{
"id": 1,
"title": "初投稿です!",
"body": "はじめましてこんにちは!"
}
]
きちんとデータが検索できている!! しゅごい!!
どういうことなの…?
Laravelには**「特定クラスのメソッド引数において、タイプヒント指定したクラスを自動的にインスタンス化してブチ込んでくれる」**というステキ機能が付いています。Controllerはそのようなクラス群の一つというわけですね。
Controllerのように「様々なクラスをかき集めて処理を実現する」という役割を任されやすいクラスにおいて、「依存性解決の処理が肥大化して保守性が低下する」という光景は非常にありがちです。しかしLaravelなら、初心者が陥りがちな「Fat Controller」を自然に回避できる仕組みが最初から備わっているのです。便利ですね!!
もっと危険なサンプル
実はこの機能、「引数のクラス解決が多段式に作用する」という特長があります。すなわち、「『Controllerが必要とするクラス』が必要とするクラス」もまた、自動的に解決されるのです。
もう少し複雑なサンプルを作ってみましょう。先述したサンプルと同じBlog検索機能を、わざと多段式のクラス解決を使って実装してみます。今回はレイヤード・アーキテクチャっぽく「なんちゃって」層構造を持たせてみることにしました。[2]
[2]:あくまでも「なんちゃって」です。真面目にやるならもう少しInterfaceを挟む等の工夫が必要になります。
クラスの挿入構造を図にすると、ざっくり次のような感じでしょうか。
この一連の挿入を、Controllerのメソッドインジェクションで一挙に終わらせます。
Controller
根元から遡るように見ていきましょう。まずはControllerです。
呼び出すUsecase
クラスを変更しましたが、相変わらずまったく仕事をしていません。
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」同様にこの状態は良い意味で「仕事をしていない」「依存性が少ない」状況と言えます。
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
として位置付けてみました。
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層の責務じゃありませんが、「なんちゃって」なので許してね…
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を変えているので注意)
[
{
"id": 1,
"title": "初投稿です!",
"body": "はじめましてこんにちは!"
}
]
同じ結果が取れていますね! やったー!
おしまい!
今回はタイプヒントによる挿入だけを扱いましたが、Laravelには他にも様々なサービスコンテナの使用法が存在します。色々試してみてくださいね!
2018/8/28 追記
GitHub上に当記事のコードを含むサンプルプロジェクト(Laravel-di-sample)を作成しました。
興味のある方は是非ご参考下さい。
ところで…
「明日中には記事が書き上がりそうだな!」とグッスリ寝たら、夜中に完全上位互換っぽい記事が上がっていて朝からうなだれる事件がありました…。(仕事が速い男になりたい人生だった…)
せっかくなのでこの場を借りて紹介します。クリーンアーキテクチャライクな設計で言えばこちらの方がずっと洗練されているのでぜひ一読を!
Laravel で Request, UseCase, Resource を使いコントロールフローをシンプルにする
また、今回のUsecaseパターンを使った設計は@shin1x1氏の「DDDパターンを活用した Laravelアプリケーション開発」に多大な影響を受けています(大分端折ってますが)。こちらも大変優れた資料なので、ご参考まで。