четверг, 12 августа 2010 г.

Кеш-драйвер для Doctrine на основе Zend_Cache

Продолжаем серию статей посвященную интеграции Doctrine 2.0 и Zend Framework 1.10. В этой статье мы добавим поддержку кеш-драйвера для Doctrine на основе Zend_Cache.
Введение

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

По умолчанию, в доктрине поддерживаются следующие драйверы:
  • Doctrine\Common\Cache\ApcCache
  • Doctrine\Common\Cache\MemcacheCache
  • Doctrine\Common\Cache\XcacheCache
  • Doctrine\Common\Cache\ArrayCache
Собственно для "продакшина" пригодны только первые три. Последний предназначен только для разработки и кеширует данные только в рамках одного запроса. Проблема заключается в том, что все эти три драйвера требуют дополнительного ПО, установленного на сервере.

В ZF есть отличный компонент для работы с кешем - Zend_Cache. Стоит отметить, что Zend_Cache кроме поддержки APC, XCache, Memcache умеет работать с простыми файлами, SQLite и другими бэкэндами. Напишем адаптер, позволяющий Doctrine работать c Zend_Cache.

Development

Адаптер

Паттерн проектирования Адаптер (Adapter), известный так же под названием Wrapper (обертка). Преобразует интерфейс одного класса в интерфейс другого, который ожидают клиенты. Адаптер обеспечивает совместную работу классов с несовместимыми интерфейсами, которая без него была бы невозможна.
Э.Гамма и др. (GoF) - "Design Patterns".

Начнем как всегда с тестов. Наш драйвер должен реализовывать Doctrine\Common\Cache\Cache интерфейс.  В котором определены базовые операции: сохранения, проверки, удаления и т.д данных в кеш/из кеша.  Удобно взять готовый тестовый набор из тестового пакета самой  Doctrine-Common. По идее, эти же тесты должны отрабатывать и на нашем драйвере.
Ниже приведена часть тестового класса:
<?php

require_once 'PHPUnit/Framework.php';

/**
 * Description of DoctrineAdapterTest
 *
 * @author yugeon
 */
class DoctrineAdapterTest extends PHPUnit_Framework_TestCase
{
    /**
     *
     * @var Xboom_Cache_DoctrineAdapter
     */
    protected $object;

    public function setUp()
    {
        parent::setUp();
        $config = new Zend_Config_Ini(
                        APPLICATION_PATH . '/configs/application.ini',
                        APPLICATION_ENV);
        $options = $config->toArray();
        $options = $options['doctrine']['cacheOptions'];
        $zendCache = Zend_Cache::factory('Core', 'File',
                $options['frontendOptions'], $options['backendOptions']);
        $this->object = new Xboom_Cache_DoctrineAdapter($zendCache);
    }

    public function testGetCache()
    {
        $this->assertType('Doctrine\\Common\\Cache\\Cache', $this->object);
    }

    public function testBasics()
    {
        $cache = $this->object;

        // Test save
        $cache->save('test_key', 'testing this out');

        // Test contains to test that save() worked
        $this->assertTrue($cache->contains('test_key'));

        // Test fetch
        $this->assertEquals('testing this out', $cache->fetch('test_key'));

        // Test delete
        $cache->save('test_key2', 'test2');
        $cache->delete('test_key2');
        $this->assertFalse($cache->contains('test_key2'));
    }
...
За основу нашего драйвера, возьмем адаптер Benjamin Steininger, только переделаем его так, что бы он расширял абстрактный класс Doctrine\Common\Cache\AbstractCache - который реализует необходимый нам интерфейс.

Во время тестирования обнаружилось, что Doctrine генерирует идентификаторы содержащие символы $, @, <, > которые являются запрещенными для Zend_Cache. Вот пример имени такого идентификатора:
Application_Model_Domain_User$id@<annot>
Разрешенными для зенда является следующий набор символов: [a-zA-Z0-9_]. Для снятия данного ограничения, добавим двустороннее кодирование идентификатора путем преобразования каждого символа в hex-представление. Возможно существует и более красивый способ, но за умеренное время я не смог найти ничего лучше.

Окончательный вид адаптера:

<?php

/**
 * An Adapter for using a Zend_Cache_Core-Instance as Query or Result-Cache
 * for Doctrine
 *
 * Offers an additional Prefix for its entries for usage within prefix-based
 * Cache-Structure (for example when using one Zend_Cache_Core for a complete
 * system)
 *
 * @author     Benjamin Steininger
 * @author     yugeon
 * @license    New BSD License
 * @category   Xboom
 * @package    Xboom_Cache
 * @todo       Add support for Tags to automatically tag all Entry made with a
 *             set of Tags provided by the constructor
 */
class Xboom_Cache_DoctrineAdapter extends Doctrine\Common\Cache\AbstractCache
{

    /**
     * @var Zend_Cache_Core
     */
    protected $_cache = null;

    /**
     * @param string
     */
    protected $_prefix = '';

    public function __construct(Zend_Cache_Core $cache, $prefix = '')
    {
        $this->_cache = $cache;
        $this->_prefix = $prefix;
    }

    /**
     * Convert hexidecimal string to normal string
     * @param string $hexstr Hexidecimal string
     * @return string
     */
    protected function hex2str($hexstr)
    {
        $retstr = pack('H*', $hexstr);
        return $retstr;
    }

    /**
     * Convert string to hexidecimal presenter
     * @param string $string
     * @return string
     */
    protected function str2hex($string)
    {
        $hexstr = unpack('H*', $string);
        return array_shift($hexstr);
    }

    /**
     * Fetches an entry from the cache.
     *
     * @param string $id cache id The id of the cache entry to fetch.
     * @return string The cached data or FALSE, if no cache entry exists for the given id.
     */
    protected function _doFetch($id)
    {
        $hId = $this->str2hex($id);
        return $this->_cache->load($this->_prefix . $hId);
    }

    /**
     * Test if an entry exists in the cache.
     *
     * @param string $id cache id The cache id of the entry to check for.
     * @return boolean TRUE if a cache entry exists for the given cache id, FALSE otherwise.
     */
    protected function _doContains($id)
    {
        $hId = $this->str2hex($id);
        return (bool) $this->_cache->test($this->_prefix . $hId);
    }

    /**
     * Puts data into the cache.
     *
     * @param string $id The cache id.
     * @param string $data The cache entry/data.
     * @param int $lifeTime The lifetime. If != false, sets a specific lifetime for this cache entry (null => infinite lifeTime).
     * @return boolean TRUE if the entry was successfully stored in the cache, FALSE otherwise.
     */
    protected function _doSave($id, $data, $lifeTime = false)
    {
        $hId = $this->str2hex($id);
        try
        {
            return $this->_cache->save($data, $this->_prefix . $hId, array(), $lifeTime);
        }
        catch (Zend_Cache_Exception $e)
        {
            return false;
        }
    }

    /**
     * Deletes a cache entry.
     *
     * @param string $id cache id
     * @return boolean TRUE if the cache entry was successfully deleted, FALSE otherwise.
     */
    protected function _doDelete($id)
    {
        $hId = $this->str2hex($id);
        return $this->_cache->remove($this->_prefix . $hId);
    }

    /**
     * Get an array of all the cache ids stored
     *
     * @return array $ids
     */
    public function getIds()
    {
        return array_map(array($this, 'hex2str'), $this->_cache->getIds());
    }

}

Настройка Dependency Injection контейнера

Наш адаптер на вход требует настроенного объекта Zend_Cache_Core или его подкласса. С другой стороны, драйверы ApcCache, ArrayCache и XcacheCache не требуют никаких объектов. MemcacheCache так же требует определенных действий при создании.

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

Заменим описание сервиса doctrine.common.cache следующим образом.
<!-- C A C H E  S E T T I N G -->
        <service id="doctrine.common.cache" class="Xboom_Cache_DoctrineFactory"
                constructor="getCache">
            <argument>%doctrine.common.cache_class%</argument>
            <argument>%doctrine.common.cache_options%</argument>
        </service>

Abstract Factory и Factory Method

Паттерн проектирования Abstract Factory предоставляет интерфейс для создания семейств взаимосвязанных или взаимозависимых объектов, не специфируя их конкретных классов.

Часто, абстрактная фабрика реализуется с помощью параметризованного фабричного метода.

Параметризованные фабричные методы - один из вариантов паттерна Factory Method, который позволяет фабричному методу создавать разные виды продуктов, путем передачи параметра, который идентифицирует вид создаваемого объекта.
Э.Гамма и др. (GoF) - "Design Patterns".

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

Оформим эти требование в виде тестов и реализуем фабрику (напоминаю, что рекомендуется писать и реализовывать тесты по одному, а не все сразу).
<?php
require_once 'PHPUnit/Framework.php';
/**
 * Description of DoctrineFactoryTest
 *
 * @author yugeon
 */
class DoctrineFactoryTest extends PHPUnit_Framework_TestCase
{
    /**
     *
     * @var Xboom_Cache_DoctrineFactory
     */
    protected $object = null;

    public function testGetArrayCache()
    {
        $arrayCache = Xboom_Cache_DoctrineFactory::getCache('Doctrine\Common\Cache\ArrayCache');
        $this->assertType('Doctrine\\Common\Cache\\ArrayCache', $arrayCache);
    }
    public function testGetApcCache()
    {
        $apcCache = Xboom_Cache_DoctrineFactory::getCache('Doctrine\Common\Cache\ApcCache');
        $this->assertType('Doctrine\\Common\Cache\\ApcCache', $apcCache);
    }
    public function testGetXCache()
    {
        $xCache = Xboom_Cache_DoctrineFactory::getCache('Doctrine\Common\Cache\XcacheCache');
        $this->assertType('Doctrine\\Common\Cache\\XcacheCache', $xCache);
    }
    public function testGetZendCache()
    {
        $options = array('frontendOptions' => array(),
                         'backendOptions'  => array()
            );
        $zendCache = Xboom_Cache_DoctrineFactory::getCache('Xboom_Cache_DoctrineAdapter', $options);
        $this->assertType('Xboom_Cache_DoctrineAdapter', $zendCache);
    }
    public function testGetMemcache()
    {
        if (!extension_loaded('memcache'))
        {
            $this->markTestSkipped('Memcache extenstion is not available.');
        }
        $options = array(
            'memcache' => array('host' => 'localhost', 'port' => 11211)
            );
        $memCache = Xboom_Cache_DoctrineFactory::getCache('Doctrine\\Common\\Cache\\MemcacheCache', $options);
        $this->assertType('Doctrine\\Common\\Cache\\MemcacheCache', $memCache);
    }
}
Здесь последний тест проверяет установлено ли расширение Memcache и если не установлено то пропускает тест. При этом при запуске тестов, вместо зеленой полосы выводится желтая полоска, сигнализирующая что есть пропущенные тесты. Мне это не очень нравится, хочется всегда наблюдать зеленую полоску, поэтому я предпочитаю заменить пометку на успешный ассерт:
    public function testGetMemcache()
    {
        if (!extension_loaded('memcache'))
        {
            $this->assertTrue(TRUE, 'Memcache extenstion is not available.');
            return;
        }
        $options = array(
            'memcache' => array('host' => 'localhost', 'port' => 11211)
            );
        $memCache = Xboom_Cache_DoctrineFactory::getCache('Doctrine\\Common\\Cache\\Memcache', $options);
        $this->assertType('Doctrine\\Common\\Cache\\Memcache', $memCache);
    }
Получившаяся фабрика для кеш-драйверов Doctrine.
<?php
/**
 * Factory for Doctrine cache driver.
 *
 * @author yugeon
 */
class Xboom_Cache_DoctrineFactory
{
    /**
     * Return configured cache driver.
     *
     * @param string $cacheDriver
     * @param array $options
     * @return Doctrine\Common\Cache\Cache
     */
    public static function getCache($cacheDriver, $options = array())
    {
        switch ($cacheDriver)
        {
            case 'Xboom_Cache_DoctrineAdapter':
                $zendCache = Zend_Cache::factory('Core', 'File',
                        $options['frontendOptions'], $options['backendOptions']);
                $_cacheDriver = new Xboom_Cache_DoctrineAdapter($zendCache);
                break;
            case 'Doctrine\\Common\\Cache\\Memcache':
                $memcache = new Memcache;
                $memcache->connect($options['memcache']['host'], $options['memcache']['port']);
                $_cacheDriver = new Doctrine\Common\Cache\MemcacheCache;
                $_cacheDriver->setMemcache($memcache);
                break;
            default :
                $_cacheDriver = new $cacheDriver();
        }
        if (! ($_cacheDriver instanceof Doctrine\Common\Cache\Cache))
        {
            throw new Xboom_Exception ('Cache driver must be an instance of Doctrine\\Common\\Cache\\Cache interface.');
            return null;
        }
        return $_cacheDriver;
    }
}

Последние штрихи

Все что осталось добавить настройки в конфигурационный файл и подправить инициализацию сервис контейнера.
[production] 
doctrine.cacheClass         = "Xboom_Cache_DoctrineAdapter"
doctrine.cacheOptions.frontendOptions.lifetime = "null"
doctrine.cacheOptions.frontendOptions.automatic_serialization = 1
doctrine.cacheOptions.backendOptions.cache_dir = APPLICATION_PATH "/../data/cache/"
doctrine.cacheOptions.backendOptions.file_name_prefix = "doctrine2"

Настройка контейнера для EntityManager в Bootsrap.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_entities' => $options['pathToEntities'],
            'doctrine.orm.path_to_proxies'  => $options['pathToProxies'],
            'doctrine.orm.proxy_namespace'  => $options['proxiesNamespace'],
            'doctrine.orm.autogenerate_proxy_classes'
                                            => $options['autogenerateProxyClasses'],
            'doctrine.common.cache_class'   => $options['cacheClass'],
            'doctrine.common.cache_options' => $options['cacheOptions']
        ));
    }
Не забываем прогнать тесты еще раз!

Заключение

В этой статье описан процесс пошагового добавления поддержки кеш драйвера для Doctrine на основе Zend_Cache через TDD. Для этого был написан адаптер, являющийся связующим звеном между интерфейсом Cache, который понимает Doctrine и интерфейсом предоставленным компонентой Zend_Cache.

Так же, для гибкого управления кеш драйверами, была написана фабрика, которая в зависимости от инжектированных настроек выдает объект нужного типа. Инжектирование происходит посредством Symfony DI-контейнера.

Ссылки

Исходный код проекта zf_symfony_di_doctrine2_zend_cache.zip
TDD - Разработка через тестирование (Test Driven Development)
Symfony Dependency Injection Component (offsite)
Doctrine ORM 2.0 - официальный сайт
Интеграция Zend Framework 1.10 и Doctrine 2.0
Адаптер для Zend_Cache от Benjamin Steininger
Manual Zend_Cache
Wikipedia Адаптер (Шаблон проектирования)
Abstract Factory/Абстрактная фабрика
Factory Method/Фабричный метод

1 комментарий:

  1. Статья очень понравилась!!!Все написано граматно и с описание поттернов.

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