21 December 2011

Кеширование и теги при использовании ZF + memcached

Website development
Sandbox
Предисловие


В процессе разработки с использованием связки Zend Framework + Memcached приходится сталкиваться иногда как с (чрезмерной) обильностью имеющегося функционала фреймворка, так и с определёнными ограничениями. Об одном из таких случаев и найденном решении я и попытаюсь рассказать в этой статье.

Описание проблемы

Как известно, Memcached представляет собой относительно простое для использование Key/Value хранилище с простым, необходимым и достаточным функционалом. Предоставляемые ZF интерфейсы для взаимодействия с Memcached включены в общую библиотеку работы с кешем (включает в себя также адаптеры для Sqlite, Xcache, ZendServer и т.д.). Некоторые из этих систем кеширования поддерживают использование тегов для объектов кеширования, однако Memcached такой функцией не обладает, поэтому попытки использовать стандартные интерфейсы классов ZF для кеширования объектов с указанием тегов при работе с Memcached приведут лишь к ошибкам (в логах) вплоть до исключений. (Подробнее можно прочитать в документации).



Одной из задач в разработке стояло “умное” использование кеша в следующем понимании:
  • при необходимости все объекты и списки объектов для какой-либо модели могут быть положены в кеш;
  • если объект изменяется (редактируется в админке):
    1. он должен быть удалён из кеша, каким бы образом он бы не был найден (т.е., какими бы не были параметры поиска этого объекта, хоть по первичном ключу, хоть по любому другому набору параметров);
    2. из кеша должны быть удалены все списки, в которых есть этот объект (либо все списки для этой модели (таблицы)).

  • подобная функциональность должна быть прозрачна для контроллеров, конечных моделей, и, частично, для мапперов (кроме возможного указания использования или не использования кеша при получении данных);
  • интерфейс взаимодействия с кешем не должен претерпеть изменений.


Таким образом, получается, что сохраняя результаты запроса к БД в кеше требуется добавлять для него некий тег, по которому можно определить, к какому объекту/списку/модели относится сохраняемое в кеш значение. Да и хранить этот тег тоже как-то требуется.

На некоторые идеи для решения проблемы в своё время натолкнула меня статья на хабре про умное удаление из кеша с использованием связки memcached + MongoDB. Однако, добавлять на сервер MongoDB возможности не было. Сейчас и в ближайшем будущем других систем кеширования (Redis etc.) на сервере не предусматривается.

Решение и код

Основная идея простая: хранение всех ключей кеширования для конкретного тега в массиве — в самом кеше, с использованием тега как ключа кеширования для этого массива. При этом сам тег строится в автоматическом режиме на основании типа запроса и самого ключа кеширования.
Не растекаясь далее мыслью по древу, рассмотрим сразу первую часть кода базового маппера, от которого наследуются все используемые в проекте мапперы (кроме кода решения самой поставленной задачи в примере будет некоторое количество и сопровождающего кода, который необходим для работы системы и частично связан с задачей). Сразу извиняюсь, если кода покажется много, но внутри него оставил достаточно комментариев.

class System_ModelMapper {
	protected $_dbTable;
	// Здесь мы храним имя модели для этого маппера
	protected $_modelName = '';
	
	// В конструкторе, если имя модели явно не задано - вычисляем его из имени маппера
	public function __construct() {
		parent::__construct();
		if (empty($this->_modelName)) {
			$parts = explode('_', get_class($this));
			$this->_modelName = str_replace('Mapper', '', $parts[2]);
		}
	}

	// Функции для автоматического получения объекта dbtable для этого маппера
	/**
	* @return /Zend_Db_Table_Abstract
	*/
	public function getDbTable() {
		if (null === $this->_dbTable) {
			$this->setDbTable('Application_Model_DbTable_' . $this->_modelName);
		}
		return $this->_dbTable;
	}
	
	public function setDbTable($dbTable) {
		if (is_string($dbTable)) {
			$dbTable = new $dbTable();
		}
		if (!$dbTable instanceof Zend_Db_Table_Abstract) {
			throw new Exception('Invalid table data gateway provided');
		}
		$this->_dbTable = $dbTable;
		return $this;
	}

	/**
	 * Получение объекта модели для текущего маппера
	 * @param array $params
	 * @return System_Model
	 */
	public function getModel($params = array()) {
		$getInstance = 'Application_Model_' . $this->_modelName; 
		return new $getInstance($params);
	}
…
}


Далее — несколько методов для поиска одного объекта:

	/**
	 * Возвращает ключ для кеширования результатов поиска одного объекта по набору параметров поиска
	 * @param array $data Параметры поиска
	 * @return string
	 */
	protected function objectCacheId($data) {
		$fields = array_keys($data);
		$values = md5(json_encode(array_values($data)));
		return 'find_' . $this->_modelName . '_' . join('_', $fields) . '_' . $values;
	}

	/**
	 * Возвращает тег для кеширования объекта на основе его первичного ключа
	 * @param $object System_Model
	 * @return string
	 */
	public function getObjectCacheTag($object) {
		return 'object_' . $this->_modelName . '_' .$object->get_id();
	}

	 /**
	 * Поиск объекта по его первичном ключу
	 * @param numeric $id Значение ID
	 * @param mixed $obj Объект, в который будут загружены результат
	 * @param bool $cache Использовать или нет кеш для сохранения результатов поиска
	 * @return bool|System_Model
	 */
	public function find($id, $obj = false, $cache = false) {
		return $this->findByFields(array('id' => $id), $obj, $cache);
	}

	 /**
	 * Поиска объекта по набору параметров
	 * @param array $data Массив с параметрами поиска
	 * @param mixed $obj Объект, в который будут загружены результат
	 * @param bool $cache Использовать или нет кеш для сохранения результатов поиска
	 * @return bool|System_Model
	 */
	public function findByFields($data, $obj = false, $cache = false) {
		// Если указано использование кеша - генерируем ключ для кеша, проверяем наличие объекта для работы с кешем (система кеширования может быть полностью отключаться в настройках сайта, а это не должно сказаться на работе системы (кроме скорости работы :) )
		if ($cache) {
			$cacheId = $this->objectCacheId($data);
			if (Zend_Registry::isRegistered(CACHE_NAME) {
				/** @var $cache System_Cache_Core */
				$cache =& Zend_Registry::get(CACHE_NAME);
				// Если нашли в кеше объект - его и возвращаем
				if ($cache->test($cacheId)) {
					return $cache->load($cacheId);
				}
			} else {
				$cache = false;
			}
		}
		// Стандартное использование Zend_Db_Table для получения одного объекта по набору параметров поиска
		$select = $this->getDbTable()->select();
		foreach ($data as $field => $value) {
			$select->where($select->getAdapter()->quoteIdentifier($field) . ' = ?', $value);
		}
		$row = $this->getDbTable()->fetchRow($select);
		if ($row) {
			if ($obj === false) {
				$obj = $this->getModel();
			}
			$obj->setOptions($row->toArray());
		} else {
			$obj = false;
		}
		// Если кеш доступен - помещаем результат в кеш
		if ($cache) {
			$cache->save($obj, $cacheId);
		}
		return $obj;
	}


По приведённому коду — как видно, методы поиска объекта не имеют понятия о системе тегов. Она будет работать на уровне объекта $cache.
Далее — несколько методов для поиска набора объектов или даже всех объектов в таблице, с учетом пагинации и сортировки:

	/**
	 * Возвращает ключ для кеширования результатов запроса с учетом параметров запроса, сортировки и пагинации
	 * @param array $data Параметры запроса
	 * @param bool|string|array $order Параметры сортировки
	 * @param bool|System_Paginator $paginator Объект с параметрами пагинации
	 * @return string
	 */
	protected function listCacheId($data = array(), $order = false, $paginator = false) {
		$fields = array_keys($data);
		$values = md5(json_encode(array_values($data)));
		return sprintf('%s_%s_%s_%s_%s',
			$this->getListCacheTag(),
			join('_', $fields),
			$values,
			empty($order) ? '' : md5(json_encode($order)),
			is_object($paginator) ? $paginator->page . '_' . $paginator->limit : ''
		);
	}

	/**
	 * Возвращает тег для кеширования списков для текущей модели
	 * @return string
	 */
	public function getListCacheTag() {
		return 'list_' . $this->_modelName;
	}

	/**
	 * Поиск строк в таблице по набору параметров
	 * @param array $data Параметры поиска
	 * @param bool|string|array $order Параметры сортировки
	 * @param bool|System_Paginator $paginator Объект с параметрами пагинации
	 * @param bool|string $cache Использовать или нет кеш для сохранения результатов поиска
	 */
	public function fetchByFields($data = array(), $order = false, $paginator = false, $cache = false) {
		if ($cache) {
			$cacheId = $this->listCacheId($data, $order, $paginator);
			$cache .= 'Cache';
			if (Zend_Registry::isRegistered(CACHE_NAME)) {
				/** @var $cache System_Cache_Core */
				$cache =& Zend_Registry::get(CACHE_NAME);
				if ($cache->test($cacheId)) {
					return $cache->load($cacheId);
				}
			} else {
				$cache = false;
			}
		}
		// Генерируем два селекта, один из которых будет использоваться для получения количества строк в базе, удовлетворяющих параметрам запроса
		$select = $this->getDbTable()->select();
		$select_paginator = $this->getDbTable()->select(true);
		foreach ($data as $field => $value) {
			$s = '=';
			// value может представлять собой массив типа ('=', 2) или ('<=', 10)
			if (is_array($value)) {
				$s = $value[0];
				$value = $value[1];
			}
			$select->where($select->getAdapter()->quoteIdentifier($field) . " $s ?", $value);
			$select_paginator->where($select->getAdapter()->quoteIdentifier($field) . " $s ?", $value);
		}
		// Устанавливаем параметры сортировки
		if (!empty($order)) {
			$select->order($order);
		} else {
			$select->order('id ASC');
		}
		// Устанавливаем параметры пагинации, если задан объект пагинации
		if (is_object($paginator)) {
			// Получаем информацию об общем количестве строк в таблице, удовлетворяющим параметрам запроса
			$fetch_count = $this->getDbTable()->fetchRow($select_paginator->columns('count(id) as _c'))->toArray();
			$paginator->total = $fetch_count['_c'];
			// На всякий случай проверяем, не запросили ли мы страницу, которой быть не может
			if ($paginator->page > $paginator->getLastPage()) $paginator->page = $paginator->getLastPage();
			// Устанавливаем для основного запроса параметры пагинации
			$select->limitPage($paginator->page, $paginator->limit);
		}
		$resultSet = $this->getDbTable()->fetchAll($select);
		$result = $this->rowsToObj($resultSet);
		// Если есть объект для пагинации - уточняем реальное количество строк на текущей выбранной странице (вдруг она последняя и там страниц меньше чем limit)
		if (is_object($paginator)) {
			$paginator->inlist = count($result);
		}
		if ($cache) {
			$cache->save($result, $cacheId);
		}
		return $result;
	}

	/**
	 * Получаем все объекты с учетом сортировки и пагинации
	 * @param bool|string|array $order Параметры сортировки
	 * @param bool|System_Paginator $paginator Объект с параметрами пагинации
	 * @param bool|string $cache Использовать или нет кеш для сохранения результатов поиска
	 * @return array|bool
	 */
	public function fetchAll($order = false, $paginator = false, $cache = false) {
		return $this->fetchByFields(array(), $order, $paginator, $cache);
	}
		
	/**
	 * Служебный метод для получения реального массива объектов модели
	 * @param Zend_Db_Table_Rowset_Abstract $rowset Объект с результатами выборки
	 * @return array|bool
	 */
	protected function rowsToObj($rowset) {
		if (!empty($rowset)) {
			$entries = array();
			foreach ($rowset as $row) {
				/** @var $entry System_Model */
				$entry = $this->getModel($row->toArray());
				$entries[$entry->get_id()] = $entry;
			}
			return $entries;
		}
		return false;
	}


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

Далее приведём код класса, который наследуется от стандартного Zend_Cache_Core и используется при инициализации в bootstrap объектов для работы с Memcached. А после уже вернёмся снова к мапперу и методам сохранения, обновления и удаления объектов в БД.

class System_Cache_Core extends Zend_Cache_Core {

	/**
	 * Перегружает стандартный методы save базового класса
	 */
	public function save($data, $id = null, $tags = array(), $specificLifetime = false, $priority = 8) {
		// Разделяем ключ кеширования на составляющие
		$ida = explode('_', $id);
		// Первые части ключей могут нам сказать о том, что эти ключи должны быть сохранены с учетом тегов
		switch ($ida[0]) {
			case 'list':
				// Для списков тег вычисляем как первые 2 чести ключа (включают в себя имя модели)
				$tag = join('_', array_splice($ida, 0, 2));
				$this->updateTagList($tag, $id);
			break;
			case 'find':
				// Для объектов - молучаем тег из маппера этого объекта
				if ($data instanceof System_Model) {
					$tag = $data->get_mapper()->getObjectCacheTag($data);
					$this->updateTagList($tag, $id);
				}
			break;
		}
		// И в конце уже вызываем базовый метод сохранения в кеш
		return parent::save($data, $id, $tags, $specificLifetime, $priority);
	}
	
	/**
	 * Обновляем список ключей кеширования для указанного тега
	 * @param string $tag
	 * @param string $cacheId
	 */
	public function updateTagList($tag, $cacheId) {
		// Получаем список ключе кеширования для тега
		$list = $this->getListByTag($tag);
		$list[] = $cacheId;
		// Добавляем в него новый ключ и пересохраняем список
		$this->saveListByTag($tag, $list);
	}

	/**
	 * Получаем список ключей для тега
	 * @param string $tag
	 */
	protected function getListByTag($tag) {
		$tagcacheId = '_taglist_' . $tag;
		$list = array();
		if ($this->test($tagcacheId)) {
			$list = $this->load($tagcacheId);
		}
		return $list;
	}

	/**
	 * Сохраняем список ключей для тега в самом кеше
	 * @param string $tag
	 * @param array $list
	 */
	protected function saveListByTag($tag, $list) {
		$tagcacheId = '_taglist_' . $tag;
		$this->save($list, $tagcacheId);
	}

	/**
	 * Удаляем из кеша все записи для указанного объекта
	 * @param System_Model $object
	 */
	public function removeByObject($object = null) {
		if ($object instanceof System_Model) {
			// Удаляем все ключи для этой модели
			$this->removeByTag($object->get_mapper()->getListCacheTag());
// Получаем тег из маппера объекта и удаляем все ключей по этому тегу
if ($object->get_id()) {
$this->removeByTag($object->get_mapper()->getObjectCacheTag($object));
			}
		}
	}
	
	/**
	 * Удаляем из кеша все ключи для указанного тега
	 * @param string $tag
	 */
	public function removeByTag($tag) {
		// Получаем список ключей для тега
		$list = $this->getListByTag($tag);
		// И по каждому чистим кеш
		foreach ((array)$list as $cacheId) {
			$this->remove($cacheId);
		}
		// Обновляем сам список ключей для тега, указывая, что он пустой
		$this->saveListByTag($tag, array());
	}
}


Ну и осталось упомянуть методы маппера для сохранения и удаления объектов:

	/**
	 * Сохранение объекта в БД
	 * @param System_Model $object Сохраняемый объект
	 * @param boolean $isInsert Флаг принудительной вставки
	 * @return array|bool|mixed
	 */
	public function save($object, $isInsert = false) {
		$data = $object->toArray();
		$find = array('id = ?' => $object->get_id());
		if (null === ($id_value = $object->get_id())) {
			$isInsert = true;
			unset($data['id']);
		}
		if ($isInsert) {
			$pk = $this->getDbTable()->insert($data);
			if ($pk) {
				$object->set_id($pk);
			}
			$this->resetCache();
			return $pk;
		} else {
			// При обновлении объекта - вызываем чистку кеша для этого объекта
			return $this->getDbTable()->update($data, $find) && $this->resetCache($object);
		}
	}
	
	/**
	 * Принудительное сохранение со вставкой
	 * @param $object System_Model
	 * @return array|bool|mixed
	 */
	public function insert($object) {
		return $this->save($object, true);
	}
	
	/**
	 * Удаление объекта из БД
	 * @param $object System_Model Удаляемый объект
	 * @return bool
	 */
	public function remove($object) {
		$primary = $this->getDbTable()->get_primary();
		$where = array('id = ?' => $object->get_id());
		// При удалении - чистим кеш для этого объекта
		return ($this->getDbTable()->delete($where) && $this->resetCache($object));
	}
	
	
	
	/**
	 * Метод очистки кеша для модели или указанного объекта
	 * @param System_Model $object
	 * @param array $cacheIds
	 * @return bool
	 */
	public function resetCache($object = null, $cacheIds = array()) {
		// Чистим кеш если он вообще подключен
		if (Zend_Registry::isRegistered(CACHE_NAME)) {
			/** @var $cache System_Cache_Core */
			$cache = Zend_Registry::get(CACHE_NAME);
			if (!empty($object)) {
				// Вызываем чистку кеша непосредственно для объекта
				$cache->removeByObject($object);
			} else {
				// Вызываем чистку для модели
				$cache->removeByTag($this->getListCacheTag());
			}
			foreach ($cacheIds as $cacheId) {
				$cache->remove($cacheId);
			}
		}
		return true;
	}
}


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

По прочим проблема и мелочам (которые планируется сделать и решить):
  • поместить все префиксы ('list', 'object', '_taglist_' т.д.) в константы;
  • тег для списков внутри System_Cache_Core определяется как $tag = join('_', array_splice($ida, 0, 2));, что не совсем хорошо и не прямо соответствует определению тега в маппере. Локализация определения тега для списка в одном месте (в методе маппере) позволит для конечных мапперов переопределять этот метод и добавлять параметры в алгоритм формирования тега и в сам тег;
  • тег для объекта строится на основе первичного ключа этого объекта, что может приводить к проблемам следующего рода: если объект ищется с учетом, например, параметра is_published = 1 и не находится (он есть, но не опубликован) — то ключ кеширования для запроса по понятным причинам не попадёт в список тега для этого объекта, и после того, как в админке проекта мы “опубликуем” объект — в кеше запись по этому ключу не будет очищена и объект не будет снова найден (а будет браться false из кеша), пока не закончится TTL для этой записи в кеше.


Все эти проблемы так или иначе постепенно решим.
Однако, хочется и послушать отзывы бывалых хабравчан и получить справедливую долю критики предлагаемого метода.

P.S. Всех с наступающим!
Tags:ZFzend frameworkmemcachedтегикеширование
Hubs: Website development
+5
1.8k 13
Comments 11