Mehrsprachige Rest Endpoints

„Wenn Sie einen Menschen in einer Sprache ansprechen, die er versteht, erreichen Sie seinen Kopf. Wenn Sie mit ihm in seiner eigenen Sprache sprechen, gehen Sie in sein Herz.“

Nelson Mandela

Hin und wieder schleichen sich I18N Anforderungen in die eigenen Anwendungen ein, die als einsprachige Anwendung gestartet wurde. Der offensichtlichste Fall für solch eine Mehrsprachigkeit ist z.B. eine Serviceseite im Kita Bereich. Da häufig Kinder verschiedenster Nationalitäten eine Kita besuchen, sind Informationen in der Muttersprache der Eltern hilfreich.

Folgender kleiner Rest-Endpoint wurde ohne Berücksichtigung von Mehrsprachigkeit umgesetzt. Es wird eine Liste von Feiertage gesucht und diese HATEOAS konform transformiert.

@GetMapping("/holidays/{country}")
public CollectionModel<EntityModel<HolidayDto>> findAll(@PathVariable("country") Locale country) {
  return convert(Holidays.in(country));
}

Wie kann nun dieser Aufruf umgebaut werden um mehrsprachige Antworten zu realisieren?

Zum einen benötigt die Anwendung mehrsprachige Texte, denn ohne diese, kann die Ausgabe nicht passend übersetzt werden. Schon seit langer Zeit werden hierfür ResourceBundles in Java Anwendungen verwendet. Die bequemste Art mit ResourceBundles umzugehen ist, die Texte für unterschiedliche Sprachen in Properties-Dateien zu hinterlegen. Anhand des Locale am Ende des Dateinamens werden die Sprachen zugeordnet. So enthält die Datei translate_de.properties deutsche Texte, die Datei translate_tk.properties Texte für türkische Mitbürger und translate_de_AT.properties ein alternatives Deutsch für österreichische Immigranten.

# Feiertag in translate_tk.properties
holiday.newyear=Y\u0131lba\u015f\u0131
holiday.boxingday=2. Noel g\u00fcn\u00fc (2. Weihnachtstag)

# Feiertage in translate_de.properties
holiday.newyear=Neujahr
holiday.boxingday=2. Weihnachtstag

# Feiertage in translate_de_AT.properties
holiday.newyear=Neujahr
holiday.boxingday=Stefanitag (2. Weihnachtstag)

Die türkischen Übersetzungen sehen eigenartig aus, weil die Property-Dateien aus Kompatibilitätsgründen noch immer ISO 8859-1 kodieren und kein UTF-8. Die Sonderzeichen in Yılbaşı fügt man daher mit der \uxxxx Notation ein. Das ist kein großes Drama, weil die Texte mit dem Tool native2ascii automatisiert konvertiert werden können.

Hat man für alle benötigten Sprachen eine Property-Datei erstellt, dann kann mit folgendem Einzeiler die Übersetzung ins Türkische ermittelt werden.

Locale locale = new Locale("tk", "DE");
...
String holidayName = ResourceBundle.getBundle("translate", locale).getString("holiday.boxingday");

Nachdem die Übersetzungen bereitstehen, muss die Zielsprache bestimmt werden. Bei Web-Applikationen haben es die Entwickler besonders einfach, weil alle Browser mitteilen, in welcher Sprache ihr Nutzer gerne Texte lesen möchte. Dies geschieht über die HTTP Header Accept-Language.

Accept-Language: de,en-US;q=0.7,en;q=0.3

Dabei ist der erste Eintrag die bevorzugte Sprache und danch folgen weitere Sprachen mit einer Priorität zwischen 0 und 1. Häufig reicht es aus, sich nur mit der bevorzugten Sprache zu beschäftigen und die restlichen Sprachen zu ignorieren. Kaum jemand, der diesen Text gerade liest, hat schon einmal die Spracheinstellungen seines Browsers verändert.

Da die bevorzugte Sprache zu betrachten für die aktuelle Anwendung ausreichen erscheint, ist es nicht nötig den HTTP Header auszuwerten. Der Spring Boot Framework erledigt diese Aufgabe automatisch, wenn der Entwickler einen nicht annotierten Locale in die Parameterliste einfügt.

@GetMapping("/holidays/{country}")
public CollectionModel<EntityModel<HolidayDto>> findAll(@PathVariable("country") Locale country, Locale language) {
  return convert(Holidays.in(country), language);
}

Im obigen Beispiel wird ein zweites Locale übergeben, das die präverierte Sprache des Nutzers enthält. Dieses Locale wird innerhalb der convert Methode benutzt um die Texte zu übersetzen.

Spring Boot Anwendungen können es sogar noch etwas einfacher haben mit der bereitgestellten MessageSource. Diese kann in den RestController injected werden und einige Dinge vereinfachen.

@Autowired MessageSource messageSource;

@GetMapping("/holidays/{country}")
public CollectionModel<EntityModel<HolidayDto>> findAll(@PathVariable("country") Locale country, Locale language) {
  return convert(Holidays.in(country), language);
}

Innerhalb der convert Methode kann nun statt eines ResourceBundles die MessageSource verwendet werden.

String holidayName = messageSource.getMessage("holiday.boxingday", null, locale);

Der zweite Parameter kann verwendet werden um Parameter im Text zu ersetzen, ist aber in unserem Beispiel unnötig. Damit die MessageSource unsere Übersetzungen findet, müssen wir dies in der application.properties konfigurieren.

spring.messages.basename=messages,translate,recepies
spring.messages.cache-duration=10m 

Der erste Parameter ist eine Auflistung aller ResourceBundles, die von der MessageSource verwendet werden. Der zweite Parameter erlaubt es, das Cachen der ResourceBundles zu konfigurieren. Üblicherweise werden diese Dateien nur ein mal geladen und dann nicht mehr beachtet. In diesem Fall wird spätestens nach 10 Minuten die Datei erneut geladen. So können Änderungen an den Texten ohne Neustart der Anwendung vorgenommen werden.

Für viele Entwickler ist an dieser Stelle die Arbeit beendet. Leider besitzt die hier vorgestelle Lösung zwei unangenehme Schönheitsfehler. Manche Nutzer sehen entweder gar keine Übersetzungen und manche Nutzer sehen mehrsprachige Texte.

Wird die Webseite mit einer Sprache aufgerufen, die nicht unterstützt wird, dann werden keine Übersetzungen gefunden. Daher verwenden viele Tools eine zusätzliche Property-Datei ohne Locale Endung. In dieser Datei stehen alle Übersetzungen in der Standardsprache. Dieser Ansatz ist unschön, weil in der Beispielanwendung translate.properties und translate_de.properties identische Werte enthalten und immer parallel gepflegt werden müssen. Mehrsprachige Texte können entstehen, wenn es mehrere Übersetzungs-Bundles gibt. Das kann durch Modularisierung der Anwendung oder durch Fremdbibliotheken passieren. Wenn dannunterschiedliche ResourceBundles unterschiedliche Sprachen unterstützen, dann wird ein Teil der Seite z.B. ins Polnische übersetzt, während andere Teile auf die Standardübersetzung Deutsch wechseln müssen.

Spring Boot liefert eine einfache Möglichkeit, die ohne eine Property-Datei mit der Standardübersetzung auskommt. Dafür wird in einer Bean Methode der LocaleResolver konfiguriert.

@Bean("localeResolver")
public LocaleResolver acceptHeaderLocaleResolver() {
  AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
  resolver.setDefaultLocale(Locale.GERMAN);
  resolver.setSupportedLocales(List.of(Locale.GERMAN, Locale.ENGLISH, new Locale("tk"));
  return resolver;
}

Die obige Methode konfiguriert den LocaleResolver so, dass als Locale nur Deutsch, English und Türkisch zurückgeliefert wird. Bei unbekannten Sprachen wird Deutsch als Ersatz gewählt. Soll dies nicht alles hard codiert in der Methode stehen, kann man die Konfigurationsmöglichkeiten von Spring Boot nutzen.

# locales in application.properties
locales.defaultLocale=de
locales.supportedLocales=de,en,tk
@ConfigurationProperties("locales") @Data
public class LocaleConfiguration {
  private Locale defaultLocale;
  private List<Locale> supportedLocales;
}

Eine LocaleConfiguration Instanz wird von Spring Boot mit den Werten locales.defaultLocale und locales.supportedLocales aus der application.properties Datei gefüllt und in der nachfolgenden Bean Methode übergeben.

@Bean("localeResolver")
public LocaleResolver acceptHeaderLocaleResolver(LocaleConfiguration locales) {
  AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
  resolver.setDefaultLocale(locales.getDefaultLocale);
  resolver.setSupportedLocales(locales.getSupportedLocales());
  return resolver;
}

Aber auch hier ist noch Vorsicht geboten, ob auch wirklich alle unterstützten Sprachen mit entsprechenden Übersetzungen versehen sind.