Главная > Coding, Блог > Мультиязычный Zend Framework и WordPress

Мультиязычный Zend Framework и WordPress

Как же долго я не мог себя заставить начать двигаться в направлении реализации мультиязычности на своем сайте… Эта задача оставалась нереализованной на протяжении двух лет. Обусловлено это по большей части тем, что движок сайта собственноручный, основанный на Zend Framework. Много раз я пытался подступиться к решению проблемы локализации Zend Framework, но ничего достойного никак не находилось. На официальном ресурсе (zendframework.ru) есть целая ветка форума, но там я тоже не нашел для себя идеального решения. В итоге, пришлось собирать его из разных источников. И вот, что в итоге получилось.
Сначала пару слов о мультиязычности WordPress. Тут все довольно просто, существует несколько достойных плагинов, одним из которых я не преминул воспользоваться. Называется он qTranslate. Устанавливается легко, конфигурируется легко. После установки заходим в меню Параметры -> Languages, и, если там отсутствует русский язык, добавляем его.
Самым важным для меня параметром настройки плагина стал URL Modification Mode
Этот параметр определяет, каким образом будет производиться диспатчинг URL на предмет определения из него языка. Варианты такие:
/en/article_human_uri/ или
?lang=en или
en.mydomain.com

Я выбрал для себя первый вариант, поскольку похожая концепция предназначалась Zend-сегменту.
Второй важный параметр — Default Language. Это язык, который выбирается, если в URL явно не указан язык. Так же присутствует параметр автоопределения языка с помощью данных о браузере пользователя. Я тоже реализовал этот механизм в Zend-части, но об этом чуть ниже.
Плагин также позволяет подключать службы переводов (типа google translate), благодаря которым можно получать автоматические переводы. Я, правда, так и не опробовал эту штуку — сомнительная это идея, машинный перевод, на мой взгляд. Поэтому перевожу статьи пока по-старинке вручную.
Самое интересное началось, когда очередь дошла до Zend Framework. Вот тут пришлось реально поломать голову. С одной стороны хотелось сделать красивое решение, не костыль, с другой стороны делать что-то сложное типа городить кучу таблиц или новых полей, желания никакого не было.
Основная проблема была в том, как хранить мультиязычный контент в существующих таблицах. Например, таблица для раздела Портфолио (/product/). Изначальна она не была рассчитана на мультиязычность. Структура таблицы простая. Условно, выглядит она примерно так:

item_id,
title,
description

Что с этим можно сделать, чтобы это стало поддерживать второй язык? Первый вариант, добавить полей:

title_ru,
description_ru,
title_en,
description_en

А что если мы захотим добавить еще язык? Второе решение, хранить контент в отдельной нормализованной таблице. Но все это сложно, заниматься этим не хотелось совсем. Поэтому я принял третье решение. Все остается так же, контент будет храниться в соответствующих полях, но заключенный в специальные тэги. Пример

<!--lang:ru-->Это русский контент<--:--><!--lang:en-->This is english content<!--:-->

Затем при отображении извлекать контент для текущего языка простым регулярным выражением.
Это решение, кстати, я подсмотрел в qTranslate.
Еще оставался существенный кусок — новости. Тут уже предыдущим решением было не обойтись. Я решил подключить англоязычные новостные ленты. После долгих сравнений и поисков мой выбор пал на ленту REUTERS. Кстати, попутно решил проблему картинок к новостям — у яндекса, с которого я беру русские новости, стало совсем плохо с картинками к новостям и мне пришла в голову хорошая идея — брать картинки с google-images. В качестве запроса подставляем заголовок статьи и получаем список релевантных изображений. Это работает в 99% случаев.
И так, с новостями разобрались. Что дальше? Остается статика — статичный текст в файлах представлений (view в концепции Model-View-Controller). В Zend Framework роль представления играют скрипты phtml в папке views/scripts
Для работы с мультиязычностью я спроектировал синглтон-объект и разместил его в папке library — один из элементов архитектуры Zend Framework. Этот синглтон (я назвал его Lang) выполняет ряд функций, обеспечивающих обработку мультиязычных данных. При создании экземпляра производится определение текущего языка и сохранение во внутреннее свойство синглтона. Определение языка производится по следующему алгоритму:
1. Анализируется URL запроса на предмет присутствия language-составляющей (например, http://heximal.ru/en/product). О диспатчинге мултиязычных URL в Zend Framework будет рассказано чуть ниже.
2. Если язык не определен из URL, производится сканирование cookie, которое устанавливается пользователю при первом успешном определении языка.
3. Если язык не определяется из cookie, производится попытка определить язык из http-заголовков запроса. Анализируется заголовок Accept-Language. Если в значении заголовка присутствует ru, назначается русский язык. Иначе английский.
4. Если по каким-то причинам заголовок Accept-Language отсутствует в запросе, назначается язык по-умолчанию, определяемый глобальными настройками.
И так, после инстанциирования синглотона мы имеем определенный язык, хранимый во внутренней переменной. Синглтон, а следовательно и свойство текущего языка, доступно в любой точке программного кода. Таким образом, мы как минимум можем сделать в скрипте view следующее:

<?php
  if ($this->lang->_lang=='ru') {
?>
  Русский статичный контент
<?php  
  } else {
?>
  English static content
<?php  
  }
?>

Когда локализуемый контент не очень большой, например, одна строка, имеет смысл воспользоваться классическим приемом мультиязычности — файлы локализации. Смысл в следующем: для каждого языка создается файл определенного формата, в котором хранятся пары ключ-значение, например

"COMMON_ERR_UNKNOWNERR": "Unknown error",
"COMMON_ERR_WRONGCAPTCHA": "Invalid confirmation code",
"COMMON_ERR_BADFIELDS": "Form fields contain errors",

"COMMON_ERR_UNKNOWNERR": "Неопределенная ошибка",
"COMMON_ERR_WRONGCAPTCHA": "Неверный код подтверждения",
"COMMON_ERR_BADFIELDS": "Поля формы содержат ошибки",

Далее дело техники. Все в том же синглтоне Lang пишем функцию, например, назовем ее ls (сокр. language select — чтоб писать каждый раз было не длинно). И далее в нужном месте представления вызываем, не задумываясь о том, какой сейчас установлен язык:

echo($this->lang->ls("COMMON_ERR_BADFIELDS"));

Также я реализовал overloaded версию функции, которая принимает два параметра: первый — текст на русском, второй — на английском. Логика простая, функция возвращает либо первое, либо второе значение в зависимости от текущего языка.

Еще одна существенная проблема — ссылки, выводимые на страницах сайта. В изначальном виде они выглядели так:
/product/takemehome/
Если ничего не менять, то при переходе по такой ссылке запускается цепочка определения языка со второго шага. Вроде бы оно и ничего, с этим можно жить: установили печенюгу пользователю, и пользуемся ей. Переключение языка бы тогда производилось простым переприсваиванием значения печенью.
Надо сказать, я долго колебался между решением с URL и гораздо более простым решением с cookie. Самым главным аргументом в пользу поддержки URL-способа было следующее. Если англоязычный пользователь зашел ко мне на сайт, и решил дать своему другу ссылку с моего сайта, что бы он получил, когда открыл ссылку, не содержашую в себе language-составляющую? Не гарантированно.
Поэтому я решил делать по-правильному.
И так, проблема с урлами решается все через тот-же lang-синглтон. Пишем функцию, назовем ее href

     public function href($href) {
       $hr = explode("/",$href);
       if ($hr[0]=='http:')
         $f=3;
       else
         $f = $href[0]=='/'?1:0;
       $ins = array($this->_lang);
       if (isset($hr[$f])&&$hr[$f]=='blog')
         array_splice($hr, $f+1, 0, $ins);
       else
         array_splice($hr, $f, 0, $ins);
       return implode('/',$hr);
     }

Думаю, идея понятна. Функция берет на вход url вида /seoscan/ и выдает в зависимости от текущего языка /ru/seoscan/ или /en/seoscan/
В нужном месте мы делаем

echo($this->lang->href('/seoscan/'));

Много переделывать? На самом деле, не так уж много. Глаза боятся, а руки делают. Если бы сайт изначально проектировался мультиязычным, переделывать вообще бы ничего не пришлось.
В том же синглотоне объявлена функция для извлечения контента из комбинированных значений

     public function extr_loc($str) {
        preg_match_all('/<!--:'.$this->_lang.'-->(.*?)/si', $str, $matches);
        if (count($matches[1])==0)                  
         return $str;
        $res = "";
        for ($i=0,$c=count($matches[1]);$i<$c;$i++) 
          $res.=$matches[1][$i];
        return $res;
     }

Самое сложное все-таки было настроить роутинг Zend Framework, чтобы он стал понимать текущую структуру сайта с учетом language-составляющей URL
То есть в классическом варианте маршрут задавался в виде

$router->addRoute('product',
     new Zend_Controller_Router_Route('product/:productId', 
       array('controller' => 'product', 'action' => 'view')
     )
);

то с приходом URL вида /en/profuct/productid/ этот контроллер переставал быть работоспособным. На просторах интернета я нашел хорошее решение — публикую его здесь. Нам понадобится суб-класс Zend_Controller_Router_Route (сохраняю авторство):

<?php
/**
 * This class has been inherited to bring an additional feature to the
 * Zend_Controller_Router_Route class. It detects a route segment named
 * locale and sets a default locale for the runnig application based on
 * the segment's value
 *
 * If the value is not a valid Locale identifier the locale is not set
 * into the registry
 *
 * @author Jiri Helmich 
 */
class Heximal_Route extends Zend_Controller_Router_Route
{
	/**
	 * Matches a user submitted path with parts defined by a map. Assigns and
	 * returns an array of variables on a successful match.
	 *
	 * @param string $path Path used to match against this routing map
	 * @return array|false An array of assigned values or a false on a mismatch
	 * @author Jiri Helmich 
	 */
	public function match($path, $partial = false)	{
		//make a copy of that for the parental class
		$originalPath = $path;
		//if the path is empty, there is no locale :-)
		if ($path !== '') {
 
			//path begins with a delimiter, so trim that and explode the path
			$path = trim($path, $this->_urlDelimiter);
			$path = explode($this->_urlDelimiter, $path);
			//loop over each part of the path
			foreach ($path as $pos => $value) {
				//a simple test if this could be a matching route
				if (!array_key_exists($pos, $this->_variables))
						break; //the route is probably longer than the path, not our business
 
				//get a name of current route segment
				$name = $this->_variables[$pos];
 
				//locale segment, that's the interesting stuff
				if ($name === 'locale' && !empty($value)) {
					try {
						//if the given value is not a valid locale identifier
						//an exception is thrown
						$locale = Zend_Locale::findLocale($value);
 
						//otherwise, we construct a new locale instance based on the identifier ...
						$locale = new Zend_Locale($locale);
						// ... and set that into the registry
						Zend_Registry::set("Zend_Locale",$locale);
						//BUT the default translator already has a locale set,
						//so we need to override that
						//we would also like if the assemble method of the
						//router would have the locale value automatically
						Zend_Controller_Front::getInstance()->getRouter()->setGlobalParam('locale',$locale);
 
						//that's all we need to do, the rest is parent's job
						array_splice($path,0,1);
						$path="/".implode("/",$path)."/";
						break;
 
					}catch(Zend_Locale_Exception $e) {
						//this could throw an exception
						//but without doing that, the standard match method
						//is executed the and default locale is used
					}
				}
			}
		}
		//let the parental class to do its job
		return parent::match($originalPath, $partial);
	}
 
}

Разместим его в нашей ветке library и добавим в ленивую загрузку (в Bootstrap.php):

Zend_Loader::loadClass('Heximal_Route');

в routes.php я оформил все следующим образом (показываю кусок):

<?php   
$router = Zend_Controller_Front::getInstance()- -->getRouter();
 
$router->addRoute('pages_loc',new Heximal_Route(
	':locale/:controller/:action/*',
	array(
		'controller' => 'index',
		'action'	 => 'index',
		'locale'	 => 'ru'
	)
));
 
$routes = array(
   'prod_index' => array(
       'url'=>'/product/', 
       'params'=>array('controller' => 'product', 'action' => 'index')
   ),
/*
здесь добавляются на похожий манер все необходимые маршруты
*/   
);
  foreach ($routes as $r_name => $info) {
    $router->addRoute($r_name.'_noloc',
       new Zend_Controller_Router_Route($info["url"], $info["params"],isset($info["validate"])?$info["validate"]:false)
    );
    $info["params"]["locale"]="ru";
    $router->addRoute($r_name,
       new Heximal_Route(':locale'.$info["url"],$info["params"],isset($info["validate"])?$info["validate"]:false)
    );  
  }

И таким образом раздел за разделом, страница за страницей я перевел сайт на мультиязычные рельсы.

Categories: Coding, Блог Tags: , ,
  1. Пока что нет комментариев.
Подписаться на комментарии по RSS