Наверняка большинство программистов, работающих с современными веб-фрейворками, реализующими схему MVC, сталкивалось с таким небольшим затруднением: кэширование фрагмента View.
Хорошие фреймворки предлагают инструменты для полного кэширования страниц, фрагментарного, или кэширования экшенов. Недавно я посмотрел
90 выпуск подкаста
Railscasts, посвященный именно фрагментарному кэшированию в Ruby on Rails и уважаемый автор решал проблему, как мне показалось, неоптимально.
Опишу ситуацию.
Мы в шаблоне страницы и хотим закэшировать ее часть, например, список новых товаров. Пока все хорошо, мы пользуемся встроенными во фреймворк удобными средствами и в две-три строчки окружаем блок - ура, он кэшируется. Но - чу!, контроллер-то об этом ничего не знает и продолжает выполнять свою работу по подготовке данных для View. Естественно, ведь проверка наличия кэша осуществляется уже из шаблона, а контроллер к тому моменту отработал.
Автор подкаста показывает некрасивое решение - перенос кода для подготовки данных в шаблон и тут же, естественно, отметает его, как "ugly". Что он предлагает - перенести этот код в модель. То есть, в модели товара создается специальный метод, который выбирает новые товары, и этот метод вызывается из шаблона.
Это лучше, чем первый вариант, но все же недостаточно хорошо, так как в модели приходится реализовывать вещи, которые могут понадобиться в одном только месте, а при смене интерфейса сайта могут оказаться ненужными и скорее всего останутся болтаться в коде просто так.
Мое решение
Я работаю со своим фреймворком на PHP, и пример буду писать на PHP, но решение простое и реализуется на любом скриптовом язке.
view.php:
<code>
...
<? if !(cacher::start('Cache_Name')) { ?>
<ul>
<? foreach ($latest as $item) { ?>
<li><?=$item->name();?>: <?=$item->price();?></li>
<? } ?>
</ul>
<? cacher::end(); } ?>
...
</code>
controller.php:
<code>
...
$latest = new model_collection('product');
$latest->load_by( $condition, $order, $limit );
$this->export('latest', $latest);
...
</code>
Метод load_by(...) выполняет один или несколько запросов к базе данных и формирует набор моделей класса Product. То есть, тратятся ресурсы на запрос, да еще и память на экземпляры модели.
Хорошо бы как-то запомнить, что мы хотим сделать, а делать это только если кэша нет.
Напишем это.
utils.php:
<code>
...
class prepared extends stdClass // крохотный класс для хранения подготовленной операции
{
// не буду усложнять пример геттерами и сеттерами
public $obj, $method, $args;
}
class utils
{
...
public static function prepare( $obj, $method, $args = null )
{
$res = new prepared();
// метод принимает неограниченное количество параметров
$args = func_get_args();
$res->obj = array_shift($args);
$res->method = array_shift($args);
// запоминаем все остальные параметры
$res->args = $args;
return $res;
}
public static function run( $prepared )
{
// страховка: шаблон не должен думать, пришли ли реальные данные, или заготовка
if (!($prepared instance_of prepared)) return $prepared;
return call_user_func_array( array($prepared->obj, $prepared->method), $prepared->args );
}
...
}
...
</code>
Метод
run() упрощен, по
подсказке
davojan.
Использование
controller.php:
<code>
...
$latest = new model_collection('product');
// ничего не грузим сразу
// $latest->load_by( $condition, $order, $limit );
// запоминаем, что мы хотим сделать, в самой переменной для шаблона
$latest = utils::prepare( $latest, 'load_by', $condition, $order, $limit );
$this->export('latest', $latest);
...
</code>
view.php:
<code>
...
<? if !(cacher::start('Cache_Name')) { ?>
<?
// только здесь выполняем запланированное, при этом шаблону не нужно знать, что именно делается
$latest = utils::run( $latest );
?>
<ul>
<? foreach ($latest as $item) { ?>
<li><?=$item->name();?>: <?=$item->price();?></li>
<? } ?>
</ul>
<? cacher::end(); } ?>
...
</code>
Предположим, в вашем фреймворке товары надо было бы грузить статическим методом. Пожалуйста, можно и так:
controller.php:
<code>
...
// ничего не грузим сразу
// $latest = Product::get_latest(...);
// запоминаем, что мы хотим сделать, в самой переменной для шаблона
$latest = utils::prepare( 'Product', 'get_latest', ... );
$this->export('latest', $latest);
...
</code>
В шаблоне же даже ничего не нужно менять.
Этот способ я использую во множестве мест и пока он меня не подводил. Недостаток: пока не удается готовить наборы операций, но в таких извращенных случаях уже можно и метод где-нибудь добавить.
Буду рад комментариям.
Апдейт
В комментариях мне указывают на наличие компонентов и возможности кэшировать их целиком. Я вынужден пояснить - заметка не об этом. Приведу другой пример, из реальной жизни.
Страница со списком новостей, экшен 'index' контроллера 'news'.
<code>
...
$news = new model_collection('news'); // или как у вас
$news->load_by( $conditions, $order, $limit );
$this->export('news', $news);
...
</code>
Шаблон со списком новостей вкладывается в лэйаут, в котором присутствует еще куча компонент (новые товары, курсы валют и прочее). Компоненты кэшируются целиком, естественно. Но вот именно "основной" экшен же нам надо выполнить, мы страницу целиком закэшировать чаще всего не можем.
Тут-то и пригождается описанный подход - данные не доставать сразу, а только приготовиться. Можно, конечно, вынести непосредственно вывод новостей в еще один экшен, но таким путем мы почти удвоим количество экшенов, а это явно неудобно.
Так должно быть понятней.
комментарии (66)
Интересный пример, над которым стоит подумать. Правда субъективно, хочется что-то более элегантное.
Спасибо за решение :)
Потом, во view придется все-таки указать сам блок для кэширования, это и сделано.
Заметка все равно не об этом, а только о маленьком нюансе.
>> ли данные в базе. Можно такой мониторинг организовать,
не могли бы вы порекомендовать пути решения? тоже бьюсь над проблемой "как узнать, expired ли кэш query, не обращаясь к БД" и кроме как "сбрасывать кэш при изменении данных в админке" (вот это действительно ugly :), ничего в голову не приходит
Второй вариант очевиден. Управлять флагом при внесении изменений в базу из модели данных. Т.е. когда PHP/Python/Ruby/C# или что-то там еще отправило данные в таблицу, выставлять флаг (можно в базе, можно вне базы).
А можно и вообще без флагов. При добавлении/изменении данных создавать кеш. Это подойдет, если кеш создается относительно быстро, а добавление/изменение происходит относительно редко.
Ну есть вообщем-то и другие варианты, но их я пожалуй опущу...
Короче, нефиг придумывать велосипеды :)
+ из этитх реализаций мне больше всего нравится реализация через модель. Она самая простая.
В рельсах очень обоснованно отказать от компанент потому что они стали попросту не нужны.
<%= render :partial => 'news_detail', :collection => @news/News.latest/вставить_нужное %>
есть такой "фронт энд" Zend_Cache_Frontend_Output
+есть еще и другие "фронт энды"
+в Zend_Cache есть еще понятие бэк эндов, т.е. способов хранения кэша например файл, Memcached, sqlite...
+теги к записям, позволяют гибко управлять очисткой кеша.
Ведь изменение в БД может производить не только приложение на рельсе, которое управляет и кэшем - к БД может подключаться и стороннее приложение, которое будет вносить изменения в БД, а рельса не будет об этом знать.
но в большинстве случаев самое то, особенно когда никакой логики при обработки кэша не требуется
- вызывать контроллер из представления логически абсурдно
- пихать в контроллер "модельную" состовляющую - некрасиво
p.s. брызь читать внимательно: http://ru.wikipedia.org/wiki/Model-view-controller
почитайте, пожалуйста, Фаулера "Архитектура корпоративных программных приложений", ставшую уже классической.
Станете еще большим занудой и эстетом, но в более правильном направлении ;)
Вы это хотите реализовать?
В документации:
$res = $cache->foobar('1', '2');, то есть для выполнения закэшированного нужно все-таки знать, ЧТО должно выполниться.У меня же этого знать не нужно. Знаем мы только в контроллере, а в шаблоне всегда одно и то же:
$res = utils::run( $res );Повторюсь: я и так использую Zend Cache, обернув его своим cacher.
{
if(!$content = $this->CI->cache->load('top_news', 60*60))
{
$this->CI->view(shablon) - К примеру
$this->CI->cache->save($content, 'top_news');
}
return $content;
}
Получаем нужный результат, все происходит до выполенения View.
Класс за основу брал отсюда: http://larin.in/archives/11
У меня про другое совсем, см. в апдейте.
:)) наверное все-таки отсюда: http://larin.in/archives/21
Мой концепт (action-controller)
...
$this->page()->getLayout()->getLayout()->getBlock('spisokTovarov)->аВотТутМожноВсе();
...
или:
$this->page()->getLayout(2)->getBlock('spisokTovarov)->аВотТутМожноВсе();
$this->page->getLayout(2)->getBlock('spisokTovarov)->аВотТутМожноВсе();
(ну да ладно:)
Из замечаний: со статическими вызовом перемудрили, не нужно никаких двух двоеточий, call_user_func умеет принимать на вход массив, в котором первый элемент строка, а не объект - тогда он делает вызов статической функции.
А вообще, спасибо за идею.
Контроллер (было):
$this->c->someVar = $this->calculateSomeVar( $argument );
Контроллер (стало):
$this->c->jitVar( 'someVar', $this, 'calculateSomeVar', $argument );
Представление (не изменяется):
<?= $c->someVar ?>
Как написать
__get()надо объяснять?Замечательное развитие мысли, спасибо.
сложнее гораздо когда один и тот же кусок данных используется в нескольких местах) в этом случае при его изменении необходимо сбрасывать кеш со всеми местами где он используется) вот в таком случае не просто получается
Если очень хочеться закэшировать html, то у меня это реализовано приблизительно следующим образом:
1. Выборка передается в шаблон, шаблон пробегается по ней в цикле, при этом непосредственно запрос к БД/кэшу происходит on-demand.
2. Если мы кэшируем какой-то участок шаблона- если кэш не найден, запоминаются все имена произведенных выборок, после чего запоминаем в кэше собствено html и связи с именованными выборками.
3. При изменении модели она сбрасывает все кэши с некоторым именем, как кэши выборок, так и непосредсвенно html.
Сам вызов кэша в шаблоне реализован как активный тег поэтому выглядит все как-то так:
контроллер
$sel= new items_select();
$sel->Where('UserID',eq,$UserID);
//Имя кэша:
$sel->setCacheName('user_items_'.$UserID);
//Постраничный вывод:
$sel->Limit((int)$_REQUEST['page']*10,10);
Шаблон:
(тупо делаем имя по строке запроса)
<!-- op:cache="user_items_{$_SERVER.REQUEST_URI}" lifetime="1000"-->
<!--op:each="$sel"-->
<a href="{$Link}">{$Title}</a>
<!--/op:each-->
<!--/op:cache-->