Kompletny przewodnik po tłumaczeniach w Liferay

Praktyczny przewodnik omawiający wszystkie główne podejścia do zarządzania tłumaczeniami w Liferay: tłumaczenia w zakresie modułu vs. globalne w MVC Portletach oraz tłumaczenia na poziomie JS vs. przez REST API w React Client Extensions. Poznaj kompromisy i dowiedz się, które podejście pasuje do Twojego przypadku.

Wprowadzenie

Tłumaczenia w Liferay to jeden z tych tematów, gdzie łatwo zacząć od czegoś, co na pierwszy rzut oka wygląda sensownie - i dopiero po czasie natknąć się na ograniczenia. W zależności od kontekstu mamy do wyboru kilka podejść. Każde z nich ma swoje wady i zalety, a znajomość ich z góry może zaoszczędzić sporo czasu.

Artykuł omawia dwa główne scenariusze:

  1. MVC Portlety – tłumaczenia w zakresie modułu vs. tłumaczenia globalne
  2. React Client Extensions – tłumaczenia na poziomie JS vs. pobieranie ich przez REST API

Nie będziemy omawiać np. tłumaczeń w szablonach FTL, bo zasada działania jest taka sama jak w MVC Portletach.

Portlety MVC: dwa sposoby zarządzania tłumaczeniami

W standardowych MVC Portletach tłumaczenia są przechowywane w plikach Language.properties (lub ich wariantach dla konkretnych języków: Language_de.properties, Language_pl.properties itd.) w katalogu src/main/resources/content/ modułu. To część wspólna dla wszystkich podejść. Różnica leży w tym, jak te tłumaczenia są rejestrowane i udostępniane.

Tłumaczenia w zakresie modułu

Najprostsze podejście to trzymanie tłumaczeń bezpośrednio w module, który ich używa. Wystarczy umieścić pliki Language.properties w katalogu src/main/resources/content/ modułu - Liferay wykrywa je automatycznie.

modules/my-portlet/src/main/resources/content/Language.properties
modules/my-portlet/src/main/resources/content/Language_de.properties
modules/my-portlet/src/main/resources/content/Language_pl.properties

Na pierwszy rzut oka wygląda to schludnie: każdy moduł jest samowystarczalny, a tłumaczenia żyją obok kodu, który ich używa. Historycznie takie podejście miało sens.

Problem polega na tym, że tłumaczenia w zakresie modułu są widoczne wyłącznie w tym module. To rodzi dwa konkretne problemy:

  • Nie są dostępne w Language Override – panel admina umożliwiający nadpisywanie tłumaczeń w czasie rzeczywistym ich nie wyświetli
  • Nie można ich współdzielić między modułami – jeśli inny moduł potrzebuje tego samego klucza, musi go zdefiniować osobno

Jeśli trzeba poprawić tłumaczenie, nie ma innego wyjścia: edytujemy plik Language_xx.properties bezpośrednio w module i wdrażamy go ponownie. Brak możliwości nadpisania w runtime, brak centralnego miejsca zarządzania, brak możliwości dostarczenia różnych tłumaczeń różnym klientom na tej samej instancji.

Tłumaczenia globalne

Alternatywą jest rejestracja tłumaczeń na poziomie portalu - dzięki czemu są widoczne we wszystkich modułach i, co ważniejsze, dostępne w narzędziu Language Override.

Format pliku jest dokładnie taki sam. Różnica polega na tym, że tłumaczenia udostępniamy przez komponent OSGi ResourceBundle. Tworzymy dedykowany moduł językowy (lub rozszerzamy istniejący) i rejestrujemy ResourceBundle dla każdego obsługiwanego języka:

@Component(
    property = {
        "language.id=de_DE"
    },
    service = ResourceBundle.class
)
public class MyLanguageResourceBundleDe extends java.util.ResourceBundle {
    private final ResourceBundle resourceBundle = ResourceBundle.getBundle("content.Language_de", UTF8Control.INSTANCE);

    @Override
    protected Object handleGetObject(String key) {
        return resourceBundle.getObject(key);
    }

    @Override
    public Enumeration<String> getKeys() {
        return resourceBundle.getKeys();
    }
}

Plik Language_de.properties nadal trafia do src/main/resources/content/ modułu językowego. Rejestrujemy jeden komponent ResourceBundle na każdy obsługiwany język.

Taka konfiguracja daje:

  • Tłumaczenia widoczne w Language Override w panelu admina
  • Możliwość współdzielenia kluczy między wszystkimi modułami portalu
  • Nadpisywanie per instancja w czasie rzeczywistym - bardzo przydatne w środowiskach multi-tenant lub przy obsłudze wielu klientów

Które podejście wybrać?

Mój wybór to zdecydowanie tłumaczenia globalne, i oto konkretne powody:

W zakresie modułu Globalne
Language Override Nie Tak
Współdzielenie między modułami Nie Tak
Nadpisywanie per instancja Nie Tak
Poprawka bez ponownego wdrożenia Nie Tak
Samowystarczalny moduł Tak Nie

Jedyną zaletą tłumaczeń w zakresie modułu jest to, że moduł jest w pełni samowystarczalny. W nowoczesnym developmencie Liferaya, gdzie zazwyczaj pracujemy z zestawem współpracujących modułów, ta zaleta ma niewielkie znaczenie. Jeśli jakiś klucz tłumaczenia ma błąd istniejący od początku istnienia modułu - a przy tłumaczeniach modułowych zdarza się to nagminnie - jesteśmy skazani na ręczną edycję pliku i ponowne wdrożenie.

Zaczynasz nowy projekt? Wybierz globalne. Utrzymujesz legacy kod z tłumaczeniami w zakresie modułu i potrzebujesz coś poprawić? Jedyne wyjście to edycja pliku Language_xx.properties bezpośrednio w tym module.

React Client Extensions: dwa podejścia do tłumaczeń

Wraz z pojawieniem się Client Extensions w Liferay 7.4+, React stał się domyślnym sposobem budowania własnych interfejsów użytkownika. Komponenty React też potrzebują tłumaczeń - a dokumentacja Liferaya sugeruje obsługę tego po stronie JS. Poniżej wyjaśniam, dlaczego uważam to za gorsze podejście, i co polecam zamiast tego.

Tłumaczenia na poziomie JS

Blogi i dokumentacja Liferaya często proponują zarządzanie tłumaczeniami bezpośrednio w Client Extension - na przykład przez obiekt JS lub bibliotekę taką jak i18next w aplikacji React. Tłumaczenia są wówczas bundlowane razem z kodem frontendowym.

To działa, ale ma poważne wady:

  • Brak współdzielenia między Client Extensions – każdy CX zarządza własnym zestawem tłumaczeń
  • Brak integracji z Language Override – admini nie mogą nadpisywać tłumaczeń w runtime
  • Brak personalizacji per instancja – jeśli obsługujesz wiele portali lub różnych klientów na jednej instancji Liferaya, nie możesz dostarczyć różnych tłumaczeń bez zmian w kodzie
  • Rozmiar bundle'a JS – wszystkie tłumaczenia trafiają do frontendu, nawet te nieużywane

Dla prostych, izolowanych Client Extensions, gdzie żadna z powyższych kwestii nie ma znaczenia - może być w porządku. Przy czymkolwiek bardziej złożonym lub produkcyjnym: polecam kolejne podejście.

Podejście przez REST API (rekomendowane)

Zamiast bundlować tłumaczenia po stronie frontendu, wystawiamy je przez endpoint REST Liferaya. Idea jest prosta:

  • Tłumaczenia zostają w globalnych plikach Language.properties (dokładnie tak samo jak w sekcji o MVC)
  • Wystawiamy endpoint REST, który przyjmuje klucze tłumaczeń i zwraca ich wartości dla aktualnego języka
  • React Client Extension pobiera to, czego potrzebuje, przez API i cache'uje wyniki

Dzięki temu wszystkie tłumaczenia są zarządzane w jednym miejscu. Language Override działa. Personalizacja per instancja działa. A ponieważ w jednym wywołaniu API można pobrać wiele kluczy naraz, wydajność nie jest problemem - tym bardziej przy dodatkowym cache'owaniu.

Tworzenie endpointu REST dla tłumaczeń

Po stronie Javy tworzymy API REST, które przyjmuje listę kluczy tłumaczeń i zwraca pary klucz-wartość dla danego języka. Podstawowe kroki z użyciem REST Buildera Liferaya:

  • Utwórz moduł typu REST Builder
  • W autogenerowanym pliku rest-openapi.yaml zdefiniuj endpoint i schemat, na przykład:
paths:
  /translations:
    post:
      summary: Fetch translations for specified keys and locale
      description: Retrieve translation values for given language keys based on the specified locale.
      tags:
        - Translations
      parameters:
        - name: locale
          in: query
          description: Locale for the translations (e.g., en_US, de_DE)
          required: true
          schema:
            type: string
            example: en_US
      requestBody:
        description: List of translation keys to fetch
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                type: string
              example:
                - "submit"
                - "save"
      responses:
        '200':
          description: Translation values retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Translations'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: "Invalid locale or body format"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: "An unexpected error occurred"
components:
    schemas:
      Translations:
        type: object
        properties:
          translations:
            type: array
            items:
              $ref: '#/components/schemas/Translation'
      Translation:
        type: object
        properties:
          key:
            type: string
            example: "language.key"
          value:
            type: string
            example: "Language Value"
  • Uruchom zadanie gradle buildREST, aby wygenerować kod API
  • Zaimplementuj endpoint w autogenerowanej klasie zasobu (np. TranslationsResourceImpl):
@Override
public Translations postTranslations(String locale, String[] strings) throws Exception {
    if (locale == null || strings == null || strings.length == 0) {
        throw new BadRequestException("Invalid locale or keys");
    }
    Set<Locale> availableLocales = language.getAvailableLocales();
    Set<String> availableLanguageIds = availableLocales.stream()
            .map(LocaleUtil::toLanguageId)
            .collect(Collectors.toSet());

    if (!availableLanguageIds.contains(locale)) {
        throw new BadRequestException("Invalid locale: " + locale);
    }

    Translations translations = new Translations();
    Locale userLocale = LocaleUtil.fromLanguageId(locale);
    List<Translation> translationList = Arrays.stream(strings)
            .map(key -> {
                Translation translation = new Translation();
                translation.setKey(key);
                translation.setValue(language.get(userLocale, key));
                return translation;
            })
            .toList();

    translations.setTranslations(translationList.toArray(new Translation[0]));

    return translations;
}
  • Pamiętaj, że jeśli chcesz udostępnić endpoint tłumaczeń dla gości (niezalogowanych użytkowników), musisz utworzyć odpowiednią Service Access Policy

Endpoint możesz oczywiście rozbudować - dodać własną obsługę brakujących kluczy, dodatkowe funkcje itp.

Pobieranie tłumaczeń w React

Po stronie frontendu pobieramy tłumaczenia przy montowaniu komponentu i cache'ujemy wyniki. Polecam stworzenie własnego hooka, który:

  1. Pobierze tłumaczenia z API
  2. Zapisze je w localStorage
  3. Zwróci tłumaczenia jako mapę

Kilka rzeczy do przemyślenia przy implementacji cache'a:

  • Cache powinien być unieważniany przy zmianie języka - albo przechowuj go osobno dla każdego języka
  • Każdy CX może potrzebować innych tłumaczeń, więc albo trzymaj je pod różnymi kluczami, albo zastosuj bardziej zaawansowane rozwiązanie
  • Osobiście tworzę jeden wspólny cache, w którym śledzę wszystkie klucze i ich tłumaczenia - jeśli jakiś CX potrzebuje nowych kluczy, wysyłam zapytanie tylko o brakujące. Dzięki temu wszystkie CX współdzielą ten sam cache, a liczba wywołań API jest znacząco mniejsza. Ważna uwaga: unieważniaj cache per klucz, nie globalnie. Jeśli śledzisz jeden globalny znacznik czasu ostatniej aktualizacji, to za każdym razem gdy nowy Client Extension doda nowe klucze, znacznik zostanie zresetowany - co spowoduje niepotrzebne ponowne pobieranie wszystkich wcześniej cache'owanych tłumaczeń, przekreślając sens współdzielonego cache'a.

Prosta struktura TypeScript, która naturalnie to wspiera, wygląda tak:

interface CachedTranslation {
  value: string;
  timestamp: number;
}

interface TranslationCache {
  [locale: string]: {
    [key: string]: CachedTranslation;
  };
}

Każdy klucz ma własny znacznik czasu, więc wygasanie i unieważnianie są w pełni niezależne - per klucz i per język.

Po stronie konsumenta wygląda to tak: każdy Client Extension deklaruje klucze, których potrzebuje, owija się providerem i używa prostej funkcji t() - bez wywołań API, bez zarządzania cache'em, bez obsługi języka:

const KEYS = ['save', 'cancel', 'submit'];

const MyWidget: React.FC = () => (
  <TranslationProvider translationKeys={KEYS}>
    <MyWidgetContent />
  </TranslationProvider>
);

const MyWidgetContent: React.FC = () => {
  const { t } = useTranslations();
  return <button>{t('save')}</button>;
};

Provider przy montowaniu sprawdza cache, pobiera tylko brakujące lub wygasłe klucze i udostępnia wyniki przez context. Różne Client Extensions na tej samej stronie współdzielą ten sam cache - ten, który ładuje się jako drugi, w większości przypadków znajdzie wszystkie swoje klucze już gotowe.

Takie rozwiązanie daje:

  • Jedno źródło prawdy – tłumaczenia żyją w plikach Language.properties, zarządzane globalnie
  • Language Override działa – admin może nadpisać dowolny klucz w runtime bez zmian w kodzie
  • Personalizacja per instancja – różne portale lub instancje dla różnych klientów mogą mieć własne wersje tłumaczeń
  • Wydajność – jedno wywołanie API przy ładowaniu strony, potem cache na czas sesji. Te same tłumaczenia są współdzielone przez wszystkie Client Extensions
  • Reużywalność – ten sam klucz tłumaczenia można użyć z dowolnego Client Extension lub MVC Portletu

Podsumowanie

Liferay oferuje kilka sposobów obsługi tłumaczeń, a najlepszy wybór zależy od kontekstu:

  • Przy MVC Portletach postaw na tłumaczenia globalne przez komponenty ResourceBundle. Tłumaczenia w zakresie modułu to podejście z poprzedniej epoki, które ogranicza integrację z Language Override i możliwość współdzielenia kluczy.
  • Przy React Client Extensions zamiast zarządzać tłumaczeniami po stronie JS, pobieraj je przez endpoint REST. Dzięki temu tłumaczenia są scentralizowane, Language Override działa, a personalizacja per instancja jest możliwa bez zmian w kodzie.

W obu przypadkach format pliku (Language.properties) jest dokładnie taki sam. Różnica sprowadza się wyłącznie do sposobu rejestracji i udostępniania tłumaczeń.

Zainteresowany naszymi usługami? Porozmawiajmy!

Możesz także napisać e-mail lub zadzwonić - czekamy na Ciebie!

contact@innray.com

+48 661 344 000

Skontaktuj się z nami

Pola oznaczone * są wymagane