Skip to content

Instantly share code, notes, and snippets.

@cowlby
Created November 4, 2011 00:47
Show Gist options
  • Save cowlby/1338379 to your computer and use it in GitHub Desktop.
Save cowlby/1338379 to your computer and use it in GitHub Desktop.
Write-through caching of session data
<?php
use PDO;
use Symfony\Component\HttpFoundation\SessionStorage\NativeSessionStorage;
/**
* CachedPdoSessionStorage.
*
* @author Jose Prado <[email protected]>
*/
class CachedPdoSessionStorage extends NativeSessionStorage
{
/**
* Default refresh time of 300 seconds (5 minutes)
*
* @var int
*/
const REFRESH_TIME = 3600;
/**
* @var PDO
*/
protected $_db;
/**
* @var CacheInterface
*/
protected $_cache;
/**
* @var array
*/
protected $dbOptions;
/**
* @var int
*/
protected $lifetime;
protected $initialSessionId;
/**
* @var array
*/
protected $initialSessionData;
/**
* Constructor.
*
* @param CacheInterface $cache A CacheInterface instance
* @param PDO $db A PDO instance
* @param array $options Optional options array
* @param array $dbOptions Optional DB options array
*/
public function __construct(CacheInterface $cache, PDO $db, array $options = array(), array $dbOptions = array())
{
if (!array_key_exists('db_table', $dbOptions)) {
throw new \InvalidArgumentException('You must provide the "db_table" option for a PdoSessionStorage.');
}
$this->_cache = $cache;
$this->_db = $db;
$this->lifetime = intval(ini_get("session.gc_maxlifetime"));
$options = array_merge(array(
'refresh_time' => self::REFRESH_TIME
), $options);
$this->dbOptions = array_merge(array(
'db_id_col' => 'sess_id',
'db_data_col' => 'sess_data',
'db_time_col' => 'sess_time',
), $dbOptions);
parent::__construct($options);
}
/**
* Starts the session.
*/
public function start()
{
if (self::$sessionStarted) {
return;
}
// use this object as the session handler
session_set_save_handler(
array($this, 'sessionOpen'),
array($this, 'sessionClose'),
array($this, 'sessionRead'),
array($this, 'sessionWrite'),
array($this, 'sessionDestroy'),
array($this, 'sessionGC')
);
return parent::start();
}
/**
* Reads initial session data if a session exists and stores it for
* later comparison.
*/
public function sessionOpen($path = null, $name = null)
{
$id = session_id();
if ($id != '') {
$this->initialSessionId = $id;
$this->initialSessionData = $this->sessionRead($id);
}
return TRUE;
}
/**
* Unsets all object data.
*/
public function sessionClose()
{
return TRUE;
}
/**
* Destroys a session.
*
* @param string $id A session ID
* @return Boolean true, if the session was destroyed, otherwise an exception is thrown
* @throws RuntimeException If the session cannot be destroyed
*/
public function sessionDestroy($id)
{
$this->_cache->delete($id);
$this->_cache->delete('db-'.$id);
// get table/column
$dbTable = $this->dbOptions['db_table'];
$dbIdCol = $this->dbOptions['db_id_col'];
// delete the record associated with this id
$sql = "DELETE FROM $dbTable WHERE $dbIdCol = :id";
try {
$stmt = $this->_db->prepare($sql);
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
$stmt->execute();
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
}
return TRUE;
}
/**
* Cached sessions expire automatically so we just need to evict all expired
* DB sessions.
*
* @param int $lifetime The lifetime of a session
* @return Boolean true, if old sessions have been cleaned, otherwise an exception is thrown
* @throws RuntimeException If any old sessions cannot be cleaned
*/
public function sessionGC($lifetime)
{
// get table/column
$dbTable = $this->dbOptions['db_table'];
$dbTimeCol = $this->dbOptions['db_time_col'];
// delete the record associated with this id
$sql = "DELETE FROM $dbTable WHERE $dbTimeCol < (:time - $lifetime)";
try {
$this->_db->query($sql);
$stmt = $this->_db->prepare($sql);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
$stmt->execute();
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
}
return TRUE;
}
/**
* Reads the session data from either the cache or the DB in case of
* a miss. Then updates the database expiration time based on the time
* since the last DB access.
*
* @see Symfony\Component\HttpFoundation\SessionStorage.PdoSessionStorage::sessionRead()
*/
public function sessionRead($id)
{
$now = time();
$data = $this->_cache->get($id);
if ($data === FALSE) {
$dbTable = $this->dbOptions['db_table'];
$dbDataCol = $this->dbOptions['db_data_col'];
$dbIdCol = $this->dbOptions['db_id_col'];
try {
$sql = "SELECT $dbDataCol FROM $dbTable WHERE $dbIdCol = :id";
$stmt = $this->_db->prepare($sql);
$stmt->bindParam(':id', $id, \PDO::PARAM_STR, 255);
$stmt->execute();
$sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM);
// If session is found, update the time value, otherwise create
// a new session.
if (count($sessionRows) == 1) {
$data = $sessionRows[0][0];
$this->updateSessionTime($id, $now);
} else {
$data = '';
$this->createNewSession($id);
}
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
}
} else {
$expiration = $this->_cache->get('db-'.$id);
if ($expiration === FALSE || $now - $this->options['refresh_time'] > $expiration - $this->lifetime) {
$this->updateSessionTime($id, $now);
}
}
$this->_cache->set($id, $data, $this->lifetime);
return $data;
}
/**
* Writes the session data to the cache and the DB and updates the
* last db update record.
*
* @param string $id A session ID
* @param string $data A serialized chunk of session data
* @return Boolean true, if the session was written, otherwise an exception is thrown
* @throws \RuntimeException If the session data cannot be written
*/
public function sessionWrite($id, $data)
{
$retVal = $this->_cache->set($id, $data, $this->lifetime);
if ($this->initialSessionId !== $id || $this->initialSessionData !== $data) {
// get table/column
$dbTable = $this->dbOptions['db_table'];
$dbDataCol = $this->dbOptions['db_data_col'];
$dbIdCol = $this->dbOptions['db_id_col'];
$dbTimeCol = $this->dbOptions['db_time_col'];
switch ($this->_db->getAttribute(\PDO::ATTR_DRIVER_NAME)) {
case 'mysql':
$sql = "INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol) VALUES (:id, :data, :time)";
$sql .= " ON DUPLICATE KEY UPDATE $dbDataCol = VALUES($dbDataCol),";
$sql .= " $dbTimeCol = CASE WHEN $dbTimeCol = :time THEN (VALUES($dbTimeCol) + 1)";
$sql .= " ELSE VALUES($dbTimeCol) END";
break;
default:
$sql = "UPDATE $dbTable SET $dbDataCol = :data, $dbTimeCol = :time WHERE $dbIdCol = :id";
break;
}
try {
$stmt = $this->_db->prepare($sql);
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
$stmt->bindParam(':data', $data, \PDO::PARAM_STR);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
$stmt->execute();
if (!$stmt->rowCount()) {
// No session exists in the database to update. This happens when we have called
// session_regenerate_id()
$this->createNewSession($id, $data);
}
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
}
$expiration = time() + $this->lifetime;
$this->_cache->set('db-'.$id, $expiration, $this->lifetime);
}
return $retVal;
}
/**
* Creates a new session with the given $id and $data
*
* @param string $id
* @param string $data
*/
public function createNewSession($id, $data = '')
{
// get table/column
$dbTable = $this->dbOptions['db_table'];
$dbDataCol = $this->dbOptions['db_data_col'];
$dbIdCol = $this->dbOptions['db_id_col'];
$dbTimeCol = $this->dbOptions['db_time_col'];
$sql = "INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol) VALUES (:id, :data, :time)";
$stmt = $this->_db->prepare($sql);
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
$stmt->bindParam(':data', $data, \PDO::PARAM_STR);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
$stmt->execute();
return true;
}
/**
* Updates the time of the session in the database and refreshes the last
* DB access value in the cache.
*
* @param integer $id The session id to update.
* @param integer $now Optional time to use.
* @return Boolean true, if the session time was updated or no session was found. Otherwise exception is thrown.
*/
public function updateSessionTime($id, $now = NULL)
{
if ($now === NULL) {
$now = time();
}
$dbTable = $this->dbOptions['db_table'];
$dbTimeCol = $this->dbOptions['db_time_col'];
$dbIdCol = $this->dbOptions['db_id_col'];
try {
$sql = "UPDATE $dbTable SET $dbTimeCol = :time WHERE $dbIdCol = :id";
$stmt = $this->_db->prepare($sql);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
$stmt->execute();
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
}
$expiration = $now + $this->lifetime;
$this->_cache->set('db-'.$id, $expiration, $this->lifetime);
return TRUE;
}
}
<?php
/**
* Cache interface to provide a uniform interface for the SessionStorage classes.
*
* @author Jose Prado <[email protected]>
*/
interface CacheInterface
{
/**
* Retrieves an entry from the cache.
*
* @param string $id The id to retrieve
* @return mixed The data found or FALSE if cache miss
*/
public function get($id);
/**
* Stores an entry in the cache using the supplied id and data.
*
* @param string $id The cache id
* @param mixed $data The data to store
* @param integer $lifetime The lifetime of the record.
* @return Boolean Whether or not the item was stored in the cache.
*/
public function set($id, $data, $lifetime);
/**
* Deletes an entry from the cache.
*
* @param string $id The cache id to delete.
* @return Boolean Whether or not the item was deleted.
*/
public function delete($id);
}
<?php
/**
* MemcacheAdapter
*
* @author Jose Prado <[email protected]>
*/
class MemcacheAdapter implements CacheInterface
{
/**
* @var Memcache
*/
protected $_cache;
/**
* Constructor.
*
* @param Memcache $cache A Memcache instance
*/
public function __construct(\Memcache $cache)
{
$this->setCache($cache);
}
/**
* @return Memcache
*/
public function getCache()
{
return $this->_cache;
}
/**
* @param Memcache $cache
*/
public function setCache(\Memcache $cache)
{
$this->_cache = $cache;
return $this;
}
/**
* @see CacheInterface::get()
*/
public function get($id)
{
return $this->_cache->get($id);
}
/**
* @see CacheInterface::set()
*/
public function set($id, $data, $lifetime)
{
return $this->_cache->set($id, $data, FALSE, $lifetime);
}
/**
* @see CacheInterface::delete()
*/
public function delete($id)
{
return $this->_cache->delete($id);
}
}
<?php
/**
* MemcachedAdapter
*
* @author Jose Prado <[email protected]>
*/
class MemcachedAdapter implements CacheInterface
{
/**
* @var Memcached
*/
protected $_cache;
/**
* Constructor.
*
* @param Memcached $cache A Memcached instance
*/
public function __construct(\Memcached $cache)
{
$this->setCache($cache);
}
/**
* @return Memcached
*/
public function getCache()
{
return $this->_cache;
}
/**
* @param Memcached $cache
*/
public function setCache(\Memcached $cache)
{
$this->_cache = $cache;
return $this;
}
/**
* @see CacheInterface::get()
*/
public function get($id)
{
return $this->_cache->get($id);
}
/**
* @see CacheInterface::set()
*/
public function set($id, $data, $lifetime)
{
return $this->_cache->set($id, $data, $lifetime);
}
/**
* @see CacheInterface::delete()
*/
public function delete($id)
{
return $this->_cache->delete($id);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment