среда, 11 августа 2010 г.

Интеграция Zend Framework 1.10 и Doctrine 2.0

В этой статье я продолжу разработку некого каркаса приложения на Zend Framework'e, придерживаясь лучших практик из мира объектно-ориентированного проектирования. Мы шаг за шагом будем интегрировать Doctrine 2, а поможет нам Dependency Injection контейнер, который мы успешно интегрировали в предыдущей статье.


Подготовка
Для лучшего понимания материала, советую ознакомиться с документацией по Doctrine 2.0, хотя бы раздел про подключение. Так же необходимо скачать последние доступные версии Doctrine-Common (на момент написания статьи это 2.0.0BETA4), Doctrine DBAL (2.0.0BETA3-DEV) и Doctrine ORM (2.0.0-BETA3) и положить соответствующие библиотеки в каталог library вашего приложения. Должно получиться вот такое дерево.

Так же необходим Dependency Injection контейнер от Symfony (см. предыдущую статью). Если у вас *nix то проще всего это организовать через символьные ссылки.

Далее, в ваш конфигурационный файл application.ini необходимо добавить новое пространство имен для Zend_Autoloder'а.
[production]
autoloaderNamespaces[] = "Doctrine"

Development

Начнем разработку снова с тестов. Мы хотим получить из Symfony DI-контейнера объект, представляющий собой входную точку в ORM Doctrine 2.0. Этот объект называется EntityManager. Теперь надо определить, где разместить тест. Т.к. контейнер находится в бутстрапе, то и разместим тест в уже имеющемся bootstrapTest.php (см. предыдущую статью)

Начинаем тест с проверки. Мы хотим, что бы сервисный контейнер Symfony вернул объект типа EntityManager. Написание теста рекомендуется всегда начинать с проверки (assert).
    public function testGetDoctrineEntityManager()
    {
        $sc = $this->_myBootstrap->getContainer();
        $em = $sc->getService('doctrine.orm.entitymanager');

        $this->assertType('Doctrine\\ORM\\EntityManager', $em);
    }
Запускаем тесты, и получаем следующее сообщение:
bootstrapTest::testGetDoctrineEntityManager
InvalidArgumentException: The service definition "doctrine.orm.entitymanager" does not exist.
Нам сообщается, что сервис который мы получаем из контейнера - не описан. Для его описания открываем файл config/serviceTest.xml и описываем его согласно документации. В принципе не обязательно использовать xml, но мне удобней работать с xml, чем с yaml.
    <service id="doctrine.orm.entitymanager"
             class="Doctrine\ORM\EntityManager"
             constructor="create">
    </service>
Снова запускаем тесты, и получаем следующее сообщение об ошибке.
1) bootstrapTest::testGetDoctrineEntityManager
Missing argument 1 for Doctrine\ORM\EntityManager::create()
Супер, это сообщение говорит нам, что по крайней мере объект EntityManager успешно подгружается Zend_Autoloader'ом. Продолжаем описывать сервис в конфигурационном файле serviceTest.xml. Добавим описание аргументов.
    <service id="doctrine.orm.entitymanager"
             class="Doctrine\ORM\EntityManager"
             constructor="create">
        <argument>%doctrine.connection.options%</argument>
        <argument>%doctrine.config%</argument>
    </service>
Так же добавим в тест установку этих аргументов.
    public function testGetDoctrineEntityManager()
    {
        $sc = $this->_myBootstrap->getContainer();
        $sc->addParameters(array(
            'doctrine.connection.options' => array(),
            'doctrine.config' => array()
        ));
        $em = $sc->getService('doctrine.orm.entitymanager');

        $this->assertType('Doctrine\\ORM\\EntityManager', $em);
    }
Снова запускаем тесты, и получаем следующее сообщение:
1) bootstrapTest::testGetDoctrineEntityManager
Argument 2 passed to Doctrine\ORM\EntityManager::create() must be an instance of Doctrine\ORM\Configuration, array given
Которое говорит о том, что второй параметр переданный в EntityManager::create() должен быть типа Doctrine\ORM\Configuration, а у нас передается массив. Устраним это, причем опять воспользуемся контейнером Symfony и опишем второй параметр как сервис.
    <service id="doctrine.orm.configuration" class="Doctrine\ORM\Configuration" >

    <service id="doctrine.orm.entitymanager"
             class="Doctrine\ORM\EntityManager"
             constructor="create">
        <argument>%doctrine.connection.options%</argument>
        <argument id="doctrine.orm.configuration" type="service"/>
    </service>
Снова запускаем тесты, теперь нам сообщают,
1) bootstrapTest::testGetDoctrineEntityManager
Doctrine\ORM\ORMException: It's a requirement to specify a Metadata Driver and pass it to Doctrine\ORM\Configuration::setMetadataDriverImpl().
что необходимо установить Metadata Driver. Если вы читали документацию, то этот параметр указан как обязательный.

Продолжаем описывать сервис "doctrine.orm.configuration" устраняя все ошибки, пока не добьемся зеленой полоски. Далее я не буду так же подробно описывать каждый шаг, я просто показал как советуют продвигаться до цели небольшими шажками. Сам шаг каждый для себя подбирает индивидуально, если вы чувствуете что, можете за раз сделать больше, то делайте, но как только теряете нить или начинают сбоить тесты, то лучше уменьшить шаг.

Привожу итоговое минимальное описание сервиса, необходимое для того, что бы тест стал зеленым.
    <service id="doctrine.orm.mapping.driver" 
             class="Doctrine\ORM\Mapping\Driver\XmlDriver">
        <argument>%doctrine.orm.path_to_mappings%</argument>
    </service>

    <service id="doctrine.orm.configuration" class="Doctrine\ORM\Configuration">
        <call method="setMetadataDriverImpl">
            <argument type="service" id="doctrine.orm.mapping.driver"/>
        </call>
        <call method="setProxyDir">
            <argument>%doctrine.orm.path_to_proxies%</argument>
        </call>
        <call method="setProxyNamespace">
            <argument>%doctrine.orm.proxy_namespace%</argument>
        </call>
    </service>

    <service id="doctrine.orm.entitymanager"
             class="Doctrine\ORM\EntityManager"
             constructor="create">
        <argument>%doctrine.connection.options%</argument>
        <argument type="service" id="doctrine.orm.configuration" />
    </service>
Итоговый код теста
    public function testGetDoctrineEntityManager()
    {
        $sc = $this->_myBootstrap->getContainer();
        $sc->addParameters(array(
            'doctrine.connection.options' => $this->_options['doctrine']['connection'],
            'doctrine.orm.path_to_mappings' => $this->_options['doctrine']['pathToMappings'],
            'doctrine.orm.path_to_proxies' => $this->_options['doctrine']['pathToProxies'],
            'doctrine.orm.proxy_namespace' => $this->_options['doctrine']['proxiesNamespace'],
        ));
        $em = $sc->getService('doctrine.orm.entitymanager');

        $this->assertType('Doctrine\\ORM\\EntityManager', $em);
    }
Здесь я уже вынес конфигурационные опции в конфиг.
[production]
; Doctrine 2.0 options
doctrine.connection.driver  = "pdo_sqlite"
doctrine.connection.path    = "/dev/null"
doctrine.pathToMappings     = APPLICATION_PATH "/configs/mappings"
doctrine.pathToProxies      = APPLICATION_PATH "/models/proxies"
doctrine.proxiesNamespace   = "Application\Proxies"
Меня сейчас мало интересуют сами значения опций, благо теперь их без труда можно настроить в конфиге, кроме того если использовать например pdo_mysql то достаточно просто добавить опции задающие хост, порт, пользователя и т.д без изменения существующего кода.

Вносим изменения в приложение

О чем говорит успешное срабатывание этого теста? Оно говорит, что теперь мы из любого места приложения можем получить объект EntityManager через сервис контейнер. При этом, происходит отложенная инициализация, таким образом если мы обращаемся в первый раз, то этот объект для нас создастся и настроится. Все последующие обращения к контейнеру вернут нам уже созданный объект.

Осталось перенести настройку из тестового кода, в наше приложение, например в ресурс метод.  И удалить эту настройку из нашего теста testGetDoctrineEntityManager(). Замечу, мы переносим только настройку контейнера. Сам EntityManager мы будем получать позже, когда начнем работать с моделью.
    // bootstrap.php 
    protected function _initEntityManager()
    {
        $sc = $this->getContainer();
        $options = $this->getOption('doctrine');
        $sc->addParameters(array(
            'doctrine.connection.options'   => $options['connection'],
            'doctrine.orm.path_to_mappings' => $options['pathToMappings'],
            'doctrine.orm.path_to_proxies'  => $options['pathToProxies'],
            'doctrine.orm.proxy_namespace'  => $options['proxiesNamespace']
        ));
    }
Далее необходимо перенести настройку контейнера из serviceTest.xml в service.xml. Но, мы не будем это делать копированием, т.к. любое дублирование  не приводит к хорошему. Вынесем эту настройку в отдельный конфиг и просто подключем его в оба файла. Если понадобится, то для тестовой конфигурации мы сможем переопределить конфигурацию, т.к. сначала происходит импорт, а потом парсинг с автоматическим переопределением.
<!-- serviceTest.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony-project.org/2.0/container">

  <imports>
    <import resource="entitymanager.xml" />
  </imports>
  
</container>
<!-- entitymanager.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony-project.org/2.0/container">

  <services>
    <service id="doctrine.orm.mapping.driver"
             class="Doctrine\ORM\Mapping\Driver\XmlDriver">
        <argument>%doctrine.orm.path_to_mappings%</argument>
    </service>

    <service id="doctrine.orm.configuration" class="Doctrine\ORM\Configuration">
        <call method="setMetadataDriverImpl">
            <argument type="service" id="doctrine.orm.mapping.driver"/>
        </call>
        <call method="setProxyDir">
            <argument>%doctrine.orm.path_to_proxies%</argument>
        </call>
        <call method="setProxyNamespace">
            <argument>%doctrine.orm.proxy_namespace%</argument>
        </call>
    </service>

    <service id="doctrine.orm.entitymanager"
             class="Doctrine\ORM\EntityManager"
             constructor="create">
        <argument>%doctrine.connection.options%</argument>
        <argument type="service" id="doctrine.orm.configuration" />
    </service>
  </services>

</container>
Обязательно еще раз проверяем что бы тесты были зелеными!

Кастомизация
Приведенный выше код, делает минимально-необходимую настройку. Если прочитать документацию по Doctrine 2.0, то там строго рекомендуют использовать кеширование, как для метаданных, так и для запросов. Кроме того, можно опционально задать логирование и автогенерацию прокси классов. Так же, для генерации маппинга мы подключили XML-драйвер, а хотелось бы генерацию через аннотации, как мне кажется это намного удобней. Код и правила в одном месте, повышается наглядность, снижается возможность изменить модель и забыть изменить правила генерации отображения.

Благодаря DI-контейнеру, все это легко кастомизировать под ваши нужды. Я не буду расписывать сам процесс и тесты, а приведу готовую настройку контейнера. Сами тесты и остальной код проекта можно скачать в конце статьи.
<!-- entitymanager.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony-project.org/2.0/container">

    <services>

        <!-- M E T A D A T A  M A P P I N G -->
        <service id="doctrine.orm.metadata_driver.xml"
             class="Doctrine\ORM\Mapping\Driver\XmlDriver">
            <argument>%doctrine.orm.path_to_mappings%</argument>
        </service>

        <service id="doctrine.orm.metadata_driver.annotation"
             class="Doctrine\ORM\Mapping\Driver\AnnotationDriver">
            <argument type="service">
                <service class="Doctrine\Common\Annotations\AnnotationReader">
                    <argument type="service" id="doctrine.common.cache" />
                    <call method="setDefaultAnnotationNamespace">
                        <argument>Doctrine\ORM\Mapping\</argument>
                    </call>
                </service>
            </argument>
            <argument>%doctrine.orm.path_to_entities%</argument>
        </service>

        <!-- C A C H E  S E T T I N G -->
        <service id="doctrine.common.cache" class="%doctrine.common.cache_class%"/>

        <!-- C O N F I G U R A T I O N -->
        <service id="doctrine.orm.configuration" class="Doctrine\ORM\Configuration">
            <call method="setMetadataDriverImpl">
                <argument type="service" id="doctrine.orm.metadata_driver.annotation"/>
            </call>
            <call method="setProxyDir">
                <argument>%doctrine.orm.path_to_proxies%</argument>
            </call>
            <call method="setProxyNamespace">
                <argument>%doctrine.orm.proxy_namespace%</argument>
            </call>
            <call method="setAutoGenerateProxyClasses">
                <argument>%doctrine.orm.autogenerate_proxy_classes%</argument>
            </call>
            <call method="setMetadataCacheImpl">
                <argument type="service" id="doctrine.common.cache"/>
            </call>
            <call method="setQueryCacheImpl">
                <argument type="service" id="doctrine.common.cache"/>
            </call>
        </service>

        <!-- E N T I T Y   M A N A G E R -->
        <service id="doctrine.orm.entitymanager"
             class="Doctrine\ORM\EntityManager"
             constructor="create">
            <argument>%doctrine.connection.options%</argument>
            <argument type="service" id="doctrine.orm.configuration" />
        </service>
    </services>

</container>
Практически все настройки вынесены в конфиг application.ini, кроме выбора драйвера для метаданных. Он прописывается в контейнере, т.к. недопустимо  задавать шаблонное имя сервиса. С другой стороны, это все равно конфигурирование, а не жесткое программирование.

Следующие настройки вынесены в конфиг.
[production]
; Doctrine 2.0 options
doctrine.connection.driver  = "pdo_sqlite"
doctrine.connection.path    = "/dev/null"
;doctrine.connection.host = 'localhost'
;doctrine.connection.port = 3306
;doctrine.connection.dbname = 'your_db'
;doctrine.connection.user = 'root'
;doctrine.connection.password = ''
;doctrine.connection.unix_socket = ''

doctrine.pathToMappings     = APPLICATION_PATH "/configs/mappings"
doctrine.pathToEntities[]   = APPLICATION_PATH "/models/domain"
doctrine.pathToProxies      = APPLICATION_PATH "/models/proxies"
doctrine.proxiesNamespace   = "Application\Model\Proxies"
doctrine.autogenerateProxyClasses = 0 ; for production must be false
doctrine.cacheClass         = "Doctrine\Common\Cache\ApcCache"

[testing : production]
; Doctrine 2.0 options
doctrine.autogenerateProxyClasses = 1
doctrine.cacheClass               = "Doctrine\Common\Cache\ArrayCache"

Стоит отметить что автоматическая генерация прокси-объектов должна быть отключена для production. В версии для тестирования и разработки рекомендуется включить эту опцию.

Так же обратите внимание на драйвер для кеширования. Доктрина по-умолчанию работает со следующими драйверами:
  1. Doctrine\Common\Cache\ApcCache
  2. Doctrine\Common\Cache\MemcacheCache
  3. Doctrine\Common\Cache\XcacheCache
  4. Doctrine\Common\Cache\ArrayCache
ArrayCache рекомендуется использовать только при разработки.
В следующей статье, расширим этот список, добавив драйвер на основе Zend_Cache.

Простой пример
Что бы было понятней как это все работает, сделаем простенький пример.
Допустим у нас есть сущность пользователь, она простая т.к. цель продемонстрировать что все работает.

ЗАДАЧА: При заходе на главную страницу должен выводиться список существующих пользователей. Так же можно добавить нового пользователя или удалить имеющегося.

Доменная модель

Тест для модели
<?php

require_once 'PHPUnit/Framework.php';
require_once APPLICATION_PATH . '/models/Domain/User.php';

class Application_Model_Domain_UserTest extends PHPUnit_Framework_TestCase
{
    /**
     * @var Application_Model_Domain_User
     */
    protected $object;

    protected function setUp()
    {
        $this->object = new Application_Model_Domain_User;
    }

    public function testGetSetUserName()
    {
        $testName = 'Vasya';
        $this->object->setName($testName);
        $this->assertEquals($testName, $this->object->getName());
    }
}
Сама модель
<?php
// application/models/Domain/User.php

/**
 * @Entity
 * @Table(name="users")
 */
class Application_Model_Domain_User
{
    /**
     * @Id @Column(type="integer")
     * @GeneratedValue(strategy="AUTO")
     */
    private $id;

    /** @Column(type="string", length=50) */
    private $name;

    public function getId()
    {
        return $this->id;
    }
    public function setName($string) {
        $this->name = $string;
        return true;
    }
    public function getName()
    {
        return $this->name;
    }
}
Генерация схемы БД

Для этой цели Doctrine имеет консольный инструмент. Который мы сейчас настроим для удобства работы. Создадим в корне проекта каталог tools и в нем каталог doctrine. Поместим туда содержимое каталога bin из поставки Doctrine ORM. Так же, поместим в библиотеку консольный компонент Symfony\Components\Console (поставляется вместе с Doctrine). Изменим doctrine.php следующим образом.

<?php
ob_start();

// Define path to application directory
defined('APPLICATION_PATH')
    || define('APPLICATION_PATH', realpath(dirname(__FILE__) . '/../../application'));

// Define application environment
define('APPLICATION_ENV', 'development');

// Ensure library/ is on include_path
set_include_path(implode(PATH_SEPARATOR, array(
    realpath(APPLICATION_PATH . '/../library'),
)));

require_once 'Doctrine/Common/ClassLoader.php';
$classLoader = new \Doctrine\Common\ClassLoader('Doctrine', APPLICATION_PATH . '/../library');
$classLoader->register();
$classLoader = new \Doctrine\Common\ClassLoader('Symfony', APPLICATION_PATH . '/../library');
$classLoader->register();

// Create application, bootstrap
/** Zend_Application */
require_once 'Zend/Application.php';
$application = new Zend_Application(
    APPLICATION_ENV,
    APPLICATION_PATH . '/configs/application.ini'
);

$application->bootstrap();
$sc = $application->getBootstrap()->getContainer();
$em = $sc->getService('doctrine.orm.entitymanager');

$helperSet = new \Symfony\Components\Console\Helper\HelperSet(array(
    'db' => new \Doctrine\DBAL\Tools\Console\Helper\ConnectionHelper($em->getConnection()),
    'em' => new \Doctrine\ORM\Tools\Console\Helper\EntityManagerHelper($em)
));

$helperSet = ($helperSet) ?: new \Symfony\Components\Console\Helper\HelperSet();

$cli = new \Symfony\Components\Console\Application('Doctrine Command Line Interface', Doctrine\ORM\Version::VERSION);
$cli->setCatchExceptions(true);
$cli->setHelperSet($helperSet);
$cli->addCommands(array(
    // DBAL Commands
    new \Doctrine\DBAL\Tools\Console\Command\RunSqlCommand(),
    new \Doctrine\DBAL\Tools\Console\Command\ImportCommand(),

    // ORM Commands
    new \Doctrine\ORM\Tools\Console\Command\ClearCache\MetadataCommand(),
    new \Doctrine\ORM\Tools\Console\Command\ClearCache\ResultCommand(),
    new \Doctrine\ORM\Tools\Console\Command\ClearCache\QueryCommand(),
    new \Doctrine\ORM\Tools\Console\Command\SchemaTool\CreateCommand(),
    new \Doctrine\ORM\Tools\Console\Command\SchemaTool\UpdateCommand(),
    new \Doctrine\ORM\Tools\Console\Command\SchemaTool\DropCommand(),
    new \Doctrine\ORM\Tools\Console\Command\EnsureProductionSettingsCommand(),
    new \Doctrine\ORM\Tools\Console\Command\ConvertDoctrine1SchemaCommand(),
    new \Doctrine\ORM\Tools\Console\Command\GenerateRepositoriesCommand(),
    new \Doctrine\ORM\Tools\Console\Command\GenerateEntitiesCommand(),
    new \Doctrine\ORM\Tools\Console\Command\GenerateProxiesCommand(),
    new \Doctrine\ORM\Tools\Console\Command\ConvertMappingCommand(),
    new \Doctrine\ORM\Tools\Console\Command\RunDqlCommand(),
    new \Doctrine\ORM\Tools\Console\Command\ValidateSchemaCommand(),

));
$cli->run();

Проверим что все работает. Для этого перейдем в консоле в каталог tools/doctrine и выполним команду
./doctrine
Должна вывестись справка по командам.

Для генерации схемы БД необходимо выполнить команду:
doctrine orm:schema-tool:create
Для перегенерации можно использовать команду
doctrine orm:schema-tool:update
или связку, т.к update не пересоздает схему, а добавляет изменения и может не работать с некоторыми БД, например SQLite не поддерживает ALTER TABLE.
doctrine orm:schema-tool:drop
doctrine orm:schema-tool:create

Контроллер
 Пример использования Doctrine ORM из контроллера.
<?php
class IndexController extends Zend_Controller_Action
{
    /**
     *
     * @var Doctrine\ORM\EntityManager
     */
    protected  $em;

    public function init()
    {
        $sc = $this->getInvokeArg('bootstrap')->getContainer();
        $this->em = $sc->getService('doctrine.orm.entitymanager');
    }

    public function indexAction()
    {
        $results = $this->em->createQuery('SELECT u FROM Application_Model_Domain_User u')
                            ->getResult();
        $this->view->users = $results;
    }

    public function addAction()
    {
        $user = new Application_Model_Domain_User();
        $user->setName('TestName' . rand(1, 100));

        $this->em->persist($user);
        $this->em->flush();

        $this->_helper->getHelper('Redirector')
             ->gotoUrl('/');
    }

    public function deleteAction()
    {
        $user = $this->em->find('Application_Model_Domain_User', (int) $this->_getParam('id'));

        $this->em->remove($user);
        $this->em->flush();

        $this->_helper->getHelper('Redirector')
             ->gotoUrl('/');
    }
}

Вид

<!-- index.phtml -->
<p><a href="/index/add">Добавить пользователя</a></p>
<?php foreach ($this->users as $user): ?>
        <?php echo $user->getName() ?> |
        <a href="/index/delete/id/<?php echo $user->getId() ?>">Удалить</a><br />
<?php endforeach;?>


Заключение

В статье рассмотрен процесс интеграции ORM Doctrine 2.0 с Zend Framework 1.10.x. Интеграция осуществляется через Dependency Injection контейнер Symfony. Что поваляет нам гибко настраивать приложение, а так же легко проводить тестирование.

Разработка ведется через тестирование, что гарантирует нам высокую стабильность, надежность, снижает риски. Позволяет проводить рефакторинг и т.д. На самом деле плюсов от тестирования больше.

Так же, разработан простой тестовый контроллер, который показывает как легко работать с сервисным контейнером Symfony и EntityManager'ом Doctrine.

Ссылки
Исходный код проекта zf_symfony_di_doctrine2.zip права на папку data должны быть доступны для записи вашего веб-сервера.
TDD - Разработка через тестирование (Test Driven Development)
Symfony Dependency Injection Component (offsite)
Doctrine ORM 2.0 - официальный сайт
Интеграция Symfony DI-контейнера в Zend Framework 1.10.x

Комментариев нет:

Отправить комментарий