Кеширование настроек контекстов в MODX Revolution. Я негодую

Печально новый год начинать с такого нехорошего топика, но ничего не поделаешь…

Конечно много уже кто бросал камни в MODX из-за проблем с кешированием, но сегодня сделаю это и я. Я конечно же очень люблю MODX, но некоторые вещи меня прямо-таки вымораживают! Сразу оговорюсь, что описываемые здесь проблемы касаются только тех случаев, когда предполагается большое количество документов в одном контексте (более 10 000).

Сегодня мы рассмотрим процесс генерации кеша контекстов и на что и как мы можем влиять.

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

А в чем проблема? А проблема в том, что это как минимум накладывает очень серьезные ограничения на максимальное кол-во документов в контексте. Почти два года назад я уже писал о своих исследованиях по этому поводу еще на версии Revo 2.0.8, так вот — с тех пор практически ничего не поменялось…

Сразу определим основную проблему: при обновлении кеша контекста, MODX перебирает все документы этого контекста (читай: делает много-много запросов к базе данных и получает и обрабатывает очень большой объем информации) и формирует карты ресурсов и алиасов. При этом он хранит эти карты не в отдельном кеш-файле, а именно в кеше настроек контекста.

Есть проблема — сразу же можно предположить парочку вариантов ее решения:

  1. Запретить MODX-у делать выборку всех документов контекста. (Это был бы идеальный вариант — частично закешировать только важные документы, участвующие в формировании менюшек Wayfinder-ом и т.п., а те документы, которые мы получаем динамически нашими собственными специфическими скриптами, пропустить).
  2. Вообще отключить кеширование контекста. (Почему это оказывается очень плохой вариант, мы рассмотрим и поймем позже).

Для начала немного теории: каждый раз при генерации настроек контекста, MODX собирает не только его настройки как таковые, но и собирает все его документы и набивает в карты ресурсов, алиасов и т.п. Плюс к этому, если используются ЧПУ, он еще и проверяет их на уникальность.

Выполняется это все в одном методе modCacheManager::generateContext(). Давайте посмотрим на исходник:

<?php
public function generateContext($key, array $options = array())
{
    $results = array();
    if (!$this->getOption('transient_context', $options, false))
    {
        /** @var modContext $obj */
        $obj = $this
            ->modx
            ->getObject('modContext', $key, true);
        if (is_object($obj) && $obj instanceof modContext && $obj->get('key'))
        {
            $cacheKey = $obj->getCacheKey();
            $contextKey = is_object($this
                ->modx
                ->context) ? $this
                ->modx
                ->context
                ->get('key') : $key;
            $contextConfig = array_merge($this
                ->modx->_systemConfig, $options);

            /* generate the ContextSettings */
            $results['config'] = array();
            if ($settings = $obj->getMany('ContextSettings'))
            {
                /** @var modContextSetting $setting */
                foreach ($settings as $setting)
                {
                    $k = $setting->get('key');
                    $v = $setting->get('value');
                    $matches = array();
                    if (preg_match_all('~\\{(.*?)\\}~', $v, $matches, PREG_SET_ORDER))
                    {
                        foreach ($matches as $match)
                        {
                            if (array_key_exists("{$match[1]}", $contextConfig))
                            {
                                $matchValue = $contextConfig["{$match[1]}"];
                            }
                            else
                            {
                                $matchValue = '';
                            }
                            $v = str_replace($match[0], $matchValue, $v);
                        }
                    }
                    $results['config'][$k] = $v;
                    $contextConfig[$k] = $v;
                }
            }
            $results['config'] = array_merge($results['config'], $options);

            /* generate the aliasMap and resourceMap */
            $collResources = $obj->getResourceCacheMap();
            $results['resourceMap'] = array();
            $results['aliasMap'] = array();
            if ($collResources)
            {
                /** @var Object $r */
                while ($r = $collResources->fetch(PDO::FETCH_OBJ))
                {
                    $results['resourceMap'][(string)$r->parent][] = (string)$r->id;
                    if ($this
                        ->modx
                        ->getOption('friendly_urls', $contextConfig, false))
                    {
                        if (array_key_exists($r->uri, $results['aliasMap']))
                        {
                            $this
                                ->modx
                                ->log(xPDO::LOG_LEVEL_ERROR, "Resource URI {$r->uri} 
already exists for resource id = {$results['aliasMap'][$r
                                ->uri]}; skipping duplicate resource URI for resource id = {$r->id}");
                            continue;
                        }
                        $results['aliasMap'][$r
                            ->uri] = $r->id;
                    }
                }
            }

            /* generate the webLinkMap */
            $collWebLinks = $obj->getWebLinkCacheMap();
            $results['webLinkMap'] = array();
            if ($collWebLinks)
            {
                while ($wl = $collWebLinks->fetch(PDO::FETCH_OBJ))
                {
                    $results['webLinkMap'][$wl
                        ->id] = $wl->content;
                }
            }
            $this
                ->modx
                ->log(modX::LOG_LEVEL_ERROR, $key);
            /* generate the eventMap and pluginCache */
            $results['eventMap'] = array();
            $results['pluginCache'] = array();
            $eventMap = $this
                ->modx
                ->getEventMap($obj->get('key'));
            if (is_array($eventMap) && !empty($eventMap))
            {
                $results['eventMap'] = $eventMap;
                $pluginIds = array();
                $plugins = array();
                $this
                    ->modx
                    ->loadClass('modScript');
                foreach ($eventMap as $pluginKeys)
                {
                    foreach ($pluginKeys as $pluginKey)
                    {
                        if (isset($pluginIds[$pluginKey]))
                        {
                            continue;
                        }
                        $pluginIds[$pluginKey] = $pluginKey;
                    }
                }
                if (!empty($pluginIds))
                {
                    $pluginQuery = $this
                        ->modx
                        ->newQuery('modPlugin', array(
                        'id:IN' => array_keys($pluginIds)
                    ) , true);
                    $pluginQuery->select($this
                        ->modx
                        ->getSelectColumns('modPlugin', 'modPlugin'));
                    if ($pluginQuery->prepare() && $pluginQuery
                        ->stmt
                        ->execute())
                    {
                        $plugins = $pluginQuery
                            ->stmt
                            ->fetchAll(PDO::FETCH_ASSOC);
                    }
                }
                if (!empty($plugins))
                {
                    foreach ($plugins as $plugin)
                    {
                        $results['pluginCache'][(string)$plugin['id']] = $plugin;
                    }
                }
            }

            /* cache the Context ACL policies */
            $results['policies'] = $obj->findPolicy($contextKey);
        }
    }
    else
    {
        $results = $this->getOption("{$key}_results", $options, array());
        $cacheKey = "{$key}/context";
        $options['cache_context_settings'] = array_key_exists('cache_context_settings', $results) ? (boolean)$results : false;
    }
    if ($this->getOption('cache_context_settings', $options, true) && is_array($results) && !empty($results))
    {
        $options[xPDO::OPT_CACHE_KEY] = $this->getOption('cache_context_settings_key', $options, 'context_settings');
        $options[xPDO::OPT_CACHE_HANDLER] = $this->getOption('cache_context_settings_handler', $options, $this->getOption(xPDO::OPT_CACHE_HANDLER, $options));
        $options[xPDO::OPT_CACHE_FORMAT] = (integer)$this->getOption('cache_context_settings_format', $options, $this->getOption(xPDO::OPT_CACHE_FORMAT, $options, xPDOCacheManager::CACHE_PHP));
        $options[xPDO::OPT_CACHE_ATTEMPTS] = (integer)$this->getOption('cache_context_settings_attempts', $options, $this->getOption(xPDO::OPT_CACHE_ATTEMPTS, $options, 10));
        $options[xPDO::OPT_CACHE_ATTEMPT_DELAY] = (integer)$this->getOption('cache_context_settings_attempt_delay', $options, $this->getOption(xPDO::OPT_CACHE_ATTEMPT_DELAY, $options, 1000));
        $lifetime = (integer)$this->getOption('cache_context_settings_expires', $options, $this->getOption(xPDO::OPT_CACHE_EXPIRES, $options, 0));
        if (!$this->set($cacheKey, $results, $lifetime, $options))
        {
            $this
                ->modx
                ->log(modX::LOG_LEVEL_ERROR, 'Could not cache context settings for ' . $key . '.');
        }
    }

    return $results;
}

Первое, на что сразу следует обратить внимание — откуда происходит выборка настроек, к примеру вот здесь:

$this->getOption('transient_context', $options, false)

$this — это не объект контекста, а сам modCacheManager, то есть выборка настроек происходит не из настроек контекста, а из переменной $options, переданной в метод generateContext. Посмотрим, какой параметр передается сюда при генерации кеша контекстов:

$contextResults[$context] = ($this->generateContext($context) ? true : false);

Ответ — никакой. То есть все настройки при генерации кеша берутся из самой системы. На что это влияет? А влияет это на то, что когда мы работаем в бэкенде, то при обновлении кеша мы не можем указать какие-то индивидуальные параметры кеширования для отдельных контекстов. К примеру есть системная настройка cache_context_settings, которая указывает кешировать настройки контекста или нет. По сути должно быть так: какому-то контексту мы установили эту настройку в false, и для этого контекста настройки не должны были бы кешироваться. Ан нет. Здесь или общая системная настройка установлена в true, и все контексты кешируются, или false, и тогда ни один контекст не кешируется, независимо от их настроек. Но следует отметить, что параметр $options передается самим контекстом в методе modContext::prepare();

$context = $this->xpdo->cacheManager->generateContext($this->get('key'), $options);

То есть можно отключить кеширование контекстов в принципе, а для отдельных контекстов кеширование указать. Тогда при заходе на сайт, когда MODX выполнит $this->context->prepare(), тогда если контекст кешируемый, то кеш для этого контекста запишется. Но если при этом для контекста mgr кеширование будет установлено, то опять-таки по описанной выше причине, будут кешироваться все контексты в момент очистки кеша всего сайта.

Кстати, $options можно передать как второй параметр в метод $modx->initialize(). К примеру так:

$options = array(
    'site_start' => 3,
    'site_name' => 'New sitename'
);
$modx->initialize('web', $options)

Но эта фишка вообще бесполезная, так как могла бы иметь смысл только для динамической подмены каких-либо кешируемых настроек, так как само собой выполнение было бы быстрее, чем на уровне плагина, но переданные таким образом настройки тоже кешируются, так что единственный уместный момент — это только ручная очистка кеша контекста и опять-таки ручная инициализация его. Но это полный изврат, к тому же вообще не оправданный. Хотя нет, один момент есть: через интерфейс в настройки нельзя сохранять массивы. А так можно было бы передавать массивы, чтобы они сохранялись в кеш настроек.

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

Ладно, это было лирическое отступление, вернемся к нашей функции генерации кеша контекста. Опять обратим внимание на эту строчку практически в самом начале функции: if (!$this->getOption('transient_context', $options, false)) { То есть если для контекста указана эта настройка в true, то весь блок, в котором происходит выборка документов и настроек, пропускается. НО: как было написано выше, нельзя указать этот параметр отдельно для выбранных контекстов так, чтобы в админке для них эта настройка имела смысл, а для других нет. То есть если и устанавливать, то для всего сайта. А что происходит, если установить эту настройку для всего сайта? Забавная неприятность — 404 для всей админки после обновления кеша :-)

К слову, все контексты тоже окажутся неработающими, так как в их настройках не будет карты ресурсов, и даже если указать site_start, MODX все-равно не будет искать стартовый документ, не указанный в карте ресурсов. В итоге еще одна по сути не работающая фишка.

И все-таки, хоть на что-то мы можем воздействовать или нет? Можем. На ЧПУ. Единственное, что проверяется для каждого конкретного контекста в отдельности, это использование ЧПУ, и если не используется, то просто карта алиасов не будет набиваться. Все.

Вывод: cache_context_settings никогда не стоит устанавливать в false, так как это только увеличит нагрузку на систему, и никак вообще нам не поможет.

Еще одна забавная вещь: системная настройки cache_disabled. В официальной документации написано cache_disabled

If true, disables all MODx caching features.

И жирное предупреждение:

// This feature is experimental. MODx recommends not turning off caching site-wide, as it can significantly slow down your site.

А в чем фишка? А в том, что эта настройка вообще нигде не используется. Вообще. Только в конфиге прописано

if (!defined('MODX_CACHE_DISABLED')) {
    $modx_cache_disabled= false;
    define('MODX_CACHE_DISABLED', $modx_cache_disabled);
}

То есть можете сколько угодно переключать ее в true, это вообще ни на что не влияет.

Честно сказать, вообще грустно от такого бардака в системе кеширования. Получается хочешь ты этого, или нет, но если у тебя в контексте много документов, то проблем тебе не избежать…

Но отчаиваться не будем, а постараемся все-таки найти хоть какое-то решение. И для себя я такое решение нашел. В общем так как узкое место во всем этом деле — это выборка документов для генерации карты ресурсов, я решил это дело и прикрыть.

Выборка ресурсов для генерации карты ресурсов выполняется в методе modContext_mysql::getResourceCacheMapStmt(). Исходник:

public static function getResourceCacheMapStmt(&$context) {
$stmt = false;
if ($context instanceof modContext) {
$tblResource= $context->xpdo->getTableName('modResource');
$tblContextResource= $context->xpdo->getTableName('modContextResource');
$resourceFields= array('id','parent','uri');
$resourceCols= $context->xpdo->getSelectColumns('modResource', 'r', '', $resourceFields);
$bindings = array($context->get('key'), $context->get('key'));
$sql = "SELECT {$resourceCols} FROM {$tblResource} `r` 
FORCE INDEX (`cache_refresh_idx`) 
LEFT JOIN {$tblContextResource} `cr` ON `cr`.`context_key` = ? AND `r`.`id` = `cr`.`resource` 
WHERE `r`.`id` != `r`.`parent` 
AND (`r`.`context_key` = ? OR `cr`.`context_key` IS NOT NULL) AND `r`.`deleted` = 0 GROUP BY `r`.`parent`, 
`r`.`menuindex`, `r`.`id`";
$criteria = new xPDOCriteria($context->xpdo, $sql, $bindings, false);
if ($criteria && $criteria->stmt && $criteria->stmt->execute()) {
$stmt =& $criteria->stmt;
}
}
return $stmt;
}

Как видно, выборка ресурсов происходит практически без разбору, и это следует исправить. Модифицированный код выглядит вот так:

public static function getResourceCacheMapStmt(&$context) {
$stmt = false;
if ($context instanceof modContext) {

// Get context setting
$settings = array();
if($result = $context->getMany('ContextSettings')){
foreach($result as $r){
$settings[$r->get('key')] = $r->get('value');
}
}

// If resource map disabled, skip it
if($context->xpdo->getOption('cacheoptimizer.resource_map_disabled', $settings , false)){
return false;
}

$tblResource= $context->xpdo->getTableName('modResource');
$tblContextResource= $context->xpdo->getTableName('modContextResource');
$resourceFields= array('id','parent','uri');
$resourceCols= $context->xpdo->getSelectColumns('modResource', 'r', '', $resourceFields);
$bindings = array($context->get('key'), $context->get('key'));
$sql = "SELECT {$resourceCols} FROM {$tblResource} `r` FORCE INDEX (`cache_refresh_idx`) 
LEFT JOIN {$tblContextResource} `cr` ON `cr`.`context_key` = ? AND `r`.`id` = `cr`.`resource` 
WHERE `r`.`id` != `r`.`parent` 
AND (`r`.`context_key` = ? OR `cr`.`context_key` IS NOT NULL) 
AND `r`.`deleted` = 0 GROUP BY `r`.`parent`, `r`.`menuindex`, `r`.`id`";
$criteria = new xPDOCriteria($context->xpdo, $sql, $bindings, false);
if ($criteria && $criteria->stmt && $criteria->stmt->execute()) {
$stmt =& $criteria->stmt;
}
}
return $stmt;
}

То есть если глобальная настройка cacheoptimizer.resource_map_disabled или настройка конкретно для этого контекста установлена в true, то для контекста выборка документов не выполняется.

Вообще можно было бы еще более хитро поступить (к примеру добавить условие пропускать ресурсы с замороженным URI, и все ресурсы, которые не следует в карту подбирать, пропускать, а остальные брать, но это мне кажется уже лишнее). А так получается, что если у нас предполагается большое кол-во ресурсов на сайте, то мы создаем один контекст основной (рабочий), для которого все будет кешироваться, где будут все положенные проверки доступов и т.п., и создаем один (или несколько) контекстов, которые не будут кешироваться, и из которых мы будем делать выборку документов своими скриптами. Кстати, второй контекст (catalog) часто используется в shopKeeper. Надо поэкспериментировать, наверняка эта фишка там будет работать.

И да, если кто обратил внимание на то, что настройка имеет префикс cacheoptimizer: да, это оформленно в готовый пакетик :-) Этакий патч. И доступен он в моем репозитории rest.modxstore.ru/extras/

А исходник лежит на гитхабе: https://github.com/Fi1osof/cacheOptimizer

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