RSS Мои друзья Контакты

Интернационализация в Magento

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

В Magento вопрос интернационализации немного не очевиден (думаю из-за большой абстрактности). Этот функционал реализуется посредством скоупов, о которых упоминалось в одной из прошлых статтей (разделение на скоупы - идея, которая очень глубоко заложена в ядре системы). А именно при помощи так называемых StoreView.

Создание StoreView

Для реализации магазина на 2 языках (например, русский и английский) нужно 2 StoreView. Для русского можно использовать дефолтный, а для английского нужно создать еще один (можно и наоборот). Идем в System -> Manage Stores и нажимаем кнопку Create Store View

В поле имя можно писать все что угодно, но так чтобы было понятно за что отвечает этот Store View (вид магазина). В поле Code нужно прописать код магазина (для интернационализации понятно, что лучше использовать код языка к которому принадлежит вид). Все остальное понятно. Потом нажимаете Save Store View. Также можно переименовать и изменить код для Store View по умолчанию (например код можно поменять на en, а название на English).

Чтобы все переводы отображались на русском для Store View с кодом ru, нужно зайти в конфигурацию, в скоуп этого вида (System > Configuration > (выбираем Русский Store View) > General > Locale Options)

В поле Locale выбираете русский язык, а в поле First Day of Week - Monday. Сохраняете конфигурацию (не забудьте сделать переиндексацию!). Теперь, если это предусмотрено вашей темой, на фронтенде появится переключатель языков.

Фантазируем

К сожалению в Magento нет стандартного функционала, который упрощал бы создание многоязычной сущности (кроме EAV конечно, но ИМХО - слишком сложно для такого простого задания).

Задача собственно

  • для реализации многоязычности для одной сущности используем 2 связанные таблицы
    • в одной хранится общая информация для всех языков
    • в другой хранятся поля, который зависят от языка (в данном случае от Store View)
  • написать table класс и behavior интерфейс, которые упростят создание специфической для задания структуры базы
  • реализовать i18n behavior на основе интерфейса
  • написать ресурс модели, которые упрощает сохранение данных (в данном случае ресурс модель и коллекцию)
  • API доступа к бизнес моделям должен остаться без изменений, за исключением добавления фильтра по Store View

Вокруг да около

В Magento, начиная с версии 1.6, появился класс Varien_Db_Ddl_Table, используется для создания таблиц в setup файлах модуля. Например

$table = $installer->getConnection()
    ->newTable($installer->getTable('core/config_data'))
    ->addColumn('config_id', Varien_Db_Ddl_Table::TYPE_INTEGER, null, array(
        'identity'  => true,
        'unsigned'  => true,
        'nullable'  => false,
        'primary'   => true,
        ), 'Config Id')
    ->addColumn('scope', Varien_Db_Ddl_Table::TYPE_TEXT, 8, array(
        'nullable'  => false,
        'default'   => 'default',
        ), 'Config Scope')
    ->addColumn('scope_id', Varien_Db_Ddl_Table::TYPE_INTEGER, null, array(
        'nullable'  => false,
        'default'   => '0',
        ), 'Config Scope Id')
    ->addColumn('path', Varien_Db_Ddl_Table::TYPE_TEXT, 255, array(
        'nullable'  => false,
        'default'   => 'general',
        ), 'Config Path')
    ->addColumn('value', Varien_Db_Ddl_Table::TYPE_TEXT, '64k', array(
    ), 'Config Value')
    ->addIndex($installer->getIdxName('core/config_data', array('scope', 'scope_id', 'path'), Varien_Db_Adapter_Interface::INDEX_TYPE_UNIQUE),
        array('scope', 'scope_id', 'path'), array('type' => Varien_Db_Adapter_Interface::INDEX_TYPE_UNIQUE))
    ->setComment('Config Data');
$installer->getConnection()->createTable($table);

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

Класс FI_I18n_Model_Resource_Table добавляет несколько важных методов: addBehavior, applyBehaviors и save. По именах методов можно понять, что они делают, кроме последнего. Метод save - это обертка

class FI_I18n_Model_Resource_Table extends Varien_Db_Ddl_Table {
    public function save(Mage_Core_Model_Resource_Setup $context) {
        $this->applyBehaviors($context);
        return $context->getConnection()->createTable($this);
    }
............................................................
}

В метод addBehavior нужно передать объект, который реализует behavior интерфейс

class FI_I18n_Model_Resource_Table extends Varien_Db_Ddl_Table {
    public function addBehavior(FI_I18n_Model_Resource_Behavior_Interface $behavior) {
        $this->_behaviors[] = $behavior;
        return $this;
    }
............................................................
}

Метод applyBehaviors итерирует по все поведениям (behaviors) и применяет их к таблице

class FI_I18n_Model_Resource_Table extends Varien_Db_Ddl_Table {
    public function applyBehaviors(Mage_Core_Model_Resource_Setup $context) {
        if (empty($this->_behaviors)) {
            return false;
        }

        foreach ($this->_behaviors as $behavior) {
            $behavior->applyTo($this, $context);
        }
        return true;
    }
............................................................
}

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

Создадим теперь класс I18n, который реализует Behavior интерфейс. Последний очень прост:

interface FI_I18n_Model_Resource_Behavior_Interface {
    function applyTo(Varien_Db_Ddl_Table $table, Mage_Core_Model_Resource_Setup $context);
}

В нашем случае класс поведения должен создать еще одну таблицу удалив из базовой некоторые поля. Что и сделано в методе applyTo

class FI_I18n_Model_Resource_Behavior_I18n implements FI_I18n_Model_Resource_Behavior_Interface {
    public function applyTo(Varien_Db_Ddl_Table $table, Mage_Core_Model_Resource_Setup $setup) {
        $columns = $table->removeColumns($this->_fields);

        $tableI18n = $this->_newI18nTable($table, $columns);
        $setup->getConnection()->createTable($tableI18n);
        return $this;
    }
............................................................
}

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

class FI_I18n_Model_Resource_Behavior_I18n implements FI_I18n_Model_Resource_Behavior_Interface {
    const TABLE_SUFIX = '_store';
    const LANG_COLUMN_NAME = 'store_id';
............................................................
}

Также реализован статический метод getI18nTableName, который возвращает имя дополнительной таблицы для указанной (просто к имени указанной добавляет TABLE_SUFIX константу). Конструктор этого класса в качестве единого параметра принимает массив полей, которые должны быть разными для разных языков.

Например, есть таблица новостей с полями: id, title, desciption, is_active, created_at, updated_at. Поля title и description мультиязычны. Тогда install файл выглядит следующим образом:

$installer = $this;
$installer->startSetup();

$table = new FI_I18n_Model_Resource_Table();
$table->setName($installer->getTable('fi_i18n/news'))
    ->addColumn('id', Varien_Db_Ddl_Table::TYPE_SMALLINT, null, array(
        'identity'  => true,
        'unsigned'  => true,
        'nullable'  => false,
        'primary'   => true,
        ), 'Website Id')
    ->addColumn('title', Varien_Db_Ddl_Table::TYPE_TEXT, 64, array(
        ), 'News Title')
    ->addColumn('description', Varien_Db_Ddl_Table::TYPE_TEXT, '64k', array(
        'nullable' => false
        ), 'Decsription')
    ->addColumn('is_active', Varien_Db_Ddl_Table::TYPE_SMALLINT, null, array(
        'unsigned'  => true,
        'nullable'  => false,
        'default'   => '0',
        ), 'News Activity')
    ->addColumn('created_at', Varien_Db_Ddl_Table::TYPE_TIMESTAMP, null, array(
        ), 'Date of News Creation')
    ->addColumn('updated_at', Varien_Db_Ddl_Table::TYPE_TIMESTAMP, null, array(
        ), 'Date of News Modification')
    ->addBehavior(new FI_I18n_Model_Resource_Behavior_I18n(array(
        'title', 'description'
    )));

$table->save($installer);
$installer->endSetup();

Этот код сгенерирует следующий запрос к базе (на сервере MySQL):

CREATE TABLE `i18n_news` (
 `id` smallint(5) unsigned NOT NULL AUTO_INCREMENT COMMENT 'Website Id',
 `is_active` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT 'News Activity',
 `created_at` timestamp NULL DEFAULT NULL COMMENT 'Date of News Creation',
 `updated_at` timestamp NULL DEFAULT NULL COMMENT 'Date of News Modification',
 PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COMMENT='i18n_news'

CREATE TABLE `i18n_news_store` (
 `id` smallint(5) unsigned NOT NULL COMMENT 'Translation Id',
 `title` varchar(64) DEFAULT NULL COMMENT 'News Title',
 `description` text NOT NULL COMMENT 'Decsription',
 `store_id` smallint(5) unsigned NOT NULL COMMENT 'Store Id',
 PRIMARY KEY (`id`,`store_id`),
 CONSTRAINT `FK_I18N_NEWS_STORE` FOREIGN KEY (`id`) REFERENCES `i18n_news` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='i18n_news_store'

Теперь не стоит беспокоится о создание индексов и еще одной таблицы. Это за Вас сделает Behavior_I18n.

Ресурс модели

Упрощение для создания setup файлов конечно хорошо, но также хотелось бы иметь возможность просто работать с данными, т.е. не думать о том, что нужно объединить таблицы при выборке и т.д. Также логичным кажется тот факт, что бизнес модели не должны знать ничего о таблицах и их связях. Именно поэтому стоит написать ресурс модель, которая при сохранении распределяет данные по нужным таблицам, а при выборке их объединяет.

Для этого создадим класс FI_I18n_Model_Resource_I18n, в котором нужно переопределить методы _getLoadSelect (для загрузки данных из базы) и save (для распределения данных по разным таблицам). В первом просто объединим 2 таблицы (базовою и i18n)

class FI_I18n_Model_Resource_I18n extends Mage_Core_Model_Resource_Db_Abstract {
    protected function _getLoadSelect($field, $value, $object)
    {
        if (empty($this->_i18nFields)) {
            throw new Exception('Specify i18n fields for your resource model');
        }

        $select = parent::_getLoadSelect($field, $value, $object);
        $tableName = $this->getMainTable();
        $i18nTableName = FI_I18n_Model_Resource_Behavior_I18n::getI18nTableName($tableName);
        $adapter = $select->getAdapter();

        $select->joinLeft(
            array(self::I18N_TABLE_ALIAS => $i18nTableName),
            self::I18N_TABLE_ALIAS . '.id = ' . $adapter->quoteInto($tableName . '.' . $field)
            . ' AND ' . $this->getLangColumnName() . ' = ' . $adapter->quoteInto($object->getStoreId()),
            $this->_i18nFields
        );

        return $select;
    }
..........................................................
}

В методе save сначала фильтруем данные по разным таблицам, потом создаем новую транзакцию. Также стоит упомянуть, что поле id в i18n таблице указывает на id в базовой таблице. Поэтому сохраняем сначала данные в базовую таблицу, а потом и в i18n.

class FI_I18n_Model_Resource_I18n extends Mage_Core_Model_Resource_Db_Abstract {
..........................................................
    public function save(Mage_Core_Model_Abstract $object)
    {
        $adapter = $this->_getWriteAdapter();
        $adapter->beginTransaction();
        try {
            $data = $object->getData();

            $i18n = array();
            foreach ($this->_i18nFields as $field) {
                if (isset($data[$field])) {
                    $i18n[$field] = $data[$field];
                    unset($data[$field]);
                }
            }
            $result = parent::save($object);

            $i18n['id'] = $object->getId();
            $i18n[FI_I18n_Model_Resource_Behavior_I18n::LANG_COLUMN_NAME] = $object->getStoreId();

            $update = array_keys($i18n);
            $adapter->insertOnDuplicate(
                FI_I18n_Model_Resource_Behavior_I18n::getI18nTableName($this->getMainTable()),
                $i18n,
                array_combine($update, $update)
            );

            $adapter->commit();
            return $result;
        } catch (Exception $e) {
            $adapter->rollback();
            throw $e;
        }
    }
}

В этом же классе реализован метод provideCollectionQuery - используется для модификации запроса коллекции. Это сделано с целью не раскрывать внутренней реализации ресурс модели (вообще было бы логично, если бы коллекции в Magento загружались используя тот же класс ресурса, что и бизнес модели и не имели собственного объекта Zend_Db_Select). Этот метод просто объединяет базовую и i18n таблицы

class FI_I18n_Model_Resource_I18n extends Mage_Core_Model_Resource_Db_Abstract {
    public function provideCollectionQuery(Zend_Db_Select $query) {
        $tableName = $this->getMainTable();
        $i18nTableName = $i18nTableName = FI_I18n_Model_Resource_Behavior_I18n::getI18nTableName($tableName);
        $adapter = $query->getAdapter();

        $from = $query->getPart(Zend_Db_Select::FROM);
        $aliases = array_keys($from);
        $mainTableAlias = $aliases[0];

        $query->joinLeft(
            array(self::I18N_TABLE_ALIAS => $i18nTableName),
            self::I18N_TABLE_ALIAS . '.id = ' . $adapter->quoteInto($mainTableAlias . '.' . $this->getIdFieldName()),
            $this->_getI18nColumns()
        );
        return $this;
    }
......................................................................................
}

Код коллекции по сути ничего не делает (просто делегирует полномочия ресурсу)

class FI_I18n_Model_Resource_I18n_Collection
    extends Mage_Core_Model_Resource_Db_Collection_Abstract
{
    protected function _initSelect()
    {
        $result = parent::_initSelect();
        $this->getResource()->provideCollectionQuery($this->getSelect());

        return $result;
    }

    public function addStoreToFilter($store)
    {
        if ($store instanceof Mage_Core_Model_Store) {
            $store = $store->getId();
        }
        $this->addFieldToFilter($this->getResource()->getLangColumnName(), $store);
        return $this;
    }
}

Практикуемся

Модели написаны. Чтобы получить их функционал - достаточно в своем модуле их наследовать. Вернемся к модулю новостей. Создадим бизнес и ресурс модели, а также класс коллекции (2 последние наследуются от FI_I18n_Model_Resource_I18n и FI_I18n_Model_Resource_I18n_Collection классов соответственно). Тогда создать одну новость можно таким образом

$news = Mage::getModel('my_module/news');
$news->setTitle('Test Title')
    ->setDescription('Test Description')
    ->setStoreId(Mage::app()->getStore()->getId())
    ->save();

Загрузить новость из базы

$news = Mage::getModel('my_module/news')->setStoreId(Mage::app()->getStore()->getId())
   ->load(1);
print_r($news->getData());

Загрузить список новостей из базы

$collection = Mage::getModel('my_module/news')->getCollection()
    ->addStoreToFilter(Mage::app()->getStore());

foreach ($collection as $news) {
    print_r($news->getData());
}

Стоит помнить, что при работе с этими моделями всегда нужно указывать store для которого нужно получить данные!

Скачать модуль можно здесь.

P.S.: поддержка версий ниже 1.6 - затруднительна, так что setup файлы придется писать как и раньше, но ресурс модели можно использовать и в старых версиях, стоит только переименовать классы от которых они наследуются (заменить Resource_Db на Mysql4).

Добавить комментарий

Комментариев: 3

  • Юрий
    Ответить 15 марта 2014 г., 10:41
    Здравствуйте. Подскажите в чем принципиальная разница на каком уровне делать перевод или или мультиязычность - Main Website, Main Website Store, Default Store View ?
    • Сергей (Администратор)
      Ответить 21 марта 2014 г., 9:28
      Перевод нужно делать на уровне StoreView, он для этого предназначен (разные отображения для одного веб-сайта).
  • Sergey
    Ответить 15 марта 2015 г., 20:26
    Добрый день! Долго ищу подходящую информацию по интернационализации в magento. Скажите, пожалуйста, как можно это сделать средствами magento? Создал два Store View ru и en, но столкнулся с проблемой, не могу понять как создать каталог, чтобы можно было бы заполнить описание на разных языках. И ещё вопрос касается главной страницы при создании двух Store View, возможно ли изменять образующиеся url "?___store=ru&___from_store=en"?