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:
- MVC Portlety – tłumaczenia w zakresie modułu vs. tłumaczenia globalne
- 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.yamlzdefiniuj 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:
- Pobierze tłumaczenia z API
- Zapisze je w localStorage
- 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ń.