Backend meets Frontend Reloaded: I18NProvider für Vaadin Flow

© Shutterstock / HelenField & bkf (modifiziert)
Im letzten Teil haben wir uns angesehen, wie man mit einem ResourceBundle umgehen kann, bzw. wie man mit einer eigenen Implementierung beginnt. Das Thema ist auf der einen Seite schon recht alt, aber auf der anderen Seite im Bereich von Webanwendungen topaktuell. Heute werden wir uns damit beschäftigen, wie man eine praxistaugliche Implementierung eines I18NProviders für den Einsatz in Vaadin Flow umsetzen kann.
Die Anforderungen
Als kurze Wiederholung hier nochmals das Interface
public interface I18NProvider extends Serializable { List<Locale> getProvidedLocales(); String getTranslation(String var1, Locale var2, Object... var3); }
Die Aktivierung der jeweiligen Implementierung hatten wir im voherigen Teil mittels der System-Property vaadin.i18n.provider durchgeführt. Hierzu hatten wir in der main
-Methode, die zum Starten des Servlet-Containers verwendet wird, folgende Zeile hinzugefügt:
setProperty("vaadin.i18n.provider" , I18NProviderImpl.class.getName());
Heute werden wir uns die Implementierung des I18NProvider
nochmals im Detail ansehen und um einige Eigenschaften erweitern, die in einem produktiven Einsatz von Vorteil sein werden. Zuerst tragen wir nochmals jene Eigenschaften zusammen, die für einen effizienten Einsatz die Grundlage sein werden. Hierzu zählt zum einen, dass zur Laufzeit die Ressourcen nachgeladen werden können. Sowohl die Behandlung von Fehlerfällen, wenn zum Beispiel eine Sprache angefordert wird, die von der Anwendung nicht direkt unterstützt wird, als auch das Hinzufügen, Entfernen und Modifizieren von Sprachen zur Laufzeit sind wichtige Elemente für einen produktiven Betrieb.
Als Erinnerung möchte ich an dieser Stelle erneut die Implementierung aus dem vorherigen Teil anführen:
public class I18NProviderImpl implements I18NProvider, HasLogger { private static final Map<String, String> translations = new HashMap<>(); static { translations.put("login.username.placeholder" , "username"); translations.put("login.password.placeholder" , "password"); translations.put("login.rememberme.label" , "remember Me"); translations.put("login.button.ok.text" , "OK"); translations.put("login.button.cancel.text" , "Cancel"); } @Override public List<Locale> getProvidedLocales() { return List.of(Locale.ENGLISH); } @Override public String getTranslation(String s , Locale locale , Object... objects) { return translations.getOrDefault(s , s); } }
Die Implementierung – getTranslation
Die erste Implementierung, die wir hier als Basis nehmen, hat einen sehr begrenzent Funktionsumfang. Jedoch wollen wir mit der Methode getTranslation
beginnen und diese schrittweise erweitern.
Sprache nicht unterstützt
Der Fall, dass eine Sprache, die angefordert worden ist, nicht unterstützt wird, ist sehr wahrscheinlich. Das System könnte nun mit einer Exception reagieren, was allerdings aus der Sicht eines Benutzers weniger hilfreich sein dürfte. Wenn wir davon ausgehen, dass die englische Sprache eine allgemein konsumierbare Lösung darstellt, so macht diese als Fallback Sinn. In manchen geographischen Bereichen werden andere Sprachen sicherlich sinnvoller sein.
Wir gehen ´inm Folgenden davon aus, dass wir Deutsch und Englisch in unserer Anwendung unterstützen möchten/müssen. Somit könnte die erste Implementierung wie folgt aussehen:
public static final String RESOURCE_BUNDLE_NAME = "labels"; private static final ResourceBundle RESOURCE_BUNDLE_EN = getBundle(RESOURCE_BUNDLE_NAME , ENGLISH); private static final ResourceBundle RESOURCE_BUNDLE_DE = getBundle(RESOURCE_BUNDLE_NAME , GERMAN); @Override public String getTranslation(String key , Locale locale , Object... params) { logger().info("VaadinI18NProvider getTranslation.. key : " + key + " - " + locale); ResourceBundle bundleToUse = (GERMAN.equals(locale)) ? RESOURCE_BUNDLE_DE : RESOURCE_BUNDLE_EN; return bundleToUse.getString(key); }
Es liegt auf der Hand, dass es nun ein Einfaches ist, dieses zum einen auf eine Anzahl beliebiger Sprachen zu erweitern. Ebenfalls wird die impliziet als Root Locale verwendete englische Sprache auch auf diese Weise definiert.
Alles, was den Umgang mit den ResourceBundles
betrifft, lagern wir als nächstes in eine Klasse mit dem Namen ResourceBundleService
aus. Da wir an dieser Stelle noch keinen Persistence Layer haben, definieren wir die verfügbaren ResourceBundles
im Moment noch direkt als statische Attribute.
public static final String RESOURCE_BUNDLE_NAME = "labels"; private static final ResourceBundle RESOURCE_BUNDLE_ROOT = getBundle(RESOURCE_BUNDLE_NAME , ROOT); private static final ResourceBundle RESOURCE_BUNDLE_EN = getBundle(RESOURCE_BUNDLE_NAME , ENGLISH); private static final ResourceBundle RESOURCE_BUNDLE_DE = getBundle(RESOURCE_BUNDLE_NAME , GERMAN); private static Map<Locale, ResourceBundle> persistenceStorage = new ConcurrentHashMap<>(); public ResourceBundleService() { postProcess(); } //LifeCycle dependent private void postProcess() { persistenceStorage.put(ROOT , RESOURCE_BUNDLE_ROOT); persistenceStorage.put(ENGLISH , RESOURCE_BUNDLE_EN); persistenceStorage.put(GERMAN , RESOURCE_BUNDLE_DE); }
Anschließend definieren wir das Verhalten, das bei einem nicht direkt unterstützten Locale auf das Root Locale verwiesen wird. Hier wird aus der Menge an verfügbaren Locales entweder das gesuchte herausgefiltert, oder das Root Bundle zurückgegeben. In diesem Schritt wird noch nicht der Fall abgefangen, dass es spezialisierte Locales geben kann. Hier ist derzetig nur ein direkter Treffer möglich.
private BiFunction<Stream<Locale>, Locale, Locale> selectLocaleToUse() { return (availableLocales , requestedLocale) -> availableLocales .filter((l) -> l.equals(requestedLocale)) .findFirst() .orElse(ROOT); }
Die Verwendung dieser BiFunction
ist in unserem Fall zur Hälfte immer vorgegeben, da der Parameter availableLocales
immer aus derselben Quelle bezogen werden wird. Hier lohnt es sich, die BiFunction
auf eine Function
zurückzuführen .
private Function<Locale, Locale> convertLocale() { return Transformations .<Stream<Locale>, Locale, Locale>curryBiFunction() .apply(selectLocaleToUse()) .apply(providedLocalesAsStream()); }
Die Funktion curryBiFunction
ist in der Serie CheckPoint Java – Functional Reactive mit Core Java beschrieben. In Kurzform, wir setzen den ersten Parameter innerhalb dieser Funktion und können somit in der weiteren Verwendung ausschließlich das angefragte Locale betrachten.
Der Persistence Layer verfügt über die aktuell verwendbaren Locales. In unserem Fall handelt es sich noch um eine ConcurrentHashMap
, demnach enthält das KeySet
alle Locales. Da das Modifizieren der Elemente im KeySet
Auswirkungen auf die Map an sich hat, werden wir die Elemente immer entnehmen und in einer neu erzeugten Datenstruktur anbieten. Des Komforts wegen gibt es verschiedene Varianten der Methode.
//don´t remove from the Set itself -> will be reflected to the map public Supplier<Stream<Locale>> providedLocalesAsSupplier() { return this::providedLocalesAsStream; } public Stream<Locale> providedLocalesAsStream() { return persistenceStorage .keySet() .stream() .filter((l) -> ! l.equals(ROOT)); } public List<Locale> providedLocalesAsList() { return providedLocalesAsStream() .collect(toList()); }
Der Ladevorgang eines definierten Locales ist ein einfacher Delegate zur Map die hier noch zum Einsatz kommt.
private Function<Locale, ResourceBundle> loadResourceBundle() { return (locale) -> persistenceStorage.get(locale); }
Alles zusammen erhalten wir nun folgenden Ablauf:
- Überprüfe, ob der geforderte Locale in der Menge der unterstützten Locales vorhanden ist, und liefere diesen zurück.
- Wenn dem nicht so ist, verwende den Fallback, in unserem Fall den
ROOT
–Locale.
Basierend auf diesem Schritt wird nachfolgend aus dem Persistence Layer das korrespondierende ResourceBundle
geladen.
public Function<Locale, ResourceBundle> resourceBundleToUse() { return (locale) -> convertLocale() .andThen(loadResourceBundle()) .apply(locale); }
Damit wird die Implementierung des I18NProvider
sehr übersichtlich.
public class I18NProviderImpl02 implements I18NProvider, HasLogger { private final ResourceBundleService resourceBundleService = new ResourceBundleService(); @Override public List<Locale> getProvidedLocales() { logger().info("VaadinI18NProvider getProvidedLocales.. "); return resourceBundleService .providedLocalesAsList(); } @Override public String getTranslation(String key , Locale locale , Object... params) { logger().info("VaadinI18NProvider getTranslation.. key : " + key + " - " + locale); return resourceBundleService .resourceBundleToUse() .apply(locale) .getString(key); } }
Key not found
Was soll eigentlich passieren, wenn sich der angeforderte Schlüssel nicht im ResourceBundle
befindet? Diese Frage muss muss beantwortet werden, wenn man nicht mit einer MissingKeyException
konfrontiert werden möchte. Es gibt nur einige Fälle, die abgefangen werden sollten. Der erste ist eine Anfrage, bei der ein Schlüssel null
oder leer ist. Ob man diese beiden Fälle gesondert behandeln möchte, muss in dem jeweiligen Projekt entschieden werden. In diesem Beispielprojekt wird es unterschieden werden, allerdings gleich behandelt.
public String getTranslation(String key , Locale locale , Object... params) { logger().info("VaadinI18NProvider getTranslation.. key : " + key + " - " + locale); final ResourceBundle resourceBundle = resourceBundleService .resourceBundleToUse() .apply(locale != null ? locale : ROOT); return match( matchCase(() -> failure("###-" + key + "-###-" + locale)) , matchCase(() -> isNull(key) , () -> failure(NULL_KEY)) , matchCase(key::isEmpty , () -> failure(EMPTY_KEY)) , matchCase(() -> resourceBundle.containsKey(key) , () -> success(resourceBundle.getString(key))) ) .ifFailed(msg -> logger().warning(msg)) .getOrElse(() -> "###-KEY_NOT_FOUND-" + key + " - " + locale + "-###"); }
Hiermit wird jeder Schlüssel aufgelöst, wobei fehlende Schlüssel entsprechend markiert werden. In der Obefläche wird sofort ersichtlich, welche Schlüssel bzw. Sprachkombinationen nicht vorhanden sind.
Fazit
Wir haben nun eine für viele Fälle ausreichende Implementierung eines I18NProvider
. Was wir uns hier noch nicht angesehen haben, ist der Persistence Layer, in dem diese Elemente gespeichert werden. Hier kann man sich an den Mechanismen, die in dem jeweiligen Projekt zum Einsatz kommen, orientieren. Der Zugriff kann dann in der Klasse ResourceBundleService
realisiert werden.
Das Beispiel zu diesem Teil ist wie immer auf GitHub zu finden.
Wer Fragen und Anmerkungen hat, meldet sich am besten per Twitter an @SvenRuppert oder direkt per Mail an [email protected]
Happy Coding!
Hinterlasse einen Kommentar