Magento шаг за шагом

Magento — система управления интернет-магазинами. По данным Alexa, Magento — самая популярная система управления интернет-магазинами в мире на февраль 2013 г.

В настоящее время, из всех e-commerce решений, я отдаю предпочтение именно Magento:
  • EAV-модель базы данных, позволяет легко манипулировать аттрибутами товаров, категорий, пользователей
  • Большое количество платных и бесплатных модулей на Connect (пусть и не всегда написанных хорошо)
  • Открытый исходный код
  • Легкоразворачиваемый и обновляемый out-of-the-box интернет-магазин
  • Отличная и мощная админка
  • В сравнении с остальными, пожалуй, самый мощный базовый функционал для ecommerce


В интернете есть полно статей на эту тему, но на хабре этот раздел все еще наполняется, поэтому попробую поделиться своими знаниями в этой области.

Любая разработка модулей в Magento должна вестись в local (или community, если вы планируете выкладывать свой модуль в connect)
Итак, базовый модуль состоит из:
  • app/etc/modules/[Namespace]_[Module].xml — bootstrap-файл модуля. Содержит в себе местоположение модуля (core|community|local) и зависимости (depends)
  • app/code/local/[Namespace]/[Module] — основная директория модуля
    • etc директория с конфигурационными файлами модуля (config.xml|system.xml|adminhtml.xml|api.xml|api2.xml ...)
    • controllers — директория контроллеров модуля
    • data — директория установочных скриптов модуля, только данные
    • sql — директория установочных скриптов модуля, только структуры таблиц
    • Block — директория классов блоков
    • Helper — директория вспомогательных классов
    • Model — директория классов моделей



Подгрузка модулей довольно проста: Magento сперва грузит app/etc/local.xml с настройками БД, сессий, кэша и т.д., затем подгружает bootstraps модулей из app/etc/modules/*.xml, сортирует их с учетом зависимостей в <depends>, подгружает app/code/*/*/*/etc/config.xml, сливает все XML в один глобальный XML, и снова грузит app/etc/local.xml в глобальный XML, чтобы избежать утери данных из local.xml.

По сути, смысл всей разработки в Magento заключается в том, чтобы вмешиваться в исходный код модулей ядра (или любого другого модуля) по минимуму, для этого в Magento существует несколько подходов:
1) и, пожалуй, самый основной — события. Если нужно добавить/изменить какой-то существующий функционал, в большинстве случаев достаточно перехвата события.
2) Rewrite класса
3) Локальный override

События

События в Magento это самый правильный подход при изменении существующего функционала. При разработке собственного функционала, не забывайте использовать события, тем более, что это не так уж и сложно, например
Mage::dispatchEvent('namespace_module_something', array('model' => $this, 'something' => $that))

Далее, при перехвате события:
// app/code/local/Namespace/Module/Observer.php
public function someName(Varien_Event_Observer $observer)
{
  $model = $observer->getModel(); // getData('model')
  $something = $observer->getSomething(); // getData('something')
}

Само определение перехвата описывается в config.xml в ноде events:
<config>
  <global>
        <events>
            <namespace_module_something>
                <observers>
                    <some_name>
                        <class>namespace_module/observer</class>
                        <method>someName</method>
                    </some_name>
                </observers>
            </namespace_module_something>
        </events>
  </global>
</config>

Так же, перехват события глобально — не всегда нужен, поэтому events можно прописать, помимо глобального, еще и в frontend / adminhtml / crontab

Rewrite класса

Модели, хелперы и блоки практически везде в коде, вызываются через фабричные методы:
  • Mage::getModel() / Mage::getSingleton()
  • Mage::helper()
  • Mage::getBlockSingleton / Mage_Core_Model_Layout::createBlock

Все эти методы используют соответственно getModelClassName / getHelperClassName / getBlockClassName в Mage_Core_Model_Config
Первым параметром во всех методах идет алиас модели/хелпера/блока: например, catalog/product
Можно так же использовать и имя класса напрямую, но это будет неверно, т.к. убьет напрочь систему rewrite-ов — по сути то же самое, что делать new Namespace_Module_Model_Something() — вы никак не сможете через конфиг перопределить этот класс, поэтому старайтесь в своих модулях не использовать прямых имен классов, например:

fgetcsv($handle, 0, Namespace_Module_Model_Something::DELIMITER, Namespace_Module_Model_Something::ENCLOSURE, Namespace_Module_Model_Something::ESCAPE )

Фактически заставит всех пользователей вашего модуля использовать только DELIMITER/ENCLOSURE/ESCAPE указанные в нем. Такая проблема, например, несколько версий назад была в Mage_ImportExport модуле, и подобные ей до сих пор встречаются иногда в коде.
Итак, рассмотрим конфигурационный файл:

<config>
    <modules>
        <Namespace_Module>
        </Namespace_Module>
    </modules>
    <global>
        <models>
            <namespace_module>
                <class>Namespace_Module_Model</class>
            </namespace_module>
        </models>
        <helpers>
            <namespace_module>
                <class>Namespace_Module_Helper</class>
            </namespace_module>
        </helpers>
        <blocks>
            <namespace_module>
                <class>Namespace_Module_Block</class>
            </namespace_module>
        </blocks>
    </global>
</config>

В нем указывано, что модели, хелперы и блоки будут доступны под алиасом namespace_module.
Таким образом, если нужно достучаться до Namespace_Module_Model_Modelname, достаточно использовать Mage::getModel('namespace_module/modelname') (или Mage::getSingleton, если нужен синглтон). С блоками и хелперами та же ситуация, с одним лишь дополнением: Mage::helper('namespace_module') вызовет основной хелпер модуля: Namespace_Module_Helper_Data
Так же этот хелпер будут использовать блоки и контроллеры при вызове функции перевода ($this->__(«Somestring»)), поэтому хелпер должен быть унаследован от Mage_Core_Helper_Abstract.
Для реврайта достаточно указать следующее:
...
<models>
  <catalog>
    <rewrite>
      <product>Namespace_Module_Model_Product</product>
    </rewrite>
  </catalog>
</models>
...

Для каждой модели (блока, хелпера) нужно указывать свой реврайт. В XML выше мы переопределяем модель товара на Namespace_Module_Model_Product, таким образом Mage::getModel('catalog/product') абсолютно везде будет возвращать Namespace_Module_Model_Product

Локальный override

Нужен только при отсутствии возможности исправления ошибки в файле, например при ошибке в Abstract-классе, который никак нельзя переопределить (особенно если этот абстрактный класс используется десятком других классов).
По умолчанию, include_path = app/code/local; app/code/community; app/code/core; lib, поэтому при ошибке в community или core классе, его можно скопировать в то же место, что и файл, только в local: например local/Mage/Catalog/Model/Abstract.php, и файл будет загружен из local вместо core.
Не самый лучший способ, конечно, т.к. при обновлении Magento файл надо будет обновлять (если проблема не будет устранена), но имеет право на жизнь, особенно когда дело доходит до оптимизации.

Применение на практике


Задание: добавить поле «is_exported» для заказов и отобразить его в списке заказов в админке.
В первую очередь, создадим bootstrap:
app/etc/modules/Easy_Interfacing.xml
<?xml version="1.0"?>
<config>
    <modules>
        <Easy_Interfacing>
            <active>true</active>
            <codePool>local</codePool>
			<depends>
				<Mage_Sales/>
			</depends>
        </Easy_Interfacing>
    </modules>
</config>

В зависимостях обязательно нужно установить Mage_Sales, так как мы собираемся изменить структуру таблицы, создаваемой модулем Mage_Sales. Если этого не установить, все пройдет без проблем на уже существующей Magento, однако при разворачивании с нуля, инсталляционные скрипты могут «упасть» ввиду отсутствия таблицы sales_flat_order, если Ваш скрипт запустится первым.
app/code/local/Easy/Interfacing/etc/config.xml
<?xml version="1.0"?>
<config>
    <modules>
        <Easy_Interfacing>
			<version>0.0.1</version>
        </Easy_Interfacing>
    </modules>
    <global>
		<resources>
            <easy_interfacing_setup>
                <setup>
                    <module>Easy_Interfacing</module>
                    <class>Mage_Sales_Model_Resource_Setup</class>
                </setup>
            </easy_interfacing_setup>
        </resources> 
        <helpers>
            <easy_interfacing>
                <class>Easy_Interfacing_Helper</class>
            </easy_interfacing>
        </helpers>
        <blocks>
            <easy_interfacing>
                <class>Easy_Interfacing_Block</class>
            </easy_interfacing>
        </blocks>
    </global>
</config>

в ноде «resources» по сути указан, что у модуля есть инсталляционный скрипт и что он будет класса Mage_Sales_Model_Resource_Setup
создадим app/code/local/Easy/Interfacing/sql/easy_interfacing_setup/install-0.0.1.php

/* @var $this Mage_Sales_Model_Resource_Setup */
$this->addAttribute('order', 'is_exported', array('type' => 'int', 'grid' => true));

Здесь добавляем int-аттрибут в таблицу заказов, grid => true так же указывает что нужно обновить таблицу грида этим аттрибутом. Важно не использовать заглавных букв в имени полей — геттеры и сеттеры не смогут с ними работать правильно — (get)setSomeValue эквивалентно только (get)setData('some_value'), но никак не (get)setData('SomeValue')

Изначально, заказы были реализованы в паттерне EAV, однако, ввиду особенности EAV — записывать один объект с несколькими дочерними объектами, суммарно с пол-тысячей аттрибутов в десяток таблиц, только чтобы записать один заказ — невыгодно с точки зрения производительности, поэтому заказы и все, что с ними связано — инвойсы, доставки, квоты, адреса — это плоские таблицы, которые используют дублирующую grid-таблицу.
Хорошая практика использовать DDL-методы, т.к. испольовать $this->run($sql) безопасно можно только для новосоздаваемых таблиц, использование ALTER TABLE в методе run не очищает кэш таблиц Zend и можно надолго «залипнуть» в непонимании причин несохранения поля.
Теперь попробуем сделать небольшую модификацию админки в списке заказов.
Часто встречаемая ошибка в community-модулях — JOIN-ы в списке заказов — вместо того, чтоб просто записывать данные в grid-таблицу, люди присоединяют данные к коллекции 'sales/order_grid_collection', переопределив блок Mage_Adminhtml_Block_Sales_Order_Grid, что не совсем верно с точки зрения производительности: если данные уже итак записываются в grid-таблице, почему бы просто не добавить туда еще одно или несколько полей?
Итак, переопределим блок Mage_Adminhtml_Block_Sales_Order_Grid. Его алиас — adminhtml/sales_order_grid:
....
        <blocks>
            <adminhtml>
                <rewrite>
                    <sales_order_grid>Easy_Interfacing_Block_Adminhtml_Sales_Order_Grid</sales_order_grid>
                </rewrite>
            </adminhtml>
        </blocks>
....

Обратите внимание, я добавил Adminhtml в начале имени блока — так удобнее отличать блоки с админки и с фронта — ведь Sales_Order_Grid находится в модуле Adminhtml, а не Sales, и если Вам потребуется переопределить еще и sales/order_history — это приведет к появлению в директории Block/Order рядом с Grid.php еще и History.php, что приведет к неразберихе.

Создадим файл app/code/local/Easy/Interfacing/Block/Sales/Order/Grid.php:

<?php
class Easy_Interfacing_Block_Adminhtml_Sales_Order_Grid extends Mage_Adminhtml_Block_Sales_Order_Grid
{
    protected function _prepareColumns()
    {
        parent::_prepareColumns();
        
        $options = array(
            null => $this->helper('eav')->__('No'),
            1    => $this->helper('eav')->__('Yes'),
        );

        $this->addColumnAfter(
            'is_exported',
            array(
                'header' => $this->__('Exported'),
                'index' => 'is_exported',
                'type'  => 'options',
                'width' => '70px',
                'options' => $options
            ),
           'status'
        );
        $this->sortColumnsByOrder();
    }
}

Все просто, добавили колонку Exported в грид, после статуса. Так как type = options, нужно указать опции в виде массива id => value.
Использовать напрямую eav/entity_attribute_source_boolean нельзя, так как там No = 0, а не NULL, как в нашем случае.

Для тестирования подобного функционала я использую простенький консольный скрипт:

<?php
require 'app/Mage.php';
Mage::app('admin');
Mage::getModel('sales/order')->load(194)->setIsExported(1)->save();

Так как аттрибут is_exported существует в обеих таблицах, при сохранении обьекта заказа, а точнее в методе Mage_Sales_Model_Abstract::_afterCommitCallback выполняется updateGridRecords из ресурс-модели заказа, которая и копирует данные полей из модели в grid-таблицу.

Теперь же достаточно реализовать любой интересующий нас экспорт через событие (не забудьте добавить описание модели):
<config>
    <global>
...
        <models>
            <easy_interfacing>
                <class>Easy_Interfacing_Model</class>
            </easy_interfacing>
        </models>
        <events>
            <sales_order_save_commit_after>
                <observers>
                    <easy_interfacing_order>
                        <class>easy_interfacing/observer</class>
                        <method>exportOrder</method>
                    </easy_interfacing_order>
                </observers>
            </sales_order_save_commit_after>
        </events>
...
    </global>

Как узнать название события? Все очень просто — откройте app/Mage.php, найдите метод dispatchEvent и добавьте логирование (временно, конечно же):

    public static function dispatchEvent($name, array $data = array())
    {
        Mage::log($name, LOG_DEBUG, 'events.log', true);

Затем выполните требуемое действие и смотрите список запущенных событий (файл "<MAGENTO_ROOT>/var/log/events.log"). Правильнее всего будет выбрать afterCommit, т.к. это будет именно то событие, которое будет запущено при успешном сохранении объекта заказа. Если делать это afterSave, то если какой-то из модулей бросит исключение, может оказаться, что ваш модуль мог уже экспортировать данные, что неверно, т.к. вся транзакция будет отменена и заказ вернется в исходное состояние (которое может даже быть несуществующим, в случае отката INSERT INTO).

Создадим сам класс Observer app/code/local/Easy/Interfacing/Model/Observer.php:

<?php
class Easy_Interfacing_Model_Observer
{
    public function exportOrder(Varien_Event_Observer $observer)
    {
        $order = $observer->getOrder();
        /* @var $order Mage_Sales_Model_Order */
        if (!$order->getIsExported() && $order->getState() == Mage_Sales_Model_Order::STATE_PROCESSING) {
            try {
                Mage::getModel('easy_interfacing/order')->export($order);
                $order->setIsExported(1)->addStatusHistoryComment('Exported order');
            } catch (Exception $ex) {
                $order->addStatusHistoryComment('Failed exporting order: ' . $ex->getMessage())->save();
            }
        }
    }
}

Ничего сложного, простая проверка не экспортирован ли заказ уже и можно ли его вообще экспортировать — только заказы в состоянии PROCESSING подходят под экспорт — это оплаченные заказы.

Ну и, наконец easy_interfacing/order app/code/local/Easy/Interfacing/Model/Order.php

<?php
class Easy_Interfacing_Model_Order
{
    public function export(Mage_Sales_Model_Order $order)
    {
        Mage::throwException('Not implemented');
    }
}


Для тестирования функционала экспорта изменим наш тестовый скрипт на:

<?php
require 'app/Mage.php';
Mage::app('admin');
Mage::getModel('sales/order')->load(188)->setDummyValue(1)->save();


Установка несуществуюего значения нужно для того, чтобы пошло сохранение заказа — сохранение модели в Magento не происходит, если _origData = _data, поэтому события beforeSave/afterSave/afterCommit не будут вызваны. После запуска этого скрипта, в истории комментариев заказа появится новый комментарий:
22/07/2014 6:43:43 AM|Processing
Customer Not Notified
Failed exporting order: Not implemented

Финальный config.xml
<?xml version="1.0"?>
<config>
    <modules>
        <Easy_Interfacing>
            <version>0.0.1</version>
        </Easy_Interfacing>
    </modules>
    <global>
        <resources>
            <easy_interfacing_setup>
                <setup>
                    <module>Easy_Interfacing</module>
                    <class>Mage_Sales_Model_Resource_Setup</class>
                </setup>
            </easy_interfacing_setup>
        </resources>
        <models>
            <easy_interfacing>
                <class>Easy_Interfacing_Model</class>
            </easy_interfacing>
        </models>
        <helpers>
            <easy_interfacing>
                <class>Easy_Interfacing_Helper</class>
            </easy_interfacing>
        </helpers>
        <blocks>
            <easy_interfacing>
                <class>Easy_Interfacing_Block</class>
            </easy_interfacing>
            <adminhtml>
                <rewrite>
                    <sales_order_grid>Easy_Interfacing_Block_Adminhtml_Sales_Order_Grid</sales_order_grid>
                </rewrite>
            </adminhtml>
        </blocks>
        <events>
            <sales_order_save_commit_after>
                <observers>
                    <easy_interfacing_order>
                        <class>easy_interfacing/observer</class>
                        <method>exportOrder</method>
                    </easy_interfacing_order>
                </observers>
            </sales_order_save_commit_after>
        </events>
    </global>
</config>



Вот таким вот нехитрым образом в ~12 килобайт реализуется скелет для экспорта в Magento при создании заказа. Дальше остается всего лишь реализовать нужный Вам алгоритм экспорта в модели Easy_Interfacing_Model_Order.
Поделиться публикацией

Комментарии 6

    +9
    image
    простите, не смог удержаться
      0
      mysql4 prefix лучше не используйте, ввиду того что это устаревшая конструкция.
        0
        таки да, думал они от него только в ресурс-моделях поизбавлялись, подправил, спасибо.
        0
        Коль речь зашла о правильности использования ивентов вместо рерайтов, то логичнее добавить колонку в грид через ивенты.
        Для этого достаточно добавить observer на core_block_abstract_prepare_layout_after или core_block_abstract_prepare_layout_before и в методе обсервера проверять тип блока.

        if ($_block->getType() == 'adminhtml/sales_order_grid') {}
        


        Есть второй способ — это добавление колонок через layout
        Для этого достаточно добавить в файл layout:
        <adminhtml_sales_order_grid>
                <reference name="sales_order.grid">
                    <action method="addColumnAfter" translate="header">
                        <columnId>COLUMN_ID</columnId>
                        <arguments>
                            <header>HEADER</header>
                            <index>COLUMN_INDEX</index>
                            <filter_index>FILTER_INDEX</filter_index>
                            <type>TYPE</type>
                            <renderer>CUSTOM_RENDERER_BLOCK</renderer>
                        </arguments>
                        <after>AFTER_COLUMN_ID</after>
                    </action>
                </reference>
            </adminhtml_sales_order_grid>
        


        filter_index используется при поиске если поле фильтруемо. Здесь необходимо указать значение. которое будет подставляться в sql запрос. Например, main_table. is_exported
        renderer — Указываем блок, который будет отвечать за рендеринг. Данные поля являются необязятельными.
          0
          Все же я хотел описать все три возможных варианта добавления функционала наиболее простыми словами. Конечно же, для добавления поля в грид будет достаточно и лейаута, но лейауты заслуживают отдельной темы, которую я еще планирую написать в будущем. Плюс, с учетом того, как часто в админке встречается хардкод — не всегда выходит добавить что-то лейаутом через action method
            0
            Бывает конечно и хардкод, но в основном (то что я встречал) именование блоков остается неизменным. Поэтому к ним не составит труда обратиться через layout.
            Буду ждать новые статьи. Всегда интересно пообщаться с единомышленниками

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

        Самое читаемое