Aspect Weaving with DDD
今年はLaravel界隈でもDDDの話題が目立つ様になりました。
データベースなどのストレージへのアクセスを抽象化させる目的で、
リポジトリパターンを利用するケースが増えてきたのではないかと思います。
ビジネス領域に焦点を合わせて設計を行うため、
当然ながら、みなさんがそれぞれ開発するアプリケーションでは
どれがEntityとなるのか、ValueObjectはどれになるかは大きく異なり、
アプリケーションごとにDomain Serviceは大きく変わるのをよく目にすると思います。
そんなモデリングの世界において、
データベースのトランザクションや、ロギング、キャッシュといったビジネス領域に当てはまらないケースが多いものはどの様に分離するべきでしょうか?
feature
開発に入る前に仕様や、ストーリーなどを記述することが多いと思います
Story: Account Holder withdraws cash
As an Account Holder
I want to withdraw cash from an ATM
So that I can get money when the bank is closed
Scenario 1: Account has sufficient funds
Given the account balance is \$100
And the card is valid
And the machine contains enough money
When the Account Holder requests \$20
Then the ATM should dispense \$20
And the account balance should be \$80
And the card should be returned
(上記は口座などの入出金の例です)
実装時にはこうしたユーザーに提供する機能を整理し、それにそったEntityなどを用いて設計を行います。
例えば上記のシナリオの中で、データベースのトランザクションや、
処理のログ、データのキャッシュといったものは出てきません。
これらの処理はシナリオには登場しませんが、
このアプリケーションのシステム要件としては重要な役割として、
このシナリオ以外の多くの処理のなかで利用されるものになると予想されます。
こうした横断的な関心事をドメインオブジェクトから取り除き、
よりよいアプリケーション設計の手助けになるものとしてアスペクト指向プログラミングが利用できます。
ToDoリスト
利用者は自分だけのシンプルなToDoリストを例にしてみます。
ここでは ToDoリストを一覧で見ることができる 機能を実装します。
フレームワークの機能を利用せずにこれを表現してみましょう
サンプルコードをご覧になりたい方はこちら
Entity
namespace Acme\Domain\Entity;
use Acme\Domain\ValueObject\TaskStatus;
use PHPMentors\DomainKata\Entity\EntityInterface;
/**
* Class Task
*/
final class Task implements EntityInterface
{
/** @var int */
private $id;
/** @var string */
private $taskName;
/** @var TaskStatus */
private $taskStatus;
/**
* Task constructor.
*
* @param int $id
* @param string $taskName
* @param TaskStatus $taskStatus
*/
public function __construct(int $id, string $taskName, TaskStatus $taskStatus)
{
$this->id = $id;
$this->taskName = $taskName;
$this->taskStatus = $taskStatus;
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return string
*/
public function getTaskName(): string
{
return $this->taskName;
}
/**
* @return TaskStatus
*/
public function getTaskStatus(): TaskStatus
{
return $this->taskStatus;
}
}
ごく普通のタスクであり、ステータスを自由に記述できるものとしています
(ステータスを変更してもタスク自体は変わらないためValue Objectにしています)
Repository
namespace Acme\Domain\Repository;
use Acme\Domain\Entity\TaskCriteria;
use Acme\Domain\Entity\TaskCollection;
use PHPMentors\DomainKata\Entity\EntityInterface;
use PHPMentors\DomainKata\Repository\RepositoryInterface;
use PHPMentors\DomainKata\Repository\Operation\CriteriaBuilderInterface;
/**
* Class TaskRepository
*/
class TaskRepository implements RepositoryInterface
{
/**
* @param EntityInterface $entity
*/
public function add(EntityInterface $entity)
{
// TODO
}
/**
* @param EntityInterface $entity
*/
public function remove(EntityInterface $entity)
{
// TODO
}
/**
* @param CriteriaBuilderInterface $criteria
*
* @return \Acme\Domain\Entity\Task[]|array
*/
public function queryBy(CriteriaBuilderInterface $criteria)
{
/** @var TaskCriteria $criteria */
$criteria = $criteria->build();
return (new TaskCollection($criteria->all()))->toArray();
}
}
タスクのリポジトリで、フレームワークの知識を排除しています。
Collection
namespace Acme\Domain\Entity;
use Acme\Domain\ValueObject\TaskStatus;
use PHPMentors\DomainKata\Entity\EntityInterface;
use PHPMentors\DomainKata\Entity\EntityCollectionInterface;
class TaskCollection implements EntityCollectionInterface
{
/** @var array */
protected $tasks;
/**
* TaskCollection constructor.
*
* @param array $tasks
*/
public function __construct(array $tasks = [])
{
$this->tasks = $tasks;
}
// 省略
/**
* @return Task[]
*/
public function toArray(): array
{
$entities = [];
foreach ($this->tasks as $task) {
$entities[] = new Task($task['id'], $task['task_name'], new TaskStatus($task['task_status']));
}
return $entities;
}
/**
* @return int
*/
public function count(): int
{
return count($this->tasks);
}
}
リポジトリはこのコレクションを利用してEntityを返却します。
Service
namespace Acme\Domain\Service;
use Acme\Domain\Repository\TaskRepository;
use PHPMentors\DomainKata\Entity\EntityInterface;
use PHPMentors\DomainKata\Service\ServiceInterface;
use PHPMentors\DomainKata\Repository\RepositoryInterface;
use PHPMentors\DomainKata\Repository\Operation\CriteriaBuilderInterface;
/**
* Class TaskList
*/
class MyTaskList implements ServiceInterface
{
/** @var RepositoryInterface|TaskRepository */
protected $repository;
/**
* MyTaskList constructor.
*
* @param RepositoryInterface $repository
*/
public function __construct(RepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* @param CriteriaBuilderInterface $criteriaBuilder
*
* @return \Acme\Domain\Entity\Task[]|array
*/
public function listOf(CriteriaBuilderInterface $criteriaBuilder)
{
return $this->repository->queryBy($criteriaBuilder);
}
}
リスト取得のみのシンプルなクラスです。
この機能とLaravelを結びつけるために、ここではアプリケーションサービスを利用します。
Application Service
namespace App\AppService;
use Acme\Domain\Entity\Task;
use Acme\Domain\Service\MyTaskList;
use Acme\Domain\Specification\TaskSpecification;
use PHPMentors\DomainKata\Service\ServiceInterface;
/**
* Class MyTaskApplicationService
*/
class MyTaskApplicationService
{
/** @var ServiceInterface|MyTaskList */
protected $service;
/** @var TaskSpecification */
protected $specification;
/**
* MyTaskApplicationService constructor.
*
* @param ServiceInterface $service
* @param TaskSpecification $specification
*/
public function __construct(ServiceInterface $service, TaskSpecification $specification)
{
$this->service = $service;
$this->specification = $specification;
}
/**
* @return \Acme\Domain\Entity\Task[]
*/
public function listOf(): array
{
return $this->service->listOf($this->specification);
}
}
ここではSpecificationパターンを利用しています。
フレームワークとドメイン層の接続は、サービスプロバイダを利用して行います。
namespace App\Providers;
use Acme\Domain\Service\MyTaskList;
use Acme\Domain\Specification\TaskSpecification;
use App\DataAccess\TaskArrayStorage;
use Illuminate\Support\ServiceProvider;
use App\AppService\MyTaskApplicationService;
use PHPMentors\DomainKata\Service\ServiceInterface;
/**
* Class AppServiceProvider
*/
final class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register()
{
$this->app->when(MyTaskApplicationService::class)
->needs(ServiceInterface::class)
->give(MyTaskList::class);
$this->app->resolving(TaskSpecification::class, function (TaskSpecification $specification) {
$specification->criteria(new TaskArrayStorage);
return $specification;
});
}
}
EloquentやQueryBuilderを使いデータベースとの接続を行い、
Specificationクラスと接続を行っています。(この例ではただの配列を渡しています)
多少大げさな例ではありますが、ドメイン層とフレームワークとも完全に分離させてTodoリストを表現することができました。
機能要件としてはこれで満たされたものとしましょう。
では、先に述べた様にデータのキャッシュや、タスク追加時のトランザクション処理はどこに実装すべきでしょうか?
リポジトリを継承し、cacheを利用可能な状態にするか、もしくはSpecificationクラスを利用して、
Cacheも依存として外から与えることもできます。
トランザクションは機能としては必要ですが、コードを追加するとなるとどうしても複雑になりがちです。
public function __construct(
ServiceInterface $service,
TaskSpecification $specification,
CacheManager $cacheManager
) {
$this->service = $service;
$this->specification = $specification;
$this->cacheManager = $cacheManager;
}
/**
* @return \Acme\Domain\Entity\Task[]
*/
public function listOf(): array
{
if ($this->cacheManager->has("task:list")) {
return $this->cacheManager->get("task:list");
}
$result = $this->service->listOf($this->specification);
if (count($result)) {
$this->cacheManager->add("task:list", $result, 120);
}
return $result;
}
データベースへ問い合わせ、結果が返却されれば次回以降はキャッシュする、という一般的な処理です。
結果をそのままキャッシュしてしまうと、0件やnullといった値もキャッシュしてしまうため判定を行っています
取得の例ですが、
タスク追加時にキャッシュを削除するなどどうしても要求仕様外の多くのものが必要になってしまいます。
こういった処理が多くなると、ドメインサービスと絡みあい、シンプルな機能であっても複雑になっていきます。
ビジネスロジックに注力できる様にこうした処理をアスペクト指向プログラミングを利用して分離することができます。
アスペクト指向の簡単な概要や例は去年のアドベントカレンダーなどをご覧ください。1
アスペクト指向による解決
Cache
では上記のキャッシュ処理にアスペクト指向を適用させてみましょう。2
/**
* MyTaskApplicationService constructor.
*
* @param ServiceInterface $service
* @param TaskSpecification $specification
*/
public function __construct(ServiceInterface $service, TaskSpecification $specification)
{
$this->service = $service;
$this->specification = $specification;
}
/**
* @Cacheable(cacheName="task:list")
* @return \Acme\Domain\Entity\Task[]
*/
public function listOf(): array
{
return $this->service->listOf($this->specification);
}
Cacheableアノテーションをメソッドのコメントを追記しています。
こうすることで実装コードに影響を与えることなく、キャッシュの利用を追加することができます。
当然ながら要件によってはnullなどのネガティブキャッシュをさせたい場合もあるかもしれません。
こうした場合でも、注釈(アノテーション)に加えることによって実装コードには影響を与えることなく、
Laravelのサービスコンテナの機能を使うことによって(拡張しています)、
簡単に表現できる様になります。
/**
* @Cacheable(
* cacheName="task:list",
* tags={"hello", "laravel"},
* lifetime=30,
* negative=true
* )
* @return \Acme\Domain\Entity\Task[]
*/
TransactionとLog
これまでの機能要件外のキャッシュと同様に、
アプリケーションの品質担保のためにタスク追加時のトランザクションと、Exceptionがスローされた場合のロギングも追加になるケースがほとんどだと思います。
これらはキャッシュの操作よりもより複雑になります。
これらをCache同様に実装コードに影響を与えることなく、トランザクションや失敗時のロギングなどが追加されます。
/**
* @LogExceptions
* @Transactional
*
* @param Task $task
* @throws \Exception
*/
public function append(Task $task)
{
throw new \Exception('database error');
}
上記の例で実際に出力されるものは以下のとおりです。
[2016-12-23 15:08:37] local.ERROR: LogExceptions:App\AppService\MyTaskApplicationService.append {"args":{"task":"[object] (Acme\\Domain\\Entity\\Task: {})"},"code":0,"error_message":"database error"}
laravel標準のlogger(monolog)を利用して、引数の情報などが一緒に出力されます。
(オブジェクトの中身までは出力されませんが、プリミティブな値はすべて表示されます。)
この他にもリトライ処理なども挙げられます。
other
他にもアスペクト指向は、機能拡張などにも役立てることができます。
例えばToDoリストで、10件以上登録されている場合は追加できない様にする、など
ToDoリストにタスクが登録ができる という基本的な機能の拡張機能として考えられます。
大抵の場合は、基本機能のクラスを継承して実装することがほとんどだと思いますが、
機能拡張が例えばユーザーのロールによって登録件数が異なるなど、
様々な条件が追加されることによって元の処理の多くの分岐が追加され、
複雑さが増してしまうケースが多くあります。3
こういった問題解決にもアスペクト指向を併用することによって、
アプリケーションの複雑さを回避しながら、ドメイン層の実装に注力できる様になるかもしれません。
(すべてを解決すものではありません・・。)
-
当然ながらAOPはLaravelに標準ではありません 利用する場合はパッケージをご利用くださいLaravel-Aspect ↩
-
アスペクト指向によるアプリケーション拡張 (詳しくはこちらも参照ください) ↩