こんにちは、kazuheiです。Laravel Advent Calender 2019 8日目の記事です。
この記事について
2日目の@nunulkさんが Laravel のモデルクラスをどこに配置するか問題について考えてみるという記事を書いていらして、そこに「パターン4: アプリケーションと独立した Domain」というのが紹介されており、メリデメを書いて欲しいとありました。ちょうど自社で開発するM&Aクラウドというサービスがそのような設計方針を採用していたので紹介させていただきます。
なぜそのような設計にしているのか
このような設計にしたい理由は2点です。
- データベースへのアクセスをするクラスとロジックを持つクラスを明確に分ける
- LaravelのDIコンテナを利用して外部のデータソースへのアクセスを抽象化し、レイヤーを分けた設計をする
従来のPHPフレームワークでのMVCでは、viewに依存しないロジックを共有するための置き場が必然的にModelになってしまい、Modelがデータアクセスとロジックの2つの役割を持ってしまいます。さらに、複数のModelに関係のあるロジックを書くときに、どのModelにロジックを書けばよいかわらないといった問題も起きます。
また、Model自身がデータをすべて持ってしまっているため、Model内のロジックを更に他のクラスに分解するということが難しいため、Modelがどんどん大きくなっていきます。Model内のロジックを別々のクラスに切り出すことができないので、複数のModel同士でのロジックの共有がしづらくなります。
例えば、ECサイトを考えたときに、ユーザーとお店の両方がEmailを持っているというようなことはよくあることだと思いますが、このEmailの部分のロジックを共通化するということが、Model自体にロジックを書いていると容易には出来ません。
ModelをDomainとしてアプリケーションのロジック上必要な単位に組み直すことで、細かくクラス分けができ、ロジックの共有をしやすくなります。
データソースへのアクセスを抽象化すると、EloquentModelからQueryBuilderに実装を変更する。DBに直接アクセスするかCacheを通すようにするか選べるようにする。管理画面のデータだけは必ずMasterのデータベースから取る等、アクセスするデータソースを安全に切り替えられるというメリットがあります。
これにより当初の目的が達成されます。
概念的なモデルの方はドメインという名前をつけドメインクラスと呼ぶことにし。Modelの方はEloquentModelとしてデータベースにアクセスする便利な機能として位置づけます。
実現方法
実現方法の例として、Userというドメインのみがあるアプリケーションを考えます。実用性はないですが…。appにはユーザーに対するアプリケーションとしてのインターフェースを、domainにはロジックを、infraにはデータソースへのアクセスの実装を書いていきます。実際の開発しているサービスはappの部分にさらに細かい役割のクラス郡がありますが、今回はモデルをどうしているかという話なので割愛します。
ディレクトリ配置例
./app
├── Console
│ └── Commands
├── Exceptions
├── Http
│ ├── Controllers
│ ├── Middleware
│ └── Requests
└── Providers
└── RepositoryServiceProvider.php
./domain
├── Base
│ ├── BaseId.php
│ ├── BaseStringValue.php
│ └── BaseTime.php
├── Common
│ ├── Email.php
│ ├── RawPassword.php
│ └── HashedPassword.php
└── User
├── User.php
├── UserDomainService.php
├── UserId.php
├── UserList.php
├── UserMailer.php
└── UserRepository.php
./infra
├── EloquentModel
│ └── UserModel.php
└── EloquentRepository
├── Cached
│ └── UserRepository.php
└── UserRepository.php
またnamespaceを効かせるためにcomposer.jsonに設定を追加しています。
{
... 省略 ...
"autoload": {
"classmap": [
"database/seeds",
"database/factories"
],
"psr-4": {
"App\\": "app/",
"Domain\\": "domain/",
"Infra\\": "infra/"
}
},
... 省略 ...
}
autoloadのpsr-4にDomainとInfraを指定し、namespaceを追加します。
app
WebアクセスかConsoleのコマンドかに関係なくDomainのロジックを呼び出して、ユーザーのリクエストに答えるのがappです。
RepositoryServiceProviderでドメインのインターフェースに対してどの実装を使うかを設定できます。
<?php
namespace App\Providers;
use Domain\User\UserRepository;
use Illuminate\Support\ServiceProvider;
use Infra\EloquentRepository\UserRepository as EloquentUserRepository;
class RepositoryServiceProvider extends ServiceProvider
{
public function boot()
{
}
public function register()
{
$this->app->bind(UserRepository::class, EloquentUserRepository::class);
}
}
domain
domainはロジックを書く場所としていて、その実現方法はinfraに任せるように実装します。
domain内はフレームワークにも依存しない純粋なPHPのクラスのみになっています。baseに基本的なクラスのabstractクラスを、Commonに共通で使うクラスを、置いています。クラスをEmailやRawPassword、HashedPasswordなどの細かいクラス単位に分けることによって、メールアドレスの整合性やPasswordの検証などのロジックを細かいクラス単位に持たせることができます。ユーザーの種類が複数あるアプリケーションの場合は当然ここらへんのロジックを使い回せるわけです。
<?php
namespace Domain\User;
use Domain\Common\Email;
use Domain\Common\HashedPassword;
class User {
private $id;
private $email;
private $hashedPassword;
public function __construct(
UserId $id,
Email $email,
HashedPassword $hashedPassword
)
public function checkPassword(Hasher $hasher, RawPassword $rawPassword): bool
{
return $this->hashedPassword->check($hasher, $rawPassword);
}
}
<?php
namespace Domain\Common;
use Domain\Base\BaseStringValue;
use Illuminate\Contracts\Hashing\Hasher;
class HashedPassword extends BaseStringValue
{
public function check(Hasher $hasher, RawPassword $rawPassword): bool
{
return $hasher->check($rawPassword->rawValue(), $this->rawValue());
}
}
また、リポジトリーパターンを導入し、データソースへのアクセスはInterfaceをDomain側に、実装をInfra側に置いています。
<?php
namespace Domain\User;
use Domain\Common\Email;
interface UserRepository
{
public function get(UserId $id): User;
public function getByEmail(Email $email): User;
}
infra
infraには外部のデータソースにアクセスする実装を置きます。実装するそれぞれのクラスはDomain側のinterfaceを実装しています。
<?php
namespace Infra\EloquentRepository;
use Domain\Common\Email;
use Domain\User\User;
use Domain\User\UserId;
use Domain\User\UserRepository as UserRepositoryInterface;
class UserRepository implements UserRepositoryInterface
{
public function get(UserId $id): User
{
$model = UserModel::where('id', $id->rawValue())
->first();
if (!$model) {
throw new NotFoundException();
}
return $model->toDomain();
}
public function getByEmail(Email $email): User
{
$model = UserModel::where('email', $email->rawValue())->first();
if (!$model) {
throw new NotFoundException();
}
return $model->toDomain();
}
}
EloquentModelには自分自身をDomainクラスに変換する関数をもたせています。
<?php
namespace Infra\EloquentModel;
use Domain\User\User as UserDomain;
class User {
public function toDomain(): UserDomain;
{
return new UserDomain(
new UserId($this->id),
new Email($this->email),
new HashedPassword($this->password),
);
}
}
メリット、デメリット
この設計でのメリットとデメリットを紹介します。
メリット
当初の目的通り、ロジックとデータアクセスを分離して、ロジックのみのクラスを作ることができることが大きいです。これにより細かいクラスを作ってロジックを分解し、使い回すことができます。
また、レイヤーが分かれることにより、データアクセスについて実装が抽象化されるので、複数の実装を持つことができ、コンテキストによりすげ替えたり、実装を安全に移行したりできます。例えば、ユーザーからのアクセスではCacheを使ったアクセスをしたいが管理画面からはCacheを使わないデータアクセスをしたい、などのニーズにコンテキストに合わせてRepositoryServiceProviderで使うRepositoryの実装クラスを代えることで対応できます。
デメリット
デメリットは
- EloquentModelからDomainクラスへのデータをマッピング大変
- EloquentModelがただのDaoになっているので、機能を活かしきれない
といったようなところだと思います。
アプリケーションが複雑になり、Domain側のロジックが増えれば増えるほど、Infra側の面倒くささは相対的に少なくなってくるので、こっちについては諦めていいかなと思っています。あと、正直このパターンだったらもはやEloquentは使わなくても良いともいえます。
おわりに
Laravelは規約のないフレームワークなので、自分でディレクトリ分けやレイヤー分けを設計しなければいけず、その割にはこういうふうにしたほうが良いよ、という情報がまだまだ少ないと思うので、もっと色んな人にこのテーマについて書いて欲しいな〜と思っています。自分も初めてLaravelを使ったときはだいぶ手探りでした。
以前これに近いテーマでLTしたときの資料があるので、ぜひ見ていただければと思います。
https://speakerdeck.com/kazuhei0108/sabisukontenafalseshi-jian-de-nahuo-yong?slide=23
設計の参考にした書籍