Рассмотрим полученный опыт разработки разного рода модулей для OpenCart версий 2.3 и 3.0.
В ходе работы с OpenCart у одного из клиентов был странный баг, который долгое время не могли решить, рекомендую ознакомиться.
Файловая структура OpenCart устроена таким образом, что конкретный модуль не имеет своей директории, однако может иметь один единственный файл, например admin/controller/extension/module/modulename.php
.
В большинстве случаев файлы модуля расположены в различных директориях среди файлов других модулей. При этом файлы могут быть как в admin
так и в catalog
контекстах. Контроллеры, модели, шаблоны, вся файловая структура компонуется по типу файлов, в отличии от многих других CMS.
Как правило я веду разработку модулей через git
репозиторий, файлы модуля складываю в директорию src
, а в директорию cms-name
располагаю исходники CMS. Поднимается все это в docker
. Пример структуры проекта:
В 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 с дефолтными настройками.
OpenCart построен по MVCl
архитектуре, в нем есть 4 вида файлов:
admin/model
или catalog/model
admin/view
или catalog/view
admin/controller
или catalog/controller
admin/language
или catalog/language
OpenCart имеет 2 контекста:
admin
- администраторский, вход возможен только по специальному доступуcatalog
- пользовательский, вход свободен, но не для всех endpoint
Кроме перечисленных видов файлов в 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
должен содержать методы:
index
- вывод страницы настроек, обработки и сохраненияvalidate
- для валидации настроек из метода index
(вообще-то необязательно)install
- установка модуляuninstall
- деинсталяция/удаление (из системы движка) модуляПримерно так выглядит шаблон контроллера модуля без реализации:
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
.
У этого объекта нас интересуют следующие методы:
query($sql, $params = array())
- выполнить SQL
запрос в переменной sql
, подставив параметры из массива params
escape($value)
- экранирует строку запроса value
countAffected()
- возвращает количество строк затронутых последним запросомgetLastId()
- возвращает последний вставленный IDconnected()
- установлено ли соединение с БДДля детального разбора, знакомым с 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.
Разберем подробнее:
admin/model/customer/customer
- путь до php файла в котором есть класс ModelCustomerCustomer
addCustomer
- метод класса ModelCustomerCustomer
after
- после того как метод выполнитьсяСистема событий OpenCart это генерируемые события до и после загрузки файлов движка или модулей.
Более подробно про события можно прочитать в статье События OpenCart.
Рекомендую ознакомится со статьей работа с заказом через админку 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 метода:
addHeader($header)
- добавить http заголовок, header
строковый аргументsetOutput($output)
- установить данные для вывода, output
строковый аргументДля формирования ответа на запрос, в методе контроллера можно использовать
$this->response
.
Так как OpenCart имеет 2 режима доступа/контекста (admin
, catalog
), то передаваемые данные в запросах разные:
admin
- требует токен в get
параметре (получить можно из объекта класса контроллера):
token
, который берется из $this->session->data['token']
user_token
, который берется из $this->session->data['user_token']
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
:
HTTP_SERVER
или HTTPS_SERVER
- путь до директории admin
, где будет осуществлен поиск контроллера для выполнения запросаHTTP_CATALOG
или HTTPS_CATALOG
- корень сайта, однако контроллеры будут браться из директории catalog
$this->request->post != $_POST
так какsystem/library/request.php
производит обработку данных черезhtmlspecialchars
. Это касается и других суперглобальных переменныхphp
:$_GET
,$_REQUEST
,$_COOKIE
,$_FILES
,$_SERVER
.
Просматривая файл представления 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
. Он:
catalog/model/account/api.php - ModelAccountApi::addApiSession
) иtoken
находится в system/helper/general.php
),
который возвращается в json
этого ajax запроса, если доступ по api (api_key
) разрешен для текущего пользователя (Админка
-Система
-API
).Дальше разбирая представление 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 через токен. Вкратце:
api/
и наличия get
параметра token
$_COOKIE["api"]
Теперь когда исполнение кода дойдет до целевого контроллера,
$this->session->data['api_id']
уже будет иницилизировано если указана актуальная комбинация токена и ip адреса.
Разбирая админку OpenCart 3.0 (в 2.3 такой кнопки нет), в разделе Панель состояния
была найдена кнопка, при клике по которой показалось всплывающее окно. То что нужно, начинаем разбор ...
Модальное окно в админке 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 интуитивно понятны, это обусловлено просто архитектурой и единообразными правилами.