Расширение системных (и не только) таблиц в MODX Revolution
В настоящий момент занимаюсь переделкой одного новостного портала на 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).
К примеру у меня этот файл выглядит примерно так 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. чистка ненужных данных). Посмотреть код
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. Конечно не все ТВшки будут фигачиться в системную таблицу (а только те, что используются для поиска и сортировки), и какие-то данные конечно будут в таблице ТВ-полей, но нагрузка все же серьезно снизилась, а запросы стали попроще.