0,0
рейтинг
20 июня 2014 в 13:22

Разработка → Yii2 и организация мультиязычности из песочницы

Yii*, PHP*
Долгожданный выход Yii 2.0 Beta дал стимул многим разработчикам, использующих Yii, к переходу на вторую версию фреймворка. Разработчики фреймворка указали, что постараются не трогать обратную совместимость и в основном будут сосредоточены над исправлением ошибок и заканчивать документацию. Это дает еще больший импульс к использованию Yii2 в реальных проектах.

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

Постановка задачи


1. Количество языков неограниченно.
2. URL сайта представлены как ЧПУ и SEO оптимизированы. Ссылки вида:
example.com/en/mypage
example.com/ru/mypage
example.com/de/mypage
3. Минимальные изменения в работе с фреймворком. Ресурс по ссылке example.com/mypage должен отдаваться на языке, установленным по умолчанию. Правила роутинга не должны изменяться в зависимости от количества языков.

Хранение языков


Исходя из того, что количество языков неограниченно и должен указываться язык по умолчанию, то было решено хранить этот список в отдельной таблице БД. Создаем таблицу lang с такими полями:
id — идентификатор языка
url — буквенный идентификатор языка для отображения в URL(ru, en, de,...)
local — язык (локаль) пользователя
name — название(English, Русский,...)
default — флаг, указывающий на язык по умолчанию(1 — язык по умолчанию)
date_update — дата обновления(в unixtimestamp)
date_create — дата создания(в unixtimestamp)

CREATE TABLE IF NOT EXISTS `lang` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `url` varchar(255) NOT NULL,
  `local` varchar(255) NOT NULL,
  `name` varchar(255) NOT NULL,
  `default` smallint(6) NOT NULL DEFAULT '0',
  `date_update` int(11) NOT NULL,
  `date_create` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;


И вносим в таблицу два языка, учитывая что один должен быть из значением default=1:

INSERT INTO `lang` (`url`, `local`, `name`, `default`, `date_update`, `date_create`) VALUES
('en', 'en-EN', 'English', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
('ru', 'ru-RU', 'Русский', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());


Или cоздаем миграцию, выполнив команду php yii migrate/create lang. В созданный файл вставляем:

public function safeUp()
{
    $tableOptions = null;
    if ($this->db->driverName === 'mysql') {
        $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_general_ci ENGINE=InnoDB';
    }

    $this->createTable('{{%lang}}', [
        'id' => Schema::TYPE_PK,
        'url' => Schema::TYPE_STRING . '(255) NOT NULL',
        'local' => Schema::TYPE_STRING . '(255) NOT NULL',
        'name' => Schema::TYPE_STRING . '(255) NOT NULL',
        'default' => Schema::TYPE_SMALLINT . ' NOT NULL DEFAULT 0',
        'date_update' => Schema::TYPE_INTEGER . ' NOT NULL',
        'date_create' => Schema::TYPE_INTEGER . ' NOT NULL',
    ], $tableOptions);

    $this->batchInsert('lang', ['url', 'local', 'name', 'default', 'date_update', 'date_create'], [
        ['en', 'en-EN', 'English', 0, time(), time()],
        ['ru', 'ru-RU', 'Русский', 1, time(), time()],
    ]);
}

public function safeDown()
{
    $this->dropTable('{{%lang}}');
}


Применяем миграцию командой php yii migrate.

Модель языка


С помощью gii создаем модель Lang и генерируем CRUD.
В модель добавляем поведение для автоматического обновления даты при редактировании и создании записи в таблице lang:

public function behaviors()
{
    return [
        'timestamp' => [
            'class' => 'yii\behaviors\TimestampBehavior',
            'attributes' => [
                \yii\db\ActiveRecord::EVENT_BEFORE_INSERT => ['date_create', 'date_update'],
                \yii\db\ActiveRecord::EVENT_BEFORE_UPDATE => ['date_update'],
            ],
        ],
    ];
}


Так же добавим вспомогательные методы для работы с объектом языка в модель Lang:

//Переменная, для хранения текущего объекта языка
static $current = null;

//Получение текущего объекта языка
static function getCurrent()
{
    if( self::$current === null ){
        self::$current = self::getDefaultLang();
    }
    return self::$current;
}

//Установка текущего объекта языка и локаль пользователя
static function setCurrent($url = null)
{
    $language = self::getLangByUrl($url);
    self::$current = ($language === null) ? self::getDefaultLang() : $language;
    Yii::$app->language = self::$current->local;
}

//Получения объекта языка по умолчанию
static function getDefaultLang()
{
    return Lang::find()->where('`default` = :default', [':default' => 1])->one();
}

//Получения объекта языка по буквенному идентификатору
static function getLangByUrl($url = null)
{
    if ($url === null) {
        return null;
    } else {
        $language = Lang::find()->where('url = :url', [':url' => $url])->one();
        if ( $language === null ) {
            return null;
        }else{
            return $language;
        }
    }
}


Формирование URL


Менеджер URL(urlManager) — встроенный компонент приложения для создания URL-адресов. Через этот компонент создаются все URL в приложении. Для добавления префикса буквенного идентификатора языка в URL достаточно переопределить метод createUrl класса UrlManager и в конфигурации приложения указать используемый менеджер URL.

В блок components конфигурационного файла config/main.php добавляем:

'urlManager' => [
    'enablePrettyUrl' => true,
    'showScriptName' => false,
    'class'=>'frontend\components\LangUrlManager',
    'rules'=>[
        '/' => 'site/index',
        '<controller:\w+>/<action:\w+>/*'=>'<controller>/<action>',
    ]
],


Создаем файл components/LangUrlManager.php и переопределяем createUrl:

<?php
namespace frontend\components;

use yii\web\UrlManager;
use frontend\models\Lang;

class LangUrlManager extends UrlManager
{
    public function createUrl($params)
    {
        if( isset($params['lang_id']) ){
            //Если указан идентификатор языка, то делаем попытку найти язык в БД,
            //иначе работаем с языком по умолчанию
            $lang = Lang::findOne($params['lang_id']);
            if( $lang === null ){
                $lang = Lang::getDefaultLang();
            }
            unset($params['lang_id']);
        } else {
            //Если не указан параметр языка, то работаем с текущим языком
            $lang = Lang::getCurrent();
        }
        
        //Получаем сформированный URL(без префикса идентификатора языка)
        $url = parent::createUrl($params);
        
        //Добавляем к URL префикс - буквенный идентификатор языка
        if( $url == '/' ){
            return '/'.$lang->url;
        }else{
            return '/'.$lang->url.$url;
        }
    }
}


Определения языка


Информация о идентификаторе языка храниться только в URL. Соответственно определить текущий язык можно лишь путем парсинга URL. Для этого переопределим метод resolvePathInfo класса Request и в конфигурационном файле приложения укажем используемый компоненте request. Метод resolvePathInfo — возвращает часть URL($pathInfo) до знака "?" и после срипта входа.

Что бы не переписывать rules в UrlManager с учетом буквенного идентификатора языка, его(буквенный идентификатор) можно убрать с $pathInfo, установить текущий язык через Lang::setCurrent и возвращать $pathInfo, но уже без префикса языка.

Создаем файл components/LangRequest.php и переопределяем resolvePathInfo:

<?php
namespace frontend\components;

use Yii;
use yii\web\Request;
use frontend\models\Lang;

class LangRequest extends Request
{
    private $_lang_url;

    public function getLangUrl()
    {
        if ($this->_lang_url === null) {
	        $this->_lang_url = $this->getUrl();
	        
	    	$url_list = explode('/', $this->_lang_url);

	    	$lang_url = isset($url_list[1]) ? $url_list[1] : null;

	    	Lang::setCurrent($lang_url);

                if( $lang_url !== null && $lang_url === Lang::getCurrent()->url && 
				strpos($this->_lang_url, Lang::getCurrent()->url) === 1 )
                {
                     $this->_lang_url = substr($this->_lang_url, strlen(Lang::getCurrent()->url)+1);
                }
        }

        return $this->_lang_url;
    }

    protected function resolvePathInfo()
    {
        $pathInfo = $this->getLangUrl();

        if (($pos = strpos($pathInfo, '?')) !== false) {
            $pathInfo = substr($pathInfo, 0, $pos);
        }

        $pathInfo = urldecode($pathInfo);

        // try to encode in UTF8 if not so
        // http://w3.org/International/questions/qa-forms-utf-8.html
        if (!preg_match('%^(?:
            [\x09\x0A\x0D\x20-\x7E]              # ASCII
            | [\xC2-\xDF][\x80-\xBF]             # non-overlong 2-byte
            | \xE0[\xA0-\xBF][\x80-\xBF]         # excluding overlongs
            | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}  # straight 3-byte
            | \xED[\x80-\x9F][\x80-\xBF]         # excluding surrogates
            | \xF0[\x90-\xBF][\x80-\xBF]{2}      # planes 1-3
            | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
            | \xF4[\x80-\x8F][\x80-\xBF]{2}      # plane 16
            )*$%xs', $pathInfo)
        ) {
            $pathInfo = utf8_encode($pathInfo);
        }

        $scriptUrl = $this->getScriptUrl();
        $baseUrl = $this->getBaseUrl();
        if (strpos($pathInfo, $scriptUrl) === 0) {
            $pathInfo = substr($pathInfo, strlen($scriptUrl));
        } elseif ($baseUrl === '' || strpos($pathInfo, $baseUrl) === 0) {
            $pathInfo = substr($pathInfo, strlen($baseUrl));
        } elseif (isset($_SERVER['PHP_SELF']) && strpos($_SERVER['PHP_SELF'], $scriptUrl) === 0) {
            $pathInfo = substr($_SERVER['PHP_SELF'], strlen($scriptUrl));
        } else {
            throw new InvalidConfigException('Unable to determine the path info of the current request.');
        }

        if ($pathInfo[0] === '/') {
            $pathInfo = substr($pathInfo, 1);
        }

        return (string) $pathInfo;
    }
}


В блок components конфигурационного файла config/main.php добавляем:

'request' => [
    'class' => 'frontend\components\LangRequest'
],


Интернационализация приложения


В переводе приложения участвуют два языка:
язык приложения ($language) — язык пользователя, который работает с приложением;
исходный язык приложения ($sourceLanguage) — язык, который используется в исходном коде приложения. По умолчанию $sourceLanguage = 'en'.

Перевод сообщений осуществляется с помощью метода Yii::t( $category, $message, $params = [], $language = null ).

Установим значения по умолчанию $sourceLanguage='en' и $language='ru-RU' в конфигурационном файле. Значение $language — устанавливается заново(метод Lang::setCurrent, строчка Yii::$app->language = self::$current->local;) при определении $pathInfo в LangRequest::resolvePathInfo, то есть при каждом HTTP запросе.

Переводы сообщений будем хранить в директории messages. Для каждого языка своя директория (message/ru и message/en), в которой хранится переводы по категориям.

В блок components конфигурационного файла config/main.php добавляем:

'language'=>'ru-RU',
'i18n' => [
    'translations' => [
        '*' => [
            'class' => 'yii\i18n\PhpMessageSource',
            'basePath' => '@frontend/messages',
            'sourceLanguage' => 'en',
            'fileMap' => [
                //'main' => 'main.php',
            ],
        ],
    ],
],


Более подробную информацию можно найти здесь.

Виджет переключения языков


Создаем frontend/widgets/Lang.php:

<?php
namespace frontend\widgets;
use frontend\models\Lang;

class WLang extends \yii\bootstrap\Widget
{
    public function init(){}

    public function run() {
        return $this->render('lang/view', [
            'current' => Lang::getCurrent(),
            'langs' => Lang::find()->where('id != :current_id', [':current_id' => Lang::getCurrent()->id])->all(),
        ]);
    }
}


И отображение frontend/widgets/views/lang/view.php:

<?php
use yii\helpers\Html;
?>
<div id="lang">
    <span id="current-lang">
        <?= $current->name;?> <span class="show-more-lang">▼</span>
    </span>
    <ul id="langs">
        <?php foreach ($langs as $lang):?>
            <li class="item-lang">
                <?= Html::a($lang->name, '/'.$lang->url.Yii::$app->getRequest()->getLangUrl()) ?>
            </li>
        <?php endforeach;?>
    </ul>
</div>


Вывод виджета:

<?php
use frontend\widgets\WLang;
?>
...
<?= WLang::widget();?>
Антон Чернецкий @developinwed
карма
2,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (30)

  • +2
    > Долгожданный релиз Yii 2.0 Beta

    Брр, релиз бета-версии?
  • +2
    Может я не внимательно прочитал, но не увидел как все-таки лучше организовать хранение самих текстов страниц на разных языках в базе данных. Вопрос более широкий и не касается конкретно Yii. Где по данной теме можно прочитать советы, какой подход наиболее правильный?
    • –2
      Ну раз уж написали вопрос к статье про Yii, отвечу касательно фреймворка. Взгляните как предлагается хранить это не на уровне файлов, а централизованно в БД.
    • +2
      Сомневался в добавлении этого в статью. Добавлю комментом. Обычно делаем через таблицу связей. Типичный пример для блога и постов.
      Таблица post(модель Post) — храним метаинформацию, которая не имеет отношения к контенту. То есть такие поля: id, owner_id, date_create, date_update.
      Таблица post_lang(модель PostLang) — связь контента по языкам. Поля: id, post_id, lang_id, name, description. Соответственно поля post_id — это id из таблицы post, lang_id — это id из таблицы lang. name и description — контент на соответствующем языке.
      В модель Post добавляем отношение:

      public function getContent($lang_id=null)
      {
      $lang_id = ($lang_id === null)? Lang::getCurrent()->id: $lang_id;

      return $this->hasOne(PostLang::className(), ['post_id' => 'id'])->where('lang_id = :lang_id', [':lang_id' => $lang_id]);
      }

      Тогда в отображении, для текущего языка, контент можно получить так: $post->content->name; — заголовок поста, $post->content->description; — сам пост.
      Или для контента на определенном языке, задав идентификатор языка($lang_id): $post->getContent($lang_id)->name; $post->getContent($lang_id)->description;
      • 0
        Видел много разных вариантов реализации хранения мультиязычного контента, но в свое время также остановился именно на этом. Как по мне, то это самый удачный вариант.
    • 0
      Propel ORM и его behaviour i18n вам в помощь propelorm.org/behaviors/i18n.html. Реализация там примерно такая же, как описал товарищ developinwed, зато из коробки.
    • 0
      Когда то давно делал мультиязычный проект на Yii1 + mysql, сделал составной PRIMARY KEY из полей id, lang и хранил для каждого языка запись, т.е. получалось 3 записи с одним id и разным lang, а в выборке указывал where lang = $lang AND id = $id, понимаю что не самый красивый подход, но на то время как придумал так и сделал :)
  • 0
    Спасибо, как раз надо было реализовать многоязычность при переписывание портала
  • 0
    В 1м Yii для определения языка в урле, пришлось переопределять метод removeUrlSuffix класса CUrlManager
    • 0
      Не самый лучший способ. Да и нелогичный.
      • 0
        А какой есть ещё способ?
        • 0
          Нелогичночность в том, что вы префиксы обрабатываете в методе, предназначенном для разруливания суффиксов. Я когда реализовывал схожий механизм, добавил метод removeUrlPrefix и вызывал его из перегруженного parseUrl. Правда это вынуждает нас копипастить код. Собственно это единственный плюс вашего подхода. У меня просто изменения довольно координальными были и кто-либо кроме меня не вжисть не догадался бы при таком раскладе где какая магия происходит. Правда когда пришлось треть класса переопределить, уже понял что это безнадежно и прилепил symfony/routing.
  • 0
    Спасибо, после прочтения этой статьи, понял что пора бы портировать мое расширение под yii2.
    • 0
      Пора!
  • +1
    Друг попросил откомментировать.
    Юрий Шеховцов:
    Для этого способа надо что-то делать с сабмитами.
    Иначе в каждом определении формы надо явно указывать action.
    Если этого не делать, префиксы ru-en не будут подставляться. createUrl() в сабмите не участвует.
    • 0
      Спасибо за вопрос. Действительно, createUrl — не участвует в формировании action в форме. Так же он не участвует в виджетах, например GridView. Для этого пришлось немного переписать класс LangRequest, и переопределить в нем resolvePathInfo, а не resolveRequestUri. Изменения коснулись только пункта «Определения языка» и «Виджет переключения языков»(сменить Yii::$app->getRequest()->getUrl() на Yii::$app->getRequest()->getLangUrl()). Изменения внес в статью. Теперь в формах и виджетах урлы формируются корректно.
  • 0
    А если мы переопределим метод resolveRequestUri:, то как на это отреагирует все, что это использует? да те же миграции, например?
  • 0
    1. а кто как относится к решению, где в качестве языка источника используются ключи?
    сами же переводы располагаются в хранилище (например в базе данных). Плюс такого подхода в том, что в случае опечаток (или не совсем лаконичная фраза) в исходниках, переводчик может через админ интерфейс быстро перевести значение на самом сайте.
    • 0
      У вас 2 тезиса, на какой отвечать? :)

      >> а кто как относится к решению, где в качестве языка источника используются ключи?

      Имеятся в виду идентификаторы строк в БД? Думаю, это неудачное решение, потому что при редактировании будет очень сложно разобрать, где какой перевод. Лучше просто завести поле с уникальным ключом.

      >> сами же переводы располагаются в хранилище (например в базе данных)

      А это здравое решение, и многие (и мы) так и делают.
      • 0
        имелось в виду, на сколько жизнеспособна идея использовать в качестве sourceLanguage = 'key'?
        например:
        вместо того чтобы писать в коде Yii:t('app', 'ваша модель {model} не найдена потому что ...(длинный текст с опечатками)', ['model' => 'User']);
        можно просто написать Yii:t('app', '{model} not_found', ['model' => 'User']);
  • 0
    А как можно сгенирировать URL с учетом языка?
    Yii::$app->getRequest()->getLangUrl()
    

    Выдает только \
    • 0
      //Для текущего языка
      Yii::$app->urlManager->createUrl('/myurl', array('lang_id'=>Lang::getCurrent()->id));
      
      //Для кастомного
      $lang = Lang::find()->where('local = :local', [':local' => 'en-En'])->one();
      Yii::$app->urlManager->createUrl('/myurl', array('lang_id'=>$lang->id));
      
      • 0
        Спасибо. Это мне помогло.
  • 0
    Изучаю вопрос, и наткнулся на расширение yii2-translate-manager. Релиз, вроде как, от вчера.

    В силу неопытности не могу сказать стоит его «прикручивать» в рабочий проект. Код вроде солидный… Может кто посмотрит опытным глазом!?
  • 0
    дублируются классы Lang
    • 0
      Переименуйте класс виджета.
    • 0
      Спасибо! Поправил
  • 0
    А как правильно сделать такую вещь, чтобы при запросе example.com/mypage не просто грузился контент на языке по-умолчанию, а перекидывало на адрес example.com/ru/mypage (где ru — язык по-умолчанию). Чтобы не было дублирующихся страниц. Потому что, если не ошибаюсь, дублирующиеся страницы плохо сказываются на SEO.
    • 0
      мидлвэры спасут мир.

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