Опыт разработки модуля для OpenCart

2022.11.13
Рассмотрим полученный опыт разработки разного рода модулей для OpenCart версий 2.3 и 3.0

Рассмотрим полученный опыт разработки разного рода модулей для OpenCart версий 2.3 и 3.0.

В ходе работы с OpenCart у одного из клиентов был странный баг, который долгое время не могли решить, рекомендую ознакомиться.

Репозиторий и окружение

Файловая структура OpenCart устроена таким образом, что конкретный модуль не имеет своей директории, однако может иметь один единственный файл, например admin/controller/extension/module/modulename.php.

В большинстве случаев файлы модуля расположены в различных директориях среди файлов других модулей. При этом файлы могут быть как в admin так и в catalog контекстах. Контроллеры, модели, шаблоны, вся файловая структура компонуется по типу файлов, в отличии от многих других CMS.

Как правило я веду разработку модулей через git репозиторий, файлы модуля складываю в директорию src, а в директорию cms-name располагаю исходники CMS. Поднимается все это в docker. Пример структуры проекта:

Файловая структура репозитория модуля для Opencart

В ocStore3.x лежат исходники CMS, в которую необходимо прокинуть файлы изsrc.

Более добробно можно посмотреть в недавнем модуле интеграции Opencart => Salesman.

Для удобства получения итогового zip архива, который можно устанавливать через админку, создадим в корне репозитория build.bash:

#!/bin/sh

rm -f mymodule.ocmod.zip
cp -R src upload
zip -rm mymodule.ocmod.zip upload

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

Для запуска проекта использую docker образы php-fpm и apache2, конфигурацию которых можно посмотреть все в том же репозитории в директории .env. В качестве СУБД mysql:5.7 с дефолтными настройками.

Архитектура движка

MVCl

OpenCart построен по MVCl архитектуре, в нем есть 4 вида файлов:

MVCl

OpenCart имеет 2 контекста:

Библиотеки

Кроме перечисленных видов файлов в OpenCart есть библиотеки расположенные по пути system/library/, библиотеки могут быть представлены как одним единственным файлом или целой директорией.

Сам файл библиотеки должен содержать класс, имя которого должно совпадать в именем файла, например system/library/FileName.php:

class FileName
{
    /**
     * @param Registry $registry
     */
    public function __construct($registry)
    {
        // ...
    }
}

На стороне контроллеров/моделей его можно загрузить, а затем использовать так:

$this->load->library('FileName');
// ...
$this->FileName->method();

Если библиотека состоит из нескольких связных файлов, то удобнее ее разместить в директории. Загружаться может также один файл, но подключать другие внутри. Например system/library/dir/FileName.php:

<?php

namespace Dir;

class FileName
{
    /**
     * @param Registry $registry
     */
    public function __construct($registry)
    {
        // ...
    }
}

Состав модуля

Модуль обязательно состоит из контроллера в admin, может иметь модель, вид и перевод. Кроме того контроллер/модель/вид/перевод могут быть и в catalog части.

Для примера рассмотрим абстратный модуль MyModule.

Перевод

Файл перевода admin/language/ru-ru/extension/module/mymodule.php должен содержать минимально необходимый набор переводов (а при необходимости все переводы):

$_['doc_title'] = 'Мой модуль';
$_['heading_title'] = 'Мой модуль';

$_['settings_success'] = 'Настройки успешно изменены!';
$_['settings_edit'] = 'Настройки модуля';

$_['entry_setting1'] = 'Введите настройку 1';
$_['entry_setting2'] = 'Введите настройку 2';

Без ключа heading_title модуль появится в списке модулей с неправильным именем.

Контроллер

Контроллер модуля admin/controller/extension/module/mymodule.php должен содержать методы:

Примерно так выглядит шаблон контроллера модуля без реализации:

class ControllerExtensionModuleMyModule extends Controller
{
	// страница настроек модуля
	public function index() {}
	
	// установка модуля
	public function install() {}
	
	// деинсталяция модуля
	public function uninstall() {}
	
	// валидация настроек модуля
	protected function validate() {}
	
	// линейный массив с ошибками
	private $m_aErrors = [];
};

Страница настроек модуля

Метод контроллера позволяющий вывести интерфейс страницы настроек (метод по умолчанию):

public function index()
{
...
}

Метод сам по себе не выведет интерфейс страницы. Помещаем в этот метод код. Осуществляем первичные действия:

// загрузка файла перевода модуля
$this->load->language('extension/module/mymodulesettings');

// установка title страницы
$this->document->setTitle($this->language->get('doc_title'));

// загрузка модели настроек
$this->load->model('setting/setting');

// создаем пустой массив, позже заполним его данными для шаблона
$data = [];

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

Установка основных данных для страницы редактирования модуля в админке:

// загрузка представления головной части страницы
$data['header'] = $this->load->controller('common/header');

// загрузка сайдбара
$data['column_left'] = $this->load->controller('common/column_left');

// загрузка подвала админки
$data['footer'] = $this->load->controller('common/footer');

// заголовок h1 (но не title)
$data['heading_title'] = $this->language->get('heading_title');

$data['button_save'] = $this->language->get('button_save');
$data['settings_edit'] = $this->language->get('settings_edit');

// плейхолдеры для настроек
$data['entry_setting1'] = $this->language->get('entry_setting1');
$data['entry_setting2'] = $this->language->get('entry_setting2');

Теперь настройки модуля:

// получаем массив настроек модуля (ранее мы загружали модель setting/setting)
$moduleSettings = $this->model_setting_setting->getSetting("mymodule");
$data = array_merge($moduleSettings, $data);

/* или
$data['mymodule_setting1'] = $moduleSettings["mymodule_setting1"];
$data['mymodule_setting2'] = $moduleSettings["mymodule_setting2"];
*/
//...

Еще нужны хлебные крошки:

$data['breadcrumbs'] = [];
$data['breadcrumbs'][] = [
	'text' => $this->language->get('text_home'),
	'href' => $this->url->link('common/dashboard', 'token=' . $this->session->data['token'], true)
];
$data['breadcrumbs'][] = [
	'text' => $this->language->get('text_extension'),
	'href' => $this->url->link('marketplace/extension', 'token=' . $this->session->data['token'] . '&type=module', true)
];
$data['breadcrumbs'][] = [
	'text' => $this->language->get('heading_title'),
	'href' => $this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'], true)
];

В заключение нужно еще добавить ссылку на отправку формы редактирования настроек нашего модуля:

if (!array_key_exists('module_id', $this->request->get)) {
	$data['action'] = $this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'], true);
} else {
	$data['action'] = $this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'] . '&module_id=' . $this->request->get['module_id'], true);
}

И наконец - все это отправляем в шаблон:

$this->response->setOutput($this->load->view('extension/module/mymodulesettings', $data));

Изменение настроек

Этот раздел нужно делать перед рендером настроек.

Если текущий метод POST, тогда нужно обработать входящие данные и сохранить настройки модуля если проверка корректности данных прошла успешно (если нет, то останутся старые данные).

if ($this->request->server['REQUEST_METHOD'] == 'POST') {
	// если валидация прошла успешно
	if($this->validate()) {
		// сохранение настроек модуля
		$this->model_setting_setting->editSetting('mymodulesettings', $this->request->post);
		
		// записываем в сессию статус успеха сохранения настроек
		$this->session->data['settings_success'] = $this->language->get('settings_success');
	} else {
        // валидация закончилась ошибкой, запишем информацию об этом в сессию
		$this->session->data['settings_error'] = $this->m_aErrors;
    }

	// перенаправляем на страницу настроек модуля 
	$this->response->redirect($this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'] . '&type=module', true));
}

Нам как-то надо иметь информацию о статусе сохранения (удалось или нет) и показывать ее в интерфейсе. А у нас тут перенаправление при сохранении ведь нам нужно сбросить текущий метод запроса страницы, а он сбрасывается редиректом, если не сбросить то при обновлении страницы на сервер будет отправляться POST запрос.

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

Статус мы уже сохранили в сессию ранее, теперь обработаем сохраненные данные и вставим в массив для шаблона то, что нам нужно:

// если было успешное изменение настроек - 
// показываем сообщение и удаляем из сессии чтобы больше не показывать
if (array_key_exists("settings_success", $this->session->data)) {
	$data['settings_success'] = $this->language->get('settings_success');
	unset($this->session->data["settings_success"]);
} else {
	$data['settings_success'] = false;
}

// если есть ошибки - показываем и удаляем из сессии чтобы больше не показывать
if(array_key_exists("settings_error", $this->session->data)) {
	$data['error_warning'] = implode("<br/>", $this->session->data["settings_error"]);
	unset($this->session->data["settings_error"]);
} else {
	$data['error_warning'] = false;
}

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

Один из вариантов обработки статуса сохранения настроек - это сохранение его в сессии и удаление при первом обращении.

Установка модуля

public function install()
{
    // ...
}

В методе install могут устанавливаться обработчики событий и создаваться все необходимые данные для работы модуля.

В данном методе нам нужно создать настройки по умолчанию:

$this->load->model('setting/setting');
$this->model_setting_setting->editSetting('mymodule', [
	'mymodule_setting1' => '',
	'mymodule_setting2' => ''
]);

Имена параметров настроек должны иметь префикс имени модуля, например modulename_settingname.

Удаление модуля

Здесь можно удалить настройки модуля:

$this->load->model('setting/setting');
$this->model_setting_setting->deleteSetting('mymodule');

А также произвести нужные манипуляция с базой данных через тот же объект $this->db.

Вид

Файл admin/model/extension/module/mymodule.php содержит все что касается работы с базой данных. В нем нужно объявить класс ModelExtensionModuleMyModule:

class ModelExtensionModuleMyModule extends Model
{
    ...
}

Внутри каждого контроллера и каждой модели есть объект класса DB расположенного по пути system/library/db.php (а по пути system/library/db/ лежат классы-адапторы для конкретной низкой реализации), который доступен как $this->db.

У этого объекта нас интересуют следующие методы:

Для детального разбора, знакомым с pdo можно посмотреть файл system/library/db/mpdo.php.

Представление

А теперь посмотрим как сделаны другие файлы представления из директории admin/view/template/extension/module/ и частично возьмем оттуда код шаблона.

В OpenCart 2.3 используется шаблонизация на основе php кода в файле шаблона (.tpl), а в OpenCart 3.0 появился Twig

Создаем файл admin/view/template/extension/module/mymodule.tpl, в который помещаем адаптированный код для нашего модуля подсмотренный в других файлах шаблонов.

Пропустим здесь этот момент, лучше посмотреть файл в репозитории.

События

Система событий OpenCart построена вокруг загрузки файлов. Например существует такое событие admin/model/customer/customer/addCustomer/after это событие после добавления нового пользователя через админку OpenCart.

Разберем подробнее:

Система событий OpenCart это генерируемые события до и после загрузки файлов движка или модулей.

Более подробно про события можно прочитать в статье События OpenCart.

AJAX

Рекомендую ознакомится со статьей работа с заказом через админку OpenCart.

Клиент

Клиентская часть OpenCart работает с использованием jquery, а значит можно использовать $.ajax. Примеры ajax запросов на клиентской части можно посмотреть в admin/view/template/sale/order_form.tpl (.twig для OpenCart 3.0).

Сервер

Просматривая все тот же файл admin/view/template/sale/order_form.tpl (для OpenCart 2.3) можно понять, что в качестве адреса вызова используется классическая схема роутинга OpenCart. Посмотрим на один из запросов:

$.ajax({
    url: 'index.php?route=customer/customer/autocomplete&token=<?php echo $token; ?>&filter_name=' +  encodeURIComponent(request),
    ...

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

Создадим контроллер нашего нового тестового модуля по пути admin/controller/extension/module/myajax.php:

class ControllerExtensionModuleMyAjax extends Controller
{
    public function index()
    {
        $this->response->addHeader('Content-Type: application/json');
        $this->response->setOutput(json_encode(
            [
                "success" => true, 
                "message" => "ok", 
                "data" => []
            ]
        ));
    }
}

В классе контроллера есть объект response, это экземпляр класса Response который расположен по пути system/library/response.php. Он позволяет управлять ответом сервера. Нас интересуют только 2 метода:

Для формирования ответа на запрос, в методе контроллера можно использовать $this->response.

Так как OpenCart имеет 2 режима доступа/контекста (admin, catalog), то передаваемые данные в запросах разные:

Теперь чтобы осуществить ajax запрос достаточно в файл представления подставить JavaScript (код для OpenCart 2.3):

$.ajax({
    url: '<?php echo $admin; ?>index.php?route=extesion/module/myajax&token=<?php echo $token; ?>',
    type: 'get',
    dataType: 'json',
    success: function(json) {
        alert("success: "+json["success"]+"\n"+"message: "+json["message"]);
    },
    error: function(xhr, ajaxOptions, thrownError) {
        alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
    }
});

В этом коде в url admin это путь указывающий контекст запроса (admin или catalog). Для контекста есть 2 дефайна определенных в admin/config:

$this->request->post != $_POST так как system/library/request.php производит обработку данных через htmlspecialchars. Это касается и других суперглобальных переменных php: $_GET, $_REQUEST, $_COOKIE, $_FILES, $_SERVER.

Ajax API

Просматривая файл представления admin/view/template/sale/order_form.tpl (OpenCart 2.3), можно увидеть что из админки осуществляются ajax запросы на catalog контекст, с использованием особого токена.

Сначала объявляется глобальная переменная token, затем ajax запрос на адрес /index.php?route=api/login, который отвечает json данными в которых есть ключ token:

var token = '';

// Login to the API
$.ajax({
    url: '<?php echo $catalog; ?>index.php?route=api/login',
    type: 'post',
    data: 'key=<?php echo $api_key; ?>',
    dataType: 'json',
    crossDomain: true,
    success: function(json) {
    //...

        if (json['token']) {
            token = json['token'];
        }
    },
    error: function(xhr, ajaxOptions, thrownError) {
        alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
    }
});

Контроллер этого запроса находится в catalog/controller/api/login.php ControllerApiLogin::index. Он:

Дальше разбирая представление admin/view/template/sale/order_form.tpl можно увидеть что последующие ajax запросы, которые по адресу route=api/... используют этот самый token для определения права доступа, таким образом (в каждом api файле, в каждом методе) сущетвует такой кусок кода для определения права осуществлять запрос:

if (!isset($this->session->data['api_id'])) {
    $json['error']['warning'] = $this->language->get('error_permission');
} else {
    ...
}

Ajax запросы через catalog контекст можно осуществлять с использованием token для безопасного доступа.

А теперь копнем глубже и выясним как это происходит внутри движка, ведь можно отправлять ajax запросы и без токена.

Просматривая код файла index.php отправляемя в system/startup.php, оттуда следуем в system/framework.php в самый конец и видим такое вот:

// Front Controller
$controller = new Front($registry);

// Pre Actions
if ($config->has('action_pre_action')) {
    foreach ($config->get('action_pre_action') as $value) {
        $controller->addPreAction(new Action($value));
    }
}

Здесь видим новое понятие front controller, код которого находится в system/engine/front.php в классе Front. Он запускает общий контроллер startup/router относительно директории controller контекста (admin/controller или catalog/controller), который выполняет первичные контроллеры указанные в $_['action_pre_action']; в файле system/config/catalog.php.

В коде выше происходит только добавление первичных контроллеров во front controller, а их исполнение осуществляется кодом ниже в методе dispatch (внутри метода перед выполнением action указанного в $config->get('action_router')):

// Dispatch
$controller->dispatch(new Action($config->get('action_router')), new Action($config->get('action_error')));

Среди первичных контроллеров есть startup/session относительно catalog/controller где в ControllerStartupSession::index находится интересующий нас код для авторизации в api через токен. Вкратце:

Теперь когда исполнение кода дойдет до целевого контроллера, $this->session->data['api_id'] уже будет иницилизировано если указана актуальная комбинация токена и ip адреса.

Модальные окна

Разбирая админку OpenCart 3.0 (в 2.3 такой кнопки нет), в разделе Панель состояния была найдена кнопка, при клике по которой показалось всплывающее окно. То что нужно, начинаем разбор ...

Кнопка при клике по которой показывается модальное окно

Модальное окно в админке OpenCart Настройки разработчика:

Модальное окно в админке OpenCart Настройки разработчика

Заходим на страницу Панель состояния, открываем ее исходный код и смотрим в конце скрипт:

$('#button-setting').on('click', function() {
	$.ajax({
		url: 'index.php?route=common/developer&user_token=D9aTD65JQVdyOY9pcVxcRUx0M3eTefnr',
		dataType: 'html',
		beforeSend: function() {
			$('#button-setting').button('loading');
		},
		complete: function() {
			$('#button-setting').button('reset');
		},
		success: function(html) {
			$('#modal-developer').remove();
			
			$('body').prepend('<div id="modal-developer" class="modal">' + html + '</div>');
			
			$('#modal-developer').modal('show');
		},
		error: function(xhr, ajaxOptions, thrownError) {
			alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
		}
	});	
});	

Как видно, на кнопку с id button-setting на клик вешается ajax запрос, успешный результат которого показывается в popup окне. А для показа этого окна используется:

$('#modal-developer').modal('show');

Похоже на библиотеку jquerymodal. Однако в bootstrap тоже есть поддержка модальных окон. Опытным путем было выявлено что это bootstrap.

Итог

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

В телеграм канале DevOps от первого лица можно оставить комментарий или почитать интересные истории из практики DevOps