Эксперт по MODX
2,4
рейтинг
22 марта 2015 в 21:52

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

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

UPD: Более универсальный плагин для рендеринга и обработки ТВшек.
Ланец Николай @Fi1osof
карма
13,0
рейтинг 2,4
Эксперт по MODX
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (3)

  • 0
    Спасибо, интересно. У себя в проекте не используем tv вообще. А какие советы если используются свои таблицы в базе.
    • 0
      Пожалуйста.
      А какие советы если используются свои таблицы в базе.
      А никаких особенно нет советов. Там все стандартно для любого проекта. Вот только разве что aggregates/composites-связи. Простой пример: вот вы, к примеру, добавили какой-то свой дополнительный класс для пользователей (соответственно с таблицей). К примеру это extUserProfile. Здесь есть два основных момента:
      1. Связанные объекты. Чтобы было легче обращаться к связанным объектам, например так: $extProfile = $modx->user->ExtProfile;
      2. Целостность данных. Вообще правильно рулить целостность данных на уровне базы данных, оперируя вторичными ключами, но у xPDO свой подход на уровне php, поэтому нам придется использовать именно его. Не буду сейчас вдаваться в подробности какие именно там подводные камни. Так вот, логично, что если это расширенный профиль для пользователя, то при удалении пользователя этого расширенного профиля не должно оставаться, то есть он должен автоматически удаляться при удалении объекта пользователя.
      Вам наверняка известно, что для этого достаточно прописать правила этих связей в мап-файлы. Но проблема в том, что нам связь по сути нужна не только из кастомного объекта, но и из системного. А файлы ядра мы не можем трогать. Вот тут мы тоже можем использовать озвученный в статье механизм, чтобы дописать эту связь для класса modUser.
      $this->map['modUser']['composites']['ExtProfile'] = array(
      'class' => 'ExtUserProfile',
      'local' => 'id',
      'foreign' => 'internalKey',
      'cardinality' => 'one',
      'owner' => 'local',
      );
      

      Вот такая связь и решает эти две проблемы. То есть вы в любом месте можете выполнить $modx->getObject('modUser', $id)->remove(), и вместе с пользователем удалится и запись из вашей кастомной таблицы.
      • 0
        да отлично, не знал… буду думать)

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.