Action-Domain-Responder
MVCをより洗練させたパターンとして提唱された派生パターンです 1
このADRのフローは次の通りです
フロー
- WebハンドラはRequestを受け取り、ActionをDispatch
- ActionはDomainとやりとりを行う
- アクションはデータをResponderへ
- Responderは、アクションによって生成されたデータを使用してResponseを作成
- Responseをクライアントに返却
ほとんどのwebアプリケーションはこのパターンとなります。
特定のAction(endpoint)は、ドメイン、つまり特定の業務ロジックを実行、それをレスポンスとして返却します。
複数のエンドポイント、リソースを一つのコントローラで解決するのではなく、
一つ一つが独立したクラス、責務となりますので、より小さく、シンプルな実装となります。
メンテナンス性や、複雑さも生まれにくいパターンであるともいえると思います。
このパターンはフレームワーク依存ではなく、あくまで設計によることも多数ありますが、
慣れ親しんだMVCのControllerで複数のエンドポイントを待たせないように注意する必要があるかもしれません。
最近ではPSR-7対応をしているマイクロフレームワークがこのパターン前提になっているケースが多く見られます。
(__invokeでActionを記述するもの、もしくはPSR-15採用のもの) 2
Controllerに相当するActionもアプリケーションにとってはミドルウェアの一つに過ぎませんので、
自然な流れと言えるのかもしれません。
Laravel/Lumen でもこのパターンを取り入れることができます。
クラス設計でカバーできるものではありますが、__invokeメソッドを利用することで、
フレームワーク内部(router)でエンドポイントとクラスを結びつけることができます。3
ユーザー一覧取得APIの例
ユーザー情報を一覧で取得する業務ロジックを実装していきます。
まずはドメインの実装を行います。
Entity
ここで注力するものはユーザーとなりますので、最も小さなクラスであるEntityは次のようにします。
(ValueObjectは扱わない小さな例です)
namespace App\Domain\Entity;
use Cake\Chronos\Date;
use JMS\Serializer\Annotation as Serializer;
/**
* Class User
*/
class User implements EntityInterface
{
/**
* @var int
* @Serializer\Type("int")
* @Serializer\SerializedName("user_id")
*/
private $identifier;
/**
* @var string
* @Serializer\Type("string")
*/
private $name;
/**
* @Serializer\Accessor("getUserCreatedAt")
*/
private $createdAt;
/**
* User constructor.
*
* @param int $identifier
* @param string $name
*/
public function __construct(int $identifier, string $name)
{
$this->identifier = $identifier;
$this->name = $name;
}
/**
* @return int
*/
public function getIdentifier(): int
{
return $this->identifier;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return int
*/
public function getUserCreatedAt(): int
{
$today = new Date();
return $today->getTimestamp();
}
}
Entityを返却する際にシリアライズを行うために、jms/serializer を利用した例です。
返却時にEntityを何かしらの方法でjsonなどに整形する場合がほとんどだと思いますので、
それぞれのアプリケーションにあった方法でjsonなどへの変換を行うことになります。
(foreachやGeneratorを使うなど)
Entity Collection
データベースなどから取得した値をEntityとマップします。
PDOでサポートされているデータベースであればFETCH_CLASSを使って、マッピングすることもできます。
(Laravel/Lumenでも当然その機能を利用できます)
namespace App\Domain\Entity;
/**
* Class UserCollection
*/
class UserCollection
{
/** @var array */
private $users;
/**
* UserCollection constructor.
*
* @param array $users
*/
public function __construct(array $users)
{
$this->users = $users;
}
/**
* @return \App\Domain\Entity\User[]
*/
public function toArray(): array
{
$entities = [];
foreach ($this->users as $user) {
$entities[] = new User($user['id'], $user['name']);
}
return $entities;
}
}
Repository
Specificationを合わせた例です
namespace App\Domain\Repository;
use App\Domain\Entity\UserCollection;
use App\Domain\Specification\ActiveUserSpecification;
/**
* Class UserRepository
*/
class UserRepository implements UserRepositoryInterface
{
/**
* @param ActiveUserSpecification $specification
*
* @return array
*/
public function findAll(ActiveUserSpecification $specification): array
{
return $specification->satisfyingSpecification($this);
}
/**
* @param array $attributes
*
* @return \App\Domain\Entity\User[]|array
*/
public function queryAll(array $attributes): array
{
return (new UserCollection($attributes))->toArray();
}
}
Usecase
ユースケース: ユーザー情報を一覧で取得する を実装したクラスです。
namespace App\Domain\Usecase;
use App\Domain\Repository\UserRepositoryInterface;
/**
* Class ActiveUserSearchUsecase
*/
class ActiveUserSearchUsecase implements UsecaseInterface
{
/** @var UserRepositoryInterface */
protected $repository;
/**
* ActiveUserSearchUsecase constructor.
*
* @param UserRepositoryInterface $repository
*/
public function __construct(UserRepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* @param EntityInterface $entity
*
* @return array
*/
public function run(EntityInterface $entity): array
{
return $this->repository->findAll($entity);
}
}
ドメインとアクションでやりとりが可能なように次のようにクラスを記述します
Action
Laravel/Lumenではミドルウェアをコントローラのコンストラクタに記載をすることができますが、
このクラスは1つのルーティングのみに対応するため、ルーターで専用に記述することができます。
ルーティングに対応したドメインとのやりとり、Responderへの指示のみを行う小さなクラスです。
ここでは App\Responder\User\IndexResponder
が対応するResponderクラスになります。
namespace App\Action\User;
use Illuminate\Http\Request;
use App\Responder\User\IndexResponder;
use App\Domain\Usecase\ActiveUserSearchUsecase;
use App\Domain\Specification\ActiveUserSpecification;
use Symfony\Component\HttpFoundation\Response;
/**
* Class IndexAction
*/
class IndexAction
{
/** @var ActiveUserSearchUsecase */
protected $usecase;
/** @var ActiveUserSpecification */
protected $specification;
/**
* IndexAction constructor.
*
* @param ActiveUserSpecification $specification
* @param ActiveUserSearchUsecase $usecase
*/
public function __construct(
ActiveUserSpecification $specification,
ActiveUserSearchUsecase $usecase
) {
$this->specification = $specification;
$this->usecase = $usecase;
}
/**
* @param Request $request
* @param IndexResponder $responder
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function __invoke(Request $request, IndexResponder $responder): Response
{
return $responder->emit($this->usecase->run($this->specification));
}
}
上記のように__invokeメソッドを利用すると、routeには下記の記述方法が可能です。
$app->get('/users', IndexAction::class);
このようにエンドポイントと対応するActionクラスを結び付けられます。
Responder
上記のアクションに対応したResponderクラスはつぎのようになります。
ここではドメインの中心核であるEntityのシリアライズを行い、jsonのレスポンスを返却するようになっています。
namespace App\Responder\User;
use JMS\Serializer\Serializer;
use JMS\Serializer\SerializerBuilder;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Class IndexResponder
*/
class IndexResponder
{
/**
* @param array $attribute
*
* @return Response
*/
public function emit(array $attribute): Response
{
$data = [];
if (count($attribute)) {
$data = $this->serializer()->toArray($attribute);
}
return new JsonResponse($data, Response::HTTP_OK);
}
/**
* @return \JMS\Serializer\Serializer
*/
protected function serializer(): Serializer
{
return SerializerBuilder::create()->build();
}
}
最後にServiceProviderを使って、Domainと、アプリケーションが利用するデータストレージなどをバインドします。
namespace App\Providers;
use App\DataAccess\UserStorage;
use App\Domain\Repository\UserRepository;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Specification\ActiveUserSpecification;
use Illuminate\Support\ServiceProvider;
/**
* Class AppServiceProvider
*/
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register()
{
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
$this->app->resolving(ActiveUserSpecification::class, function (ActiveUserSpecification $specification) {
$specification->criteria(new UserStorage);
return $specification;
});
}
}
処理フローに合わせて一つ一つのクラスの責務が明らかになり、小さなクラスとなりました。
当然手法はこれだけではありませんので、
アプリケーションに合わせて手法で取り入れていくと良いのではないでしょうか。4
(Facade未使用の例です)