🎻

Symfony4で_locale入りのURLにいい感じで対応したくて色々やったのでメモ

2018/02/11に公開約6,500字

やりたかったこと

  • 日本語と英語に対応したサイトで
  • URLの_localeパラメーターで言語を切り替えられるようにしつつ
  • _localeの指定を省略したURLでもアクセスできる(デフォルトロケールになる)ようにし
  • なおかつデフォルトロケールを明示的に指定したURLにアクセスされた場合は_localeを省略したURLにリダイレクトする

具体的に言うと、

URL 期待する動作
/ 日本語(デフォルトロケール)でトップページが表示される
/path/to/page 日本語(デフォルトロケール)でページが表示される
/ja/ /にリダイレクトされる
/ja/path/to/page / にリダイレクトされる OR /path/to/pageにリダイレクトされる
/en/ 英語でトップページが表示される
/en/path/to/page 英語でページが表示される

みたいな感じ。

デフォルトロケールの場合はURLにjaを入れたくない、という動機。

結論

  • 不可能
  • トップページしか存在しないサイトではこれで上手くいってるように見えるけど、下層ページがあると詰む
  • やりたいことをやるには_locale部分に空文字を許可する必要があるけど、それをやると後に続くパスの1つ目が_localeと誤認されてしまうため
  • /enをURLの末尾につけるという仕様なら可能な気がする(試してない)
  • ローカルでの動作確認とかがちょっとだけ面倒になるけど、ホスト名をhoge.comen.hoge.comとかで分けるのが簡単できれいな気がする

顛末

ロケール対応する前のコード

# config/routes/annotations.yaml

controllers:
  resource: ../../src/Controller/
  type: annotation
// src/Controller/HomeController.php

namespace App\Controller;

/**
 * @Route("/", name="home_")
 * @Template()
 */
class HomeController extends Controller
{
    /**
     * @Route("/", name="index")
     */
    public function index()
    {
        return [];
    }
}

やったこと

一手目

  # config/routes/annotations.yaml
  
+ default_locale_redirection:
+   path: '/%locale%/{any}'
+   controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction
+   requirements:
+     any: .*
+   defaults:
+     path: /
+     permanent: true
  controllers:
    resource: ../../src/Controller/
    type: annotation
+   prefix: /{_locale}/
+   requirements:
+     _locale: ja|en|.*
  • 下半分でまずロケールを指定しないURLを許可
  • ja|en|.*ではなく単に.*としてしまうと/en/にアクセスしてもロケールがセットされない
  • 上半分でデフォルトローケルを指定されたURLへのアクセスをロケール指定なしのトップページへリダイレクト
  • /ja/以下に続くパスを取得して後方参照よろしくリダイレクト先のURLにくっつけることができたら最高だったけどそんな手段はなさそうだった

参考:https://symfony.com/doc/current/routing/redirect_in_config.html

→結局先述した理由でNGだった

二手目

  # config/routes/annotations.yaml

  default_locale_redirection:
    path: '/%locale%/{any}'
    controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction
    requirements:
      any: .*
    defaults:
      path: /
      permanent: true
- controllers:
+ controllers_with_locale:
    resource: ../src/Controller/
    type: annotation
    prefix: /{_locale}/
-   requirements:
-     _locale: ja|en|.*
+   condition: "request.geRequestUri() matches '#^/(ja|en)#'"
+ controllers_without_locale:
+   resource: ../src/Controller/
+   type: annotation
+   condition: "not (request.geRequestUri() matches '#^/(ja|en)#')"

condition を使うには composer require expression-language が必要

とか迷走気味にやってみて、要は

  • リクエストURIが/jaまたは/enで始まるリクエストはcontrollers_with_locale設定に
  • リクエストURIがそれ以外で始まるリクエストはcontrollers_without_locale設定に

それぞれ処理させるという人知を超えた荒技が使えないかと思ったのだけど、ルーティング設定ってそういうものじゃないし普通に無理だった。

requirementsconditionは「その条件に当てはまらなかったらマッチしない」というだけで、マッチしなかったら次のルーティング設定がマッチするか試行されるみたいなことはない。常に後に書かれているルーティング設定が勝つ。(体験談。要出典)

マッチしなかった場合の扱いは

# config/packages/routing.yaml

framework:
  router:
  strict_requirements: ~

これ次第。(あんまり詳しく調べてない)

参考:
https://symfony.com/doc/current/routing/conditions.html
https://symfony.com/doc/current/components/expression_language/syntax.html

三手目

ググってたら /{param1}{slash}{param2} 的なルート定義を駆使してなんかハックしてる人を見つけた(URLメモるの忘れてて逸失)ので、参考にして以下のようにしてみた。

  # config/routes/annotations.yaml

  default_locale_redirection:
    path: '/%locale%{any}'
    controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction
    requirements:
      any: .*
    defaults:
      path: /
      permanent: true
  controllers:
    resource: ../../src/Controller/
    type: annotation
+   prefix: /{_locale}{slash_or_none}
+   defaults:
+     _locale: '%locale%'
+     slash_or_none: ~
+   requirements:
+     _locale: ja|en|
+     slash_or_none: /?

これによってロケールを上手く取得することまではできた。
けど、「デフォルトロケール指定のURLだった場合はロケール指定なしのURL のトップページ にリダイレクト」という処理が残っている、アプリの仕様によっては予期せぬリダイレクトループが発生しうることが分かった(というか作ってたアプリで発生して気づいた)ので、そのリダイレクトは諦めてサーバーにやってもらうことにした。
たぶん、多くのアプリではこの方法で特に問題なく動くと思う。

四手目

サーバーでリダイレクトするなら、アプリ内のルーティングの知識を漏らしたくないのでロケールはホスト名に入れることにした。
Symfony側のルーティング設定は↑と同じ考え方で割と簡単に行けた。

  # config/routes/annotations.yaml

  controllers:
    resource: ../../src/Controller/
    type: annotation
+   host: '{_locale}{dot_or_none}%domain%'
+   defaults:
+     _locale: ~
+     dot_or_none: .
+   requirements:
+     _locale: ja|en|
+     dot_or_none: \.?
# /etc/nginx/conf.d/server.conf

server {
 listen 80;
 server_name ja.hoge.com;
 return 301 $scheme://hoge.com$request_uri;
}

参考:https://symfony.com/doc/current/routing/hostname_pattern.html

やらなかったこと

コントローラーの@Routeアノテーションで対応

// src/Controller/HomeController.php

namespace App\Controller;

/**
 * @Route("/", name="home_")
 * @Template()
 */
class HomeController extends Controller
{
    /**
     * @Route("{_locale}/", name="index")
     * @Route("/", defaults={"_locale" = "ja"})
     */
    public function index()
    {
        return [];
    }
}

ドキュメントによれば多分これでできる。(試してない)

けど、

  • アプリ横断的な仕様なのにコントローラーの全アクションメソッドに繰り返し書きたくない
  • デフォルトロケールが"ja"じゃなくなったら?(何か方法ありそうな気もする。調べてない)

という点でイマイチ。
せめてコントローラーのClassアノテーションでやれたら、全メソッドに書くよりはまだマシかと思って

// src/Controller/HomeController.php

namespace App\Controller;

/**
 * @Route("/{_locale}/", name="home_")
 * @Route("/", defaults={"_locale" = "ja"})
 * @Template()
 */
class HomeController extends Controller
{
    /**
     * @Route("/", name="index")
     */
    public function index()
    {
        return [];
    }
}

とかやってみたけどClassアノテーションでは複数の@Routeは使えなかった。(上が勝つ)

まとめ

かなり強引なハックっぽいやり方だけどコントローラーに一切手を加えずに対応できたのはまあまあ嬉しい。
(なんかもっとスマートな方法を知ってる方や、この方法の問題点等に気づかれた方がいたらぜひ教えていただきたいです!)

追記:ちなみに

URL 期待する動作
/ja/ 日本語でトップページが表示される
/ja/path/to/page 日本語でページが表示される
/en/ 英語でトップページが表示される
/en/path/to/page 英語でページが表示される
/ /ja/ にリダイレクトされる
/path/to/page 404 Not Found

という(普通の?)要件でよければ、

controllers:
  resource: ../../src/Controller/
  type: annotation
  prefix: /{_locale}/
  requirements:
    _locale: ja|en

default_locale_redirection:
  path: /
  controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction
  defaults:
    path: /ja/
    permanent: true

これでOK。

GitHubで編集を提案

Discussion

ログインするとコメントできます