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

19日目: 国際化とローカライゼーション

Symfony version
Language
ORM

昨日は、AJAXのよいところを追加することで検索エンジンを終わらせました。

今日は、Jobeetの 国際化(Internationalization - i18n)と ローカライゼーション(Localization - l10n)を話します。

Wikipediaより:

国際化とはエンジニアリングの変更なしにさまざまな言語と地域に適応できるようにするソフトウェアアプリケーションの設計プロセスです。

ローカライゼーション とはロケール固有のコンポーネントを追加しテキストを翻訳することで特定の地域もしくは言語用にソフトウェアを適応させるプロセスです。

いつものように、symfonyフレームワークは車輪を再発明しないので国際化(i18n)とローカライゼーションはICU標準に基づいてサポートされます。

ユーザー

ユーザーのいない国際化はありえません。 世界の異なる地域もしくは異なる言語でWebサイトが利用できるとき、ユーザーは自身にもっともフィットする言語を選ぶことができます。

note

すでにsymfonyのUserクラスは13日目に話しました。

ユーザーculture

symfonyの国際化とローカライゼーションの機能はユーザーcultureに基づいています。 cultureはユーザーの言語と国の組み合わせです。 たとえば、フランス語を話すユーザーcultureはfrでフランス出身のユーザーはfr_FRです。

UserオブジェクトのsetCulture()getCulture()メソッドを呼び出すことでユーザーcultureを管理できます:

// アクションにおいて
$this->getUser()->setCulture('fr_BE');
echo $this->getUser()->getCulture();

tip

ISO 639-1標準に従って、言語コードは小文字の2文字で構成されます。

オプションとしてのculture

デフォルトでは、ユーザーcultureはsettings.yml設定ファイルで設定できます:

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

tip

cultureはUserオブジェクトによって管理されるので、これはユーザーセッションに保存されます。 開発期間に、デフォルトcultureを変更する場合ブラウザーで新しい設定を有効にするためにセッションCookieをクリアしなければなりません

ユーザーがJobeetのWebサイトでセッションを始めるとき、HTTPヘッダーのAccept-Languageによって提供される情報に基づいてベストなcultureを決定することもできます。

リクエストオブジェクトのgetLanguages()メソッドは現在のユーザーに許容される言語の配列を返します。 これはプリファレンスの順序によってソートされます:

// アクションにおいて
$languages = $request->getLanguages();

しかしたいていの場合、あなたのWebサイトでは世界の136の主要な言語は利用できません。 ユーザーが選択した言語とWebサイトでサポートされる言語を比較することでgetPreferredCulture()メソッドはベストな言語を返します:

// アクションにおいて
$language = $request->getPreferredCulture(array('en', 'fr'));

以前の呼び出しで、ユーザーが選択した言語に従って返される言語は英語もしくはフランス語のどちらか何に一致しない場合は英語(配列の最初の言語)です。

URLのculture

JobeetのWebサイトは英語とフランス語の両方で利用できます。 URLは単独のリソースのみを表すので、cultureをURLに埋め込まなければなりません。 これを行うためには、routing.ymlファイルを開き、api_jobshomepage以外のすべてのルートに対して特殊な変数:sf_cultureを追加します。シンプルなルートに関して、/:sf_cultureurlの前に追加します。 コレクションルートに関して、/:sf_cultureで始まるprefix_pathオプションを追加します。

# 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変数が使われるとき、ユーザーcultureを変更するためにsymfonyは自動的にこの値を使います。

多言語(/en//fr/、・・・)で多くのホームページをサポートする必要があるので、デフォルトのホームページ(/)はユーザーcultureに従って適切にローカライズされたページにリダイレクトされなければなりません。 しかし、ユーザーがJobeetに初めて訪問し、ユーザーcultureを持たない場合、そのユーザーのためにcultureが選択されます。

最初に、isFirstRequest()メソッドをmyUserに追加します。 このメソッドはユーザーセッションの一番最初のリクエストに対してのみtrueを返します:

// apps/frontend/lib/myUser.class.php
public function isFirstRequest($boolean = null)
{
  if (is_null($boolean))
  {
    return $this->getAttribute('first_request', true);
  }
  else
  {
    $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)

セッションの最初のリクエストでユーザーを"ベストな"ホームページにリダイレクトするロジックを実装するためにjobモジュールのindexアクションを変更します:

// 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::getTable('JobeetCategory')->getWithJobs();
}

リクエストの中にsf_culture変数が存在しない場合、ユーザーは/のURLに到達することを意味します。 これがあてはまりセッションが新しい場合、選択されたcultureがユーザーcultureとして使われます。 さもなければ現在のユーザーcultureが使われます。

最後のステップはユーザーをlocalized_homepageのURLにリダイレクトすることです。 symfonyはsf_culture変数を自動的に追加するのでsf_culture変数はリダイレクト呼び出しに渡されていないことに注意してください。

これで、/it/のURLに移動しようとすると、sf_culture変数をenもしくはfrに制限したので、symfonyは404エラーを返します。 このルート要件をcultureを埋め込むすべてのルートに追加します:

requirements:
  sf_culture: (?:fr|en)

cultureのテスト

実装をテストしましょう。 しかしさらにテストを追加する前に、既存のものを修正する必要があります。 すべてのURLは変更されるので、test/functional/frontend/の機能テストすべてを編集しすべてのURLの前に/enを追加します。 lib/test/JobeetTestFunctional.class.phpファイルのURLも変更することをお忘れなく。 テストを正しく修正したことを確認するためにテストスイートを立ち上げます:

$ php symfony test:functional frontend

ユーザーテスターは現在のユーザーのcultureをテストする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')
;

言語の切り替え

cultureを変更するユーザーのために、言語フォームはレイアウトに追加しなければなりません。 フォームフレームワークはそのまま使えるフォームを提供しませんが多言語サイトの共通のニーズなので、symfonyコアチームはsfFormExtraPluginをメンテナンスしています。 このプラグインはバリデーター、ウィジェットとフォームを含みます。 これらはsymfonyのメインパッケージに含めることができません。 これらは限定的すぎるもしくは外部依存があるにせよとても役に立つものだからです。

plugin:installタスクでこのプラグインをインストールします:

$ php symfony plugin:install sfFormExtraPlugin

プラグインは新しいクラスを定義するのでキャッシュをクリアします:

$ php symfony cc

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>

問題を見つけられましたか? そうです、フォームオブジェクト作成はViewレイヤーに所属していません。 これはアクションから作成しなければなりません。 しかしこのコードはレイアウトにあるので、すべてのアクションに対してフォームが作成されます。 これは実用とはほど遠いものです。 このような場合、コンポーネント(component)を使います。 コンポーネントはパーシャルに似ていますがコードが添付されます。 軽量のアクションとみなしてください。

テンプレートからコンポーネントのインクルードするにはinclude_component()ヘルパーを使うことで可能です:

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

ヘルパーは引数としてモジュールとアクションを受け取ります。 3番目の引数はコンポーネントにパラメーターを渡すために使われます。

実際にユーザーの言語を変更するコンポーネントとアクションをホストする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>

プラグインはユーザーcultureを変更するアクションを提供しないので、change_languageルートを作成するためにrouting.ymlファイルを編集します:

# 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');
  }
}

sfFormLanguageクラスのprocess()メソッドは、ユーザーのフォーム投稿に基づいて、ユーザーcultureの変更を考慮します。

国際化されたフッター

国際化

言語、文字集合、エンコーディング

異なる言語は異なる文字集合を持ちます。 英語はASCII文字のみを使うのでもっともシンプルですが、フランス語には"é"のようなアクセントつきの文字があるので少し複雑です。 ロシア語、中国語、アラビア語は文字がASCIIの外側にあるのではるかに複雑です。 このような言語は完全に異なる文字集合で定義されます。

国際化されたデータを扱うとき、Unicode標準を使うほうがよいです。 Unicodeの背景にあるアイディアはすべての言語のためのすべての文字を含む文字の全体集合です。 Unicodeの問題は単独の文字が21ビットで表されることです。 それゆえ、Webに関して、私たちはUTF-8を使います。 これはUnicodeコードポイントをオクテットの可変長のシーケンスにマッピングします。 UTF-8において、もっともよく使われる言語の文字は3ビット未満でコード化されます。

UTF-8はsymfonyで使われるデフォルトのエンコーディングで、settings.yml設定ファイルで定義されます:

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

また、symfonyの国際化レイヤーを有効にするには、settings.ymli18nonにセットしなければなりません:

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

テンプレート

国際化されたWebサイトとはユーザーインターフェイスが複数の言語に翻訳されることを意味します。

テンプレートにおいて、言語に依存する文字列は__()ヘルパー(アンダースコアが2つあることに注意)でラップしなければなりません。

__()ヘルパーはI18Nヘルパーグループの一部です。 これはテンプレートの国際化管理を楽にしているヘルパーを含みます。 デフォルトではこのヘルパーグループはロードされないので、すでにTextヘルパーグループに対して行ったようにuse_helper('I18N')で手作業でそれぞれのテンプレートに追加するかstandard_helpers設定を追加することでグローバルにロードする必要があります:

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

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?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は現在のユーザーのculture用の翻訳を探します。 翻訳が見つかればそれが使われ、そうでなければ、最初の引数とフォールバックの値として返されます。

すべての翻訳はカタログに保存されます。 国際化フレームワークは翻訳を保存するために多くの異なる戦略を提供します。 私たちは"XLIFF"フォーマットを使います。 これは標準で最も柔軟なものです。 これはadminジェネレーターとsymfonyのたいていのプラグインにも使われます。

note

保存する他のカタログはgettextMySQLSQLiteです。 いつものことですが、詳細はi18n APIをご覧ください。

i18n:extract

カタログファイルを手作業で作る代わりに、組み込みのi18n:extractタスクを使います:

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

i18n:extractタスクはfrontendアプリケーションでfrに翻訳される必要のあるすべての文字列を見つけ対応するカタログを作成もしくは更新します。 --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>

それぞれの翻訳は一意のid属性を持つtrans-unitタグで管理されます。 このファイルを編集してフランス語の翻訳を追加できます:

<!-- 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はXLIFFエディターが統合されたオープンソースのJavaプロジェクトです。

tip

XLIFFはファイルをベースとしたフォーマットなので、symfonyの設定ファイル用に存在する同じ手続きとマージルールも適用できます。 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%プレースホルダーは__()ヘルパーに2番目の引数として渡される値のおかげで、実行時に本当の数値に置き換えられます。

trans-unitタグをmessages.xmlファイルに挿入することで新しい文字列を手作業で追加するかファイルを自動的に更新するためにi18n:extractタスクを使います:

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

タスクを実行した後で、フランス語翻訳を追加するためにXLIFFファイルを開きます:

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

翻訳された文字列の唯一の要件はどこかで%count%プレースホルダーを使うことです。

数、センテンスへの変化に応じた複数形を持つためにより複雑な文字列がありますが、 すべての言語で必ずしも同じ方法ではありません。 ポーランド語やロシア語のように、複数形に関して非常に複雑な文法ルールを持つ言語があります。

カテゴリページにおいて、現在のカテゴリの求人件数が表示されます:

<!-- apps/frontend/modules/category/templates/showSuccess.php --> 
<strong><?php echo $pager->getNbResults() ?></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>'.$pager->getNbResults().'</strong>'),
    $pager->getNbResults()
  )
?>

format_number_choice()ヘルパーは3つの引数を受け取ります:

  • 件数に応じて使う文字列
  • プレースホルダーの置き換えの配列
  • 使用するテキストを決定するために使う数

件数に応じて翻訳を記述する文字列は次のようにフォーマットされます:

  • それぞれのpossibilityはパイプ文字(|)で隔てられる
  • それぞれの文字列は翻訳の後に続く範囲で構成される

範囲は範囲の数を記述できます:

  • [1,2]: 境界値を含み、1と2の間の値を受け取る
  • (1,2): 境界値は含まず、1と2の間の値を受け取る
  • {1,2,3,4}: 集合で定義される値のみが受け取られる
  • [-Inf,0): 負の無限大より大きいもしくは等しく、 0よりも小さい値を受け取る
  • {n: n % 10 > 1 && n % 10 < 5}: 2, 3, 4, 22, 23, 24のような数にマッチする

文字列の翻訳は他のメッセージ文字列と似ています:

<trans-unit id="6">
  <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>

すべての種類の文字列を国際化する方法を理解したので、フロントエンドアプリケーションのすべてのテンプレートに対して__()の呼び出しを追加してみましょう。 バックエンドアプリケーションは国際化しません。

フォーム

ラベル、エラーメッセージとヘルプメッセージのようにフォームクラスは翻訳される必要のある文字列をたくさん含んでいます。 これらすべての文字列はsymfonyによって自動的に国際化されるので、XLIFFファイルで翻訳を提供することだけが必要です。

note

不幸なことに、i18n:extractタスクは未翻訳の文字列のためにフォームクラスを解析しません。

Doctrineオブジェクト

JobeetのWebサイトのために、すべてのテーブルを国際化しません。 求人投稿をすべて利用可能な言語に翻訳するかを求人の投稿者にたずねるのは意味がないからです。 しかしあきらかにカテゴリテーブルは翻訳する必要があります。

Doctrineプラグインはi18nテーブルをネイティブにサポートします。 ローカライズされたデータを格納するそれぞれのテーブルに対して、2つのテーブルを作成する必要があります: 1つは国際化から独立しているカラムを持ち、もう一方は国際化するために必要なカラムを持ちます。 2つのテーブルは一対多のリレーションでリンクされます。

schema.yml|schema.yml(I18n)を更新します:

# 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という名前のモデルが自動的に作成され指定されたfieldsがそのモデルに移動させられます。

I18nビヘイビアを有効にして自動的に作成されるJobeetCategoryTranslationモデルに添付されるSluggableビヘイビアを移動させていることに注目してください。 uniqueByオプションはスラッグが一意的かそうではないかをフィールドが決定することをSluggableビヘイビアに伝えます。 この場合それぞれのスラッグはlangnameの組に対して一意的でなければなりません。

カテゴリ用のフィクスチャを更新します:

# 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

JobeetCategoryTablefindOneBySlug()メソッドをオーバーライドすることも必要です。 Doctrineはモデルのすべてのカラム用のマジックファインダーを提供するので、Doctrineが提供するデフォルトのマジック機能をオーバーライドするためにfindOneBySlug()メソッドを作ることが必要です。

カテゴリが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-reload --no-confirmation
$ php symfony cc

tip

doctrine:build-all-reloadはデータベースからすべてのテーブルとデータを削除するので、 guard:create-userタスクでJobeetバックエンドにアクセスするユーザーを 再作成することを忘れないでください。 代わりに、自動的に追加するためにフィクスチャファイルを追加できます。

I18nビヘイビアを利用するとき、JobeetCategoryオブジェクトとJobeetCategoryTranslationオブジェクトの間にプロキシが作成されるのでカテゴリの名前を検索するためのすべての古い関数はまだ動作し、現在のculture用の値を読み取ります。

$category = new JobeetCategory();
$category->setName('foo'); // 現在のculture用の名前を設定する
$category->getName(); // 現在のculture用の名前を取得する
 
$this->getUser()->setCulture('fr'); // アクションクラスから
 
$category->setName('foo'); // フランス語の名前を設定する
echo $category->getName(); // フランス語の名前を取得する

tip

データベースリクエストの回数を減らすために、クエリにJobeetCategoryTranslationをJOINします。 これは1つのリクエストでメインオブジェクトとi18nオブジェクトを読み取ります。

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

上記のWITHキーワードはクエリのON条件を自動的に追加する条件を追加します。 ですので、joinのON条件は最終的に次のようになります。

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

categoryルートはJobeetCategoryモデルクラスに結びつけられます。 slugJobeetCategoryTranslationの一部で、 ルートはCategoryオブジェクトを自動的に読み取ることができないからです。 ルーティングシステムを手助けするために、オブジェクトの読み取りを考慮するメソッドを作りましょう:

すでにfindOneBySlug()をオーバーライドしたのでこれらのメソッドが共有されるようにもう少しリファクタリングしましょう。 findOneBySlugAndCulture()メソッドを単に使うために新しいfindOneBySlugAndCulture()doSelectForSlug()メソッドを作り、findOneBySlug()メソッドを変更します。

// 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');
}
 
// ...

}

categoryルートにオブジェクトを読み取るdoSelectForSlug()メソッドを使うよう指示するためにmethodオプション|methodオプション(ルーティング)を使います:

# 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

adminジェネレーター

symfony 1.2.1のバグのため、editセクションのtitleをコメントアウトする必要があります:

# apps/backend/modules/category/config/generator.yml
edit:
  #title: Editing Category "%%name%%" (#%%id%%)

バックエンドに関して、英語とフランス語を同じフォームで編集できるようにすることを考えます:

バックエンドカテゴリ

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');
  }
}

adminジェネレーターインターフェイスは国際化をネイティブでサポートします。 これには20以上の言語の翻訳が付属しており、新しい翻訳を追加もしくはカスタマイズするのはとても簡単です。 i18nディレクトリ(アプリケーションのlib/vendor/symfony/lib/plugins/sfDoctrinePlugin/i18n/で見つかるadminの翻訳) からカスタマイズしたい言語のファイルをコピーします。 アプリケーションのファイルはsymfonyのものにマージされるので、修正した文字列をアプリケーションファイルの中だけに留めておきます。

adminジェネレーターの翻訳ファイルは、fr/messages.xmlの代わりに、sf_admin.fr.xmlのように名づけられていることに注目してください。 実際のところ、messagesはsymfonyによって使われるデフォルトのカタログの名前で、アプリケーションの異なる部分の間のよりよい分離を可能にするために変更できます。 __()ヘルパーを使うときにデフォルト以外のカタログを使う場合は指定する必要があります:

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

上記の__()の呼び出しにおいて、symfonyはjobeetカタログの"About Jobeet"の文字列を探します。

テスト

テストの修正は国際化への移行のための不可欠な部分です。 test/fixtures/categories.ymlで定義したフィクスチャファイルをコピーして 最初に、カテゴリ用のテストフィクスチャを更新します。 test環境用のモデルをリビルドします:

$ php symfony doctrine:build-all-reload --no-confirmation --env=test

正しく動作しているかチェックするためにすべてのテストを立ち上げることができます:

$ php symfony test:all

note

Jobeet用のバックエンドインターフェイスを開発したとき、機能テストを書きませんでした。 しかしsymfonyコマンドラインでモジュールを作成するとき、symfonyはテストスタブも生成します。 これらのスタブを削除しても安全です。

ローカライゼーション

テンプレート

異なるcultureをサポートすることは日付と数値をフォーマットする異なる方法もサポートすることを意味します。 テンプレートにおいて、現在のユーザーcultureに基づいて、これらすべての違いを考慮することを手助けしてくれるいくつかのヘルパーを自由に使えます:

Dateヘルパーグループ:

ヘルパー 説明
format_date() 日付をフォーマットする
format_datetime() 日付をフォーマットする
time_ago_in_words() 単語で現在と指定した日付の間の経過時間を表示する
distance_of_time_in_words() 単語で2つの日付の間の経過時間を表示する
format_daterange() 日付の範囲をフォーマットする

Numberヘルパーグループ:

ヘルパー 説明
format_number() 数値をフォーマットする
format_currency() 通貨をフォーマットする

I18Nヘルパーグループ において:

ヘルパー 説明
format_country() 国の名前を表示する
format_language() 言語の名前を表示する

フォーム(I18n)

フォームフレームワークはローカライズされたデータ用のウィジェットとバリデーターをいくつか提供します:

また明日

symfonyでは国際化とローカライゼーションは第一級の扱いを受けます。 symfonyはすべての基本的なツールを提供しコマンドラインタスクで素早く実行できるので、ユーザーにローカライズされたWebサイトを提供するのはとても簡単です。

明日はsymfonyプロジェクトを編成するためにたくさんのファイルを移動させ、異なるアプローチを探求するので、特別なチュートリアルに備えてください。

This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.