Расширение системных (и не только) таблиц в MODX Revolution

в 18:52, , рубрики: modx, modx revolution, xpdo

В настоящий момент занимаюсь переделкой одного новостного портала на 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js