Symfony + Doctrine でマルチドメイン対応

ひとつの Web アプリを、複数の顧客に対し、ドメインを分けてそれぞれにサービスを提供したい。各顧客はそれぞれでユーザーを登録し、ログインをおこなう。データはドメインごとに完全に独立させたい。

## 環境

- Symfony 2.8
- Doctrine 2.4

## 参考

- [Doctrine filters and annotations to improve security and ease development in a Symfony app](http://blog.michaelperrin.fr/2014/12/05/doctrine-filters/)

## 方針

- 顧客をアクセス時のサブドメインで識別する。(DNS や証明書はワイルドカードで何でもアクセスできるようにしておく。)
- すべてのテーブルに customer_id フィールドを持たせる。
- 読み出すときは検索条件に常に customer_id による絞り込みをおこなう。
- 書き込むときは常に customer_id を設定する。

## 実装

### すべてのテーブルに customer_id フィールドを持たせる

定義を共通化するため、customer_id フィールドを定義した trait を作成する。

```php
<?php
namespace AppBundle\Filter\Doctrine;

trait CustomerAware
{
    protected $customerId;

    public function setCustomerId($customerId)
    {
        $this->customerId = $customerId;
        return $this;
    }

    public function getCustomerId()
    {
        return $this->customerId;
    }
}
```

この trait を、顧客毎に分離する必要があるすべての Entity から use する。

```php
<?php
namespace AppBudnle\Entity;

import AppBundle\Filter\CustomerAware;

class User {
    use CustomerAware;

    ...
}
```

### 顧客IDをアクセスドメインから取得する

アクセスドメインから顧客IDを取得するためのサービスを作成する。RequestStack->getMasterRequest()->getHost() でドメインを取得し、先頭の要素を顧客IDとする。

```php
<?php
namespace AppBundle\Library;

use Symfony\Component\HttpFoundation\RequestStack;

class CustomerManager
{
    protected $requestStack;

    function __construct(RequestStack $requestStack)
    {
        $this->requestStack = $requestStack;
    }

    function getCurrentCustomerId()
    {
        $request = $this->requestStack->getMasterRequest();
        $h = explode('.', $request->getHost());
        return $h[0];
    }
}
```

サービスに登録する。

```yml:app/config/services.yml
services:
    app.customer:
        class: AppBundle\Library\CustomerManager
        arguments: [ "@request_stack" ]
```

### DBからの読み出し時、常に customer_id による絞り込みをおこなう

Doctrine の Filter を使用すると、SELECT 時に常に条件を付与することができる。

SQLFilter を継承したクラスを作成する。フィルタ対象は CustomerAware トレイトを use した Entity のみとする。比較対象の顧客IDは、フィルタ初期化時に setCustomerId() で設定する(後述)。

```php
<?php
namespace AppBundle\Filter\Doctrine;

use Doctrine\ORM\Mapping\ClassMetaData;
use Doctrine\ORM\Query\Filter\SQLFilter;

class CustomerFilter extends SQLFilter
{
    protected $customerId = null;

    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
    {
        // The Doctrine filter is called for any query on any entity
        // Check if the entity uses CustomerAware
        if (!in_array('AppBundle\Filter\Doctrine\CustomerAware',
            $targetEntity->getReflectionClass()->getTraitNames())) {
            return '';
        }

        if (is_null($this->customerId)) {
            return $targetTableAlias . '.customer_id IS NULL';
        } else {
            return $targetTableAlias . '.customer_id = ' . $this->getParameter('customer_id');
        }
    }

    public function setCustomerId($customerId)
    {
        $this->customerId = $customerId;
        $this->setParameter('customer_id', $customerId);
    }
}
```

Filter を登録する。

```yml:app/config/config.yml
doctrine:
    orm:
        entity_managers:
            default:
                filters:
                    customer_filter:
                        class: AppBundle\Filter\Doctrine\CustomerFilter
                        enabled: true
```

この Filter を初期化するためのイベントハンドラを作成する。

```php
<?php
namespace AppBundle\Filter\Doctrine;

use AppBundle\Library\CustomerManager;
use Doctrine\ORM\EntityManager;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class Configurator
{
    protected $customerManager;
    protected $entityManager;

    public function __construct(CustomerManager $customerManager, EntityManager $entityManager)
    {
        $this->customerManager = $customerManager;
        $this->entityManager = $entityManager;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        /** @var CustomerFilter $filter */
        $filter = $this->entityManager->getFilters()->enable('customer_filter');
        $filter->setCustomerId(
            $this->customerManager->getCurrentCustomerId($event->getRequest()));
    }
}
```

このイベントハンドラを、「kernel.request」イベントに登録する。注意点としてデフォルトのプライオリティだとこのイベント処理がユーザー認証後になってしまい認証時に絞り込みされないため、「priority: 255」(最優先)を設定している。

```yml:app/config/services.yml
services:
    app.doctrine.filter.configurator:
        class: AppBundle\Filter\Doctrine\Configurator
        arguments: [ "@app.customer", "@doctrine.orm.entity_manager" ]
        tags:
            - { name: kernel.event_listener, event: kernel.request, priority: 255 }
```

### 保存時に customer_id を設定する

保存時に customer_id を設定するための Listener を作成する。

```php
<?php
namespace AppBundle\Filter\Doctrine;

use AppBundle\Library\CustomerManager;
use Doctrine\ORM\Event\LifecycleEventArgs;

class CustomerListener
{
    protected $customerManager;

    public function __construct(CustomerManager $customerManager)
    {
        $this->customerManager = $customerManager;
    }

    public function prePersist(LifecycleEventArgs $args)
    {
        // Check if the entity uses CustomerAware
        $entity = $args->getEntity();
        if (!in_array('AppBundle\Filter\Doctrine\CustomerAware',
            (new \ReflectionClass($entity))->getTraitNames())) {
            return;
        }

        $entity->setCustomerId(
            $this->customerManager->getCurrentCustomerId());
    }
}
```

Doctrine の prePersist イベントに登録する。

```yml:app/config/services.yml
services:
    app.doctrine.customer_listener:
        class: AppBundle\Filter\Doctrine\CustomerListener
        arguments: [ "@app.customer" ]
        tags:
            - { name: doctrine.event_listener, event: prePersist }
```