Начиная с версии 3.0 Joomla стала для базы данных MySQL использовать по умолчанию таблицы типа InnoDB. Движок InnoDB считается более продвинутым по сравнению с MyISAM, он поддерживает транзакции с различными уровнями изоляции и внешние ключи, позволяя контролировать целостность данных на уровне СУБД. Что же дает использование этого типа таблиц для Joomla?

Особенности таблиц InnoDB

Основные отличиями таблиц InnoDB от MyISAM:

  • блокировки чтения/записи на уровне строк, а не целых таблиц;
  • поддержка целостности данных через внешние ключи;
  • поддержка транзакций;
  • обработка каскадных удалений и обновлений при использовании внешнего ключа.

Если не вдаваться в подробности, то можно сказать что сильные стороны InnoDB проявляются на больших объемах данных с большим количеством одновременных запросов. Точнее падение скорости обработки запросов InnoDB при значительном росте объема данных будет намного меньше чем у таблиц MyISAM. Транзакции и внешние ключи, можно сказать, являются со стороны CMS дополнением к собственным системам контроля. Они требуют дополнительных затрат на разработку и незначительно снижают скорость запросов, но зато гарантируют целостность и соответствие данных.

Надо заметить, что движок MyISAM не зря долгое время использовался Joomla как основной и считается движком по умолчанию для MySQL. При последовательных запросах, на небольших таблицах, движок MyISAM дает выигрыш в скорости выборки данных. Еще одним преимуществом движка MyISAM является полнотекстовой поиск. То есть для многих, если не большинства, сайтов Joomla движок MyISAM будет заведомо лучшим выбором.

Как же используются возможности InnoDB в расширениях Joomla и как их можно использовать при разработке собственных расширений?

InnoDB в расширениях Joomla

Joomla 3 хоть и переведена на  InnoDB, но в схеме базы данных даже версии 3.4.3 внешние ключи не используются ни в системных таблицах, ни в таблицах встроенных расширений.

Использование транзакций в коде системы встречается только единожды — в классе FOFTableNested, который в расширениях Joomla не используется.

Видимо, неполное использование возможностей движка объясняется тем, что до этого просто не дошли руки, ведь использование и внешних ключей и транзакций требует значителной переработки основ CMS и всех стандартных расширений.

Особенность фреймворка Joomla

В версии Joomla 3 большинство встроенных компонентов используют паттерн работы с данными «список элементов таблицы / форма редактирования элемента таблицы / действие (создать/изменить) над элементом», в котором используются расширяющие Legacy классы: контроллера JControllerLegacy + модели JModelLegacy + представления JViewLegacy.

Одной из основных особенностей этого паттерна является обработка за один HTTP запрос данных в одной таблице БД.

Это хорошо отлаженный и удобный для программирования паттерн. Работа с ним описана в цикле статей Создание компонента для Joomla 2.5.

Посмотрим, какие проблемы могут возникать при использовании внешних ключей и почему в Joomla не используются транзакции.

Внешние ключи в таблицах InnoDB в расширениях Joomla

Внешние ключи предоставляют встроенные в СУБД механизмы контроля целостности данных.

Если для поля одной таблицы создан внешний ключ ссылающийся на поле второй таблицы, то внешний ключ гарантирует, что для каждой записи в первой таблице есть соответствующая запись во второй таблице. Это "строгий" внешний ключ. "Нестрогий" внешний ключ допускает что поле внешнего ключа может не иметь связи.

Строгий внешний ключ

Предположим в разрабатываемом компоненте необходимо хранить две сущности, одна из которых (Сhild) зависит от другой. При этом каждый Сhild обязательно должен быть связан с Parent.

Строгий внешний ключ

 

#Родительская сущность
CREATE TABLE IF NOT EXISTS `parent` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`))
ENGINE = InnoDB;

 

# Дочерняя сущность
CREATE TABLE `child` (
`id` INT NOT NULL AUTO_INCREMENT,
`parent_id` INT NOT NULL,
`title` VARCHAR(45) NULL,
PRIMARY KEY (`id`),
INDEX `fk_child_parent1_idx` (`parent_id` ASC),
CONSTRAINT `fk_child_parent1` FOREIGN KEY (`parent_id`) REFERENCES
`parent` (`id`)
ENGINE = InnoDB;

 

 

Для поля child.parent_id создан внешний ключ fk_child_parent1, и поддерживающий его индекс fk_child_parent1_idx. Внешний ключ гарантирует наличие элемента Parent для каждого Сhild.

Aтрибут NOT NULL поля child.parent_id требует обязательного указания существующего parent_id для каждого элемента Child.

При попытке создать элемент Child с несуществующим или пустым parent_id MySQL вернет ошибку.

Работа с такой схемой БД полностью прозрачна для программиста Joomla. Единственное о чем следует побеспокоится — обработка ошибок, возникающих при записи данных, но об этом позже.

Нестрогий внешний ключ

Если предположить, что Child не обязательно должен быть связан с Parent, то схема немного изменится.

Нестрогий внешний ключ

 

#Родительская сущность
CREATE TABLE IF NOT EXISTS `parent` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`))
ENGINE = InnoDB;

 

# Дочерняя сущность
CREATE TABLE `child` (
`id` INT NOT NULL AUTO_INCREMENT,
`parent_id` INT NULL DEFAULT NULL,
`title` VARCHAR(45) NULL,
PRIMARY KEY (`id`),
INDEX `fk_child_parent1_idx` (`parent_id` ASC),
CONSTRAINT `fk_child_parent1` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`)
ENGINE = InnoDB;

 

 

Эта схема предполагает что каждый элемент Сhild в поле parent_id записан либо NULL, либо parent.id существующего элемента Parent.

СУБД не даст сделать запись child.parent_id если такого parent.id не существует, но если child.parent_id = NULL, то такая запись вполне может существовать.

Простая и понятная схема хранения данных, но в модели Joomla и в PHP программировании в целом, она вызывает некоторые сложности.

Пример

Есть страница с HTML-формой редактирования элемента Child. Сохраняя элемент мы отправляем POST запрос c task=child.save или task=child.apply. Эта задача будет обработана методом save() контролера MycompControllerChild extends JControllerForm.

Метод MycompControllerChild::save() получает данные из запроса, проверяет и очищает их, и передает методу save() класса MycompModelChild extends JModelAdmin.

Метод MycompModelChild::save() формирует объект JTable и сохраняет его через JTable::store().

Проблемы начинаются, когда требуется сохранить NULL в поле child.parent_id.

Первая проблема - HTML форма не может передать значение NULL. Из формы можно передать 0 или '' (пустая строка) или любой другой набор символов, который программно можно будет транслировать в NULL. Для примера примем, что когда HTML форма передает параметр jform[parent_id] = 0, то его требуется записать в поле child.parent_id как значение NULL. В данном случае такое соглашение допустимо, т.к. число 0 в поле child.parent_id не может быть записано ни при каких условиях, и значение 0 будет пропускаться фильтром типа INT, который логично использовать для этого поля.

Вторая проблема - при выполнении запроса на сохранение элемента  в стеке вызовов методов модели JModelAdmin имеется несколько мест блокирующих передачу на запись в БД значений NULL. Поэтому, чтобы записать parent_id = NULL, необходимо переопределить несколько методов в классе MycompModelChild extends JModelAdmin.

 /**
 * образец переопределения методов записи элемента
 */
class MycompModelChild extends JModelAdmin 
{
    ...
 
    /**
     * Переопределяет метод с возможностью записи значения NULL.
     * Можно скопировать весь код родительского метода JModelAdmin::save(), заменив единственную строку.
     */
    public function save($data)
    {
        ...
        // заменить вхождение
        // $table->store()
        // на
        // $table->store(TRUE)
        ...
    }
 
    ...
     
    /**
     * Метод позволяет изменить объект JTable непосредственно перед вызовом JTable::store()
     * Соглашение: если поле parent_id – пустое, записать в него NULL
     */
    protected function prepareTable($table)
    {
        if (empty($table->parent_id))
        {
            $table->parent_id = NULL;
        }
    }
 
    ...
 
}

 

 

Пояснения к коду.

Метод save() в классе JModelAdmin вызывает метод JTable::store(), который, при вызове без параметров, при создании запроса игнорирует все поля со значением NULL.

Вызов метода JTable::store(true)с параметром TRUE, указывает на необходимость вставлять в поля значение NULL.

Надо учитывать, что NULL будет вставляться только в SQL запросы UPDATE. В запросах INSERT поля со значением NULL все равно будут отфильтрованы. Поэтому важно задать в схеме БД значение по умолчанию для этого поля:

 

`parent_id` INT NULL DEFAULT NULL

 

Метод prepareTable() вызывается перед вызовом JTable::store(), и позволяет работать напрямую с объектом JTable, изменяя значения его свойств.

В переопределенном методе любое empty-значение (0, '', FALSE) в свойстве parent_id будет заменено на NULL.

Общий вариант этого метода. Например, по соглашению внутри схемы БД использовать наименование включающее подстроку "_id", например, "item_id" для всех полей, имеющих внешние ключи. Тогда это будет универсальный метод для любого класса дочернего JModelAdmin.

/**
 * Метод позволяет изменить объект JTable непосредственно перед вызовом JTable::store()
 * Соглашение: если имя поля содержит вхождение _id и пустое значение, записать в него NULL
 */
protected function prepareTable($table)
{
    // перебираем все поля таблицы
    foreach ($table->getProperties() as $key => $value)
    {
        // т.к. имя таблицы всегда на латинице можно использовать strpos($key, '_id', 1)
        // если в имени поля встречается подстрока _id, а значение у поля пустое, то поле = NULL
        if (\Joomla\String\String::strpos($key, '_id', 1) !== FALSE && empty($value))
        {
            $table->$key = NULL;
        }
    }
}

 

Обработка ошибок целостности данных

При попытке записать в таблицу Сhild строку с parent_id, которому нет соответствия в таблице Parent, MySQL возвращает ошибку с кодом 1452 и сообщением:

Cannot add or update a child row: a foreign key constraint fails
`child`, CONSTRAINT `fk_child_parent1` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`))

При попытке удалить строку из таблицы Parent, на которую ссылается хотя бы одна строка Child, MySQL вернет ошибку 1451:

Cannot delete or update a parent row: a foreign key constraint fails

Ошибка поднимается по стеку вызовов JTable::store() -> JModelAdmin::save() -> JControllerForm::save().

Обработчик ошибок в контроллере JControllerLegacy обработает подобные ошибки, выводя стандартное сообщение типа error о невозможности сохранить элемент.

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

В каких случаях могут возникать ошибки целостности данных?

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

Проблемы могут возникать когда одновременно два пользователя изменяют данные. Например, первый пользователь редактирует элемент Child, он открыл форму редактирования элемента, в которой сформирован список существующих parent.id, пользователь выбирает некий id. Второй пользователь удаляет этот элемент Parent. Первый пользователь нажимает кнопку сохранить и на запись отправляется элемент Child с несуществующим parent_id.

Фильтр Joomla посчитает такую запись валидной, т.к. значение будет соответствовать заданному типу и при отсутствии контроля целостности данных со стороны СУБД, такая запись будет успешно сохранена. Для реализации защиты от таких коллизий без использования внешних ключей необходимо добавить проверку на существование элемента. В случае установленного внешнего ключа СУБД вернет ошибку.

Транзакции в запросах к БД Joomla

Редактирование элемента из формы

При редактировании элемента в форме предполагается, что форма передает данные одного редактируемого элемента, задача на запись (&task=child.save) обрабатывается в контролере MycompControllerChild::save(), данные запроса передаются в модель MycompModelChild::save(), которая в свою очередь сохраняет их в таблицу JTable::store(). В результатом должна быть одна измененная строка в единственной таблице БД.

Поскольку записывается единственная строка в одной таблице, то транзакция не имеет смысла.

Разработчики Joomla заложили возможность добавить в этот паттерн обработку дополнительных данных через метод контроллера postSaveHook($model).

Например, в одной HTML форме можно изменить и данные элемента Parent и связанного с ним Child и отправить их одним запросом.

Метод postSaveHook($model) получает управление только в случае успешного завершения вызова $model->save().

Переопределив метод в контроллере MycompControllerParent::postSaveHook($model), можно получить доступ к модели MycompModelParent и получить значение id сохраненного элемента Parent, после этого вызвать модель MycompControllerChild и сохранить данные элемента Child.

Поскольку в одном HTTP запросе будут сохраняться данных двух связанных таблиц, логично было бы обернуть запросы в транзакцию. Но реализовать транзакцию достаточно сложно, т.к. запросы вызываются из разных моделей и согласовать закрытие транзакции с учетом всех возможных вариантов ошибок будет проблемой.

Одним из вариантов решения этой проблемы является перенос логики из postSaveHook в метод save() Parent модели. Это даст возможность откатить транзакцию, если возникла проблема при сохранении Child.

Получение данных из потока или файла

Отдельной задачей в работе сайтов стоит обработка потоковых данных или парсинг файлов. Под подобную задачу приходится писать отдельную модель или использовать библиотеку. Хорошим примером подобных задач является разбор данных CommerceML или подобных форматов обмена между учетными системами и сайтом. При парсинге CommerceML, данные требуется писать связанные данные в несколько таблиц БД.

Запросы на запись в таблицы в таких случаях не только можно, но и желательно обернуть в транзакцию. Транзакции могут быть реализованы на уровне логических блоков файла данных или на весь файл/поток целиком.

Работа с транзакциями через класс JDatabaseDriver описана в статье JDatabaseDriver - использование транзакций.

Подводим итоги

  • InnoDB мощный механизм имеющий широкие возможности для улучшения производительности и надежности крупных и нагруженных сайтов.
  • На текущем этапе CMS Joomla и встроенные компоненты не использует возможности не только InnoDB, но и других транзакционных баз данных.
  • При разработке расширений можно и нужно использовать  возможности предоставляемые InnoDB для увеличения надежности работы с данными.
  • Разрабатывая публичные расширения необходимо учесть возможность установки на таблицы типа MyISAM и эмулировать работу систем целостности данных и транзакций на программном уровне.