Teil 4: Praxistauglich implementiert

Backend meets Frontend Reloaded: I18NProvider für Vaadin Flow

Sven Ruppert

© 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 , das für die Integration in eine Vaadin-Flow-Anwendung eingesetzt werden soll:

 
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.

In der Implementierung der Klasse Locale gibt es ebenfalls eine Implementierung mit dem Namen Cache. Man muss also im Hinterkopf behalten, das hier ebenfalls Dinge vorgehalten werden, die man selber nicht nochamls cachen sollte. Im JDK gibt es immer wieder Implementierungen von Caches, die nur erkennbar werden wenn man sich den Quelltext genauer ansieht. In unserem konkrekten Fall bedeutet dieses, das vermieden werden muss, das der Cache der Implementierung Locale noch Ergebnisse liefert, die in unserem Persistenlayer nicht mehr vorhanden sind. Also alle Anfragen nach existierenden bzw unterstützten Locales, müssen in dem System an die eigene Anbindung delegiert werden.

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 ROOTLocale.

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 sven.ruppert@gmail.com

Happy Coding!

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: