步骤 28: 将应用程序本地化

面对来自世界各地的用户,Symfony 一直以来都是自带国际化(i18n)和本地化(l10n)的功能。将一个应用程序本地化并不仅仅是翻译界面,它也包括了处理复数、日期和货币格式、URL 以及更多方面。

将 URL 国际化

将一个网站国际化的第一步是先将 URL 国际化。当翻译网站界面时,不同的区域设置需要对应不同的 URL,这样才能很好地搭配 HTTP 缓存(绝不要使用同样的 URL 并把区域设置放在会话数据里)。

使用特殊的 _locale 路由参数在路由中引用区域设置:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
  4. $this->bus = $bus;
  5. }
  6. - #[Route('/', name: 'homepage')]
  7. + #[Route('/{_locale}/', name: 'homepage')]
  8. public function index(ConferenceRepository $conferenceRepository): Response
  9. {
  10. $response = new Response($this->twig->render('conference/index.html.twig', [

现在的首页里,区域设置会根据 URL 在内部设定好;比如,如果你访问 /fr/ 路径,那 $request->getLocale() 会返回 fr

由于你很可能无法把内容翻译到所有合法区域设置对应的语言,我们来限制下你想要支持的区域设置:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
  4. $this->bus = $bus;
  5. }
  6. - #[Route('/{_locale}/', name: 'homepage')]
  7. + #[Route('/{_locale<en|fr>}/', name: 'homepage')]
  8. public function index(ConferenceRepository $conferenceRepository): Response
  9. {
  10. $response = new Response($this->twig->render('conference/index.html.twig', [

可以用 <> 里的正则表达式来限制每个路由参数。现在只有当 _locale 参数是 enfr 时,homepage 路由才会被匹配到。试一下访问 /es/,你会看到 404 页面,因为没有路由被匹配到。

因为我们会在所有路由中使用这个匹配规则作为必要条件,我们来把它移动到容器参数中:

patch_file

  1. --- a/config/services.yaml
  2. +++ b/config/services.yaml
  3. @@ -7,6 +7,7 @@ parameters:
  4. default_admin_email: [email protected]
  5. default_domain: '127.0.0.1'
  6. default_scheme: 'http'
  7. + app.supported_locales: 'en|fr'
  8. router.request_context.host: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
  9. router.request_context.scheme: '%env(default:default_scheme:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
  10. --- a/src/Controller/ConferenceController.php
  11. +++ b/src/Controller/ConferenceController.php
  12. @@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
  13. $this->bus = $bus;
  14. }
  15. - #[Route('/{_locale<en|fr>}/', name: 'homepage')]
  16. + #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
  17. public function index(ConferenceRepository $conferenceRepository): Response
  18. {
  19. $response = new Response($this->twig->render('conference/index.html.twig', [

通过更新 app.supported_languages 参数,我们就能增加一个语言。

把同样的区域设置路由前缀加到其它 URL 上:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -44,7 +44,7 @@ class ConferenceController extends AbstractController
  4. return $response;
  5. }
  6. - #[Route('/conference_header', name: 'conference_header')]
  7. + #[Route('/{_locale<%app.supported_locales%>}/conference_header', name: 'conference_header')]
  8. public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
  9. {
  10. $response = new Response($this->twig->render('conference/header.html.twig', [
  11. @@ -55,7 +55,7 @@ class ConferenceController extends AbstractController
  12. return $response;
  13. }
  14. - #[Route('/conference/{slug}', name: 'conference')]
  15. + #[Route('/{_locale<%app.supported_locales%>}/conference/{slug}', name: 'conference')]
  16. public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir): Response
  17. {
  18. $comment = new Comment();

我们快完成了。我们不再有路由匹配到 / 路径了。我们来把它加回来,让它重定向到 /en/

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -33,6 +33,12 @@ class ConferenceController extends AbstractController
  4. $this->bus = $bus;
  5. }
  6. + #[Route('/')]
  7. + public function indexNoLocale(): Response
  8. + {
  9. + return $this->redirectToRoute('homepage', ['_locale' => 'en']);
  10. + }
  11. +
  12. #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
  13. public function index(ConferenceRepository $conferenceRepository): Response
  14. {

请注意,由于所有的路由都能处理区域配置了,页面上生成的 URL 会自动包含这个信息。

添加区域设置切换器

我们在页头里增加一个切换器,让用户可以从默认的 en 切换到其它区域设置:

patch_file

  1. --- a/templates/base.html.twig
  2. +++ b/templates/base.html.twig
  3. @@ -34,6 +34,16 @@
  4. Admin
  5. </a>
  6. </li>
  7. +<li class="nav-item dropdown">
  8. + <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
  9. + data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
  10. + English
  11. + </a>
  12. + <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
  13. + <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>
  14. + <a class="dropdown-item" href="{{ path('homepage', {_locale: 'fr'}) }}">Français</a>
  15. + </div>
  16. +</li>
  17. </ul>
  18. </div>
  19. </div>

为了切换区域设置,我们显示地传递 _locale 路由参数给 path() 函数。

更新模板来展示当前的区域设置名称,用它取代硬编码的 “English”:

patch_file

  1. --- a/templates/base.html.twig
  2. +++ b/templates/base.html.twig
  3. @@ -37,7 +37,7 @@
  4. <li class="nav-item dropdown">
  5. <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
  6. data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
  7. - English
  8. + {{ app.request.locale|locale_name(app.request.locale) }}
  9. </a>
  10. <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
  11. <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>

app 是一个全局的 Twig 变量,通过它可以访问当前请求对象。我们使用 locale_name 这个 Twig 过滤器来把区域设置转换为有良好可读性的字符串。

区域设置名并不总是用大写,这要根据具体的区域设置而定。为了正确地将其转化为大写,我们需要一个能处理 Unicode 的过滤器。Symfony 的 String 组件和它的 Twig 实现就提供这样一个过滤器:

  1. $ symfony composer req twig/string-extra

patch_file

  1. --- a/templates/base.html.twig
  2. +++ b/templates/base.html.twig
  3. @@ -37,7 +37,7 @@
  4. <li class="nav-item dropdown">
  5. <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
  6. data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
  7. - {{ app.request.locale|locale_name(app.request.locale) }}
  8. + {{ app.request.locale|locale_name(app.request.locale)|u.title }}
  9. </a>
  10. <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
  11. <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>

现在你能通过切换器从法语切换到英语,界面会漂亮地跟着适配:

步骤 28: 将应用程序本地化 - 图1

对界面进行翻译

为了开始翻译网站,我们需要安装 Symfony 的 Translation 组件:

  1. $ symfony composer req translation

翻译大型网站上的每一句话是很枯燥的,但幸运的是我们的网站上只有几句话。我们从首页上的句子开始翻译:

patch_file

  1. --- a/templates/base.html.twig
  2. +++ b/templates/base.html.twig
  3. @@ -20,7 +20,7 @@
  4. <nav class="navbar navbar-expand-xl navbar-light bg-light">
  5. <div class="container mt-4 mb-3">
  6. <a class="navbar-brand mr-4 pr-2" href="{{ path('homepage') }}">
  7. - &#128217; Conference Guestbook
  8. + &#128217; {{ 'Conference Guestbook'|trans }}
  9. </a>
  10. <button class="navbar-toggler border-0" type="button" data-toggle="collapse" data-target="#header-menu" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Show/Hide navigation">
  11. --- a/templates/conference/index.html.twig
  12. +++ b/templates/conference/index.html.twig
  13. @@ -4,7 +4,7 @@
  14. {% block body %}
  15. <h2 class="mb-5">
  16. - Give your feedback!
  17. + {{ 'Give your feedback!'|trans }}
  18. </h2>
  19. {% for row in conferences|batch(4) %}
  20. @@ -21,7 +21,7 @@
  21. <a href="{{ path('conference', { slug: conference.slug }) }}"
  22. class="btn btn-sm btn-blue stretched-link">
  23. - View
  24. + {{ 'View'|trans }}
  25. </a>
  26. </div>
  27. </div>

Twig 的 trans 过滤器会为给定输入寻找当前区域设置对应的翻译。如果没有找到,它就会回退到 config/packages/translation.yaml 中配置的 默认区域设置

  1. framework:
  2. default_locale: en
  3. translator:
  4. default_path: '%kernel.project_dir%/translations'
  5. fallbacks:
  6. - en

注意,web 调试工具栏的翻译“标签”变成了红色:

步骤 28: 将应用程序本地化 - 图2

它告诉我们有 3 个消息还没有翻译:

点击这个“标签”,它会列出所有那些 Symfony 没有找到翻译的消息:

步骤 28: 将应用程序本地化 - 图3

提供翻译文案

正如你可能在 config/packages/translation.yaml 中看到的那样,翻译文案存放在 translations/ 根目录下,这个目录已为我们自动创建好了。

使用 translation:update 命令,这样我们就不用手工创建翻译文件了:

  1. $ symfony console translation:update fr --force --domain=messages

这个命令(使用 --force 选项)为 fr 区域配置和 messages 域生成了一个翻译文件。messages 域包含了应用本身的消息,不包含那些来自 Symfony 自身的消息,比如验证和安全方面的错误消息。

编辑 translations/messages+intl-icu.fr.xlf 文件,把里面的消息翻译成法语。你不说法语?我来帮你:

patch_file

  1. --- a/translations/messages+intl-icu.fr.xlf
  2. +++ b/translations/messages+intl-icu.fr.xlf
  3. @@ -7,15 +7,15 @@
  4. <body>
  5. <trans-unit id="LNAVleg" resname="Give your feedback!">
  6. <source>Give your feedback!</source>
  7. - <target>__Give your feedback!</target>
  8. + <target>Donnez votre avis !</target>
  9. </trans-unit>
  10. <trans-unit id="3Mg5pAF" resname="View">
  11. <source>View</source>
  12. - <target>__View</target>
  13. + <target>Sélectionner</target>
  14. </trans-unit>
  15. <trans-unit id="eOy4.6V" resname="Conference Guestbook">
  16. <source>Conference Guestbook</source>
  17. - <target>__Conference Guestbook</target>
  18. + <target>Livre d'Or pour Conferences</target>
  19. </trans-unit>
  20. </body>
  21. </file>

注意,我们不会翻译所有的模板,但你自己当然可以去这样做:

步骤 28: 将应用程序本地化 - 图4

翻译表单

Symfony 自动展示翻译系统处理过的表单 label 文本。打开会议页面,点击 web 调试工具栏上的 “Translation” 标签;你应该会看到要翻译的所有 label:

步骤 28: 将应用程序本地化 - 图5

对日期进行本地化

如果你切换到法语并且打开带有评论的会议页面,你会注意到评论的日期已经被本地化了。之所以这样是因为我们使用了 Twig 的 format_datetime 过滤器,它知道如何处理区域设置({{ comment.createdAt|format_datetime('medium', 'short') }})。

本地化可用于日期、时间(format_time)、货币(format_currency)和各种数字(format_number,它可以处理百分数、时间长度和书写成单词形式的数字)。

翻译复数

根据条件来选择翻译,这是个更具一般性的问题,它的应用之一就是管理复数的翻译。

在会议页上,我们展示评论的数量:There are 2 comments。对于只有 1 条评论的情况,我们展示 There are 1 comments,但从语法上这是错误的。修改模板,将这个句子转换成一个可翻译的消息:

patch_file

  1. --- a/templates/conference/show.html.twig
  2. +++ b/templates/conference/show.html.twig
  3. @@ -44,7 +44,7 @@
  4. </div>
  5. </div>
  6. {% endfor %}
  7. - <div>There are {{ comments|length }} comments.</div>
  8. + <div>{{ 'nb_of_comments'|trans({count: comments|length}) }}</div>
  9. {% if previous >= 0 %}
  10. <a href="{{ path('conference', { slug: conference.slug, offset: previous }) }}">Previous</a>
  11. {% endif %}

我们对这个消息用了另一个翻译策略。我们在模板中用一个唯一标识符代替了英文版本消息。这个策略更适用于翻译复杂和大量的文本。

更新翻译文件,在其中加入这个新消息:

patch_file

  1. --- a/translations/messages+intl-icu.fr.xlf
  2. +++ b/translations/messages+intl-icu.fr.xlf
  3. @@ -17,6 +17,10 @@
  4. <source>Conference Guestbook</source>
  5. <target>Livre d'Or pour Conferences</target>
  6. </trans-unit>
  7. + <trans-unit id="Dg2dPd6" resname="nb_of_comments">
  8. + <source>nb_of_comments</source>
  9. + <target>{count, plural, =0 {Aucun commentaire.} =1 {1 commentaire.} other {# commentaires.}}</target>
  10. + </trans-unit>
  11. </body>
  12. </file>
  13. </xliff>

我们还没有完成,现在我们需要提供英文翻译。创建 translations/messages+intl-icu.en.xlf 文件。

translations/messages+intl-icu.en.xlf

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
  3. <file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
  4. <header>
  5. <tool tool-id="symfony" tool-name="Symfony"/>
  6. </header>
  7. <body>
  8. <trans-unit id="maMQz7W" resname="nb_of_comments">
  9. <source>nb_of_comments</source>
  10. <target>{count, plural, =0 {There are no comments.} one {There is one comment.} other {There are # comments.}}</target>
  11. </trans-unit>
  12. </body>
  13. </file>
  14. </xliff>

更新功能测试

不要忘记更新功能测试,让它使用更新后的 URL 和网站内容:

patch_file

  1. --- a/tests/Controller/ConferenceControllerTest.php
  2. +++ b/tests/Controller/ConferenceControllerTest.php
  3. @@ -11,7 +11,7 @@ class ConferenceControllerTest extends WebTestCase
  4. public function testIndex()
  5. {
  6. $client = static::createClient();
  7. - $client->request('GET', '/');
  8. + $client->request('GET', '/en/');
  9. $this->assertResponseIsSuccessful();
  10. $this->assertSelectorTextContains('h2', 'Give your feedback');
  11. @@ -20,7 +20,7 @@ class ConferenceControllerTest extends WebTestCase
  12. public function testCommentSubmission()
  13. {
  14. $client = static::createClient();
  15. - $client->request('GET', '/conference/amsterdam-2019');
  16. + $client->request('GET', '/en/conference/amsterdam-2019');
  17. $client->submitForm('Submit', [
  18. 'comment_form[author]' => 'Fabien',
  19. 'comment_form[text]' => 'Some feedback from an automated functional test',
  20. @@ -41,7 +41,7 @@ class ConferenceControllerTest extends WebTestCase
  21. public function testConferencePage()
  22. {
  23. $client = static::createClient();
  24. - $crawler = $client->request('GET', '/');
  25. + $crawler = $client->request('GET', '/en/');
  26. $this->assertCount(2, $crawler->filter('h4'));
  27. @@ -50,6 +50,6 @@ class ConferenceControllerTest extends WebTestCase
  28. $this->assertPageTitleContains('Amsterdam');
  29. $this->assertResponseIsSuccessful();
  30. $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
  31. - $this->assertSelectorExists('div:contains("There are 1 comments")');
  32. + $this->assertSelectorExists('div:contains("There is one comment")');
  33. }
  34. }

深入学习


This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.