Caution: You are browsing the legacy symfony 1.x part of this website.

День 19: Интернационализация и Локализация

1.4 / Doctrine
Symfony version
1.2
Language ORM

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

Сегодня мы поговорим об интернационализации (или i18n) и локализации (или l10n) Jobeet.

Из Википедии:

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

Локализация - это процесс адаптации программного обеспечения для конкретного региона или языка с помощью компонентов, зависящих от локали, и добавления перевода текстов.

Как всегда, фреймворк Symfony не изобретал колесо и поддержка i18n и l10n основана на стандартах ICU.

Пользователь

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

note

Мы уже говорили о классе User в фреймворке Symfony в течении дня 13.

Локаль пользователя

Функциональность i18n и l10n фреймворка Symfony базируется на локали пользователя. Локаль это комбинация языка и страны пользователя. Например локаль для пользователя который говорит на французском fr, а локаль для пользователя из Франции fr_FR.

Вы можете управлять локалью пользователя с помощью методов setCulture() и getCulture() объекта User:

// В действии (action)
$this->getUser()->setCulture('fr_BE');
echo $this->getUser()->getCulture();

tip

Язык закодирован как две строчные буквы согласно стандарту ISO 639-1, а страна закодирована как две заглавные буквы согласно стандарту ISO 3166-1.

Предпочтительная локаль

Локаль пользователя по умолчанию настраивается в конфигурационном файле settings.yml:

# apps/frontend/config/settings.yml
all:
  .settings:
    default_culture: it_IT

tip

Поскольку локаль управляется объектом User, она хранится в сессии пользователя. Во время разработки, если Вы меняете локаль по умолчанию, Вы должны очистить куки вашей сессии, чтобы применить новые установки в вашем браузере.

Когда пользователь начинает сессию на веб-сайте Jobeet, мы можем определить лучшую локаль, основываясь на информации предоставленной HTTP-заголовком Accept-Language.

Метод getLanguages() объекта Request возвращает массив приемлемых языков для текущего пользователя, отсортированного в порядке предпочтений:

// В действии (action)
$languages = $request->getLanguages();

Но в большинстве случаев веб-сайт не будет поддерживать все 136 самых популярных языков мира. Метод getPreferredCulture() возвращает самый подходящий язык в сравнении с предпочтительными языками пользователя и языками, которые поддерживает Ваш веб-сайт:

// В действии (action)
$language = $request->getPreferredCulture(array('en', 'fr'));

В предыдущем вызове будет возвращен английский или французский язык в зависимости от предпочтительных языков пользователя, или английский (первый язык в массиве), если ни один из них не подходит.

Локаль в URL

Веб-сайт Jobeet будет доступен на английском и французском. Поскольку URL может представлять только один ресурс, локаль должна быть встроена в URL. Для этого откройте файл routing.yml и добавьте специальную переменную :sf_culture для всех маршрутов кроме api_jobs и homepage. Для простых маршрутов добавьте /:sf_culture в начало url. Для коллекций маршрутов добавьте опцию prefix_path, которая начинается с /:sf_culture.

# apps/frontend/config/routing.yml
affiliate:
  class: sfDoctrineRouteCollection
  options:
    model:          JobeetAffiliate
    actions:        [new, create]
    object_actions: { wait: get }
    prefix_path:    /:sf_culture/affiliate
 
category:
  url:     /:sf_culture/category/:slug.:sf_format
  class:   sfDoctrineRoute
  param:   { module: category, action: show, sf_format: html }
  options: { model: JobeetCategory, type: object }
  requirements:
    sf_format: (?:html|atom)
 
job_search:
  url:   /:sf_culture/search
  param: { module: job, action: search }
 
job:
  class: sfDoctrineRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: put, extend: put }
    prefix_path:    /:sf_culture/job
  requirements:
    token: \w+
 
job_show_user:
  url:     /:sf_culture/job/:company_slug/:location_slug/:id/:position_slug
  class:   sfDoctrineRoute
  options:
    model: JobeetJob
    type: object
    method_for_query: retrieveActiveJob
  param:   { module: job, action: show }
  requirements:
    id:        \d+
    sf_method: get

Когда в маршруте используется переменная sf_culture, фреймворк Symfony будет автоматически использовать ее значение для изменения локали пользователя.

Поскольку нам нужно домашних страниц столько же, сколько языков мы поддерживаем (/en/, /fr/, ...), домашняя страница по умолчанию (/) должна перенаправлять на соответствующую локализацию в соответствии с локалью пользователя. Но если локаль пользователя еще не определена, потому что он пришел на Jobeet первый раз, для него будет выбрана предпочтительная локаль.

Сначала добавьте метод isFirstRequest() в myUser. Он возвращает true только для самого первого запроса сессии пользователя:

// apps/frontend/lib/myUser.class.php
public function isFirstRequest($boolean = null)
{
  if (is_null($boolean))
  {
    return $this->getAttribute('first_request', true);
  }
 
  $this->setAttribute('first_request', $boolean);
}

Добавьте маршрут localized_homepage:

# apps/frontend/config/routing.yml
localized_homepage:
  url:   /:sf_culture/
  param: { module: job, action: index }
  requirements:
    sf_culture: (?:fr|en)

Измените действие index модуля job для реализации логики перенаправления пользователя на "лучшую" домашнюю страницу при первом запросе сессии:

// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  if (!$request->getParameter('sf_culture'))
  {
    if ($this->getUser()->isFirstRequest())
    {
      $culture = $request->getPreferredCulture(array('en', 'fr'));
      $this->getUser()->setCulture($culture);
      $this->getUser()->isFirstRequest(false);
    }
    else
    {
      $culture = $this->getUser()->getCulture();
    }
 
    $this->redirect('localized_homepage');
  }
 
  $this->categories = Doctrine_Core::getTable('JobeetCategory')->getWithJobs();
}

Если переменная sf_culture не существует в запросе, это означает что пользователь пришел на URL /. Это тот самый случай и это новая сессия, предпочтительная локаль такая же как локаль пользователя. В противном случае будет использована текущая локаль пользователя.

Последний шаг - это перенаправление пользователя на URL localized_homepage. Заметьте, что переменная sf_culture не передается при перенаправлении поскольку фреймворк Symfony добавляет ее автоматически.

Теперь, если Вы попробуете пойти на URL /it/, фреймворк Symfony вернет ошибку 404 поскольку мы ограничили переменную sf_culture значениями en и fr. Добавьте это требование во все маршруты которые содержат локаль:

requirements:
  sf_culture: (?:fr|en)

Тестирование локали

Самое время протестировать нашу реализацию. Но перед добавлением тестов, нам нужно исправить уже существующие. Поскольку все URLы были изменены, отредактируйте все файлы фунциональных тестов в test/functional/frontend/ и добавьте /en в начале каждого URL. Не забудьте также изменить URLы в файле lib/test/JobeetTestFunctional.class.php. Запустите тесты для того, чтобы проверить, что правильно их исправили:

$ php symfony test:functional frontend

Тестер user предоставляет метод isCulture() который тестирует текущую локаль пользователя. Откройте файл jobActionsTest и добавьте следующие тесты:

// test/functional/frontend/jobActionsTest.php
$browser->setHttpHeader('ACCEPT_LANGUAGE', 'fr_FR,fr,en;q=0.7');
$browser->
  info('6 - User culture')->
 
  restart()->
 
  info('  6.1 - For the first request, symfony guesses the best culture')->
  get('/')->
  isRedirected()->followRedirect()->
  with('user')->isCulture('fr')->
 
  info('  6.2 - Available cultures are en and fr')->
  get('/it/')->
  with('response')->isStatusCode(404)
;
 
$browser->setHttpHeader('ACCEPT_LANGUAGE', 'en,fr;q=0.7');
$browser->
  info('  6.3 - The culture guessing is only for the first request')->
 
  get('/')->
  isRedirected()->followRedirect()->
  with('user')->isCulture('fr')
;

Переключение языка

Для изменения локали пользователем, нужно добавить форму языка в шаблон. Фреймворк форм не предоставляет такой формы из коробки, но поскольку она нужна для большинства интернациональных веб-сайтов, разработчики ядра фреймворка Symfony поддерживают sfFormExtraPlugin, который содержит валидаторы, виджеты и формы, которые не могут быть включены в основной пакет фреймворка Symfony из-за своей специфичности или потому что содержат внешние зависимости, но тем не менее очень полезны.

Установите плагин с помощью комманды plugin:install:

$ php symfony plugin:install sfFormExtraPlugin

Or via Subversion with the following command:

$  svn co http://svn.symfony-project.org/plugins/sfFormExtraPlugin/branches/1.3/ plugins/sfFormExtraPlugin

In order for plugin's classes to be loaded, the sfFormExtraPlugin plugin must be activated in the config/ProjectConfiguration.class.php file as shown below:

// config/ProjectConfiguration.class.php
public function setup()
{
  $this->enablePlugins(array(
    'sfDoctrinePlugin', 
    'sfDoctrineGuardPlugin',
    'sfFormExtraPlugin'
  ));
}

note

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

Плагин sfFormExtraPlugin предоставляет форму sfFormLanguage для управления языковой секцией. Добавление языковой формы в шаблон может быть выполнено следующим образом:

note

Код ниже не предназначен для реализации. Он здесь для того, чтобы показать Вам, как можно реализовать что-то неправильно. Мы покажем Вам, как реализовать это правильно, используя фреймворк Symfony.

// apps/frontend/templates/layout.php
<div id="footer">
  <div class="content">
    <!-- footer content -->
 
    <?php $form = new sfFormLanguage(
      $sf_user,
      array('languages' => array('en', 'fr'))
      )
    ?>
    <form action="<?php echo url_for('change_language') ?>">
      <?php echo $form ?><input type="submit" value="ok" />
    </form>
  </div>
</div>

Видите ошибку? Правильно, создание объекта формы не соответсвует уровню представления. Она должна быть создана в действии. Но поскольку код в главном шаблоне, форма должна быть создана в каждом действии, что далеко не практично. В данном случае Вы должны использовать компонент. Компонент это тот же самый фрагмент (partial), но с прикрепленным к нему кодом. Считайте, что это облегченный контроллер.

Включение компонента в шаблон можно выполнить с помощью помощника include_component():

// apps/frontend/templates/layout.php
<div id="footer">
  <div class="content">
    <!-- footer content -->
 
    <?php include_component('language', 'language') ?>
  </div>
</div>

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

Создайте модуль language, который будет содержать компонент и действие, которое будет переключать язык пользователя:

$ php symfony generate:module frontend language

Компоненты будут определены в файле actions/components.class.php.

Создайте этот файл сейчас:

// apps/frontend/modules/language/actions/components.class.php
class languageComponents extends sfComponents
{
  public function executeLanguage(sfWebRequest $request)
  {
    $this->form = new sfFormLanguage(
      $this->getUser(),
      array('languages' => array('en', 'fr'))
    );
  }
}

Как Вы видите, класс компонента очень похож на класс контроллера.

Для шаблона компонента используются те же самые соглашения по именованию, как и для фрагментов: перед названием компонента ставится подчеркивание (_):

// apps/frontend/modules/language/templates/_language.php
<form action="<?php echo url_for('change_language') ?>">
  <?php echo $form ?><input type="submit" value="ok" />
</form>

Поскольку плагин не предоставляет действие, которое изменяет локаль пользователя, измените файл routing.yml и создайте маршрут change_language:

# apps/frontend/config/routing.yml
change_language:
  url:   /change_language
  param: { module: language, action: changeLanguage }

И создайте соответствующее действие:

// apps/frontend/modules/language/actions/actions.class.php
class languageActions extends sfActions
{
  public function executeChangeLanguage(sfWebRequest $request)
  {
    $form = new sfFormLanguage(
      $this->getUser(),
      array('languages' => array('en', 'fr'))
    );
 
    $form->process($request);
 
    return $this->redirect('localized_homepage');
  }
}

Метод process() формы sfFormLanguage заботится об изменении локали пользователя, опираясь на отосланную форму.

Internationalized Footer

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

Языки, таблица символов и кодировка

Разные языки содержат различные наборы символов. Английский язык один из самых, простых поскольку в нем используются ASCII символы, французский язык сложнее с акцентированными символами как "é", и языки такие как русский, китайский или арабский намного сложнее, так как все их символы находятся за пределами ASCII. Такие языки определены в совершенно разных таблицах символов.

Когда вы имеете дело с интернационализированными данными, лучше всего использовать Unicode. Идея Unicode заключается в том, чтобы использовать универсальную таблицу символов, которая содержит все символы для всех языков. Проблема Unicode состоит в том, что один символ может быть представлен с помощью 21 октета. Поэтому для веба мы используюем UTF-8, в котором код Unicode указывается различной по длине последовательностью октетов. В UTF-8 символы большинства используемых языков закодированы с помощью менее чем 3 октетов.

UTF-8 это кодировка по умолчанию, используемая фреймворком Symfony, и это определено в конфигурационном файле settings.yml:

# apps/frontend/config/settings.yml
all:
  .settings:
    charset: utf-8

Также для того, чтобы включить уровень интернационализации, Вы должны установить значение true для настройки i18n в settings.yml:

# apps/frontend/config/settings.yml
all:
  .settings:
    i18n: true

Шаблоны

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

В шаблоне все строки которые зависят от языка должны быть заключены в помощник __() (заметьте что тут два подчеркивания).

Помощник __() это часть группы помощников I18N, которые упрощают управление i18n в шаблонах. Поскольку эта группа не загружена по умолчанию, Вам нужно или добавить ее вручную в каждом шаблоне с помощью use_helper('I18N') как мы уже делали с группой помощников Text, или загрузить ее глобально добавив настройку в standard_helpers:

# apps/frontend/config/settings.yml
all:
  .settings:
    standard_helpers: [Partial, Cache, I18N]

Тут показано как использовать помощник __() для подвала (footer) Jobeet:

// apps/frontend/templates/layout.php
<div id="footer">
  <div class="content">
    <span class="symfony">
      <img src="/legacy/images/jobeet-mini.png" />
      powered by <a href="/">
      <img src="/legacy/images/symfony.gif" alt="symfony framework" /></a>
    </span>
    <ul>
      <li>
        <a href=""><?php echo __('About Jobeet') ?></a>
      </li>
      <li class="feed">
        <?php echo link_to(__('Full feed'), 'job', array('sf_format' => 'atom')) ?>
      </li>
      <li>
        <a href=""><?php echo __('Jobeet API') ?></a>
      </li>
      <li class="last">
        <?php echo link_to(__('Become an affiliate'), 'affiliate_new') ?>
      </li>
    </ul>
    <?php include_component('language', 'language') ?>
  </div>
</div>

note

Помощник __() может принять строку для языка по умолчанию, или Вы можете так же использовать уникальный идентификатор для каждой строки. Это только дело вкуса. Для Jobeet, мы будем использовать прежнюю стратегию, чтобы шаблоны были более читабельными.

Когда Symfony исполняет шаблон, каждый раз, когда вызывается помощник __(), Symfony ищет перевод для текущей локали пользователя. Если перевод найден, он будет использован, если нет, первый аргумент будет возвращен как значение по умолчанию.

Все переводы хранятся в некотором каталоге. Фреймворк i18n предоставляет много различных стратегий для сохранения переводов. Мы будем использовать формат "XLIFF", который является стандартом и наиболее гибким. Это так же хранилице, используемое генератором админки и большинством плагинов Symfony.

note

Другие каталоги хранения это gettext, MySQL, и SQLite. Как всегда, посмотрите в i18n API для более подробной информации.

i18n:extract

Вместо создания файла каталога вручную, используйте встроенную задачу i18n:extract:

$ php symfony i18n:extract frontend fr --auto-save

Задача i18n:extract находит все строки, которые нужно перевести на fr в приложении frontend и создает или обновляет соответствующий каталог. Опция --auto-save сохраняет новые строки в каталог. Вы также можете использовать опцию --auto-delete для автоматического удаления строк, которые больше не существуют.

В нашем случае, заполняется файл, который мы создали:

<!-- apps/frontend/i18n/fr/messages.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xliff PUBLIC "-//XLIFF//DTD XLIFF//EN"
  "http://www.oasis-open.org/committees/xliff/documents/xliff.dtd">
<xliff version="1.0">
  <file source-language="EN" target-language="fr" datatype="plaintext"
      original="messages" date="2008-12-14T12:11:22Z"
      product-name="messages">
    <header/>
    <body>
      <trans-unit id="1">
        <source>About Jobeet</source>
        <target/>
      </trans-unit>
      <trans-unit id="2">
        <source>Feed</source>
        <target/>
      </trans-unit>
      <trans-unit id="3">
        <source>Jobeet API</source>
        <target/>
      </trans-unit>
      <trans-unit id="4">
        <source>Become an affiliate</source>
        <target/>
      </trans-unit>
    </body>
  </file>
</xliff>

Каждый перевод управляется тегами trans-unit у которого есть уникальный аттрибут id. Теперь вы можете отредактировать этот файл и добавить перевод для французского языка:

<!-- apps/frontend/i18n/fr/messages.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xliff PUBLIC "-//XLIFF//DTD XLIFF//EN"
  "http://www.oasis-open.org/committees/xliff/documents/xliff.dtd">
<xliff version="1.0">
  <file source-language="EN" target-language="fr" datatype="plaintext"
      original="messages" date="2008-12-14T12:11:22Z"
      product-name="messages">
    <header/>
    <body>
      <trans-unit id="1">
        <source>About Jobeet</source>
        <target>A propos de Jobeet</target>
      </trans-unit>
      <trans-unit id="2">
        <source>Feed</source>
        <target>Fil RSS</target>
      </trans-unit>
      <trans-unit id="3">
        <source>Jobeet API</source>
        <target>API Jobeet</target>
      </trans-unit>
      <trans-unit id="4">
        <source>Become an affiliate</source>
        <target>Devenir un affilié</target>
      </trans-unit>
    </body>
  </file>
</xliff>

tip

XLIFF это стандартный формат, и существует много инструментов для облегчения процесса перевода. Open Language Tools это проект с открытыми исходными кодами на Java с встроенным редактором XLIFF.

tip

Поскольку XLIFF это формат, базирующийся на файлах, применимы те же правила приоритетов и объединения которые существуют для других конфигурационных файлов. Файлы I18n могут существовать в проекте, в приложении или модуле, и наиболее специфичный файл перегружает перевод, найденный в глобальном.

Переводы с аргументами

Основной принцип интернационализации это переводить все предложение. Но в некоторые предложения включены динамические значения. В Jobeet, тот самый случай для ссылок "more..." на домашней странице:

<!-- apps/frontend/modules/job/templates/indexSuccess.php -->
<div class="more_jobs">
  and <?php echo link_to($count, 'category', $category) ?> more...
</div>

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

<!-- apps/frontend/modules/job/templates/indexSuccess.php -->
<div class="more_jobs">
  <?php echo __('and %count% more...', array('%count%' => link_to($count, 'category', $category))) ?>
</div>

Теперь строка которую нужно перевести "and %count% more...", и заполнитель %count% будет заменен реальным значением во время выполнения, благодаря значению переданному во втором аргументе помощника __().

Добавьте новую строку вручную с помощью вставки тега trans-unit в файл messages.xml, или используйте задачу i18n:extract для обновления файла автоматически:

$ php symfony i18n:extract frontend fr --auto-save

После запуска задачи, откройте XLIFF-файл и добавьте французский перевод:

<trans-unit id="6">
  <source>and %count% more...</source>
  <target>et %count% autres...</target>
</trans-unit>

Единственное требование к переведенной строке - это где-то использовать заполнитель %count%.

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

На странице категорий, количество работ в текущей категории отображается таким кодом:

<!-- apps/frontend/modules/category/templates/showSuccess.php -->
<strong><?php echo count($pager) ?></strong> jobs in this category

Когда предложение имеет несколько переводов в зависимости от числа, нужно использовать помощник format_number_choice():

<?php echo format_number_choice(
    '[0]No job in this category|[1]One job in this category|(1,+Inf]%count% jobs in this category',
    array('%count%' => '<strong>'.count($pager).'</strong>'),
    count($pager)
  )
?>

Помощник format_number_choice() принимает три аргумента:

  • Строка, которая зависит от числа
  • Массив заполнителей
  • Число для определения текста, который нужно использовать

Строка, которая описывает различные переводы в зависимости от числа, должна быть в следующем формате:

  • Каждый вариант разделен вертикальной чертой (|)
  • Каждая строка состоит из диапазонов для перевода

Диапазоны могут описывать любые числовые диапазоны:

  • [1,2]: Принимает значения от 1 до 2 включительно
  • (1,2): Принимает значения от 1 до 2 не включая 1 и 2
  • {1,2,3,4}: Принимаются только указанные значения
  • [-Inf,0): Принимает значения от отрицательной бесконечности до 0, не включая 0
  • {n: n % 10 > 1 && n % 10 < 5}: Соответствует числам 2, 3, 4, 22, 23, 24

Перевод строки похож на другие строки сообщений:

<trans-unit id="7">
  <source>[0]No job in this category|[1]One job in this category|(1,+Inf]%count% jobs in this category</source>
  <target>[0]Aucune annonce dans cette catégorie|[1]Une annonce dans cette catégorie|(1,+Inf]%count% annonces dans cette catégorie</target>
</trans-unit>

Теперь Вы знаете, как интернационализировать все типы строк, потратьте время на добавление вызовов __() для всех шаблонов приложения frontend. Мы не будем интернационализировать приложение backend.

Формы

Классы форм содержат много строк, которые нужно переводить, такие, как метки, сообщения об ошибках, и подсказки. Все эти строки автоматически интернационализированы фреймворком Symfony, так что вам нужно только предоставить перевод в файлах XLIFF.

note

К сожалению задача i18n:extract еще не обрабатывает классы форм для поиска непереведенных строк.

Doctrine объекты

Для веб-сайта Jobeet, мы не будем интернационализировать все таблицы, поскольку нет смысла просить тех, кто размещает вакансии, переводить их посты о вакансиях на все доступные языки. Но таблица категорий точно должна быть переведена.

Doctrine плагин поддерживает таблицы i18n из коробки. Для каждой таблицы, которая содержит локализованные данные, нужно создать две таблицы: одна для столбцов, которые не зависят от i18n, и другая со столбцами, которые нужно интернационализировать. Эти две таблицы имеют отношения один-ко-многим.

Обновите файл schema.yml следующим образом:

# config/doctrine/schema.yml
JobeetCategory:
  actAs:
    Timestampable: ~
    I18n:
      fields: [name]
      actAs:
        Sluggable: { fields: [name], uniqueBy: [lang, name] }
  columns:
    name: { type: string(255), notnull: true }

Включая поведение I18n, модель под названием JobeetCategoryTranslation будет создана автоматически и указанные поля будут перемещены в эту модель.

Заметьте, мы просто включаем поведение I18n и перемещаем поведение Sluggable в присоединенную модель JobeetCategoryTranslation, которая создается автоматически. Опция uniqueBy говорит поведению Sluggable, какие поля определять как слаг, и уникально оно или нет. В этом случае каждый слаг должен быть уникальным для каждой пары lang и name.

И обновите начальные данные для категорий:

# data/fixtures/categories.yml
JobeetCategory:
  design:
    Translation:
      en:
        name: Design
      fr:
        name: design
  programming:
    Translation:
      en:
        name: Programming
      fr:
        name: Programmation
  manager:
    Translation:
      en:
        name: Manager
      fr:
        name: Manager
  administrator:
    Translation:
      en:
        name: Administrator
      fr:
        name: Administrateur

Нам также нужно перегрузить метод findOneBySlug() в JobeetCategoryTable. Поскольку Doctrine предоставляет некоторые "магические" методы поиска для всех столбцов в модели, нам нужно просто создать метод findOneBySlug(), который переопределеит "магическую" функциональность по умолчанию, которую предоставляет Doctrine.

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

// lib/model/doctrine/JobeetCategoryTable.cass.php
public function findOneBySlug($slug)
{
  $q = $this->createQuery('a')
    ->leftJoin('a.Translation t')
    ->andWhere('t.lang = ?', 'en')
    ->andWhere('t.slug = ?', $slug);
  return $q->fetchOne();
}

Перестройте модель:

$ php symfony doctrine:build --all --and-load --no-confirmation
$ php symfony cc

tip

Поскольку doctrine:build --all --and-load удаляет все таблицы и данные из базы данных, не забудьте пересоздать пользователя для доступа к приложеню backend веб-сайта Jobeet с помощью задачи guard:create-user. В качестве альтернативы, Вы можете добавить файл начальных данных для того, чтобы он добавлялся автоматически.

При использовании поведения I18n, будет создан заместитель между объектами JobeetCategory и JobeetCategoryTranslation, чтобы все старые функции для получения имени категории продолжали работать и возвращали значение для текущей локали.

$category = new JobeetCategory();
$category->setName('foo'); // устанавливает имя для текущей локали
$category->getName(); // возвращает имя для текущей локали
 
$this->getUser()->setCulture('fr'); // из класса контроллера
 
$category->setName('foo'); // устанавливает имя для текущей French-локали
echo $category->getName(); // возвращает имя для текущей French-локали

tip

Для уменьшения количества запросов к базе данных, связывайте JobeetCategoryTranslation в Ваших запросах. Он будет получать главный объект и i18n-объект в одном запросе.

$categories = Doctrine_Query::create()
  ->from('JobeetCategory c')
  ->leftJoin('c.Translation t WITH t.lang = ?', $culture)
  ->execute();

Ключевое слово WITH будет добавлять условие к автоматически добавленному условию ON в запрос. Т.е. условие ON связывания будет выглядеть так:

LEFT JOIN c.Translation t ON c.id = t.id AND t.lang = ?

Поскольку маршрут category связан с классом модели JobeetCategory и поскольку slug теперь часть JobeetCategoryTranslation, маршрут не может получить объект Category автоматически. Чтобы помочь системе маршрутизации, давайте создадим метод, который позаботится о получении объекта:

Поскольку мы уже перегрузили findOneBySlug() давайте отрефакторим его чуть больше, чтобы эти методы могли быть общими. Мы создадим новые методы findOneBySlugAndCulture() и doSelectForSlug() и изменим метод findOneBySlug() так, чтобы он просто использовал метод findOneBySlugAndCulture().

// lib/model/doctrine/JobeetCategoryTable.class.php
public function doSelectForSlug($parameters)
{
  return $this->findOneBySlugAndCulture($parameters['slug'], $parameters['sf_culture']);
}
 
public function findOneBySlugAndCulture($slug, $culture = 'en')
{
  $q = $this->createQuery('a')
    ->leftJoin('a.Translation t')
    ->andWhere('t.lang = ?', $culture)
    ->andWhere('t.slug = ?', $slug);
  return $q->fetchOne();
}
 
public function findOneBySlug($slug)
{
  return $this->findOneBySlugAndCulture($slug, 'en');
}

Затем используем, опцию method для того, чтобы сказать маршруту category, что нужно использовать метод doSelectForSlug() для получения объекта:

# apps/frontend/config/routing.yml
category:
  url:     /:sf_culture/category/:slug.:sf_format
  class:   sfDoctrineRoute
  param:   { module: category, action: show, sf_format: html }
  options: { model: JobeetCategory, type: object, method: doSelectForSlug }
  requirements:
    sf_format: (?:html|atom)

Нужно перезагрузить начальные данные для того, чтобы перегенерировать правильные слаги для категорий:

$ php symfony doctrine:data-load

Теперь маршрут category интернационализирован и URL для категории включает слаг переведенной категории:

/frontend_dev.php/fr/category/programmation
/frontend_dev.php/en/category/programming

Генератор админки

Для backend мы хотим что бы французский и английский переводы можно было редактировать в одной форме:

Backend categories

Вложение i18n-форм может быть выполнено с помощью метода embedI18N():

// lib/form/JobeetCategoryForm.class.php
class JobeetCategoryForm extends BaseJobeetCategoryForm
{
  public function configure()
  {
    unset(
      $this['jobeet_affiliates_list'],
      $this['created_at'], $this['updated_at']
    );
 
    $this->embedI18n(array('en', 'fr'));
    $this->widgetSchema->setLabel('en', 'English');
    $this->widgetSchema->setLabel('fr', 'French');
  }
}

Интерфейс генератора админки поддерживает интернационализацию из коробки. Он поставляется с переводом для более чем 20 языков, и достаточно просто добавить новый, или изменить существующий. Скопируйте файл для языка, который Вы хотите изменить из Symfony (переводы админки можно найти в lib/vendor/symfony/lib/plugins/sfDoctrinePlugin/i18n/) в папку приложения i18n. Поскольку файл в вашем приложении будет объединен с тем, что лежит в каталоге Symfony, оставьте только измененные строки в файле приложения.

Вы заметите, что файлы переводов генератора админки названы sf_admin.fr.xml, вместо fr/messages.xml. На самом деле, messages это имя каталога по умолчанию используемое Symfony, и может быть изменено для улучшенного разделения между различными частями вашего приложения. Используя каталог, отличающийся от каталога по умолчанию, необходимо, чтобы Вы указали это в помощнике __():

<?php echo __('About Jobeet', array(), 'jobeet') ?>

В предыдущем вызове __(), Symfony будет искать строку "About Jobeet" в каталоге jobeet.

Тесты

Исправление тестов это неотъемлемая часть миграции при интернационализации. Сначала обновите тестовые начальные данные для категорий, скопировав начальные данные, которые у нас уже определены в test/fixtures/categories.yml.

Перестройте модель для окружения test:

Don't forget to update methods in the lib/test/JobeetTestFunctional.class.php file in order to care of our modifications concerning the JobeetCategory's internationalization.

public function getMostRecentProgrammingJob()
{
  $q = Doctrine_Query::create()
    ->select('j.*')
    ->from('JobeetJob j')
    ->leftJoin('j.JobeetCategory c')
    ->leftJoin('c.Translation t')
    ->where('t.slug = ?', 'programming');
 
  $q = Doctrine_Core::getTable('JobeetJob')->addActiveJobsQuery($q);
 
  return $q->fetchOne();
}
 
$ php symfony doctrine:build --all -and-load --no-confirmation --env=test

Теперь вы можете запустить все тесты для того, чтобы проверить, что все отлично работает:

$ php symfony test:all

note

Когда мы разрабатывали интерфейс backend для Jobeet, мы не писали фунциональные тесты. Но когда Вы создаете модуль с помощью командной строки, Symfony также генерирует тестовые заглушки. Эти заглушки можно безопасно удалить.

Локализация

Шаблоны

Поддержка различных локалей также означает поддержку различных способов форматирования дат и чисел. В шаблоне в Вашем распоряжении есть несколько помощников которые заботятся обо всех этих отличиях, основываясь на текущей локали пользователя:

В группе помощников Date:

Помощник Описание
format_date() Форматирует дату
format_datetime() Фоматирует дату и время (часы, минуты, секунды)
time_ago_in_words() Отображает интервал времени между заданной датой и текущим временем в словах
distance_of_time_in_words() Отображает интервал времени между двумя датами в словах
format_daterange() Форматирует диапазон дат

В группе помощников Number :

Помощник Описание
format_number() Форматирует число
format_currency() Форматирует валюту

В группе помощников I18N:

Помощник Описание
format_country() Отображает название страны
format_language() Отображает название языка

Формы

Фреймворк форм предоставляет несколько виджетов и валидаторов для локализированных данных:

Увидимся завтра!

Интернационализация и локализация - это первоклассные обитатели Symfony. Предоставление локализированного веб-сайта для ваших пользователей - это очень просто, поскольку фреймворк Symfony предоставляет все базовые инструменты и даже дает Вам задачи командной строки, для того, чтобы делать это быстро.

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