Автоматическое тегирование кеша в Yii

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

    Реализация привязки тегов к моделям на основе зависимостей уже обсуждалась на хабре habrahabr.ru/post/159079. Автору отдельная благодарность. Я буду использовать ее как основу, и дополню функциями для автоматической генерации тегов.


    Выделим задачи, которые нам придется решить:


    Генерация тегов по предопределённым правилам. (стандартизация)

    Так как в проекте имеется множество различных сущностей, и связывающие ключи сформированы без определённых стандартов, то нам нужен способ использовать один тег для столбцов, наименование которых отличается. Например, в одной таблице имеем поле user_id, а в другой имеем поле customer_id, оба столбца ссылаются на сущность user. Логично что они должны зависеть от одного и того же тега — user_id.

    Автоматическая генерация тегов

    Банально перегрузим определенные методы в CActiveRecord.
    Теги должны генерироваться для отношений, при выборке нескольких моделей одновременно и при выборке модели по главному ключу (причем главный ключ может быть составным)

    Возможность добавить теги вручную

    Как бы мы не хотели автоматизировать процесс по максимуму, все таки, найдётся достаточно ситуаций, когда нужно указать теги вручную.
    Например, когда выборка модели осуществляется с применением сложного запроса (используется CDbCriteria). Для этого, мы расширим класс CDbCriteria добавив ему свойство $tags, по которому генератор тегов поймет что есть теги добавленные «вручную».

    Реализация:


    Чтобы удовлетворить все вышеуказанные требования нам нужен функционал позволяющий определить стандарты.

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

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

    Правила будем хранить в методе модели cacheTags($mode='read'). Который должен возвращать массив. Так же, определим, что если cacheTags не объявлен в модели или возвращает пустой массив, то автоматическое кеширование не будет активировано.

    Каждый тег будет иметь префикс, состоящий из имени модели в нижнем регистре, т.е. user_id — это тег для свойства id в модели User.

    Заходя вперед скажу что, во время реализации пришлось определить несколько видов правил:

    • Статический
    • Константа
    • Ссылка
    • Композитный


    Статический — линейный элемент массива, значение которого соответствует какому либо столбцу таблицы. Тег будет состоять из префикса и текущего имени правила.

    Константа — значение, с лидирующим символом ':', которое будет использоваться вместо имени тега, предварительно удалив символ ':'. Другими словами, в наименование тега не будет добавлен глобальный префикс.

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

    Композиция — это массив правил. Будет создан соответствующий композитный тег

    Пример:
    public function cacheTags($mode='read'){
            switch ($mode) {
                case 'read':
                    return array(
                       'id', // статический
                       'user_id'=>':user_id', // ссылка, при этом значение ссылки представлено в виде константы
                       array( 'id', 'email'=>'user_email' ) // композитный
                    );
                    break;
                case 'write':
                    return array(
                       'id', // статический
                       array( 'id', 'email'=>'user_email' ) // композитный
                    );
                    break;
            }
        }
    

    В приведенном примере мы видим, что модель зависит от тегов id, user_id, композитного, состоящего из id и user_email, но при записи будут удалены только теги id и композитный. Таким образом, тег user_id останется не тронутым и другие модели зависящие от него не пострадают.

    Все выше перечисленное реализовано в виде библиотеки и выложено на github: github.com/yiix/Cache.
    Для установки можете использовать композер или просто скопировать репозиторий. Модели должны наследовать класс \Yiix\Cache\Tagging\CActiveRecord. Так же не забываем добавить метод cacheTags в каждую модель с описанием правил генерации тегов, так как вся автоматика в нем.

    Я не вижу особого смысла загромождать пост кодом из библиотеки, приведу лишь некоторые показательные примеры.
    Коды приведенные в примерах не должны использоваться напрямую. Я привожу их с целью описать технику используемую в Yiix/Cache/Tagging/CActiveRecord.

    Допустим у нас есть следующие модели:
    class User extends \Yiix\Cache\Tagged\CActiveRecord 
    {
        ...
        
        public function cacheTags($mode = 'read')
        {
            switch ($mode) {
                case 'write':
                    return array(
                        'id','email','username'=>':user_name',
                        array('id','email')
                    );
                    break;
                case 'read':
                    return array(
                        'id','email','username'=>':user_name',
                        array('id','email') 
                    );
                default:
                    break;
            }
        }
        
        ...
        
    }
    class Post extends \Yiix\Cache\Tagged\CActiveRecord 
    {
        
        ...
        
        public function cacheTags($mode = 'read')
        {
            switch ($mode) {
                case 'write':
                    return array(
                        'id',
                    );
                    break;
                case 'read':
                    return array(
                        'id',
                        'authorId'=>':user_id',
                    );
                default:
                    break;
            }
        }
        
        ...
        
    }
    class Tag extends \Yiix\Cache\Tagged\CActiveRecord 
    {
        ...
        
        public function cacheTags($mode = 'read')
        {
            switch ($mode) {
                case 'write':
                    return array(
                        'id',
                        'name',
                    );
                    break;
                case 'read':
                    return array(
                        'id',
                        'name',
                    );
                default:
                    break;
            }
        }
        
        ...
        
    }
    

    Логично, что достаточно определить правила для ключевых свойств.

    Пример 1.

    Тег будет создан по первичному ключу
    $model = User::model()->findByPk(1);
    $tags = \Yiix\Cache\Tagged\Helper::generateTags($model);
    dump($tags);
    

    результат:
    array
    (
        0 => 'user_id=1'
    )
    


    Пример 2.

    Теги создаются по данному массиву параметров.
    $tags = \Yiix\Cache\Tagged\Helper::generateTags(User::model(),array(
            'id'=>'1',
            'email'=>'webmaster@example.com',
            'username'=>'demo'
    ));
    dump($tags);
    

    результат:
    array
    (
        0 => 'user_id=1'
        1 => 'user_email=webmaster@example.com'
        2 => 'user_name=demo',
        3 => 'user:user_id=1,user_email=webmaster@example.com'
    )
    

    В результате присутствует тег (элемент массива с индексом 3) созданный по композитному правилу.

    Пример 3.

    Аналогично примеру 2.
    $tags = \Yiix\Cache\Tagged\Helper::generateTags($Tag::model(),array('name'=>'blog'));
    dump($tags);
    

    результат:
    array
    (
        0 => 'tag_name=blog'
    )
    


    Пример 4.

    Добавление тегов вручную.
    $criteria = new \Yiix\Cache\Tagged\CDbCriteria();
    $criteria->addInCondition('authorId', array('1','2'));
    $criteria->tags = array(
            'authorId'=>array('1','2'),
    );
    
    $tags = \Yiix\Cache\Tagged\Helper::generateTags(Post::model(),$criteria);
    dump($tags);
    

    результат:
    array
    (
        0 => 'user_id=1'
        1 => 'user_id=2'
    )
    

    здесь мы видим, что сработало правило «ссылка» и наименование тегов соответствуют сущности User

    В библиотеке используется пространство имен. Информация по настройке пространства имен описана здесь:
    yiiframework.ru/doc/guide/ru/basics.namespace

    Спасибо за внимание.
    Поделиться публикацией

    Комментарии 17

      +2
      не лучше ли бы было сделать так.

      эту функцию вынести например в какой нибудь behaviour
      public function cacheTags($mode = 'read')
      {
           return isset($this->cacheTags[$mode]) ? $this->cacheTags[$mode] : array(); 
      }
      


      а в каждой модели просто писать
      class Post extends CActiveRecord
      {
       //...
          protected $cacheTags = array(
               'read' => array(
                   // ...
                ),
               'write' => array(
                   // ...
                ),
          )
      }
      
        0
        возможно. я сделал это по аналогии с методом rules(), что дает возможность менять правила динамически в соответсвии с теми или иными условиями.
          +4
          я просто к чему клоню.
          допустим я уже использую какой-то свой кастомный ActiveRecord (например, чтобы работать с MongoDB). и хочу подключить тегирование кеша. но для того, чтобы использовать Вашу библиотеку, мне нужно наследоваться от вашего класса, а я этого сделать не могу, так как у меня кастомный ActiveRecord. и как мне справиться с этой ситуацией?
          а вот в случае с behaviour я просто добавлю его в нужные мне модели и спокойно буду работать дальше
            –1
            я понял. использование behaviour тоже возможно. тут в принципе основная работа проделана над генерированием тегов. ActiveRecord, в основном, нужен для обвертки ключевых методов данным функционалом.
            +1
            Если вы динамически меняете правила валидации, у меня для вас плохие новости. Это очень и очень и очень не правильно. Для изменения поведения при валидации нужно использовать сценарии.
            www.yiiframework.com/doc/guide/1.1/en/form.model#triggering-validation
              0
              я с вами полностью согласен. но случаи бывают разные :)
              например случай с динамическими свойствами объекта, частный случай в интернет магазинах. товары могут иметь различные характеристики, и подключаются к модели в виде свойств. для таких случаев приходится генерировать правила валидации динамически.
                0
                я в таких случаях просто свой валидатор определяю.
          +1
          Думаю в виде бихэйвора это смотрелось бы лучше.
            –2
            так ведь бихейвером нет возможности переопределить методы CActiveRecord. Чтобы тегирование происходило автоматически, все равно придется наследовать от Yiix/Cache/Tagging/CActiveRecord. А раз уж наследуем, тогда зачем бихейвор?
            Или вы имеете ввиду только метод cacheTags?
              +2
              я бы по рукам давал за переопределение системных методов, особенно если вы хотите запихнуть кеширование в слой для работы с базой (опустим уже то что доступ к базе идет через модель).

              Переопределять методы find* это моветон.

              И через бихейверы у вас есть доступ к ивентам beforeFind, afterFind, beforeSave и т.д. Обновление кеша при beforeSave я бы еще понял, но изменять логику find* методов непростительно.
                0
                я бы по рукам давал за переопределение системных методов

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

                И через бихейверы у вас есть доступ к ивентам beforeFind, afterFind, beforeSave и т.д

                я бы и сам рад использовать beforeFind, но нет доступа к $criteria по которому будет произведен поиск (если есть вариант, рад буду узнать). пришлось переопределить find чтобы обработать параметр $criteria и сгенерировать теги по нему.

                методы find и findAll состоят из 3 строк. Да, переопределяя их я внес потенциальную опасность что при очередном обновлении, разработчики изменят их и проект упадет. Но вероятность этого стремится к нулю. Тем более что на пороге Yii 2, и мой велосипед будет не актуален.

                Обновление кеша при beforeSave я бы еще понял

                обновление кеша происходит yii-шным ядром (не в beforeSave), я просто указываю зависимость которая будет использоваться при кешировании.

                  0
                  А метод getDbCriteria() зачем по вашему нужен?
                    0
                    давайте посмотрим глубже:
                    это код метода find, что он делает это обрабатывает параметр $condition, превращая его в criteria и вызывает метод query:
                    github.com/yiisoft/yii/blob/master/framework/db/ar/CActiveRecord.php#L1459

                    здесь же, явно видно что параметр $criteria обошёл вызов beforeFind:
                    github.com/yiisoft/yii/blob/master/framework/db/ar/CActiveRecord.php#L1346

                    т. е. getDbCriteria() можно использовать в afterFind(). Но afterFind не подходит, ну не может он генерировать теги до запроса, на то он и «after» :).
                      0
                      кастыль конечно, но все же лучше вашего решения.
                      // берем исходную критерию
                      $criteria = $this->owner->getDbCriteria();
                      // применяем скоупы сами
                      $this->owner->applyScopes($criteria);
                      // назначаем нашу критерию снова, 
                      // ибо applyScopes после того как применяет скоупы
                      // ресетит их у класса, так что мы должны ее вернуть
                      $this->owner->setDbCriteria($criteria);
                      


                      то что происходит дальше applyScope вроде как особо интересовать нас не должно. Мы свое дело сделали — получили данные для генерации тегов и можем спокойно просить yii кешировать этот запрос в будущем.

                      И да, порефакторьте helper — там много дублирования… очень много…
                        +1
                        на выходе имеем нечто подобное:
                        class CTaggableCacheBehaviour extends CActiveRecordBehavior
                        {
                        
                            public $CACHE_QUERY_COUNT;
                        
                            public $CACHE_DURATION;
                        
                            public function events()
                            {
                                return [
                                    'onBeforeFind' => 'configureCache',
                                    'onAfterSave' => 'purgeCache',
                                    'onAfterDelete' => 'purgeCache',
                                ];
                            }
                        
                            /**
                             * Generates read tags
                             */
                            public function configureCache()
                            {
                                // get criteria object
                                $criteria = $this->owner->getDbCriteria();
                                $this->owner->applyScopes($criteria);
                                $this->owner->setDbCriteria($criteria);
                        
                                // generate cache tags
                                $tags = $this->generateTags($criteria, 'read');
                        
                                // tourn on caching
                                if (!empty($tags)) {
                                    $this->owner->cache($this->CACHE_DURATION, new Depency($tags), $this->CACHE_QUERY_COUNT);
                                }
                            }
                        
                            public function purgeCache()
                            {
                                $tags = Helper::generateTags($this, $this->owner->attributes, 'write');
                                if (!empty($tags)) {
                                    Helper::deleteByTags($tags);
                                }
                            }
                        
                           ...
                        }
                        


                        конечно код не рабочий, но идею вы поняли. Мы таким образом избавились от кучи копипасты, код стал проще, огромный кастыль с перегрузкой методов заменился на маленький (возможно и в нем есть недостатки, но думаю не существенны в большинстве случаев и можно еще чего придумать, можете samdark-а поспрашивать).
                        Еще бы убрать дублирование из класса helper, там уж слишком много лишнего дублирования…
                          0
                          спасибо за конструктив.

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

                          при всем желании использовать бихейвор для автоматизации, я не вижу это возможным.

                          1. как я уже упомянул, в beforeFind (и даже в afterFind) getDbCriteria вовращает пустой объект CDbCriteria. И анализировать его не представляю возможным

                          2. тем не менее, кроме анализа $criteria, нужно еще анализировать параметры в других методах:
                          findByPk, findAllByPk — требуется анализ параметра $pk, этот параметр отличается тем что он может быть сложным
                          findByAttributes, findAllByAttributes — требуется анализ $attributes, массив ключ/значение.

                          3. нужно сделать кеширование для отношений, а то как то не красиво получается — модель загрузилась из кеша, а отношения из базы. Для этого придется переопределить метод getRelated()
                            +1
                            Вчера специально проверил, мой кастыль работает корректно. Все что происходит с критерией после нас не должно волновать, там происходит назначение алиасов и т.д. Причем beforeFind срабаывает всегда, и при ленивой загрузке, и при поиске по первичному ключу, и все что нужно будет в критерии.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое