Первичный кэш в Kohana 3 с использованием тегов

Приведенный пример является результатом решения одной задачи, которая возникла при разработке системы управления сайтом на фреймворке Kohana 3.1, в которой предполагается одна учетная запись администратора и множество незарегистрированных читателей.

Требовалось надолго кэшировать результаты работы методов моделей, которые обращаются к базе данных. Фактически, требовалось создавать копии наборов данных из базы, чтобы снизить нагрузку на СУБД. Для немедленного обновления кэша при добавлении новых данных или обновления старых требовалась очистка кэша по тегам.

Учитывая все это, и в связи с ограничениями используемых хостингов требования были следующие:
  • Кэш должен храниться в файлах.
  • Кэш должен храниться долго, для увеличения скорости извлечения данных и снижения нагрузки на СУБД.
  • При обновлении данных администратором сайта, кэш, содержащий устаревшие данные должен очищаться, причем, очищаться должен не только кэш результатов функций, напрямую извлекающих эти данные из базы, но и тех, результаты которых связаны с этими данными (например, при удалении рубрики каталога должен очищаться кэш списков позиций рубрик). Для достижения этой цели должны поддерживаться теги.
  • Ради достижения цели можно в определенных рамках пожертвовать временем, уходящим на добавление новых материалов, и которое будет затрачено в том числе на очистку кэша, так они добавляются «своим человеком», а не сторонними пользователями.

Стандартный класс Cache_File не поддерживает теги, по этой причине потребовалось писать свой класс, ему было дано имя JetCache.

Класс спроектирован по шаблону «одиночка». Рассмотрим пример работы класса в модели для банка файлов. При инициализации модели создается экземпляр:

$this->cache = JetCache::instance();


Создание кэша данных рассмотрим на примере функции для извлечения списка файлов определенной рубрики (здесь из нее удалены некоторые аргументы и некоторый код для упрощения чтения):

    //Вернуть список файлов рубрики
    /**
     *
     * @param int $rubricId Id рубрики в БД
     * @return array
     */
    public function getFiles($rubricId) 
    {
        //!!! В случае, если удается получить информацию из кэша, 
        //вернуть эту информацию
        $key = 'filebank_get_files'.$rubricId;
        $arResult = $this->cache->get($key);
        if (is_array($arResult)) {
            return $arResult;
        }
        
        $arResult = array();
        $arParams = array();
        $arParams[':rubricId'] = $rubricId;

        $query = "
            SELECT 
                * 
            FROM 
                `filebank_files` 
            WHERE 
                `rubric_id`=:rubricId 
            ORDER BY 
                `time` DESC, 
                `name` ASC
        ";
        $arResult['files'] = DB::query(Database::SELECT, $query)
                ->parameters($arParams)
                ->execute()
                ->as_array();
        
        //!!! Внести в кэш результат работы
        $this->cache->set($key, $arResult, array('filebank_rubrics', 'filebank_files'));

        return $arResult;
    }


Таким образом, запись к кэше делается с ключом $key = 'filebank_get_files'.$rubricId и тегами «filebank_rubrics» и «filebank_files», то есть, очищать эту запись требуется при обновлении информации о рубриках и непосредственно файлах.

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

    protected $cacheRegExp = '/^filebank/';

    //Удалить рубрику
    public function delRubric($rubricId) 
    {
        $query = '
            SELECT 
                `name` 
            FROM 
                `filebank_files` 
            WHERE 
                `rubric_id`=:rubricId
        ';
        $arFiles = DB::query(Database::SELECT, $query)
                ->param(':rubricId', $rubricId)
                ->execute()
                ->as_array();

        $arFiles = Arr::path($arFiles, '*.name');
        $this->delFiles($arFiles);

        $query = '
            DELETE FROM 
                `filebank_rubrics` 
            WHERE 
                `rubric_id`=:rubricId
        ';
        DB::query(Database::DELETE, $query)
                ->param(':rubricId', $rubricId)
                ->execute();
        
        //!!! Очистка кэша по тегам
        $tags = array('filebank_rubrics', 'filebank_files');
        $this->cache->delete_by_tags($tags, $this->cacheRegExp);
    }

То есть, после удаления рубрики очищается кэш данных, связанных с рубриками и позициями в рубриках.

Таким образом, была добавлена возможность первичного долговременного кэширования данных. На уровне контроллеров можно так же использовать кратковременное кэширование с помощью стандартного модуля «Cache» с файловым драйвером.

В случае больших проектов, где требуется высокая скорость очищения кэша или добавление информации пользователями, конечно, лучше использовать специальные решения. Например, драйверы «Memcached-tag» или «Xcache» модуля «Cache». Но для небольших сайтов, администрируемых одним человеком или небольшой группой людей, при использовании хостингов без предоставления специальных инструментов для кэширования, это решение подходит хорошо.

Файлы, в которых хранится кэш, содержатся в одной директории и имеют следующую структуру:
Время, после которого запись считается устаревшей (unix timestamp)\n
Список тегов через запятую\n
Сериализованные данные\n


Наконец, приведу полный код класса:
<?php defined('SYSPATH') or die('No direct access allowed.');

class JetCache
{
    protected static $instance = NULL;
    protected static $config;
    protected static $cache_dir;
    protected static $cache_time;
    
    
    public static function instance()
    {
        if (is_null(self::$instance)) {
            self::$instance = new self();
        }
        
        return self::$instance;
    }
    
    protected function __construct()
    {
        self::$config = Kohana::config('jethelix')->default;
        self::$cache_dir = self::$config['jet_cache_dir'];
        
        if (!is_dir(self::$cache_dir)) 
        {
            $oldUmask = umask(0000);
            
            if (!mkdir(self::$cache_dir, 0777, TRUE)) {
                $message = 'Неверная директория для модуля JetCache';
                throw new Exception($message);
            }
            
            umask($oldUmask);
        }
        
        self::$cache_time = self::$config['jet_cache'];
    }
    
    protected function __clone() {
    }
    
    public function set($id, $data, array $tags=array(), $lifetime=NULL)
    {
        if (!$lifetime) {
            $lifetime = self::$cache_time;
        }
        
        $filename = self::$cache_dir . '/' . $id . '.txt';
        
        $expires = time() + (int)$lifetime;
        $tagString = implode(',', $tags);
        $serData = serialize($data);
        
        $content = $expires . "\n" . $tagString . "\n" . $serData;
        
        try {
            file_put_contents($filename, $content);
        }
        catch (Exception $e) {
            return FALSE;
        }
        
        return TRUE;
    }
    
    public function get($id)
    {
        $filename = self::$cache_dir . '/' . $id . '.txt';
        
        if (!is_file($filename)) {
            return NULL;
        }
        
        try {
            $content = file_get_contents($filename);
        }
        catch (Exception $e) {
            return NULL;
        }

        $arContent = explode("\n", $content);
        unset ($content);
        
        try {
            if ($arContent[0] < time()) {
                return NULL;
            }
            
            $data = unserialize($arContent[2]);
            return $data;
        }
        catch (Exception $e) {
            return NULL;
        }
    }
    
    public function delete($id)
    {
        $filename = self::$cache_dir . '/' . $id . '.txt';
        
        try {
            unlink($filename);
        }
        catch (Exception $e) {
            return FALSE;
        }
        
        return TRUE;
    }
    
    public function garbage_collect()
    {
        $dir = opendir(self::$cache_dir);
        while ($file = readdir($dir)) 
        {
            $fullName = self::$cache_dir . '/'. $file;
            if (!is_file($fullName)) {
                continue;
            }
            
            try {
                $this->_deleteIfExpires($fullName);
            }
            catch (Exception $e) {
                return FALSE;
            }
        }
        
        return TRUE;
    }
    
    protected function _deleteIfExpires($filename)
    {
        $fhandle = fopen($filename, 'r');
        $expires = (int)fgets($fhandle);
        fclose($fhandle);
        
        if ($expires < time()) {
            unlink($filename);
        }
    }
    
    public function delete_by_tags(array $tags, $filenameRegExp=NULL)
    {
        $this->garbage_collect();
        
        try {
            $arFiles = $this->_getTaggedFiles($tags, $filenameRegExp);
            $this->_deleteFiles($arFiles);
        }
        catch (Exception $e) {
            return FALSE;
        }
        
        return TRUE;
    }
    
    protected function _getTaggedFiles(array $needTags, $filenameRegExp)
    {
        $taggedFiles = array();

        $dir = opendir(self::$cache_dir);
        while ($file = readdir($dir)) 
        {
            $fullName = self::$cache_dir . '/' . $file;
            if (!is_file($fullName)) {
                continue;
            }

            if ($filenameRegExp && !preg_match($filenameRegExp, $file)) {
                continue;
            }
            
            $hasTags = $this->_getTagsFromFile($fullName);            
            $isValid = $this->_tagsValidate($needTags, $hasTags);
            if ($isValid) {
                $taggedFiles[] = $fullName;
            }
        }
        
        return $taggedFiles;
    }
    
    protected function _getTagsFromFile($filename) 
    {
        $fhandler = fopen($filename, 'r');
        fgets($fhandler);
        $tagString = fgets($fhandler);
        fclose($fhandler);
        
        $tagString = trim($tagString);
        $arTags = explode(',', $tagString);
        
        return $arTags;
    }

    protected function _tagsValidate(array $needTags, array $hasTags)
    {
        foreach ($needTags as $tag) {
            if (in_array($tag, $hasTags)) {
                return TRUE;
            }
        }
        
        return FALSE;
    }    
    
    protected function _deleteFiles(array $files)
    {
        foreach ($files as $filename) {
            unlink($filename);
        }
    }
}
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 31

    +9
    Эмм… Скажите, а почему не расширить стандартный Cache_File до Cache_Filetag по аналогии с Cache_Memcachetag? Изменений будет немного, заново придумывать (или выдергивать из стандартного Cache_File) основные методы не надо. Если уж на то пошло, файловый кэш с тэгами существовал в версии 2.3.4, так что выдернуть оттуда его несложно.
      +4
      Еще дополню.

      1. Зря Вы храните все файлы кэша в одной директории. В оригинальном модуле кэш сохраняется в поддиректории, имена которых берутся их хэша имени файла (т.е. что-то типа cache/ab/cd/abcdef.txt).
      2. Ну и поиск по тэгам у Вас неуклюжий получается. Надо считать строку из файла. Опять же, в 2.3.4 тэги хранятся прямо в имени файла, что облегчает их поиск (там через glob() все сделано).
        0
        Сказать честно, я пользуюсь Kohana с версии 3.0, поэтому файловый кэш с тегами не застал. Нужно будет изучить эту реализацию. Остальные замечания всенепременнейше учту в JetCache 2.0 )
          +2
          Гм… Если Вы начнете с моего первого замечания, то JetCache v2 просто не будет :) Напишите Cache_Filetag и предложите его сообществу. Это и будет Kohana way.
            0
            Ну или так )

            Правда, у них уже есть готовое решение. Интересно, почему оно не вошло в 3 версию.
              +2
              Вероятно из-за тормознутости файлового кэша. Хотите более удобной и быстрой работы кэша — используйте, к примеру. Memcache/Memcachetag
                0
                Может быть. Но в условиях сайтов с невысокой посещаемостью, размещенных на стандартных хостингах, такие варианты не применимы из-за отсутствия Memcache и других замечательных вещей. Остаются только файлы.
                  0
                  А ещё есть sqlite-кеш. Он тоже поддерживает теги
                    0
                    Как было сказано в постановке задачи, в данном случае подходят только файлы )

                    К тому же, я придерживаюсь мнения, что если есть возможность использовать использовать софт из дополнительного набора, лучше использовать Memcache или Xcache. Например, в документации скорость работы Sqlite оценена как «Poor» — так же, как и у файлового кэша: kohanaframework.org/3.1/guide/cache#choosing-a-cache-provider
                      +1
                      Дык poor в случае с файлами — это еще без тэгов :) С тэгами он может стать ugly
                        0
                        Ну да ) Правда, в приведенном случае теги используются только при очистке кэша. А поскольку она происходит только при редактировании информации администратором сайта, трагизм ситуации снижается. Возможно, это даже делает преимуществом хранение тегов внутри файла.
                          0
                          Я имел в виду, что если теги хранятся внутри файла, к нему можно обратиться по хорошо известному имени напрямую, без применения маски и функции glob(). Это ускоряет чтение данных. Хранение же тегов внутри файла снижает скорость поиска по тегам, но в рассмотренном случае это не критично.
                        0
                        А sqlite — это не файл? Или на хостинге нет поддержки PDO?
                          0
                          Это верно. Но вопрос в том, стоит ли использовать Sqlite для кэширования данных, извлеченных из MySQL? Особенно если можно использовать более быстрые методы.
        0
        Делал похожий механизм для ZF, сейчас переношу его на проект, написанный на Kohana 3.1, но с небольшими дополнениями — есть такая задумка определять теги автоматически, разобрав sql-запрос и выбрав из него все используемые таблицы, используя их как теги при модификации (для сброса) и выборке (для сохранения). Решение довольно деревянное, но для сайтов с не слишком хитрой структурой кэша подойдет прекрасно.
          0
          Интересное решение. Но в моем случае использование имен таблиц в качестве тегов было бы не очень целесообразно в связи с тем, что из одних и тех же таблиц извлекаются разные наборы данных в зависимости от ситуации.
          +2
          Ох, господи, вот люди верхов нахватаются и полезут «кеширование» делать, еще и на файлах (а, еще есть оригиналы из Друпала, они вообще где-то в базе данные кешируют). Объясните, какой смысл тормозным скриптом лазать по иерархической файловой системе, открывать каждый файл и перечитывать теги (это же быдлокод), когда можно просто добавить ключики, вроде filebank_files_update_time и что там у вас еще есть, и при чтении из кеша проверять дату обновления этих ключей, если запись в кеше зависит от них, и она старше — знаичт, она устарела.

          У вас когда будут тысячи файлов в этом недокеше, вы тоже каждый открывать будете?
            0
            Почти на все вопросы ответ содержится в постановке задачи )

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

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

            Таким образом, СУБД разгружена, у клиентов скорость чтения увеличена. А администратор может потерпеть ) Тысяч файлов, опять же, не будет из-за относительно небольшого объема информации.

            А для сайтов, где огромная база данных, частое обновление информации и все такое, нужен серьезный хостинг. И для этого случая во фреймворке уже есть хорошие стандартные решения, и ничего изобретать не надо.
              0
              Ох, боюсь мы с вами не поняли друг друга. Главная моя претензия не к хранению кеша в файлах, а к использованию дурной, уродливой, придуманной видимо индусами системы тегов в кеше, зачем они нужны, когда можно сделать проще?
                0
                Об этом я и писал выше. В оригинальной библиотеке Cache v2.3.4 имена файлов были вида id~tag1+tag2+tag3~lifetime, и для обработки тэгов и lifetime достаточно обработать имя файле, не заглядывая внутрь.
                  0
                  С одной стороны, это решение действительно привело бы к оптимизации поиска по тегам. Но нужна ли она в тех условиях, которые я описал? Ведь это привело бы к увеличению времени поиска по ключу, что здесь более критично, так как поиск по ключу — для клиентов, поиск по тегам — для администратора.
                    0
                    Почему это для администраторов? По идее, тэги должны использоваться самой системой для своевременного удаления кэша при наступлении соответствующего события. Например, добавилась статья в категорию N — и кэш с тэгом categoryN должен быть очищен. Не администратор же вручную должен этим управлять.
                      0
                      Так здесь статьи добавляются только администратором ) Такое решение было выбрано с учетом этой особенности, я это указывал.
                        0
                        Комментарии, фотографии и т.д.

                        В любом случае, Вы предлагаете сообществу модуль, но при этом изначально его сильно ограничиваете. Какой смысл?
                          0
                          Да, здесь не предполагается добавление информации пользователями, ограничение существенное. Но в сфере сайтов, например, для небольших фирм, это не требуется. Модуль разрабатывался именно для подобных случаев. В других условиях, конечно, нужно было бы применять более оптимальные методы, а еще лучше — Memcache и другие хорошие вещи.
              0
              Поскольку я не претендовал на включение моего модуля в дистрибутив, я позволил себе некоторые вольности.

              Многие вещи в соглашении Коханы расходятся с моими моральными принципами. Например, для того, чтобы написать так:
              // Correct:
              $db = new Database; 
              

              мне нужно фактически переступить через себя ) Я привык писать «неправильно»:
              // Incorrect:
              $db = new Database();
              


              Все-таки, в случае, если код не будет включаться в какой-то общий проект, тонкости оформления кода, такие как, где ставить скобки или пробелы — это вопрос вкуса. К примеру, автоматическое форматирование кода в NetBeans нарушает куда больше пунктов этого соглашения, чем нарушил я.
                0
                Ну Вы хотя бы соблюдайте один и тот же стиль :) А то protected-методы с подчеркивания начинаются, а свойства — нет. То у Вас CamelCase-методы, то опять с подчеркиваниями. Как будто писали разные люди.
                  0
                  Свойства я обычно не делаю публичными, поэтому не выделяю их. Я разделяю мнение, что прямого доступа к свойствам следует избегать.

                  Методы бывают как публичные, так и защищенные, поэтому защищенные я выделяю подчеркиванием в начале, чтобы при чтении кода было понятно, что вызывается защищенный метод, вызов которого извне не предусмотрен.

                  В основном я использую CamelCase. Здесь через подчеркивание назвал аналоги методов из класса Cache_File.
                  0
                  Поскольку я не претендовал на включение моего модуля в дистрибутив, я позволил себе некоторые вольности.

                  Тут нужно или трусы надеть, или крестик снять.
                  Если 'в Kohana' — то можно было расширить (или заново реализовать), файловый кеш который уже существует, прикрутив к нему теги (как справедливо предложил dohlik), до кучи переступив через себя и приведя код в соответствие с принятыми в сообществе стандартами.
                  Если нет — слово Kohana скорее лишнее. Тем более, что в предлагаемом классе используется разве что конфиг, который из зависимостей при большом желании выпиливается ну просто на раз-два.
                  Небольшая ремарка. Сами кохановцы делают зависимость от конфига ещё более слабой, обратите внимание. Обращение к классу Config (или прокси Kohana::config) используется в основном только во вспомогательных статических методах, которые передают уже полученный конфиг в виде массива в конструктор.

                  К примеру, автоматическое форматирование кода в NetBeans нарушает куда больше пунктов этого соглашения, чем нарушил я.

                  Не настроенное под конкретную ситуацию автоформатирование.
                  Не надо сваливать на настройки IDE, поставляемые с ней по умолчанию.
                    0
                    Я не ищу оправданий в стандартном стиле IDE, я привел его как пример зависимости тонкостей стиля написания кода от вкусов человека, пишущего код.

                    Наверно, конечно, вы правы в том, что при публикации материалов о фреймворке приведение кода к его стандартам является плюсом. Но разве при разработке под каждый фреймворк обязательно нужно соблюдать индивидуальный стиль кода его дистрибутива? Например, у Zend framework'а другие стандарты, а переключаться в голове между разными стилями не так просто )

              Only users with full accounts can post comments. Log in, please.