Bài viết dưới đây hướng dẫn cách tạo file dịch trong Yii2 bằng console giup app của ta được hoàn thiện ngôn ngữ 1 cách chính xác và nhanh nhất


Khi xây dựng application sẽ tốt hơn nếu hướng suy nghĩ tạo 1 ứng dụng toàn cầu,đa ngôn ngữ  ngay từ khi bắt đầu . Nó sẽ tiết kiệm được rất nhiều thời gian và vấn đề phát sinh khi app của bạn được phát triển .

 

Config I18n và tạo file message

Một tiện ích được thêm vào của Yii2 là câu lệnh message cho phép scan toàn thư mục và lấy các từ khóa trong Yii:t để đưa vào file dịch message.

Khá bất tiện là thư viện hướng dẫn của Yii2 vẫn chưa đầu đủ và ghi chi tiết rõ ràng khiến việc config khá khó khăn bằng sự tìm hiểu của mình tôi sẽ hướng dẫn các bạn config và chạy lệnh mesage 1 cách cụ thể hơn

 

Khởi tạo file I18n config

chúng ta sẽ sự dụng template Yii2 basic application được cung cấp bởi Yii cho bài viết này

Toàn bộ code project được chứa dưới folder root /hello. Yii config file được chưá tại /hello/config/* .

Chúng ta sẽ dùng command message để build file config cho i18n tại đường dẫn common/config

từ  folder root của project ta chạy lệnh

./yii message/config @app/config/i18n.php

 

command  se tạo cho ta 1 file config có thể tùy chỉnh như sau

<?php
 
return [
    // string, required, root directory of all source files
    'sourcePath' => __DIR__,
    // array, required, list of language codes that the extracted messages
    // should be translated to. For example, ['zh-CN', 'de'].
    'languages' => ['de'],
    // string, the name of the function for translating messages.
    // Defaults to 'Yii::t'. This is used as a mark to find the messages to be
    // translated. You may use a string for single function name or an array for
    // multiple function names.
    'translator' => 'Yii::t',
    // boolean, whether to sort messages by keys when merging new messages
    // with the existing ones. Defaults to false, which means the new (untranslated)
    // messages will be separated from the old (translated) ones.
    'sort' => false,
    // boolean, whether to remove messages that no longer appear in the source code.
    // Defaults to false, which means each of these messages will be enclosed with a pair of '@@' marks.
    'removeUnused' => false,
    // array, list of patterns that specify which files/directories should NOT be processed.
    // If empty or not set, all files/directories will be processed.
    // A path matches a pattern if it contains the pattern string at its end. For example,
    // '/a/b' will match all files and directories ending with '/a/b';
    // the '*.svn' will match all files and directories whose name ends with '.svn'.
    // and the '.svn' will match all files and directories named exactly '.svn'.
    // Note, the '/' characters in a pattern matches both '/' and '\'.
    // See helpers/FileHelper::findFiles() description for more details on pattern matching rules.
    'only' => ['*.php'],
    // array, list of patterns that specify which files (not directories) should be processed.
    // If empty or not set, all files will be processed.
    // Please refer to "except" for details about the patterns.
    // If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
    'except' => [
        '.svn',
        '.git',
        '.gitignore',
        '.gitkeep',
        '.hgignore',
        '.hgkeep',
        '/messages',
    ],
 
    // 'php' output format is for saving messages to php files.
    'format' => 'php',
    // Root directory containing message translations.
    'messagePath' => __DIR__ . DIRECTORY_SEPARATOR . 'messages',
    // boolean, whether the message file should be overwritten with the merged messages
    'overwrite' => true,
 
 
    /*
    // 'db' output format is for saving messages to database.
    'format' => 'db',
    // Connection component to use. Optional.
    'db' => 'db',
    // Custom source message table. Optional.
    // 'sourceMessageTable' => '{{%source_message}}',
    // Custom name for translation message table. Optional.
    // 'messageTable' => '{{%message}}',
    */
 
    /*
    // 'po' output format is for saving messages to gettext po files.
    'format' => 'po',
    // Root directory containing message translations.
    'messagePath' => __DIR__ . DIRECTORY_SEPARATOR . 'messages',
    // Name of the file that will be used for translations.
    'catalog' => 'messages',
    // boolean, whether the message file should be overwritten with the merged messages
    'overwrite' => true,
    */
];


để thuận tiện tôi đã di chuyển messagePath lên trên và điều chỉnh lại sourcePath và messagePath đường dẫn chính xác. tại languages ta thêm vào array các ngôn ngữ muốn tạo message. Tôi sẽ thử Đức , Tây Ban Nha , Ý và Nhật . Nếu bạn không biết mã ngôn ngữ đây là list các mã ngôn ngữ    

<?php
 
return [
    // string, required, root directory of all source files
    'sourcePath' => __DIR__. DIRECTORY_SEPARATOR .'..',
    // Root directory containing message translations.
    'messagePath' => __DIR__ . DIRECTORY_SEPARATOR .'..'. DIRECTORY_SEPARATOR . 'messages',
    // array, required, list of language codes that the extracted messages
    // should be translated to. For example, ['zh-CN', 'de'].
    'languages' => ['de','es','it','ja'],
    // string, the name of the function for translating messages.
    // Defaults to 'Yii::t'. This is used as a mark to find the messages to be
    // translated. You may use a string for single function name or an array for
    // multiple function names.
    'translator' => 'Yii::t',

Bước tiếp theo ta sẽ chạy command để scan code trong folder được chỉ định ở sourcePath và ghi lại các câu translate cần được dịch vào message.  Tôi đã  chỉnh sourcePath vào folder gốc như vậy sẽ scan toàn bộ project và messagePath sẽ tạo ra file kết qủa ở common/messages
Giờ  ta chạy lệnh

./yii message/extract @app/config/i18n.php

ta có thể thấy Yii đã scan đến đâu trong log

xtracting messages from /Users/Jeff/Sites/hello/views/layouts/main.php...

Extracting messages from /Users/Jeff/Sites/hello/views/site/about.php...

Extracting messages from /Users/Jeff/Sites/hello/views/site/contact.php...

Extracting messages from /Users/Jeff/Sites/hello/views/site/error.php...

Extracting messages from /Users/Jeff/Sites/hello/views/site/index.php...

Extracting messages from /Users/Jeff/Sites/hello/views/site/login.php...

Extracting messages from /Users/Jeff/Sites/hello/views/site/say.php...

Extracting messages from /Users/Jeff/Sites/hello/views/status/_form.php...

Extracting messages from /Users/Jeff/Sites/hello/views/status/_search.php...

Extracting messages from /Users/Jeff/Sites/hello/views/status/create.php...

Extracting messages from /Users/Jeff/Sites/hello/views/status/index.php...

Extracting messages from /Users/Jeff/Sites/hello/views/status/update.php...

Extracting messages from /Users/Jeff/Sites/hello/views/status/view.php...

Extracting messages from /Users/Jeff/Sites/hello/web/index-test.php...

Extracting messages from /Users/Jeff/Sites/hello/web/index.php...

 

Khi lệnh được hoàn tất ta sẽ thấy các file được sinh ra có dạng như sau

Sử dụng Gii với I18n

Khi sữ dụng Gii để genarate Model hoặc View  ta cũng sẽ genarate  label cho các thuộc tính đó nhưng sẽ là 1 string được lấy theo tên trong database. Từ Yii 2 chúng ta được thêm tùy chọn  i18n hỗ trợ thêm vào hàm Yii::t . Dưới đây ta chọn vào i18n và "app" cho phân loại message để chỉ định phân mục của translate 

Ta kiểm tra sẽ thấy các thuộc tính đã được cho vào hàm translate Yii::t('app', ..... );

<?php
 
use yii\helpers\Html;
use yii\grid\GridView;
 
/* @var $this yii\web\View */
/* @var $searchModel app\models\StatusSearch */
/* @var $dataProvider yii\data\ActiveDataProvider */
 
$this->title = Yii::t('app', 'Statuses');
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="status-index">
 
    <h1><?= Html::encode($this->title) ?></h1>
    <?php // echo $this->render('_search', ['model' => $searchModel]); ?>
 
    <p>
        <?= Html::a(Yii::t('app', 'Create {modelClass}', [
    'modelClass' => 'Status',
]), ['create'], ['class' => 'btn btn-success']) ?>
    </p>
 
    <?= GridView::widget([
        'dataProvider' => $dataProvider,
        'filterModel' => $searchModel,
        'columns' => [
            ['class' => 'yii\grid\SerialColumn'],
 
            'id',
            'message:ntext',
            'permissions',
            'created_at',
            'updated_at',
 
            ['class' => 'yii\grid\ActionColumn'],
        ],
    ]); ?>
 
</div>

 

- kiểm tra file translate tại thư mục message ta sẽ thấy như sau

return [
    'About' => '',
    'Contact' => '',
    'Home' => '',
    'Logout' => '',
    'My Company' => '',
    'Sign In' => '',
    'Sign Up' => '',
    'Status' => '',
    ...

 

- Đáng chú ý là bạn có thể chạy lệnh message nhiều lần mà không sợ mất các từ đã dịch trước đó nếu có thêm sự thay đổi từ code. Yii sẽ giu lại các từ đã được dịch mà không ghi đè làm mất nó. Nếu từ chưa được dịch  sẽ có dạng '' Yii cũng hiểu là chưa được dịch và bỏ qua lấy từ mặc định của nó.

Mở rộng hơn với google translate

Thật đơn giản nếu bài viết chỉ dừng ở đây vì vậy tôi quyết định đi xa hơn 1 chút.

Tuy việc phát triển đa ngôn ngữ ngay từ đầu là tốt nhưng không phải dễ để có thể dịch thuật các từ trong application. Tuy không phải là lựa chọn tốt nhưng tôi sẽ hướng dẫn bạn tích hợp google translate vào các message được  scan đã được đề cập ở trên và dịch ra ngôn ngữ được chỉ định. Nói 1 cách đơn giản là ta sẽ viết các hàm để gởi các từ cần dịch lên Google Translate , lấy từ được dịch và ghi vào file dịch. 

Tôi không tin tưởng vào khả năng dịch thuật của Google Translate nhưng đây cũng là 1 giải pháp đơn giản và hiểu qủa cho các application  đơn giản không cần chính xác cao.

 

- Google translate hỗ trợ 64 ngôn ngữ . Danh sách  các ngôn ngữ  hỗ trợ và api có thể tham khảo tại https://cloud.google.com/translate/v2/using_rest#language-params

 

Xữ lí Google Translate Api 

Sau 1 thời gian tìm kiếm tôi tìm được 2 composer cho phép xử lí Google Translate Api với PHP là:

- Ở bài viết này tôi sữ dụng Velijanashvili's vì nó sữ dụng free restful nên ta không cần API key . Tuy nhiên nếu muốn có khả năng thao tác với thư viện api lớn của google và dịch vụ trả phí bạn sẽ muốn dùng Tillotson để có API key và sữ dụng mức cao hơn

Bắt đầu


-Để cài đặt composer ta chỉ cần đơn giản chạy lệnh

composer require stichoza/google-translate-php

- dưới đây là ví dụ đơn giản cách ta sử dụng composer

use Stichoza\GoogleTranslate\TranslateClient;
echo TranslateClient::translate( "en", "ja",'hello'). "\n";

 kết qủa sẽ là

こんにちは

Mở rộng khả năng của lệnh message

Tôi dự định sẽ tạo ra 1 câu lệnh là message/google_extract để tạo ra file message translate như message nhưng với 1 khả năng đặc biệt là các từ được tạo ra sẽ được đưa lên GG translate và dịch ra ngôn ngữ cần dịch.

Ngăn ngừa làm hỏng các token

chắc các bạn còn nhớ Yii cho phép định dạng đưa các token vào {} để truyền như param. Ví dụ

'Create {modelClass}'

'Registered at {0, date, MMMM dd, YYYY HH:mm} from {1}'

'{0, date, MMMM dd, YYYY HH:mm}'

'{nFormatted} {n, plural, =1{gibibyte} other{gibibytes}}'

 

chúng ta sẽ gặp ngay vấn đề đầu tiên khi nếu để nguyên câu như thế để translate dẫn tới việc các token cũng bị translate và sai hoàn toàn cấu trúc dẫn đến lổi.Tôi đã nghĩ đến việc phân lọc câu translate thành các thành phần có thể dịch và không thể dịch và chỉ dịch những phần có thể .

Tôi phải thừa nhận là đây không phải là giải pháp tốt nhất và còn nhiều vấn đề về vị trí và ngữ nghĩa  nhưng hãy dùng tạm thời giải pháp này nếu bạn không có cách nào tốt hơn

 

tôi tạo ra 1 fuction mới là parse_safe_translate()

/*
     * parses a string into an array
     * splitting by any curly bracket segments
     * including nested curly brackets
     */
     public function parse_safe_translate($s) {
       $debug = false;
       $result = array();       
       $start=0;
       $nest =0;
       $ptr_first_curly=0;
       $total_len = strlen($s);
       for($i=0; $i<$total_len; $i++) {
          if ($s[$i]=='{') {
            // found left curly
            if ($nest==0) {
              // it was the first one, nothing is nested yet
              $ptr_first_curly=$i;
            }
            // increment nesting
            $nest+=1;
          } elseif ($s[$i]=='}')  {
            // found right curly
            // reduce nesting
            $nest-=1;
            if ($nest==0) {
              // end of nesting
              if ($ptr_first_curly-$start>=0) {
                // push string leading up to first left curly
                $prefix = substr ( $s ,  $start , $ptr_first_curly-$start);
                if (strlen($prefix)>0) {
                  array_push($result,$prefix);                                  
                }
              }
              // push (possibly nested) curly string
              $suffix=substr ( $s ,  $ptr_first_curly , $i-$ptr_first_curly+1);
              if (strlen($suffix)>0) {
                array_push($result,$suffix);
              }
              if ($debug) {
                echo '|'.substr ( $s ,  $start , $ptr_first_curly-$start-1)."|\n";            
                echo '|'.substr ( $s ,  $ptr_first_curly , $i-$ptr_first_curly+1)."|\n";  
              }
              $start=$i+1;  
              $ptr_first_curly=0;
              if ($debug) {
                echo 'next start: '.$start."\n";          
              }
            }              
          }          
       }
       $suffix = substr ( $s ,  $start , $total_len-$start);
       if ($debug) {
         echo 'Start:'.$start."\n";
         echo 'Pfc:'.$ptr_first_curly."\n";
         echo $suffix."\n";            
       }
       if (strlen($suffix)>0) {
         array_push($result,substr ( $s ,  $start , $total_len-$start));         
       }
       return $result;
     }


fuction này sẽ chuyển 1 câu dịch thành 1 array chứa các thành phần có thể dịch và không thể dịch


ví dụ :

$message='The image "{file}" is too large. The height cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.';
print_r($this->parse_safe_translate($message));

 

sẽ tạo ra

Array

(

   [0] => The image "

   [1] => {file}

   [2] => " is too large. The height cannot be larger than

   [3] => {limit, number}

   [4] =>

   [5] => {limit, plural, one{pixel} other{pixels}}

   [6] => .

)

 

ta thấy câu được tách thành các phần từ và tôi sẽ chỉ đưa lên các phần tử không bắt đầu bằng { và ghép trở lại vào string các phần tử đã được dịch riêng rẽ.

 

Tiến hành dịch các câu với Google translate

Ta tạo tiếp function getGoogleTranslation() với đầu vào là string cần dịch và ngôn ngữ cần dịch đến. Ngôn ngữ gốc được lấy từ Yii::$app->language

 

public function getGoogleTranslation($message,$language) {
           $arr_parts=$this->parse_safe_translate($message);
           $translation='';
           foreach ($arr_parts as $str) {
             if (!stristr($str,'{')) {
               if (strlen($translation)>0 and substr($translation,-1)=='}') $translation.=' ';
               $translation.=TranslateClient::translate( Yii::$app->language, $language,$str);               
             } else {
               // add space prefix unless it's first
               if (strlen($translation)>0)
                 $translation.=' '.$str;
                else
                  $translation.=$str;
             }
           }
           print_r($translation);
           return $translation;
         }

Tôi đã thử nghiệm và cảm thấy nhận được kết quả đúng trong hâu hết các trường hợp

 

Mở rộng message/extract

Tôi sẽ mở rộng và kế thừa message/extract từ /console/controllers/TranslateController.php

bằng extends MessageController ngoài ra tôi còn phải chuyển fuction saveMessagesToPHP  thành saveMessagesToPHPEnhanced và saveMessagesCategoryToPHP  thành saveMessagesCategoryToPHPEnhanced để phục vụ cho mục đích mở rộng của mình


đây là function saveMessagesToPHPEnhanced();

/**
      * Writes messages into PHP files
      *
      * @param array $messages
      * @param string $dirName name of the directory to write to
      * @param boolean $overwrite if existing file should be overwritten without backup
      * @param boolean $removeUnused if obsolete translations should be removed
      * @param boolean $sort if translations should be sorted
      */
     protected function saveMessagesToPHPEnhanced($messages, $dirName, $overwrite, $removeUnused, $sort,$language)
     {       
         foreach ($messages as $category => $msgs) {           
             $file = str_replace("\\", '/', "$dirName/$category.php");
             $path = dirname($file);
             FileHelper::createDirectory($path);
             $msgs = array_values(array_unique($msgs));
             $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
             $this->stdout("Saving messages to $coloredFileName...\n");
             $this->saveMessagesCategoryToPHPEnhanced($msgs, $file, $overwrite, $removeUnused, $sort, $category,$language);
         }
     }

nó sẽ gọi function saveMessagesCategoryToPHPEnhanced

/**
          * Writes category messages into PHP file
          *
          * @param array $messages
          * @param string $fileName name of the file to write to
          * @param boolean $overwrite if existing file should be overwritten without backup
          * @param boolean $removeUnused if obsolete translations should be removed
          * @param boolean $sort if translations should be sorted
          * @param boolean $language language to translate to
          * @param boolean $force google translate
          * @param string $category message category
          */
         protected function saveMessagesCategoryToPHPEnhanced($messages, $fileName, $overwrite, $removeUnused, $sort, $category,$language,$force=true)
         {
             if (is_file($fileName)) {
                 $existingMessages = require($fileName);
                 sort($messages);
                 ksort($existingMessages);
                 if (!$force) {
                   if (array_keys($existingMessages) == $messages) {
                       $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
                       return;
                   }                   
                 }
                 $merged = [];
                 $untranslated = [];
                 foreach ($messages as $message) {
                     if (array_key_exists($message, $existingMessages) && strlen($existingMessages[$message]) > 0) {
                         $merged[$message] = $existingMessages[$message];
                     } else {
                         $untranslated[] = $message;
                     }
                 }
                 ksort($merged);
                 sort($untranslated);
                 $todo = [];
                 foreach ($untranslated as $message) {
                     $todo[$message] = $this->getGoogleTranslation($message,$language);
                 }
                 ksort($existingMessages);
                 foreach ($existingMessages as $message => $translation) {
                     if (!isset($merged[$message]) && !isset($todo[$message]) && !$removeUnused) {
                         if (!empty($translation) && strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0) {
                             $todo[$message] = $translation;
                         } else {
                             $todo[$message] = '@@' . $translation . '@@';
                         }
                     }
                 }
                  
                 $merged = array_merge($todo, $merged);
                 if ($sort) {
                     ksort($merged);
                 }
                 if (false === $overwrite) {
                     $fileName .= '.merged';
                 }
                 $this->stdout("Translation merged.\n");
             } else {
                 $merged = [];
                 foreach ($messages as $message) {
                     $merged[$message] = '';
                 }
                 ksort($merged);
             }
 
 
             $array = VarDumper::export($merged);
             $content = <<<EOD
<?php
/**
* Message translations.
*
* This file is automatically generated by 'yii {$this->id}' command.
* It contains the localizable messages extracted from source code.
* You may modify this file by translating the extracted messages.
*
* Each array element represents the translation (value) of a message (key).
* If the value is empty, the message is considered as not translated.
* Messages that no longer need translation will have their translations
* enclosed between a pair of '@@' marks.
*
* Message string can be used with plural forms format. Check i18n section
* of the guide for details.
*
* NOTE: this file must be saved in UTF-8 encoding.
*/
return $array;
EOD;
 
             file_put_contents($fileName, $content);
             $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
         }

bạn có thể thấy chú ý tôi gọi getGoogleTranslation để translate các message

foreach ($untranslated as $message) {
    $todo[$message] = $this->getGoogleTranslation($message,$language);
}

tôi cũng thêm param force = true để ép tạo đè lên file mới

if (!$force) {
    if (array_keys($existingMessages) == $messages) {
    $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
    return;
    }                   
}

Thử nghiệm kết quả

Việc tạo file config và thiết lập file config cho i18n tôi đã nói ở phần đầu bài viết tôi sẽ không nói lại để tránh dư thừa. Các bạn hãy thực hiện như hướng dẫn và tôi sẽ thêm 1 vài câu message cần translate để tiến hành thử nghiệm sau đó chạy lệnh

./yii translate/google_extract @app/config/i18n.php

Tôi khá hài lòng với kết quả nhận được dứoi đây

Chúng ta đã scan toàn project , ghi vào file và dịch thành 64 ngôn ngữ khác nhau chỉ với 1 câu lệnh điều đó khá là ấn tượng !

Kết

Hi vọng bài viết sẽ giúp bạn biết thêm 1 tiện ích khá hữu dụng của Yii2 và không cần lo lắng về vấn đề ngôn ngữ cản trở cho sự phát triển application của bạn

Toàn bộ code được tôi viết trong bài được viết trong TranslateController bạn có thể download về và đặt tại thư mục command để chạy


One comment

#124
2015-07-14 10:20
(y)

Leave a Comment

Fields with * are required.