Yii2-advanced: Робимо інтернаціоналізацію з джерелом в Redis

Yii2 є можливість реалізувати три варіанти інтернаціоналізації:

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/ або просто його створимо такого виду:

common/config/i18n.php
return [
'color' => null,
'interactive' => true,
'help' => null,
'sourcePath' => __DIR__. '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR,
'languages' => ['uk-ua','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
Далі створимо три таблиці для основного зберігання всіх мов з перекладами:

gr_language (мови)
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, 'uk', 'uk-ua', 'Російська', 1),
(3, 'uk', 'uk-UA', 'Українська', 1);


gr_dictionary_keys (ключі)
-- таблиця по ключам
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;


gr_dictionary (переклади)
-- таблиця з перекладами
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 такого виду:

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 ['uk-ua',...]
* @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:

common\config\main.php
...
'language'=> 'uk-ua',
'sourceLanguage' => 'en-US',
'components' => [
'i18n' => [
'translations' => [
'*' => [
'class' => 'common\models\AltDbMessageSource',// перевизначено клас yii\i18n\DbMessageSource
],
],
],
'lng' => [
'class' => '\common\components\LanguageExtension',
],
...


  1. Компонент Yii i18n буде спрацьовувати при викликах Yii::$app->t().Клас відповідає за це yii\i18n\DbMessageSource але ми його переопределим common\models\AltDbMessageSource.

  2. Компонент lng це наш клас \common\components\LanguageExtension відповідає за роботу з Redis
Переопределим yii\i18n\DbMessageSource
Клас відповідає за переклад ми реалізуємо по своєму

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-UK:type:uniq_wiev','значення')
будуть виглядати в RedisAdmin так:

  • uk:
    • type:
      • uniq_wiev: значення
      • uniq_wiev: значення

      • uniq_wiev: значення
Що дозволить робити з допомогою 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 контролера

beforeActionpublic function beforeAction($action)
{
Yii::$app->lng->initModel();//підключення redis і завантаження слів з бази Mysql gr_dictionary

}

common\components\LanguageExtension
namespace common\components\extensions;

use Yii;
common use\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,"С"=>$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.

Важливий момент, перед заповненням ми звичайно перевіряємо завантажений мова вже у систему, шляхом перевірки дефолного ключа
uk: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']]);

Віджет зміни мови
frontend\widgets\WLang
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),
]);
}
}




<div>
<?php
use yii\helpers\Html;
?>
<script>
function click_(el) {
var lang = $(el).attr('data-lang'); 
var date = new Date;
date.setDate(date.getDate() + 1);
document.cookie = "userLang=" + lang + "; path=/; expires=" + date.toUTCString() + ";";
location.reload();
}
</script>

<div id="lang">
<span id="current-lang">
<span class="show-more-lang" >Поточний мова <?= $current;?></span>
</span>
<ul id="langs">
<?php foreach ($langs as $lang):?>
<li class="item-lang"><a href="#" onclick="click_(this)" data-lang="<?=$lang->local?>"><?=$lang->code_lang?></a></li>
<?php endforeach;?>
</ul>
</div>

</div>


Вся суть віджету — це відобразити всі доступні мови і встановити COOKIE дані.

Далі коли додадуться ще переклади або втечуть на сторінках нашої програми ми просто викликаємо збір ключі і вносимо в MySQL їх значення.

good luck, Jekshmek
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.