Symfony2 で DDD を学びます

PHPメンターズ さんの「PHPメンターズ -> Symfony2ベースのDDD仕様パターンの利用サンプルを公開しました」を題材に、Symfony2 で ドメイン駆動設計(DDD: Domain-Driven Design)を学んでみようと思います。

セットアップ

phpmentors-jp/phpmentors-example-campaign · GitHub からダウンロードして、セットアップ。

$ php composer.phar self-update

$ php composer.phar update

$ php app/check.php

データベースの作成

app/config/parameters.yml の「database_name」が参照される様です。

$ php app/console doctrine:database:create
Created database for connection named `phpmentors`

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| ...                |
| phpmentors         |
| ...                |
+--------------------+

スキーマの生成

$ php app/console doctrine:schema:create
ATTENTION: This operation should not be executed in a production environment.

Creating database schema...
Database schema created successfully!

$ php app/console doctrine:schema:update --force
Nothing to update - your database is already in sync with the current entity metadata.

mysql> show tables;
+----------------------+
| Tables_in_phpmentors |
+----------------------+
| campaign             |
+----------------------+
1 row in set (0.00 sec)

mysql> desc campaign;
+-------------+--------------+------+-----+---------+----------------+
| Field       | Type         | Null | Key | Default | Extra          |
+-------------+--------------+------+-----+---------+----------------+
| id          | int(11)      | NO   | PRI | NULL    | auto_increment |
| title       | varchar(255) | NO   |     | NULL    |                |
| description | longtext     | NO   |     | NULL    |                |
| start_date  | datetime     | NO   |     | NULL    |                |
| end_date    | datetime     | NO   |     | NULL    |                |
+-------------+--------------+------+-----+---------+----------------+
5 rows in set (0.01 sec)

初期データの投入

データ投入用コマンドが用意されています。

$ php app/console app:testdata

mysql> select * from campaign;
+----+------------------------------------------+------------------------------+---------------------+---------------------+
| id | title                                    | description                  | start_date          | end_date            |
+----+------------------------------------------+------------------------------+---------------------+---------------------+
|  1 | 開始しているキャンペーン1                | キャンペーン1の説明          | 2014-06-27 18:25:23 | 2014-07-04 18:25:23 |
|  2 | 開始しているキャンペーン2                | キャンペーン2の説明          | 2014-06-28 18:25:23 | 2014-07-05 18:25:23 |
|  3 | 開始していないキャンペーン3              | キャンペーン3の説明          | 2014-06-29 18:25:23 | 2014-07-06 18:25:23 |
|  4 | 終了したキャンペーン4                    | キャンペーン4の説明          | 2014-06-21 18:25:23 | 2014-06-22 18:25:23 |
+----+------------------------------------------+------------------------------+---------------------+---------------------+
4 rows in set (0.00 sec)

テストコード実行

$ phpunit -c app src/Example/CampaignBundle/Tests/Page/CampaignPageTest.php
PHPUnit 3.7.21 by Sebastian Bergmann.
Configuration read from ...\phpmentors-campaign\app\phpunit.xml.dist
.
Time: 2 seconds, Memory: 14.50Mb
OK (1 test, 1 assertion)

ドメイン駆動設計(DDD: Domain-Driven Design)

エンティティを特定条件で抽出するようなメソッドリポジトリに追加する?
リポジトリメソッドを持たせてしまうと、リポジトリがさまざまな条件のファインダメソッドで膨れ上がり、
再利用しづらくなるため、仕様を仕様オブジェクトとしてくくり出す...というアプローチだそうです。

  • 単一のエンティティに対して、仕様を満たすかどうかを判定するメソッドを実装
  • リポジトリから仕様を満たすエンティティの集合を取得するメソッドを実装
src/Example/CampaignBundle/Domain
├── Data
│   ├── Campaign.php
│   └── Repository
│       └── CampaignRepository.php
├── Specification
│   └── OpenCampaignSpecification.php
└── Util
    └── Clock.php

ルーティング(URL)を確認してWebアクセス

$ php app/console router:debug

http://localhost/phpmentors-campaign/web/app_dev.php にアクセス。
確かにフィルタリングされています。

開催中のキャンペーン一覧

開始しているキャンペーン1
    開始日:6月27日
    説明:キャンペーン1の説明 
開始しているキャンペーン2
    開始日:6月28日
    説明:キャンペーン2の説明

app/logs/dev.log で実際に発行されたクエリを確認します。

doctrine.DEBUG:
SELECT
  t0.id AS id1, t0.title AS title2, t0.description AS description3,
  t0.start_date AS start_date4, t0.end_date AS end_date5
FROM
  campaign t0

んー... Repository::findAll()全データ採って来てArrayCollection::filter()フィルタリングしてるんですね...

◆コントローラ
class CampaignController extends Controller
{
    public function indexAction()
    {
        $campaignPage = $this->get('app.page.campaign');
        $campaignPage->index();

        return array('campaignPage' => $campaignPage);
    }

    ...
}

◆サービス定義
services:
  app.page.campaign:
    class: Example\CampaignBundle\Page\CampaignPage
    arguments: [ @domain.spec.open_campaign, @domain.repository.campaign ]

◆サービス実装
class CampaignPage extends AppPage
{
    public function index()
    {
        $this->campaignList = $this->campaignRepository->selectSatisfying($this->openCampaignSpec);
    }

◆レポジトリ
class CampaignRepository extends EntityRepository
{
    public function selectSatisfying(OpenCampaignSpecification $spec)
    {
        return $spec->satisfyingElementsFrom($this);
    }
}

◆仕様オブジェクト
class OpenCampaignSpecification
{
    public function isSatisfiedBy(Campaign $campaign)
    {
        if (
            ($campaign->getStartDate() <= $this->clock->getCurrentDateTime()) &&
            ($campaign->getEndDate() > $this->clock->getCurrentDateTime())
        ) {
            return true;
        }

        return false;
    }

    public function satisfyingElementsFrom(CampaignRepository $campaignRepository)
    {
        $campaignList = $campaignRepository->findAll();
        $campaignList = $campaignList->filter(function($campaign) { return $this->isSatisfiedBy($campaign); });

        return $campaignList;
    }
}