Monday 21 May 2012

Zend Framework Cache Backend Memcached + Session Cache

Storing Sessions in Memcached with Zend Framework

Zend Framework has an excellent API for sessions in order to build out your own session save handler. By taking this approach I can still leverage the cache manager and share the same connection to memcached for other things in my application. If you need to expire contents during a deployment this can still handle it utilizing the “clone” keyword.
On to the code! See the link above for the Libmemcached implementation if you do not have the latest RC release. Next we need to build out the session save handler. We will name this Zend_Session_SaveHandler_Cache as it will support any cache that you would like to throw at it. The benefit of this really comes down to the lower environments and the ability to simply store sessions in APC for a time or even going to a simple file based for development / testing.

Zend_Session_SaveHandler_Cache

You will want to store this file in library/Zend/Session/SaveHandler/Cache.php. This handles the main setup for doing the Cache save handler.
 
/**
 * Zend Framework
 *
 * LICENSE
 *
 * This source file is subject to the new BSD license that is bundled
 * with this package in the file LICENSE.txt.
 * It is also available through the world-wide-webat this URL:
 * http://framework.zend.com/license/new-bsd
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@zend.com so we can send you a copy immediately.
 *
 * @category   Zend
 * @package    Zend_Session
 * @copyright  Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
 * @license    http://framework.zend.com/license/new-bsd     New BSD License
 * @version    $Id$
 */
 
/**
 * @see Zend_Session
 */
require_once 'Zend/Session.php';
 
/**
 * @see Zend_Config
 */
require_once 'Zend/Config.php';
 
/**
 * @see Zend_Cache
 */
require_once 'Zend/Cache.php';
 
/**
 * Zend_Session_SaveHandler_Cache
 *
 
 * @category   Zend
 * @package    Zend_Session
 * @subpackage SaveHandler
 * @copyright  Copyright (c) 2010 CaringBridge. (http://www.caringbridge.org)
 */
class Zend_Session_SaveHandler_Cache
    implements Zend_Session_SaveHandler_Interface
{
 
    /**
     * Zend Cache
     * @var Zend_Cache
     */
    protected $_cache;
 
    /**
     * Destructor
     *
     * @return void
     */
    public function __destruct()
    {
        Zend_Session::writeClose();
    }
 
    /**
     * Set Cache
     *
     * @param Zend_Cache_Core $cache
     * @return Zend_Session_SaveHandler_Cache
     */
    public function setCache(Zend_Cache_Core $cache)
    {
        $this->_cache = $cache;
    }
 
    /**
     * Get Cache
     *-
     * @return Zend_Cache_Core
     */
    public function getCache()
    {
        return $this->_cache;
    }
 
    /**
     * Open Session
     *
     * @param string $save_path
     * @param string $name
     * @return boolean
     */
    public function open($save_path, $name)
    {
        $this->_sessionSavePath = $save_path;
        $this->_sessionName     = $name;
 
        return true;
    }
 
    /**
     * Close session
     *
     * @return boolean
     */
    public function close()
    {
        return true;
    }
 
    /**
     * Read session data
     *
     * @param string $id
     * @return string
     */
    public function read($id)
    {
        if (!$data = $this->_cache->load($id)) {
            return null;
        }
        return $data;
    }
 
    /**
     * Write session data
     *
     * @param string $id
     * @param string $data
     * @return boolean
     */
    public function write($id, $data)
    {
        return $this->_cache->save(
            $data,
            $id,
            array(),
            Zend_Session::getOptions('gc_maxlifetime')
        );
    }
 
    /**
     * Destroy session
     *
     * @param string $id
     * @return boolean
     */
    public function destroy($id)
    {
        return $this->_cache->remove($id);
    }
 
    /**
     * Garbage Collection
     *
     * @param int $maxlifetime
     * @return true
     */
    public function gc($maxlifetime)
    {
        return true;
    } 
 
   // add by phuc*************************************************
    /**
     * Set whether or not the lifetime of an existing session should be overridden
     *
     * @param boolean $overrideLifetime
     * @return Zend_Session_SaveHandler_DbTable
     */
    public function setOverrideLifetime($overrideLifetime)
    {
        $this->_overrideLifetime = (boolean) $overrideLifetime;

        return $this;
    }

    /**
     * Retrieve whether or not the lifetime of an existing session should be overridden
     *
     * @return boolean
     */
    public function getOverrideLifetime()
    {
        return $this->_overrideLifetime;
    }
    
    /**
     * Set session lifetime and optional whether or not the lifetime of an existing session should be overridden
     *
     * $lifetime === false resets lifetime to session.gc_maxlifetime
     *
     * @param int $lifetime
     * @param boolean $overrideLifetime (optional)
     * @return Zend_Session_SaveHandler_DbTable
     */
    public function setLifetime($lifetime, $overrideLifetime = null)
    {
        if ($lifetime < 0) {
            /**
             * @see Zend_Session_SaveHandler_Exception
             */
            require_once 'Zend/Session/SaveHandler/Exception.php';
            throw new Zend_Session_SaveHandler_Exception();
        } else if (empty($lifetime)) {
            $this->_lifetime = (int) ini_get('session.gc_maxlifetime');
        } else {
            $this->_lifetime = (int) $lifetime;
        }

        if ($overrideLifetime != null) {
            $this->setOverrideLifetime($overrideLifetime);
        }

        return $this;
    }  
}

Setting up the Application

To set up the application to work correctly, you will need to modify the application.ini file if you are using Zend_Application as well as modifying the bootstrap process to set the cache.

Application Configuration

This file is located at application/configs/application.ini
; setup the cache
resources.cachemanager.memcached.frontend.name                            = Core
resources.cachemanager.memcached.frontend.options.automatic_serialization = On
resources.cachemanager.memcached.backend.name                             = Libmemcached
resources.cachemanager.memcached.backend.options.servers.one.host         = localhost
resources.cachemanager.memcached.backend.options.servers.one.port         = 11211
resources.cachemanager.memcached.backend.options.servers.one.persistent   = On
; session savehandler class
resources.session.name = phpsessionname
resources.session.saveHandler.class        = Zend_Session_SaveHandler_Cache
resources.session.gc_maxlifetime           = 7200

Setting up the Bootstrap

This file is located at: application/Bootstrap.php. Since the Zend_Session_SaveHandler_Cache requires a cache to be set, we need to place this in the bootstrap, also you will likely want to ensure no possibility of session hijacking in the bootstrap as well.
    /** 
     * Initialize the Session Id
     * This code initializes the session and then
     * will ensure that we force them into an id to
     * prevent session fixation / hijacking.
     *
     * @return void
     */
    protected function _initSessionId()
    {   
        $this->bootstrap('session');
        $opts = $this->getOptions();
        if ('Zend_Session_SaveHandler_Cache' == $opts['resources']['session']['saveHandler']['class']) {
            $cache = $this->bootstrap('cachemanager')
                          ->getResource('cachemanager')
                          ->getCache('memcached');
            Zend_Session::getSaveHandler()->setCache($cache);
        }
        $defaultNamespace = new Zend_Session_Namespace();
        if (!isset($defaultNamespace->initialized)) {
            Zend_Session::regenerateId();
            $defaultNamespace->initialized = true;
        }
    }

Sharing Libmemcached between Content and Sessions

Say we want to share the cache within our code base but utilize an SVN revision number to keep a prefix for the cache but not the sessions. Here is an implementation to do so in the Bootstrap.php file based on the above configuration.
    /**
     * Initialize the Cache Manager
     * Initializes the memcached cache into
     * the registry and returns the cache manager.
     *
     * @return Zend_Cache_Manager
     */
    protected function _initCachemanager()
    {
        $cachemanager = $this->getPluginResource('cachemanager')
                             ->init();
 
        // fetch the current revision from svn and use it as a prefix
        // why: we do not want to restart memcached, or you will lose sessions.
        if (!$appVersion = apc_fetch('progsite_version')) {
            $dir = getcwd();
            chdir(dirname(__FILE__));
            $appVersion = filter_var(`svn info | grep "Revision"`, FILTER_SANITIZE_NUMBER_INT);
            chdir($dir);
            unset($dir);
            if (!$appVersion) {
                $appVersion = mt_rand(0, 99999); // simply handles an export instead of checkout
            }
            apc_store('progsite_version', $appVersion);
        }
 
        $memcached = $cachemanager->getCache('memcached');
        $memcached->setOption('cache_id_prefix', APPLICATION_ENV . '_' . $appVersion);
 
        return $cachemanager;
    }
 
    /**
     * Initialize the Session Id
     * This code initializes the session and then
     * will ensure that we force them into an id to
     * prevent session fixation / hijacking.
     *
     * @return void
     */
    protected function _initSessionId()
    {
        $this->bootstrap('session');
        $opts = $this->getOptions();
        if ('Zend_Session_SaveHandler_Cache' == $opts['resources']['session']['saveHandler']['class']) {
            $cache = $this->bootstrap('cachemanager')
                          ->getResource('cachemanager')
                          ->getCache('memcached'); 
            $cache = clone $cache;
            $cache->setOption('cache_id_prefix', APPLICATION_ENV);
 
            Zend_Session::getSaveHandler()->setCache($cache);
        }
        $defaultNamespace = new Zend_Session_Namespace();
        if (!isset($defaultNamespace->initialized)) {
            Zend_Session::regenerateId();
            $defaultNamespace->initialized = true;
        }
    }
Note that the above code clones the current memcached adapter. We do this so that we can keep 1 instance that utilizes the cache_id_prefix with a number for the revision number and then sessions do not need that. This allows us to see content changes at deployment when the web server is restarted but we do not need to lose our session states.

Conclusion

Memcached is a handy tool to have, it also works well to integrate into the Zend Framework. I hope someone else also finds the Session_SaveHandler_Cache useful. This has been running out in a production environment for quite a while and has been extremely stable. Why not take it for a run yourself.

No comments: