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

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

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

Wikipedia より:

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

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

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

ユーザー

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

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

~ユーザーカルチャ~

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

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

[php]
// アクションのなか
$this->getUser()->setCulture('fr_BE');
echo $this->getUser()->getCulture();

TIP ISO 639-1 標準にしたがって、~言語コード|ユーザーの言語~は小文字の2文字で構成されます。

オプションとしてのカルチャ

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

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

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

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

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

[php]
// アクションのなか
$languages = $request->getLanguages();

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

[php]
// アクションのなか
$language = $request->getPreferredCulture(array('en', 'fr'));

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

URL におけるカルチャ

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

[yml]
# apps/frontend/config/routing.yml
affiliate:
  class: sfPropelRouteCollection
  options:
    model:          JobeetAffiliate
    actions:        [new, create]
    object_actions: { wait: get }
    prefix_path:    /:sf_culture/affiliate

category:
  url:     /:sf_culture/category/:slug.:sf_format
  class:   sfPropelRoute
  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: sfPropelRouteCollection
  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:   sfPropelRoute

options: model: JobeetJob type: object methodforcriteria: doSelectActive options: model: JobeetJob type: object methodforquery: retrieveActiveJob param: { module: job, action: show } requirements: id: \d+ sf_method: get

ルートで ~sf_culture~ 変数が使われるとき、ユーザーカルチャを変更するために symfony は自動的にこの値を使います。

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

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

[php]
// 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 ルートを追加します:

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

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

[php]
// 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 = JobeetCategoryPeer::getWithJobs(); $this->categories = Doctrine_Core::getTable('JobeetCategory')->getWithJobs(); }

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

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

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

[yml]
requirements:
  sf_culture: (?:fr|en)

~カルチャ~の~テスト~

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

$ php symfony test:functional frontend

ユーザーテスターは現在のユーザーカルチャをテストする isCulture() メソッドを提供します。jobActionsTest ファイルを開き、次のテストを追加します:

[php]
// 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('/')->
  with('response')->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('/')->
  with('response')->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

プラグインのクラスがロードされるようにするには、次のように config/ProjectConfiguration.class.php のなかで sfFormExtraPlugin を有効にしなければなりません。 [php] // config/ProjectConfiguration.class.php public function setup() { $this->enablePlugins(array( 'sfDoctrinePlugin', 'sfDoctrineGuardPlugin', 'sfFormExtraPlugin' )); }

NOTE sfFormExtraPlugin は JavaScript ライブラリのような外部依存を必要とするウィジェットを含みます。リッチな日付セレクター、WYSIWYG エディターなどもあります。便利なものがたくさん見つかりましたらドキュメントを読むのに時間をかけてください。

sfFormExtraPlugin プラグインは言語選択を管理するために sfFormLanguage フォームを提供します。言語フォームは次のようにレイアウトに追加できます:

NOTE 下記のコードは実装されることを意図していません。これは間違ったやりかたで何かを実装したくなる例を示したものです。symfony を使って適切に実装する方法を示すことにとりかかります。

[php]
// 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()~ ヘルパーを使うことで可能です:

[php]
// 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 ファイルで定義されます。

では、ファイルを作りましょう:

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

ご覧のとおり、コンポーネントクラスはアクションクラスとよく似ています。

コンポーネント用のテンプレートはパーシャルと同じ命名規約を使います: コンポーネントの名前の前にアンダースコア (_) をつけます:

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

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

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

そして対応するアクションを作ります:

[php]
// 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() メソッドは、ユーザーのフォーム投稿にもとづいて、ユーザーカルチャの変更を反映させます。

国際化されたフッター

国際化

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

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

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

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

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

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

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

テンプレート

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

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

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

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

Jobeet のフッターに対する __() ヘルパーの使い方です:

[php]
// apps/frontend/templates/layout.php
<div id="footer">
  <div class="content">
    <span class="symfony">
      <img src="/images/jobeet-mini.png" />
      powered by <a href="http://www.symfony-project.org/">
      <img src="/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 は現在のユーザーカルチャ用の翻訳を探します。翻訳が見つかればそれが使われ、そうでなければ、最初の引数とフォールバックの値として返されます。

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

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

i18n:extract

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

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

i18n:extract タスクは frontend アプリケーションで fr に翻訳される必要のあるすべての文字列を見つけ対応するカタログを作成もしくは更新します。--auto-save オプションは新しい文字列をカタログに保存します。もう存在しない文字列を自動的に削除するために --auto-delete オプションを使うこともできます。

私たちの場合、作成したファイルを投入します:

[xml]
<!-- 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 タグで管理されます。このファイルを編集してフランス語の翻訳を追加できます:

[xml]
<!-- 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...」リンク用のホームページに当てはまります:

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

求人の件数は変数で翻訳用のプレースホルダによって置き換えられなければなりません:

[php]
<!-- 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...」であり、__() ヘルパーに第2引数として渡される値のおかげで、%count% プレースホルダは実行時に本当の数値に置き換わります。

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

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

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

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

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

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

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

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

センテンスが件数に応じて異なる翻訳をもつとき、format_number_choice() ヘルパーが使われます:

[php]
<?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() ヘルパー~は3つの引数を受け取ります:

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

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

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

~範囲|範囲 (I18n)~は範囲の数を記述できます:

  • [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のような数にマッチする

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

[xml]
<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>

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

~フォーム|フォーム (I18n)~

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

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

##ORM## オブジェクト

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

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

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

[yml] # config/schema.yml jobeetcategory: _attributes: { isI18N: true, i18nTable: jobeetcategory_i18n } id: ~

jobeet_category_i18n:
  id:           { type: integer, required: true, primaryKey: true,
   ➥ foreignTable: jobeet_category, foreignReference: id }
  culture:      { isCulture: true, type: varchar, size: 7,
   ➥ required: true, primaryKey: true }
  name:         { type: varchar(255), required: true }
  slug:         { type: varchar(255), required: true }

_attributes エントリはテーブル用のオプションを定義します。

そしてカテゴリ用の~フィクスチャ|フィクスチャ (I18n)~を更新します:

[yml]
# data/fixtures/010_categories.yml
JobeetCategory:
  design:        { }
  programming:   { }
  manager:       { }
  administrator: { }

JobeetCategoryI18n:
  design_en:        { id: design, culture: en, name: Design }
  programming_en:   { id: programming, culture: en, name: Programming }
  manager_en:       { id: manager, culture: en, name: Manager }
  administrator_en: { id: administrator, culture: en,
   ➥ name: Administrator }

  design_fr:        { id: design, culture: fr, name: Design }
  programming_fr:   { id: programming, culture: fr,
   ➥ name: Programmation }
  manager_fr:       { id: manager, culture: fr, name: Manager }
  administrator_fr: { id: administrator, culture: fr,
   ➥ name: Administrateur }

i18n スタブクラスを作るためにモデルをリビルドします:

$ php symfony propel:build --all --no-confirmation
$ php symfony cc

nameslug カラムは i18n テーブルに移動させたので、setName() メソッドを JobeetCategory から JobeetCategoryI18n に移動させます:

[php]
// lib/model/JobeetCategoryI18n.php
public function setName($name)
{
  parent::setName($name);

  $this->setSlug(Jobeet::slugify($name));
}

JobeetCategoryPeergetForSlug() メソッドを修正することも必要です:

[php]
// lib/model/JobeetCategoryPeer.php
static public function getForSlug($slug)
{
  $criteria = new Criteria();
  $criteria->addJoin(JobeetCategoryI18nPeer::ID, self::ID);
  $criteria->add(JobeetCategoryI18nPeer::CULTURE, 'en');
  $criteria->add(JobeetCategoryI18nPeer::SLUG, $slug);

  return self::doSelectOne($criteria);
}

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

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

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

[yml]
# 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 テーブルの英語のスラッグにもとづいて検索されるように少しの変更を行う必要があります。

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

モデルをビルドするとき、JobeetCategoryI18n で定義される i18n カラムにアクセスしやすくするために symfony はメインの JobeetCategory オブジェクトでプロキシメソッドを作成します:

[php]
$category = new JobeetCategory();

$category->setName('foo');       // 現在のカルチャの名前を設定する
$category->setName('foo', 'fr'); // フランス語の名前を設定する

echo $category->getName();     // 現在のカルチャの名前を取得する
echo $category->getName('fr'); // フランス語の名前を取得する

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

[php]
$category = new JobeetCategory();
$category->setName('foo'); // 現在のカルチャの名前を設定する
$category->getName(); // 現在のカルチャの名前を取得する

$this->getUser()->setCulture('fr'); // アクションクラスから

$category->setName('foo'); // フランス語の名前を設定する
echo $category->getName(); // フランス語の名前を取得する

TIP ~データベースのリクエスト回数|パフォーマンス~を減らすために、通常の doSelect() メソッドの代わりに、doSelectWithI18n() メソッドを使います。これは1つのリクエストでメインオブジェクトと i18n オブジェクトを検索します。

[php]
$categories = JobeetCategoryPeer::doSelectWithI18n($c, $culture);

TIP ~データベースリクエストの回数|パフォーマンス~を減らすために、クエリに JobeetCategoryTranslation を JOIN します。これは1つのリクエストでメインオブジェクトと i18n オブジェクトを検索します。

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

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

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

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

[php] // lib/model/JobeetCategoryPeer.php class JobeetCategoryPeer extends BaseJobeetCategoryPeer { static public function doSelectForSlug($parameters) { $criteria = new Criteria(); $criteria->addJoin(JobeetCategoryI18nPeer::ID, JobeetCategoryPeer::ID); $criteria->add(JobeetCategoryI18nPeer::CULTURE, $parameters['sf_culture']); $criteria->add(JobeetCategoryI18nPeer::SLUG, $parameters['slug']);

    return self::doSelectOne($criteria);
  }
}

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

[php]
// 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 オプション (ルーティング)~を使います:

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

カテゴリ用に適切なスラッグを再生成するためにフィクスチャをリロードする必要があります:

$ php symfony propel:data-load

これで category ルートは国際化されカテゴリ用の URL は翻訳されたカテゴリスラッグを埋め込みます:

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

アドミンジェネレータ

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

バックエンドカテゴリ

embedI18N() メソッドを使うことで~国際化フォーム|フォーム (翻訳)~を埋め込むことができます:

[php]
// lib/form/JobeetCategoryForm.class.php
class JobeetCategoryForm extends BaseJobeetCategoryForm
{
  public function configure()
  {

unset($this['jobeetcategoryaffiliatelist']); unset( $this['jobeetaffiliateslist'], $this['createdat'], $this['updated_at'] );

    $this->embedI18n(array('en', 'fr'));
    $this->widgetSchema->setLabel('en', 'English');
    $this->widgetSchema->setLabel('fr', 'French');
  }
}

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

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

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

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

テスト

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

JobeetCategory の国際化に関する修正を反映させるために、lib/test/JobeetTestFunctional.class.php ファイルのなかのメソッドを更新することをお忘れなく。

[php]
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();
}

test 環境のモデルをリビルドします:

$ php symfony propel:build --all --and-load --no-confirmation --env=test

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

$ php symfony test:all

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

ローカライゼーション

~テンプレート~

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

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)~

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

また明日

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

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

ORM