[Из песочницы] Yii2-advanced: Делаем интернационализацию с источником в Redis
1. Файл с массивом, вида: ключ=>перевод (гибко);
2. Файл с расширением .po,.mo бинарный (нужен компилятор, быстро);
3. База данных mysql, две таблицы для ключей и переводом (лучшая реализация при именовании уникальных категорий или привязанных к странице так как yii вытягивает по категории все ключи);
Или свой вариант взяв за основу хранения переводов в базе, но со своим управлением (формирования ключей, переводов и их хранения).
Основное
Вызов перевода остается стандартным Yii: t (). Хранить переводы с ключами будем в MySQL. Временное хранилище по текущему языку будет в Redis. Сбор ключей (категорий) остается прежним.
В чем плюсы:
- быстрее;
- гибче;
Создание конфигурационного файла i18n.php
Начнем с того что создадим конфигурационный файл сборщика ключей такой консольной командой:
php yii message/config @common/config/i18n.php
После этой консольной команды файл i18n.php появится в common/config/ или просто его создадим такого вида:
return [
'color' => null,
'interactive' => true,
'help' => null,
'sourcePath' => __DIR__. '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR,
'languages' => ['ru-RU','uk-UA','en-US'],//языки перевода должны быть
'translator' => 'Yii::t',
'sort' => false,
'overwrite' => true,
'removeUnused' => false,
'markUnused' => true,
'except' => [
'.svn',
'.git',
'.gitignore',
'.gitkeep',
'.hgignore',
'.hgkeep',
'/messages',
'/BaseYii.php',
],
'only' => [
'*.php',
],
'format' => 'db',
'db' => 'db',
//'messageTable' => '{{%message}}', // игнорируем так как будет своя таблица gr_dictionary
'sourceMessageTable' => '{{%gr_dictionary}}',// таблица переводов
'ignoreCategories' => ['yii'],
];
Создание таблиц в MySQL
Далее создадим три таблицы для основного хранения всех языков с переводами:
CREATE TABLE IF NOT EXISTS `gr_language` (
`id` smallint(5) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
`code_lang` varchar(255) NOT NULL,
`local` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`status` tinyint(4) NOT NULL DEFAULT '1',
UNIQUE KEY `code_lang` (`code_lang`),
UNIQUE KEY `local` (`local`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `gr_language` (`id`, `code_lang`, `local`, `name`, `status`)
VALUES (1, 'en', 'en-US', 'English', 1),
(2, 'ru', 'ru-RU', 'Русский', 1),
(3, 'uk', 'uk-UA', 'Українська', 1);
-- таблица по ключам
CREATE TABLE IF NOT EXISTS `gr_dictionary_keys` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
`key` varchar(250) NOT NULL,
UNIQUE KEY `id` (`id`),
KEY `key` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- таблица с переводами
CREATE TABLE IF NOT EXISTS `gr_dictionary` (
`id` int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`language_id` smallint(5) unsigned NOT NULL,
`key` int(10) unsigned NOT NULL,
`value` varchar(255) NOT NULL COMMENT 'шаблон',
`translator` text NOT NULL COMMENT 'перевод',
`type` set('w','m') DEFAULT NULL COMMENT 'w/m слово/предложение',
`status` tinyint(4) NOT NULL DEFAULT '1',
CONSTRAINT `gr_dictionary_ibfk_1`
FOREIGN KEY (`language_id`)
REFERENCES `gr_language` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `gr_dictionary_ibfk_2`
FOREIGN KEY (`key`)
REFERENCES `gr_dictionary_keys` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
UNIQUE KEY `language_id` (`language_id`,`key`,`type`),
KEY `code_lang` (`language_id`),
KEY `type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Переопределим консольный контроллер
Теперь, до сбора ключей, переопределим консольный контроллер \yii\console\controllers\MessageController который отвечает за сбор всех ключей. Для этого создам свой контроллер который наследуются от него.
Создадим файл console\controllers\Message2Controller.php такого вида:
namespace console\controllers;
use Yii;
use yii\console\Exception;
class Message2Controller extends \yii\console\controllers\MessageController
{
/**
* Saves messages to database
*
* @param array $messages Это двухмерный массив ключей [[категори]=>[[значение],[...]] ,... ]
* @param \yii\db\Connection $db
* @param string $sourceMessageTable Наша таблица для переводов
* @param string $messageTable Не используем
* @param boolean $removeUnused
* @param array $languages Это массив языков languages из i18n.php ['ru-RU',...]
* @param boolean $markUnused
*/
protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
{
try{
$pr_iskey=Yii::$app->db->createCommand("SELECT `id` FROM `gr_dictionary_keys` WHERE `key`=:key");
$pr_inskey=Yii::$app->db->createCommand("INSERT INTO `gr_dictionary_keys`( `key`) VALUES (:key)");
$pr_delkey=Yii::$app->db->createCommand("DELETE FROM `gr_dictionary_keys` WHERE `id`=:id");
$id_lang=[];
$pr_l=Yii::$app->db->createCommand("SELECT SQL_NO_CACHE id FROM gr_language WHERE local=:local LIMIT 1");
foreach ($languages as $language) {
if(!isset($id_lang[$language])){
$id_language=(int)$pr_l->bindValue(":local", $language,2)->queryScalar();
if(empty($id_language)){
continue;
// throw new Exception("Unknow lang type $language");
}
$id_lang[$language]=(int)$id_language;
}
}
if(empty($id_lang))throw new Exception("empty lang");
//ALTER TABLE `yii2advanced`.`gr_dictionary` ADD UNIQUE (`language_id`, `key`, `type`);
$pr_d=Yii::$app->db->createCommand("INSERT IGNORE INTO `gr_dictionary`( `language_id`, `key`, `value`, `type`) VALUES (:language_id,:key,:value,:type)");
foreach ($messages as $category => $msgs){
list($type,$key)=explode(":", $category);
if(empty($id=$pr_iskey->bindValue(":key", $key,2)->queryScalar())){
$pr_inskey->bindValue(":key", $key,2)->execute();
$id=Yii::$app->db->lastInsertID;
}
foreach ($id_lang as $id_language) {
$pr_d->bindValue(":language_id", $id_language,1)->bindValue(":key", $id,1)->bindValue(":value", $msgs[0],2)->bindValue(":type", $type,2)->execute();
}
}
// удалить лишние ключи со status=1 (не используемые на страницах)
$query=Yii::$app->db->createCommand("SELECT SQL_NO_CACHE dk.`id`,CONCAT(d.`type`,':',dk.`key`) as 'key_' FROM `gr_dictionary` d,gr_dictionary_keys dk WHERE d.`key`=dk.id AND status=1")->query();
//$pr_del=Yii::$app->db->createCommand("DELETE FROM `gr_dictionary` WHERE `key`=:key");
while(($data=$query->read())!=false){
if(array_key_exists($data['key_'], $messages)===false){
//$pr_del->bindValue(":key", $data['id'],1)->execute();
$pr_delkey->bindValue(":id", $data['id'],1)->execute();
}
}
Yii::$app->db->createCommand("ALTER TABLE gr_dictionary AUTO_INCREMENT = 1;")->execute();
}catch (\Exception $e){
//пишем в лог
}
}
}
Суть тут в том, что нам нужен только один метод saveMessagesToDb, который заполняет таблицу gr_dictionary из конфигурационного файла common/config/i18n.php
'sourceMessageTable' => '{{%gr_dictionary}}'
собранными ключами с нашего сайта, которые мы предварительно вызвали через Yii: t () .Но можно и другую таблицу использовать, тут мы уже решаем как нам лучше. Добавил удаление ключей, а с ними и переводов по ссылке внешнего ключа если на сайте этот ключ больше не используется.
Теперь можем вызвать сбор ключей командой к нашему контроллеру:
php yii message2/extract @common/config/i18n.php
В результате должны заполнится две таблицы (gr_dictionary и gr_dictionary_keys). По каждому языку из таблицы gr_language будет создана запись для перевода.
Добавляем components i18n
Далее добавляем в components конфигурационного файла common\config\main.php:
...
'language'=> 'ru-RU',
'sourceLanguage' => 'en-US',
'components' => [
'i18n' => [
'translations' => [
'*' => [
'class' => 'common\models\AltDbMessageSource',// переопределенный класс yii\i18n\DbMessageSource
],
],
],
'lng' => [
'class' => '\common\components\LanguageExtension',
],
...
- Компонент Yii i18n будет срабатывать при вызовах Yii::$app→t ().Класс отвечающий за это yii\i18n\DbMessageSource, но мы его переопределим common\models\AltDbMessageSource.
- Компонент lng это наш класс \common\components\LanguageExtension отвечающий за работу с Redis
Переопределим yii\i18n\DbMessageSource
Класс отвечающий за перевод мы реализуем по своему
namespace common\models;
use Yii;
class AltDbMessageSource extends \yii\i18n\MessageSource {
public $sourceLanguage;
public function init()
{
parent::init();
if ($this->sourceLanguage === null) {
$this->sourceLanguage = Yii::$app->sourceLanguage;
}
}
protected function translateMessage($category, $message, $language)
{
return Yii::$app->lng->translate($category);
}
public function translate($category, $message, $language)
{
if ( $language !== $this->sourceLanguage ) {
return $this->translateMessage($category, $message, $language);
} else {
return false;
}
}
}
Метод translateMessage вызывается когда мы вызываем Yii: t ('категория','значение'). Тут важно как мы собираемся организовать вид ключа. Можно через сепаратор
:
с помощью которого в Redis будут созданы папки с иерархией, что дает наглядность. К примеру: такие ключи Yii::t('ru-RU:type:uniq_wiev','значение')
будут выглядеть в RedisAdmin так: - ru-RU:
- type:
- uniq_wiev: значение
- uniq_wiev: значение
- uniq_wiev: значение
- type:
Что позволит делать с помощью Redis такие выборки:
$redis->executeCommand("KEYS",["KEY" => $lang_key.":".$type.":*"]);
$redis->executeCommand("KEYS",["KEY" => $lang_key.":*"]);
Ключ языка ru-RU и др. будем добавлять в момент заполнения Redis в компоненте \common\components\LanguageExtension.
Напишем компонент \common\components\LanguageExtension
Компонент нужен для возвращения перевода по ключу из Redis или массива если Redis отвалился.
Для инициализации компонента будем вызывать его в beforeAction контроллера
{
Yii::$app→lng→initModel ();//подключение redis и загрузка слов из базы Mysql gr_dictionary
…
}
namespace common\components\extensions;
use Yii;
use common\components\exceptions\LanguageException;
use yii\db\Exception;
use PDO;
/**
* Class LanguageExtension
* @package common\components\extensions
* Задачи:
* Инициализация словаря
* Заполнение словаря redis
*/
class LanguageExtension extends \yii\base\Object
{
private $language; // код языка - по умолчанию ru
private $w = []; // словарь слов
private $m = []; // словарь сообщений
private $storageConnection; // объект доступа к редису
private $storageStatus; // статус редиса для словаря
private $numbDb; // база redis
private $default_key; // флаг заполненности словаря
private $expire;
public function __construct() {
try{
$this->expire = Yii::$app->params['secretKeyExpire']??(60 * 60 * 60);
$this->language = \Yii::$app->language;
$language=LanguageExtension::currentLang();
if(!empty($language)){
if($this->idKeyLang($language)) {
$this->language= $language;
}
}
$this->numbDb=Yii::$app->params['redisLangDB']??11;
$this->storageStatus = false;
$this->default_key= $this->language.":index";
$this->storageConnection = new \yii\redis\Connection([
'hostname' => Yii::$app->params['redisHost'],
// 'password' => '',
'port' => 6379,
'database' => $this->numbDb,
]);
if(empty($this->language)) throw new LanguageException("not default language",0);
$this->init();
}catch ( LanguageException $event){
// echo $event->getMessage();
}catch ( \yii\db\Exception $event){
$this->init();
}catch (\Exception $event){
// echo $event->getMessage();
}
}
public function __destruct() {
try{
if($this->storageConnection->isActive) $this->storageConnection->close();
}catch (\Exception $event){
}
}
public function initModel()
{
return new LanguageExtension();
}
/**
* бизнес логика. Инициализация словаря. Проверка на существование словаря в редисе. Полное заполнение словаря в редис.
*/
public function init(){
try{
$this->storageConnection->open();
//if($this->storageConnection->getIsActive()==false) throw new LanguageException("No connect Redis ",0);
// загружен ли словарь в redis
if(!$this->isFullData()){
// загрузка из mysql базы слов в redis
$this->loadRedis();
}
$this->storageStatus = true;
} catch ( \yii\db\Exception $event) {
$this->storageStatus = false;
// бизнес логика. Заполнение словаря в переменные $w и $m согласно выбранному языку и интерфейсу.
$this->loadVariable();
}
}
public static function currentLang(){
try{
$language = isset($_COOKIE['userLang']) ? $_COOKIE['userLang'] : null;
if(!$language && Yii::$app->session->has('userLang')) {$language = Yii::$app->session('userLang');}
if(empty($language))$language=\Yii::$app->language;
return $language;
}
catch (\Exception $e){
//print_r($e->getMessage());exit;
}
}
private function idKeyLang(string $key){
if(!empty($key)){
return Yii::$app->db->createCommand("SELECT `id` FROM `gr_language` WHERE local=:local")->bindValue(":local", $key,PDO::PARAM_STR)->queryScalar();
}
return false;
}
/**
* @param string $type
* @param string $key
* @return string
* Строит ключ
*/
private function getKeyMD5(string $type,string $key):string {
return $this->language.":".$type.":".md5($key);
}
/**
* @return bool
* Заполнение локальной переменной словарем
*/
private function loadVariable():bool{
try{
// бизнес логика. Заполнение словаря в переменные $w и $m согласно выбранному языку и интерфейсу.
//$language_id=Yii::$app->db->createCommand("SELECT `id` FROM `gr_language` WHERE local=:local")->bindValue(":local", $this->language,PDO::PARAM_STR)->queryScalar();
$language_id=$this->idKeyLang($this->language);
$res=\Yii::$app->db->createCommand("SELECT d.`type`,d.`value`,d.`translator`, dk.`key` FROM `gr_dictionary` d,gr_dictionary_keys dk WHERE d.`key`=dk.id AND d.language_id=:language_id")
->bindValue(":language_id", $language_id,PDO::PARAM_INT)->query();
$this->w=$this->m=[];
while(($data=$res->read())!=false){
if(method_exists($this, $data['type'])){
$this->{$data['type']}[$this->getKeyMD5($data['type'],$data['key'])]=$data['translator'];
}
}
return true;
}catch (\Exception $event){
echo $event->getLine()."|".$event->getMessage();exit;
return false;
}
}
/**
* @return bool
* Загрузка слов в redis из mysql (языка системы)
*/
private function loadRedis():bool{
try{
$language_id=$this->idKeyLang($this->language);
//$res=\Yii::$app->db->createCommand("SELECT `type`,`key`, `value`,`translator` FROM `gr_dictionary` WHERE language_id=:language_id")
//->bindValue(":language_id", $language_id,PDO::PARAM_INT)->queryAll(PDO::FETCH_ASSOC | PDO::FETCH_GROUP ,1);
$res=\Yii::$app->db->createCommand("SELECT d.`type`,dk.`key`, d.`value`,d.`translator` FROM `gr_dictionary` d,gr_dictionary_keys dk WHERE d.`key`=dk.id AND d.language_id=:language_id")
->bindValue(":language_id", $language_id,PDO::PARAM_INT)->query();
$this->storageConnection->executeCommand('SETEX', [ "KEY" => $this->default_key,"SECONDS"=>$this->expire,"VALUE"=> "1"]);
while(($data=$res->read())!=false){
$this->storageConnection->executeCommand('SETEX', ["KEY" => $this->getKeyMD5($data['type'],$data['key']),"SECONDS"=>$this->expire,"VALUE"=> $data['translator']]);
}
if(empty($this->storageConnection->executeCommand('LASTSAVE', [] )))
$this->storageConnection->executeCommand('BGSAVE', [] );
return true;
}catch (\Exception $event){
echo $event->getMessage();exit;
return false;
}
}
/**
* Очистить Redis
*/
public function flushdb(){
try{
if($this->storageConnection->isActive) $this->storageConnection->executeCommand('FLUSHDB');
else {
$this->w=[];
$this->m=[];
}
}catch (\Exception $event){
}
}
/**
* @return bool
* проверка существования в redis слов по дефолтному ключу и количество ключей словаря
*/
private function isFullData():bool
{
try{
$res= $this->storageConnection->executeCommand('INFO', [ ] );
preg_match("/.*db$this->numbDb:keys=([\d])*.*?/uis",$res,$arr);
if(isset($arr[1]) && $arr[1]>1){
return $this->exists($this->default_key);
}
return false;
}catch (\Exception $event){
echo $event->getMessage();
return false;
}
}
/**
* @param string $key
* @return string
* Возвращает слово по его ключу из загруженного словаря
*/
public function w(string $key) : string {
return $this->getKeyValue($key, 'w');
}
/**
* @param string $key
* @return string
* Возвращает предложение по его ключу из загруженного словаря
*/
public function m(string $key) : string {
return $this->getKeyValue($key, 'm');
}
/**
* @param string $key
* @param string $type
* @return string
* Интерфейс выбора значения
* бизнес логика. Выборка из редиса или еще откуда-то.
*/
private function getKeyValue ( string &$key, string $type ) : string {
try{
if(!$key=trim($key))
throw new LanguageException("Error dictionary ".addslashes($key).". The ".addslashes($key)." can not be empty or contain only whitespace.", 777001);
if($this->storageStatus)
$value = $this->storageConnection->executeCommand("GET",["KEY" =>$this->getKeyMD5($type,$key)]);
else{
$value = @$this->$type[$this->getKeyMD5($type,$key)];
}
/*повесить свой обработчик if(!$value){
if ($this->hasEventHandlers(\yii\i18n\MessageSource::EVENT_MISSING_TRANSLATION)) {
$event = new \yii\i18n\MissingTranslationEvent([
'category' => $key,
'message' => $key,
'language' => $this->language,
]);
$this->trigger(\yii\i18n\MessageSource::EVENT_MISSING_TRANSLATION, $event);
}
}*/
return $value ? $value : $key;
}catch (\Exception $event){
return $key;
}
}
/**
* @param $key
* @return bool
* Удалить ключ
*/
public function del($key):bool{
try{
if($this->storageConnection->isActive){
return $this->storageConnection->executeCommand("DEL",["KEY" =>$key]);// keys test:1:v
}else{
list($lang_key,$type,$key_)= explode(":", $key);
if(method_exists($this, $type) && isset($this->$type[$key_])){
unset($this->$type[$key_]);
return true;
}
return false;
}
}catch (\Exception $event){
return false;
}
}
/**
* @param string $lang_key
* @param null $type
* @return bool
* Удалить ключи по языку типа или всего языка
*/
public function delAll(string $lang_key,$type=null){
try{
if($this->storageConnection->isActive){
$keys= $this->keys($lang_key,$type);
if(!empty($keys)){
foreach ($keys as $key){
$this->del($key);
}
if($type==null) $this->del($lang_key.":index");
}
}else{
$this->w=$this->m=[];
return true;
}
}catch (\Exception $event){
return false;
}
}
/**
* @param $type
* @param $key
* @return array
* Вернуть все ключи блока
*/
public function keys(string $lang_key,$type=null):array{
try{
if($this->storageConnection->isActive){
if($type!=null)
return $this->storageConnection->executeCommand("KEYS",["KEY" => $lang_key.":".$type.":*"]);// keys test:1:*
else
return $this->storageConnection->executeCommand("KEYS",["KEY" => $lang_key.":*"]);
}else{
if($type!=null){
return $this->w+$this->m;
}else{
if(method_exists($this, $type))return $this->$type;
}
return [];
}
}catch (\Exception $event){
return [];
}
}
/**
* @param $type
* @param $key
* @return bool
* Проверка существования ключа
*/
public function exists($key):bool{
try{
if($this->storageConnection->isActive){
return $this->storageConnection->executeCommand("EXISTS",["KEY" =>$key]);
}else{
// return (method_exists($this, $type) && isset($this->$type[$key]));
list($lang_key,$type,$key_)= explode(":", $key);
if(method_exists($this, $type))return isset($this->$type[$key_]);
return false;
}
return false;
}catch (\Exception $event){
return false;
}
}
}
Суть
Хранение всего словаря языка по дефолтному
Yii::$app->language
значению языка, если нет COOKIE данных, в Redis или в массиве если Redis не сработал, по типу значения $this->w[] слово ,$this->m[] сообщение
. Но это моя реализация, а у вас может быть все в одном буфере.Как он работает
При инициализации проверяем коннект Redis. Если его нет то заполняем буфер, если он есть то заполняем его, а источник в обоих случаях MySQL.
Важный момент, перед заполнением мы конечно проверяем загружен ли язык уже в систему, путем проверки дефолного ключа ru-RU:index
который мы устанавливаем если его нет при загрузки.
И так, в MySQL есть 4 языка. Идут коннекты от пользователей на ru-RU язык, что мы делаем? Мы грузим Redis из MySQL весь ru-RU если его там нет и раздаем его, далее есть коннект на en-US, подгружаем в Redis и этот язык, теперь у нас два языка в системе загружено.
Жить конечно они могут вечно, но у меня в компоненте устанавливается время на ключ
$redis->executeCommand('SETEX', ["KEY" => $this->getKeyMD5($data['type'],$data['key']),"SECONDS"=>$this->expire,"VALUE"=> $data['translator']]);
Виджет смены языка
namespace frontend\widgets\WLang;
use frontend\models\Lang;
use Yii;
use PDO;
class WLang extends \yii\bootstrap\Widget
{
public function init(){}
public function run() {
return $this->render('index', [
'current' => \common\components\LanguageExtension::currentLang(),
'default' => \Yii::$app->db->createCommand("SELECT `id`, `code_lang`, `local`, `name`, `default_lang`, `status` FROM `gr_language` WHERE local=:local LIMIT 1")->bindValue(":local",\common\components\LanguageExtension::currentLang(),PDO::PARAM_STR)->queryOne(PDO::FETCH_OBJ),
'langs' =>\Yii::$app->db->createCommand("SELECT `id`, `code_lang`, `local`, `name`, `default_lang`, `status` FROM `gr_language` WHERE 1")->queryAll(PDO::FETCH_OBJ),
]);
}
}
Текущий язык = $current;?>
Вся суть виджета — это отобразить все доступные языки и установить COOKIE данные.
Далее когда добавятся еще переводы или удалятся на страницах нашего приложения мы просто вызываем сбор ключей и вносим в MySQL их значения.
good luck, Jekshmek