суббота, 7 августа 2010 г.

Интеграция Symfony DI-контейнера в Zend Framework 1.10.x

Возникло у меня желание, начать новый проект на Zend Framework'е с применением лучших практик из мира объектно-ориентированной разработки ПО и современных тенденций PHP-индустрии. Целью всего этого является конечно же обучение и закрепление изученного материала.

Первым делом, решил интегрировать Dependency Injection контейнер в Zend Framework.  Ибо без этого трудно добиться "хорошего" дизайна, кроме того я не смогу эффективно тестировать свои объекты и классы. Что бы лучше понять что из себя представляют DI-контейнеры, а так же паттерн Dependency Injection советую прочитать статью Мартина Фаулера, перевод которой доступен здесь.

В этой статье я шаг за шагом интегрирую Dependency Injection контейнер от Symfony в Zend Framework версии 1.10.x. При этом я постараюсь действовать согласно методологии TDD (Test Driven Development), а именно практики Test First - написание тестов, до реализации.


Немного теории

В настоящий момент в Zend Framework'е нет своего DI-контейнера. Есть подобие некого реестра - контейнера, в который можно помещать ресурсы, и затем вытаскивать их например так.

$bootstrap = $this->getInvokeArg('bootstrap');
if ($bootstrap->hasPluginResource('Log')) {
    $log = $bootstrap->getResource('Log');
}
У этого контейнера есть недостатки, например что бы извлечь ресурс, его сначала надо инициализировать, настроить и положить в контейнер полностью готовым к использованию. Процесс инициализации и настройки вынесен в бутстрап. При этом создаются и настраиваются объекты абсолютна всех ресурсов, которые возможно в течении текущего запроса и не понадобятся.

Есть еще один недостаток - сложно контролировать зависимости между ресурсами. Так, если ваш ресурс зависит от другого, то необходимо вручную запускать все ресурсы, от которых зависим. Например так:
protected function _initView()
{
    $front = $this->bootstrap('frontcontroller');
... 

Собственно DI-контейнер и должен помочь избавится от этих проблем. Контейнер хранит только зависимости между объектами, которые можно легко и наглядно настроить в конфиг-файле. Затем, при обращении создается новый или выдается ранее созданный готовый объект.

Выбор DI-контейнера

В общем-то, DI-контейнер это простой, легковесный контейнер, который можно без труда реализовать самому. Но не будем спешить, в умных книжках "гуру" советуют использовать готовые решения, а не изобретать велосипеды. Для PHP существует много DI-контейнеров, большинство из которых являются портом из индустрии Java.

Основной выбор был между Phemto и контейнером от Symfony. В итоге отдал предпочтение последнему, т.к. у него есть удобные хелперы, умеющие загружать и выгружать конфигурационные файлы в форматах XML, PHP, YAML, INI, а также визуализировать граф зависимостей.  Кроме того, в разработке находится контейнер версии 2.0, который будет лежать в основе фреймворка Symfony 2.0.

Способ интеграции

Первое что пришло на ум, это засунуть DI-контейнер от Symfony в родной для Zend фреймоврка контейнер, в качестве ресурса. Сделать это очень просто, достаточно написать метод ресурса в бутстрапе или плагин ресурса и вернуть ссылку на экземпляр контейнера.

Второе, что пришло на ум, это спросить у гугла. В итоге нашлась статья, из которой ясно, что контейнер от Symfony может спокойно заменить родной контейнер ZF.

Как это можно сделать?
  1. Непосредственно установить контейнер перед началом бутстрапа приложения
  2. Написать метод ресурса или плагин ресурса, где установить контейнер. Этот способ сразу отпадает, т.к. необходимо что бы этот ресуср устанавливался самым первым. Иначе часть ресурсов будет в старом, родном контейнере, часть в нашем новом.
  3. Расширить класс бутстрапа, изменив поведение метода, где происходит установка контейнера.
  4. Использовать специальный set-метод, который вызывается на экземпляре класса бутстрапа.
Первый способ, приведенный в вышеупомянутой статье не  совсем хорош. Т.к. мы жестко устанавливаем наш контейнер, еще до этапа создания Zend_Application. Что нас ущемляет в плане гибкости.

$container = new sfServiceContainerBuilder();

$loader = new sfServiceContainerLoaderFileXml($container);
$loader->load(APPLICATION_PATH.'/config/objects.xml');

$application = new Zend_Application(
    APPLICATION_ENV,
    APPLICATION_PATH . '/config/application.xml' );
$application->getBootstrap()->setContainer($container);
$application->bootstrap()
            ->run();
Поискав дальше, нашел вариант описанный здесь. Применяется комбинация расширения класса бутстрапа и использование специального set-метода класса бутстрапа. Этот способ я и решил взять за основу.

Так же набор готовых решений по интеграции Symfony, Doctrine  (и не только)  в ZF можно найти здесь. Мне они показались на данном этапе чересчур навороченными. Кроме того, описанный способ не очень понравился, так как один метод имеет несколько обязанностей. Поэтому сделаем велосипедик :)

Development
 
Буду исходить из того, что имеется простое приложение на основе ZF, созданное с помощью Zedn_Tool. Также установлен phpunit и настроено окружение для тестирования вашего приложения. Подробные инструкции как это сделать можно увидить в скринкасте Джона Лебенсолда Unit Testing with the Zend Framework with Zend_Test and PHPUnit
или в этой статье Антона Шевчука.

Начнем разработку с чего? С тестов естественно!

Так как это мой первый опыт применения TDD,  сразу же возникли трудности, а как написать тест так, что бы протестировать то, чего еще нету и вообще не совсем понятно что будет. Для этого надо ответить на вопрос: А что хотим получить в результате?

В результате мы хотим, что если в конфиге указано что надо подменить родной контейнер, то необходимо это сделать. Контейнер получается в классе бутстрапа через метод getContainer(), следовательно надо это оформить в виде теста. Будем двигаться небольшими шажками, как советует практика TDD.

Расширение класса бутстрапа
Тестировать этот класс изолированно не представляется возможным, т.к. этот класс тесно связан с фреймворком. Если у вас тестовое окружение настроено так же как в скринкасте Джона то с этим проблем не будет. Т.к. для каждого теста запускается все приложение целиком.

Создаем тестовый набор в каталоге tests/application/bootstrapTest.php. (Напомню, что располагать тестовые классы желательно отображением тестируемых классов).

Мы хотим, что если в конфигурационном файле задана опция с именем 'DIContainer', то метод getContainer() вернул бы Symfony DI-контейнер. Для этого пишем тест:
    public function testGetContainer()
    {
        $container = $this->application->getBootstrap()->getContainer();
        if (isset($this->_options['DIContainer']))
        {
            $this->assertTrue($container instanceof sfServiceContainerInterface);
        }
        else
        {
            $this->assertTrue($container instanceof Zend_Registry);
        }
    }  
И соответственно реализация:
    /**
     * Retrieve resource container
     * 
     * @return object
     */
    public function getContainer()
    {
        if (null === $this->_container)
        {
            if (null === $this->getContainerFactory())
            {
                $this->_container = parent::getContainer();
            }
            else
            {
                $this->_container = $this->getContainerFactory()->makeContainer();
            }
        }
        return $this->_container;
    }

Как видно в реализации появилась фабрика, создающая сам контейнер. Здесь приведен конечный вариант, изначально там должна быть заглушка, так как никакой фабрики пока что еще нет.

Теперь протестируем создание самой фабрики. Мы хотим что бы, если в конфигурационном файле задана опция с именем 'DIContainer', то метод getContainerFactory() вернул фабрику, которая должна быть экземпляром класса указанного в опции 'factoryClass'.
    public function testGetContainerFactory()
    {
        $containerFactory = $this->_myBootstrap->getContainerFactory();
        if (isset($this->_options['DIContainer']))
        {
            $this->assertTrue(
                    $containerFactory instanceof $this->_options['DIContainer']['factoryClass']);
        }
        else
        {
            $this->assertNull($containerFactory);
        }
    }
Что бы тест был зеленым, необходимо реализовать метод getContainerFactory(), возвращающий внутреннее поле _containerFactory. Это поле должно устанавливаться защищенным методом setDIContainer() самим фреймворком по имени опции 'DIContainer'.  Этот метод вызывается до регистрации/запуска методов ресурсов и плагинов ресурсов, а следовательно до создания/использования контейнера. Т.о. мы можем безопасно установить свой контейнер.

Код реализации:
    /**
     * Factory for init container.
     */
    protected $_containerFactory = null;

    /**
     * Set factory for resource container.
     *
     * @param array $options
     */
    protected function setDIContainer(array $options)
    {
        if (isset($options['factoryClass']))
        {
            $factory = $options['factoryClass'];
            $this->_containerFactory = new $factory($options['params']);
        }
    }
    /**
     * 
     * @return Object Container Factory 
     */
    public function getContainerFactory()
    {
        return $this->_containerFactory;
    }
Так же
Приведу содержимое тестового класса bootsrapTest.php целиком:
<?php
/**
 * Test case for bootstrap class
 *
 * @author yugeon
 */
class bootstrapTest extends ControllerTestCase
{

    protected $_options;
    protected $_myBootstrap;

    public function setUp()
    {
        parent::setUp();
        $this->_options = $this->application->getOptions();
        $this->_myBootstrap = $this->application->getBootstrap();
    }

    public function testGetContainerFactory()
    {
        $containerFactory = $this->_myBootstrap->getContainerFactory();
        if (isset($this->_options['DIContainer']))
        {
            $this->assertTrue(
                    $containerFactory instanceof $this->_options['DIContainer']['factoryClass']);
        }
        else
        {
            $this->assertNull($containerFactory);
        }
    }
    public function testGetContainer()
    {
        $container = $this->application->getBootstrap()->getContainer();
        if (isset($this->_options['DIContainer']))
        {
            $this->assertTrue($container instanceof sfServiceContainerInterface);
        }
        else
        {
            $this->assertTrue($container instanceof Zend_Registry);
        }
    }
}

Фабричный класс

Я не буду расписывать подробно все шаги, а приведу тестовый набор целиком. Естественно, он образовался не сразу. И что бы на каждом этапе тест был зеленый, приходилось реализовывать саму фабрику, а так же добавлять настройки в конфинурацию.

<?php

require_once 'PHPUnit/Framework.php';

class Xboom_Application_Resource_DIContainerFactoryTest extends PHPUnit_Framework_TestCase
{

    /**
     * @var Xboom_Application_Resource_DIContainerFactory
     */
    protected $object;
    protected $options;

    protected function setUp()
    {
        parent::setUp();
        $config = new Zend_Config_Ini(
                        APPLICATION_PATH . '/configs/application.ini',
                        APPLICATION_ENV);
        $this->options = $config->toArray();
        $this->object = new Xboom_Application_Resource_DIContainerFactory(
                        $this->options['DIContainer']['params']);
    }

    protected function tearDown()
    {
        parent::tearDown();
        $file = $this->options['DIContainer']['params']['dumpFilePath'];
        if (file_exists($file))
        {
            unlink($file);
        }
    }

    public function testMakeContainer()
    {
        // example from
        // http://components.symfony-project.org/dependency-injection/trunk/book/04-Builder
        $this->object->setOptions(array('enableAutogenerateDumpFile' => '0'));
        $sc = $this->object->makeContainer();
        $sc->addParameters(array(
            'mailer.username' => 'foo',
            'mailer.password' => 'bar',
            'mailer.class' => 'Zend_Mail',
        ));
        $mailer = $sc->mailer;
        $mailerTransport = $mailer->getDefaultTransport();

        $this->assertType('sfServiceContainerInterface', $sc);
        $this->assertType('Zend_Mail', $mailer);
        $this->assertType('Zend_Mail_Transport_Smtp', $mailerTransport);
    }

    public function testMakeContainerAutogenerateDumpFile()
    {
        $filename = $this->options['DIContainer']['params']['dumpFilePath'];
        if (file_exists($filename))
        {
            unlink($filename);
        }

        $this->object->setOptions(array('enableAutogenerateDumpFile' => '1'));
        $sc = $this->object->makeContainer();
        $sc->addParameters(array(
            'mailer.username' => 'foo',
            'mailer.password' => 'bar',
            'mailer.class' => 'Zend_Mail',
        ));
        $mailer = $sc->mailer;
        $mailerTransport = $mailer->getDefaultTransport();

        $this->assertType('Zend_Mail', $mailer);
        $this->assertType('Zend_Mail_Transport_Smtp', $mailerTransport);

        $this->assertTrue(file_exists($filename));
    }

    public function testDIContainerAsNativeForZF()
    {
        $resourceName = 'TestResource';
        $resource = new Zend_Acl();

        $sc = $this->object->makeContainer();
        $sc->{$resourceName} = $resource;

        $this->assertType('Zend_Acl', $sc->{$resourceName});
    }
}
Как видно, этот тест автономный, т.е для его работы не требуется запуск всего Zend фреймворка, как было с тестами класса бутстрапа. В принципе, надо стремиться что бы все тесты были автономными и тестируемый юнит минимально зависел от окружения.

В первом тесте, проверяем что созданный контейнер является экземпляром sfServiceContainerInterface. А так же, то что созданный контейнер правильно выдает объекты, описанные в конфигурационном файле самого контейнера. Для этого, контейнер создается с тестовыми настройками из примера приведенного в документации. Здесь я немного поступил не правильно, по хорошему надо было разнести это на два теста.

Следующий тест проверяет то, что контейнер корректно создается и сохраняется в дамп, на основе плоского PHP. Для этого мы преднамеренно включаем автоматическую генерацию дампа.

Последний тест проверяет, что наш контейнер действует так же, как и родной контейнер ZF.

В итоге у меня получился вот такой фабричный класс:

<?php

/**
 * Resource for initializing dependency injection container
 *
 * @package    Xboom
 * @subpackage Application_Resources
 *
 * @author     yugeon
 * @version    SVN: $Id$
 */
class Xboom_Application_Resource_DIContainerFactory
{

    /**
     * Contains the options for the factory.
     * @var array
     */
    protected  $_options;

    /**
     * Construct a factory that created containers for Zend_Bootstrap
     * based on the Symfony DI framework component.
     *
     * @param array $options Options for factory
     */
    public function __construct(array $options = array())
    {
        $this->_options = $options;
        /**
         * Must manually require here because the autoloader does not
         * (yet) know how to find this.
         */
        require_once 'Symfony/Components/DependencyInjection/sfServiceContainerAutoloader.php';
        sfServiceContainerAutoloader::register();
    }

    public function setOptions(array $options = array())
    {
        $this->_options = array_merge($this->_options, $options);
    }

    /**
     * @return sfServiceContainerInterface The container
     */
    public function makeContainer()
    {
        $isGenerateDumpFile = $this->_options['enableAutogenerateDumpFile'];
        if (!$isGenerateDumpFile && file_exists($this->_options['dumpFilePath']))
        {
            require_once $this->_options['dumpFilePath'];
            $sc = new $this->_options['dumpFileClass']();
        }
        else
        {
            $sc = $this->buildServiceContainer();
            $this->dumpToFile($sc);
        }

        return $sc;
    }

    /**
     * Build the service container dynamically
     *
     * @return sfServiceContainer
     */
    protected function buildServiceContainer()
    {
        //
        $sc = new sfServiceContainerBuilder();
        $file = $this->_options['configFile'];
        $suffix = strtolower(pathinfo($file, PATHINFO_EXTENSION));
        switch ($suffix)
        {
            case 'xml':
                $loader = new sfServiceContainerLoaderFileXml($sc);
                break;

            default:
                throw new Zend_Exception("Invalid configuration file provided; unknown config type '$suffix'");
        }
        $loader->load($file);

        return $sc;
    }

    /**
     * Dump current service container to file.
     *
     * @param sfServiceContainer $sc
     */
    protected function dumpToFile($sc)
    {
        $dumper = new sfServiceContainerDumperPhp($sc);
        file_put_contents($this->_options['dumpFilePath'],
                $dumper->dump(array('class' => $this->_options['dumpFileClass']))
        );
    }
}  
Сейчас поясню несколько аспектов, связанных непосредственно с подключением контейнера.

Во-первых, скачиваем сам компонент. И кладем содержимое каталога lib в следующее место libray/Symfony/Components/DependencyInjection. Существует так же компонент версии 2.0, он полностью на неймспейсах php 5.3. Правда находит в разработке и не стабилен.

Во-вторых, как видно, в конструкторе мы регистрируем загрузчик Symfony контейнера. Это необходимо потому что классы в компоненте от Symfony именуются не в нотации PEAR, а кидать их в корень папки library не  красиво. Иначе бы, их смог бы без труда подхватить Zend_Autoloader. 

Конфигурация
Процесс разработки через тестирование идет таким образом, что если нам необходимо что-то для работы тестов, мы это "что-то" создаем и добиваемся что бы тест был зеленым. После устраняем дублирование, т.е. проводим рефакторинг. В итоге получились вот такие настройки в конфигурационном файле.

[production]
; Symfony Dependency Injection Container
DIContainer.factoryClass = "Xboom_Application_Resource_DIContainerFactory"
DIContainer.params.enableAutogenerateDumpFile = 0 
DIContainer.params.dumpFileClass = "DIContainer"
DIContainer.params.dumpFilePath = APPLICATION_PATH "/../data/cache/DIContainerDump.php"
DIContainer.params.configFile = APPLICATION_PATH "/configs/service.xml" 

[testing]
DIContainer.params.enableAutogenerateDumpFile = 1
DIContainer.params.dumpFilePath = APPLICATION_PATH "/../data/cache/DIContainerDumpTest.php"
DIContainer.params.configFile = APPLICATION_PATH "/configs/serviceTest.xml" 

Вместо заключения

В этой статье я преследовал две цели, первая - это собственно интеграция Symfony Dependency Injection контейнера в Zend Framework. Вторая цель - показать сам процесс разработки с применением практики TDD Test First. Скорей всего, через некоторое время, я буду смеяться над этой работой, так как тут наверняка куча глупостей, которые пока мне не видны или я не обращаю на них внимание. Но с другой стороны это вполне логично, так как необходимо около года, активного применения данной методики, что бы эффективно ее применять :)

Ссылки по теме

Исходный код проекта zf_symfony_di.zip
TDD - Разработка через тестирование (Test Driven Development)
Symfony Dependency Injection Component (offsite)
Перевод на русский документации по Symfony DI контейнеру
Using Symfony Dependency Injection with Zend_Application
Презентация с конференции ZFConf2010
Using Symfony Dependency Injection Container with Zend_Bootstrap
Unit Testing with the Zend Framework with Zend_Test and PHPUnit
Юнит тестирование приложений на Zend Framework

2 комментария:

  1. У тебя в тестах assertы выполняются по условию. Этого быть не должно. Иначе нужно писать тест теста который проверяет что тест проходит по обеим веткам)

    ОтветитьУдалить
  2. хм...да ты прав, нужно в окружении теста явно задавать тестируемые условия :)

    ОтветитьУдалить