В настоящий момент занимаюсь переделкой одного новостного портала на MODX Revolution. Так как посещаемость на сайте бывает до 100 000 человек в сутки, вопрос производительности здесь один из самых важных. С учетом того, что на текущий момент в базе более 75 000 статей, при неправильном (и даже при традиционном подходе к разработке на MODX) тормоза сайта практически гарантированы, а если частота посещений превысит время выполнения запроса, то сервер вообще ляжет. Вот часть приемов задействованных здесь для решения этих проблем я и опишу в этой статье.
1. Долгая генерация кеша.
Наверняка многие знают, что при обновлении кеша MODX проходится по всем документам и набивает карту ресурсов в кеш контекста. Если кто не в курсе, подробно я писал про это здесь. И хотя в MODX начиная с версии 2.2.7 (или в районе той) можно в настройках отключать кеширование карты ресурсов (системная настройка cache_alias_map) проблема эта решается только частично — MODX не кеширует УРЛы документов, но структуру с ID-шниками фигачит все равно, перебирая все документы из базы данных. Это приводит к тому, что во-первых, кеш-файл контекста разрастается, а во-вторых, скрипт может просто не выполниться за 30 секунд и кеш-файл побьется, что может вообще привести к фатальным ошибкам и сделать сайт нерабочим.
Но даже если сервер все-таки в состоянии дернуть все документы и набить все в кеш, давайте посмотрим на сравнительные цифры на один запрос при разных настройках. Цифры эти будут весьма относительные ибо многое зависит от настройки сервера и на разных серверах потребление памяти у одного и того же сайта будет разное, но в сравнении эти цифры дадут представление о разнице состояний. Для оценки потребления памяти буду вызывать getdata-процессор на получение 10-ти статей.
Итак, вариант первый: Полное кеширование карты ресурсов включено.
Размер кеш-файла контекста: 5 792 604 байт.
Потребление памяти при запросе: 28,25 Mb
Время: 0,06-0,1 сек.
Вариант второй: Полное кеширование карты ресурсов отключено (системная настройка cache_alias_map == false).
Размер кеш-файла контекста: 1 684 342 байт.
Потребление памяти при запросе: 15,5 Mb
Время: 0,03-0,06 сек.
Вариант третий: Полностью отключено кеширование карты ресурсов патчем cacheOptimizer.
Размер кеш-файла контекста: 54 945 байт.
Потребление памяти при запросе: 4,5 Mb
Время: 0,02-0,03 сек.
И это всего лишь на 75 000 ресурсов. На сотнях тысяч разница будет гораздо ощутимей.
Есть конечно тут и минусы. Например не будет работать Wayfinder, который строит менюшку на основе данных карты алиасов. Здесь придется самому менюшку собирать. Я чаще всего использую menu-процессор, про который писал здесь (см. раздел 2. Замена Wayfinder).
2. Низкая производительность из-за TV-параметров документов.
А вот это основная и наиболее интересная причина написания данного топика. Наверно нет ни одного MODX-разработчика, который бы не использовал телевизоры TV-поля. Они решают сразу две проблемы: 1. добавляют пользовательские поля документам, 2. дают различные интерфейсы для их редактирования в зависимости от типа поля.
Но есть у них и серьезный минус — все они хранятся в одной таблице. Это добавляет сразу несколько проблем:
1. Нельзя управлять уникальностью значений на уровне базы данных.
2. Нельзя использовать различные типы данных для различных TV-полей. Все данные TV-полей содержатся в единой колонке value с типом данных mediumtext. То есть мы и большего объема данные не можем использовать, и числовые значения у нас будут храниться как строчные (что накладывает дополнительные требования к формированию запроса с сортировкой), и сравнение данных из различных колонок у нас не по фэншую, и вторичные ключи не настроить и много-много еще всего неприятного из-за этого.
3. Низкая производительность при выборке из нескольких таблиц. К примеру, у нас для одного документа есть несколько TV-полей, из которых хотя бы 2-3 поля практически всегда заполнены. Хотим мы получить в запросе сразу данные и документов и полей к ним. У нас есть два основных варианта формирования запроса на это:
1. Просто приджоинить таблицу TV-шек.
$q = $modx->newQuery("modResource");
$alias = $q->getAlias();
$q->leftJoin("modTemplateVarResource", "tv", "tv.contentid = {$alias}.id");
$c->select(array(
"tv.*",
"{$alias}.*",
));
Но здесь есть серьезный минус: в результирующую таблицу мы получим C*TV число записей, где C — кол-во записей в site_content, а TV — количество записей в таблице site_tmplvar_contentvalues для каждого документа в отдельности. То есть, если у нас, к примеру, 100 записей документов и по 3 записи TV на каждый документ (в среднем), то мы получим в итоге 100*3 = 300 записей.
Так как по этой причине в результате на один документ приходилось более одной результирующей записи, то на уровне PHP приходится дополнительно обрабатывать полученные данные чтобы сформировать уникальные данные. Это у нас и в getdata-процессоре выполняется. А это так же увеличивает нагрузку и увеличивает время выполнения.
Вот у меня в этом новостном портале как раз и было в среднем по 3 основных записи на документ. В итоге ~225 000 записей ТВ. Даже с оптимизацией запросов выполнение с условиями занимало 1-4 секунды, что очень долго.
2. Джоинить каждое TV-поле по отдельности.
Примерный запрос:
$q = $modx->newQuery("modResource");
$alias = $q->getAlias();
$q->leftJoin("modTemplateVarResource", "tv1", "tv1.tmplvarid = 1 AND tv1.contentid = {$alias}.id");
$q->leftJoin("modTemplateVarResource", "tv2", "tv2.tmplvarid = 2 AND tv2.contentid = {$alias}.id");
// .........
$c->select(array(
"tv1.value as tv1_value",
"tv2.value as tv2_value",
"{$alias}.*",
));
Такой запрос отработается быстрее, так как в результирующей таблице будет столько же записей сколько и записей документов, но все равно нагрузка будет не маленькая когда счет записей пойдет на десятки и сотни тысяч, а а количество ТВ-шек перевалит за десяток (ведь каждая ТВ-шка — это плюс еще один джоининг таблицы).
Безусловно самый лучший вариант в данном случае — это хранение ТВ-значений в самой системной таблице site_content, то есть каждое значение хранится в отдельной колонке этой таблицы.
Если кто думает, что это очередной урок по изъезженной теме CRC, то это не совсем так. Традиционно нас учили расширять имеющиеся классы своими и там дописывать нужные нам колонки (а то и вовсе таблицу собственную прописывать). Но этот путь не оптимальный. Главная проблема здесь — это то, что мы расширяем как-то то класс, но не меняем его самого. Расширения касаются только расширяющего (а не расширяемого) класса, а так же тех расширяющих классов, которые будут расширять наш класс. Запутанно, но сложно проще сказать. Объясню. У нас есть базовые класс modResource. Его расширяют классы modDocument, modWebLink, modSimLink и т.п. Все они наследуют от modResource мапу таблицы. Если мы расширим нашим классом класс modResource, то в нашем классе будут новые колонки которые мы допишем, но их не будет в классе modDocument, так как он не расширяет наш класс. Для того, чтобы информация о новых колонках появилась во всех расширяющих modResource классах, информация эта должна быть в самом классе modResource. Но как это сделать не трогая самих системных файлов?.. На самом деле частично об этом я писал еще более двух лет назад (статью перенес сюда), но только сейчас это реализовал в боевом режиме. Делаем так:
1. Создаем новый компонент, который будет подгружаться как extensionPackage (подробно об этом писал здесь).
2. Создаем новые колонки в таблице site_content через phpMyAdmin или типа того.
3. С помощью CMPGenerator-а генерируем отдельный пакет с мапой таблицы site_content. В этой мапе будет и описание ваших новых колонок и таблиц.
4. Прописываем в вашем пакете в файле metadata.mysql.php данные ваших колонок и индексов (пример такого файла можно увидеть и в нашей сборке ShopModxBox).
<?php
$custom_fields = array(
"modResource" => array(
"fields" => array(
"article_type" => array(
"defaultValue" => NULL,
"metaData" => array (
'dbtype' => 'tinyint',
'precision' => '3',
'attributes' => 'unsigned',
'phptype' => 'integer',
'null' => true,
'index' => 'index',
),
),
"image" => array(
"defaultValue" => NULL,
"metaData" => array (
'dbtype' => 'varchar',
'precision' => '512',
'phptype' => 'string',
'null' => false,
),
),
),
"indexes" => array(
'article_type' =>
array (
'alias' => 'article_type',
'primary' => false,
'unique' => false,
'type' => 'BTREE',
'columns' =>
array (
'article_type' =>
array (
'length' => '',
'collation' => 'A',
'null' => true,
),
),
),
),
),
);
foreach($custom_fields as $class => $class_data){
foreach($class_data['fields'] as $field => $data){
$this->map[$class]['fields'][$field] = $data['defaultValue'];
$this->map[$class]['fieldMeta'][$field] = $data['metaData'];
}
if(!empty($class_data['indexes'])){
foreach($class_data['indexes'] as $index => $data){
$this->map[$class]['indexes'][$index] = $data;
}
}
}
Внимательно его изучите. Он добавляет информацию о двух колонках и одном индексе в таблицу site_content.
Давайте убедимся, что колонки действительно были добавлены. Выполним в консоли этот код:
$o = $modx->newObject('modDocument');
print_r($o->toArray());
Увидим вот такой результат:
Array
(
[id] =>
[type] => document
[contentType] => text/html
[pagetitle] =>
[longtitle] =>
// Тут еще куча колонок перечислено
// и в конце наши две колонки
[article_type] =>
[image] =>
)
Вот теперь мы можем работать с системной таблицей с нашими кастомными полями. К примеру, так можно писать:
$resource = $modx->getObject('modResource', $id);
$resource->article_type = $article_type;
$resource->save();
В таблицу для этого документа будет записано наше значение.
Создание своих колонок и индексов на чистом MODX.
Понятное дело что при таком подходе у нас возникает проблема миграции с такого кастомного сайта на чистый MODX, ведь там в таблицах нет наших кастомных полей и индектов. Но на самом деле это как бы и не проблема совсем. Дело в том, что как мы генерируем мапу из таблиц, так и таблицы, колонки и индексы мы можем создавать из мап-описаний классов. Создать колонку или индекс очень просто:
// Получаем менеджер работы с базой данных
$manager = $modx->getManager();
// Создаем колонку
$manager->addField($className, $fieldName);
// Создаем индекс
$manager->addIndex($className, $fieldName);
При этом не надо никакие данные колонок и индексов указывать кроме как их названия. Эти данные xPDO получит из нашей мапы и использует при создании описанной колонки или индекса.
Если вы свой компонент соберете в нормальный установочный пакет, то там можете прям прописать скрипт чтобы при установке пакета сразу были созданы в таблицах ваши кастомные колонки и индексы.
Рендеринг ваших кастомных данных в TV-полях при редактировании документов.
Как я и говорил выше, удобство TV-шек заключается в том, что для них созданы различные управляющие элементы (текстовые поля, выпадающие списка, чекбоксы, радиобоксы и т.п.). Плюс к этому в родном редакторе форм можно разграничить права на те или иные ТВ-поля, чтобы кому не покладено не мог видеть/редактировать приватные поля. На самом деле можно, если очень хочется, но все же приватные поля не будут мозолить глаза кому не поподя. И вот как раз эти механизмы и не хотелось бы терять, ибо иначе придется фигачить свои собственные интерфейсы на управление этими данными, а это весьма трудозатратно. Хотелось бы все-таки для редактирования таких данных использовать родной редактор ресурсов. Идеального механизма здесь нет, но боле менее пригодный вариант я отработал. Смысл его заключается в том, чтобы на уровне плагина в момент рендеринга формы редактирования документа подставить TV-поле со своим кастомным значением, а при сохранении документа перехватить данные TV-шки и эти данные сохранить в наши кастомные поля. К сожалению, не получается здесь вклиниться как положено (просто потому что API не позволяет), так что мы не можем повлиять на передаваемые процессору документа данные, из-за чего данные ТВшки все равно будут записаны в таблицу ТВшек, но это не проблема — просто после сохранения документа автоматом подчистим эту табличку и все. Вот пример плагина, срабатывающего на три события (1. рендеринг формы редактирования документа с подстановкой TV-поля и кастомными данными, 2. получение данных и изменение объекта документа перед его сохранением, 3. чистка ненужных данных).
<?php
/*
OnBeforeDocFormSave
OnDocFormSave
OnResourceTVFormRender
*/
switch($modx->event->name){
/*
Рендеринг ТВшек
*/
case 'OnResourceTVFormRender':
$categories = & $scriptProperties['categories'];
foreach($categories as $c_id => & $category){
foreach($category['tvs'] as & $tv){
/*
Рендеринг тэгов
*/
if($tv->id == '1'){
if($document = $modx->getObject('modResource', $resource)){
$q = $modx->newQuery('modResourceTag');
$q->select(array(
"GROUP_CONCAT(distinct tag_id) as tags",
));
$q->where(array(
"resource_id" => $document->id,
));
$tags = $modx->getValue($q->prepare());
$value = str_replace(",", "||", $tags);
$tv->value = $value;
$tv->relativeValue = $value;
$inputForm = $tv->renderInput($document, array('value'=> $tv->value));
$tv->set('formElement',$inputForm);
}
}
/*
Рендеринг картинок
*/
else if($tv->id == 2){
if($document = $modx->getObject('modResource', $resource)){
$tv->value = $document->image;
$tv->relativeValue = $document->image;
$inputForm = $tv->renderInput($document, array('value'=> $tv->value));
$tv->set('formElement',$inputForm);
}
}
/*
Рендеринг статусов
*/
else if($tv->id == 12){
if($document = $modx->getObject('modResource', $resource)){
$tv->value = $document->article_status;
$tv->relativeValue = $document->article_status;
$inputForm = $tv->renderInput($document, array('value'=> $tv->value));
$tv->set('formElement',$inputForm);
}
}
}
}
break;
// Перед сохранением документа
case 'OnBeforeDocFormSave':
$resource = & $scriptProperties['resource'];
/*
Тэги.
Перед сохранением документа мы получим все старые
теги и установим им active = 0.
Всем актуальным тегам будет установлено active = 1.
После сохранения документа в событии OnDocFormSave мы удалим все не активные теги
*/
if(isset($resource->tv1)){
$tags = array();
foreach((array)$resource->Tags as $tag){
$tag->active = 0;
$tags[$tag->tag_id] = $tag;
}
// $tags = array();
if(!empty($resource->tv1)){
foreach((array)$resource->tv1 as $tv_value){
if($tv_value){
if(!empty($tags[$tv_value])){
$tags[$tv_value]->active = 1;
}
else{
$tags[$tv_value] = $modx->newObject('modResourceTag', array(
"tag_id" => $tv_value,
));
}
}
}
}
$resource->Tags = $tags;
$tags_ids = array();
foreach($resource->Tags as $tag){
if($tag->active){
$tags_ids[] = $tag->tag_id;
}
}
$resource->tags = ($tags_ids ? implode(",", $tags_ids) : NULL);
}
/*
Обрабатываем изображение
*/
if(isset($resource->tv2)){
$resource->image = $resource->tv2;
}
/*
Обрабатываем статусы
*/
if(isset($resource->tv12)){
$resource->article_status = $resource->tv12;
}
break;
/*
Сохранение документа
*/
case 'OnDocFormSave':
$resource =& $scriptProperties['resource'];
/*
Удаляем все не активные теги
*/
$modx->removeCollection('modResourceTag',array(
'active' => 0,
'resource_id' => $resource->id,
));
/*
Удаляем TV-картинки, так как они сохраняются в системную таблицу
Удаляем TV-статусы, так как они сохраняются в системную таблицу
*/
$modx->removeCollection('modTemplateVarResource',array(
'tmplvarid:in' => array(
1, // Тэги
2, // Картинки
12, // Статусы
),
'contentid' => $resource->id,
));
break;
}
Благодаря этому плагину кастомные данные рендерятся в форму редактирования документа и обрабатываются при его сохранении.
Итог
Из 225+ тысяч записей в таблице дополнительных полей осталось только 78. Конечно не все ТВшки будут фигачиться в системную таблицу (а только те, что используются для поиска и сортировки), и какие-то данные конечно будут в таблице ТВ-полей, но нагрузка все же серьезно снизилась, а запросы стали попроще.
Автор: Fi1osof