SymfonyWorld Online 2020
100% online
30+ talks + workshops
Live + Replay watch talks later

Etap 28: Lokalizacja aplikacji

5.0 version
Maintained

Lokalizacja aplikacji

Dzięki użytkownikom z całego świata, Symfony od początku istnienia jest w stanie poradzić sobie z internacjonalizacją (i18n) i lokalizacją (l10n) od razu po zainstalowaniu frameworka. Lokalizacja aplikacji to nie tylko tłumaczenie interfejsu, ale także uwzględnienie liczby mnogiej tłumaczonych rzeczowników, formatowania dat i walut, adresów URL i wiele więcej.

Internacjonalizacja adresów URL

Pierwszym krokiem do internacjonalizacji strony internetowej jest internacjonalizacja adresów URL. Podczas tłumaczenia interfejsu strony internetowej, adres URL powinien być inny dla każdego miejsca, aby współgrał z pamięcią podręczną HTTP (nigdy nie przechowuj adresu URL razem z kodem języka w sesji).

Użyj specjalnego parametru trasy (ang. route), _locale, aby odnieść się do lokalizacji w trasach:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/", name="homepage")
+     * @Route("/{_locale}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Kod języka (ang. locale) na stronie głównej jest teraz ustawiany wewnętrznie w zależności od adresu URL; na przykład, jeśli trafisz /fr/, $request->getLocale() zwraca fr.

Ponieważ prawdopodobnie nie będziesz w stanie przetłumaczyć treści na wszystkie dostępne języki, ogranicz się do tych, które chcesz obsługiwać:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/{_locale}/", name="homepage")
+     * @Route("/{_locale<en|fr>}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Każdy parametr trasy może być ograniczony wyrażeniem regularnym wewnątrz < oraz >. Trasa homepage pasuje tylko wtedy, gdy parametr _locale jest ustawiony na en lub fr. Podaj URL /es/, a dostaniesz błąd 404, ponieważ żadna trasa nie została dopasowana do wyrażenia regularnego.

Ponieważ na prawie wszystkich trasach będziemy stosować ten sam wymóg, przenieśmy je do parametru kontenera:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -7,6 +7,7 @@ parameters:
     default_admin_email: [email protected]
     default_domain: '127.0.0.1'
     default_scheme: 'http'
+    app.supported_locales: 'en|fr'

     router.request_context.host: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
     router.request_context.scheme: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/{_locale<en|fr>}/", name="homepage")
+     * @Route("/{_locale<%app.supported_locales%>}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Język można dodać poprzez modyfikację parametru app.supported_languages.

Dodaj ten sam prefiks trasy dla danego kraju do innych adresów URL:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -47,7 +47,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference_header", name="conference_header")
+     * @Route("/{_locale<%app.supported_locales%>}/conference_header", name="conference_header")
      */
     public function conferenceHeader(ConferenceRepository $conferenceRepository)
     {
@@ -60,7 +60,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference/{slug}", name="conference")
+     * @Route("/{_locale<%app.supported_locales%>}/conference/{slug}", name="conference")
      */
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir)
     {

Prawie skończyliśmy. Nie mamy już trasy, która pasowałaby do /. Dodajmy ją z powrotem i przekierujmy na /en/:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,6 +33,14 @@ class ConferenceController extends AbstractController
         $this->bus = $bus;
     }

+    /**
+     * @Route("/")
+     */
+    public function indexNoLocale()
+    {
+        return $this->redirectToRoute('homepage', ['_locale' => 'en']);
+    }
+
     /**
      * @Route("/{_locale<%app.supported_locales%>}/", name="homepage")
      */

Teraz, gdy wszystkie główne trasy biorą pod uwagę obecne ustawienia regionalne, należy zauważyć, że wygenerowane adresy URL na stronach automatycznie uwzględniają kod języka.

Dodawanie przełącznika ustawień regionalnych (ang. locale)

Aby umożliwić użytkownikom przejście z domyślnego kodu języka (ang. locale) en na inny, dodajmy przełącznik w nagłówku:

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

Aby przełączyć się na inny kod języka (ang. locale), przekazujemy parametr _locale trasy do funkcji path().

Zaktualizuj szablon, aby wyświetlić aktualną nazwę języka zamiast zakodowanego na sztywno „English”:

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

app jest globalną zmienną biblioteki Twig, która umożliwia dostęp do bieżącego żądania (ang. request). Aby przekonwertować nazwę obecnej lokalizacji do formy czytelnej dla użytkownika, używamy filtra locale_name.

W zależności od języka, nazwa nie zawsze jest zapisana wielkimi literami. Do poprawnej zmiany wielkości liter w zdaniu potrzebny jest filtr, który uwzględnia Unicode. Zapewnia go komponent Symfony String i jego implementacja w Twigu:

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

Teraz można zmienić język z francuskiego na angielski za pomocą przełącznika, a cały interfejs ładnie się dostosowuje:

Tłumaczenie interfejsu

Aby rozpocząć tłumaczenie strony internetowej, musimy zainstalować komponent Symfony Translation:

1
$ symfony composer req translation

Tłumaczenie każdego zdania na dużej stronie internetowej może być żmudne, ale na szczęście u nas jest tylko kilka wiadomości. Zacznijmy od wszystkich zdań na stronie głównej:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -20,7 +20,7 @@
             <nav class="navbar navbar-expand-xl navbar-light bg-light">
                 <div class="container mt-4 mb-3">
                     <a class="navbar-brand mr-4 pr-2" href="{{ path('homepage') }}">
-                        &#128217; Conference Guestbook
+                        &#128217; {{ 'Conference Guestbook'|trans }}
                     </a>

                     <button class="navbar-toggler border-0" type="button" data-toggle="collapse" data-target="#header-menu" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Afficher/Cacher la navigation">
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -4,7 +4,7 @@

 {% block body %}
     <h2 class="mb-5">
-        Give your feedback!
+        {{ 'Give your feedback!'|trans }}
     </h2>

     {% for row in conferences|batch(4) %}
@@ -21,7 +21,7 @@

                             <a href="{{ path('conference', { slug: conference.slug }) }}"
                                class="btn btn-sm btn-blue stretched-link">
-                                View
+                                {{ 'View'|trans }}
                             </a>
                         </div>
                     </div>

Filtr trans biblioteki Twig wyszukuje tłumaczenie podanego tekstu na bieżącą lokalizację. Jeśli nie zostanie znalezione, powróci do wartości z domyślnej lokalizacji skonfigurowanej w config/packages/translation.yaml:

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

Zauważ, że na pasku narzędzi do debugowania zakładka tłumaczeń zmieniła kolor na czerwony:

To jest sygnał, że trzy wiadomości nie zostały jeszcze przetłumaczone.

Kliknij na zakładkę, aby wyświetlić listę wszystkich wiadomości, dla których Symfony nie znalazł tłumaczenia:

Dostarczanie tłumaczeń

Jak można było zauważyć w config/packages/translation.yaml, tłumaczenia są przechowywane w katalogu translations/, który został utworzony automatycznie.

Zamiast tworzyć pliki tłumaczeń ręcznie, użyj polecenia translation:update:

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

Polecenie to generuje plik tłumaczeń (flaga --force) dla kodu języka fr i domeny messages, która zawiera wszystkie wiadomości związane z aplikacją, ale bez tych, które dostarcza Symfony – takich jak związane z walidacją lub błędami bezpieczeństwa.

Edytuj plik translations/messages+intl-icu.fr.xlf i przetłumacz zawarte w nim teksty na język francuski. Nie mówisz po francusku? Pozwól, że Ci pomogę:

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

Zauważ, że nie przetłumaczyłem wszystkich szablonów, ale możesz przejąć pałeczkę:

Tłumaczenie formularzy

Etykiety formularzy są automatycznie wyświetlane przez Symfony poprzez system tłumaczeń. Przejdź na stronę konferencji i kliknij na zakładkę „Tłumaczenie” na pasku narzędzi do debugowania; wszystkie etykiety powinny być gotowe do tłumaczenia:

Ustawienia regionalne dat

Jeżeli przełączysz się na francuski i wejdziesz na stronę konferencji, na której są już jakieś komentarze, zauważysz, że daty tych komentarzy są automatycznie dostosowane do ustawień regionalnych użytkownika. Ten mechanizm działa dzięki dostępnemu w Twigu filtrowi format_datetime, który bierze pod uwagę ustawienia regionalne ({{ comment.createdAt|format_datetime('medium', 'short') }}).

Lokalizacja działa dla dat, czasu (format_time), walut (format_currency) i liczb (format_number) w wielu formach (procenty, czas trwania, zapis słowny i wiele innych).

Tłumaczenie liczby mnogiej

Zarządzanie liczbą mnogą w tłumaczeniach jest jednym z przypadków bardziej ogólnego problemu wyboru tłumaczenia w oparciu o warunki.

Na stronie konferencji wyświetlana jest liczba komentarzy: There are 2 comments. Dla jednego komentarza wyświetlamy There are 1 comments, co jest błędne. Zmodyfikuj szablon, aby przekonwertować zdanie na komunikat do tłumaczenia:

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

W tym celu zastosowaliśmy inną strategię tłumaczenia. Zamiast zachować wersję angielską w szablonie, zastąpiliśmy ją unikalnym identyfikatorem. Ta strategia lepiej sprawdza się w przypadku tekstów skomplikowanych i długich.

Zaktualizuj plik tłumaczenia poprzez dodanie nowej wiadomości:

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

Jeszcze nie skończyliśmy, ponieważ musimy teraz zadbać o tłumaczenie na język angielski. Utwórz plik``translations/messages+intl-icu.en.xlf``:

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

Aktualizowanie testów funkcjonalnych

Nie zapomnij zaktualizować testów funkcjonalnych, aby brały pod uwagę zmiany adresów URL i treści:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -11,7 +11,7 @@ class ConferenceControllerTest extends WebTestCase
     public function testIndex()
     {
         $client = static::createClient();
-        $client->request('GET', '/');
+        $client->request('GET', '/en/');

         $this->assertResponseIsSuccessful();
         $this->assertSelectorTextContains('h2', 'Give your feedback');
@@ -20,7 +20,7 @@ class ConferenceControllerTest extends WebTestCase
     public function testCommentSubmission()
     {
         $client = static::createClient();
-        $client->request('GET', '/conference/amsterdam-2019');
+        $client->request('GET', '/en/conference/amsterdam-2019');
         $client->submitForm('Submit', [
             'comment_form[author]' => 'Fabien',
             'comment_form[text]' => 'Some feedback from an automated functional test',
@@ -41,7 +41,7 @@ class ConferenceControllerTest extends WebTestCase
     public function testConferencePage()
     {
         $client = static::createClient();
-        $crawler = $client->request('GET', '/');
+        $crawler = $client->request('GET', '/en/');

         $this->assertCount(2, $crawler->filter('h4'));

@@ -50,6 +50,6 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertPageTitleContains('Amsterdam');
         $this->assertResponseIsSuccessful();
         $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
-        $this->assertSelectorExists('div:contains("There are 1 comments")');
+        $this->assertSelectorExists('div:contains("There is one comment")');
     }
 }

  • « Previous Etap 27: Budowa aplikacji jednostronicowej (ang. single-page application, SPA)
  • Next » Etap 29: Zarządzanie wydajnością

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