Kalenderspielereien mit Java – I18N

Die ersten drei Beiträge dieser Reihe handelten von einer Java API zur Feiertagsberechnung. In diesem Beitrag geht es um eine nachtragliche Verbesserung der Internationalisierung (I18N) und der minimalinvasiven Implementierung mit Hilfe des Decorator Design-Pattern.

LocalDate date = LocalDate.of(2020, 12, 25)
Holidays holidays = Holidays.in(UnitedKingdomCountryOrProvince.NIR);
String name = holidays.getHoliday(date).orElse("-"); // "Christmas Day"

Der hier dargestellte Dreizeiler bestimmt den Feiertagsnamen für den 25.12.2020 in der Provinz Nord-Irland. Es verwundert vermutlich nicht sehr, dass als Result "Christmas Day" zurückgegeben wird. Die ursprüngliche Version der API arbeitete mit festen Namen für die Feiertage, damit war die Umsetzung einfach, aber mit einigen Einschränkungen belastet.

Die auffälliste Einschränkung ist die schlechte Internationalisierung der Lösung. Im oberen Beispiel wird der Name "Christmas Day" zurückgegeben, obwohl als Standardsprache Deutsch eingestellt ist. In diesem Fall hätte das Ergebnis "1. Weihnachtstag" lauten sollen.

Ein Methode mit zusätzlichem Parameter für die gewünschte Übersetzungsprache muss noch ergänzt werden. Die Methode ohne zusätzlichen Locale Parameter für die Sprache verwendet die jeweilige Standardsprache des Systems.

LocalDate date = LocalDate.of(2020, 12, 25)
// "1. Weihnachtstag"
Holidays.in(NIR).getHoliday(date); // Default-Locale Locale.GERMANY
// "Christmas Day"
Holidays.in(NIR, Locale.UK).getHoliday(date);
// "Christtag"
Holidays.in(NIR, new Locale("de", "AT").getHoliday(date);

Die Entscheidung, den Locale Parameter der in Methode hinzuzufügen, beruht auf den bisherigen Anwendungsfällen, viele Feiertage in einer Sprache zu erfragen. Bei anderen Anfordungen könnte der Locale Parameter aber auch in die getHoliday und getHolidays Methoden wandern.

Übersetzungen

Damit die Feiertagsnamen übersetzt werden können, benötigt die angepasste API ein entsprechendes Resource-Bundle. In ihm müssen alle, im System bekannten, Feiertage als Key-Value Paar vorhanden sein. Der Key ist ein eindeutiger Schlüssel für den Feiertag und der Value eine Übersetzung des Namens.

# holidays.properties
nationalfeiertag=Nationalfeiertag

# holidays_de.properties
new-years-day=Neujahr
boxing-day=2. Weihnachtsfeiertag

# holidays_de_DE.properties
boxing-day=2. Weihnachtsfeiertag

# holidays_de_AT.properties
boxing-day=Stefanitag

Im obigen Beispiel sind Beispielzeilen aus verschiedenen Dateien des Resource Bundles dargestellt.

In der Basis-Datei holidays.properties findet sich der Eintrag nationalfeiertag für den österreichischen Nationalfeiertag. Für diesen Feiertag bietet das Holidays Projekt keine Übersetzungen an. Daher gilt dieses Eintrag für alle Sprachen.

Da der Name des Neujahrsfeiertag in den beiden unterstützten deutschsprachigen Ländern, Deutschland und Österreich, identisch ist, steht er in der Datei holidays_de.properties. Hier stehen alle länderübergreifenden deutschen Namen.

Der Name des zweiten Weihnachtsfeiertag unterscheidet sich zwischen Deutschland und Österreich. Daher steht der boxing-day Eintrag in den Dateien holidays_de_DE.properties für Deutschland und holidays_de_AT.properties für Österreich. Wird die Sprache ohne Länderzusatz genutzt, wird nur in holidays_de.properties gesucht. Daher steht hier auch noch einmal der Feiertagsname und zwar in der wahrscheinlicheren Variante (Deutschland).

Implementierung

Die internen Datenstrukturen müssen entsprechen angepasst werden, dass sie nun statt den Namen, den Key für das Resource Bundle verwenden.

Die bisherige Kernmethode für die Abfrage der Feiertage für ein Land nutzt einen internen Cache für die Feiertagsberechnungen. Den Cache um beliebig viele Sprachvarianten zu erweitern, hat keinen besonderen Charme. Dafür gibt es schon genügend Regionen die den Cache bevölkern könnten.

public static Holidays in(Locale locale) {
  MapHolidays holidays = map.get(requireNonNull(locale));
  if (holidays != null) {
    return holidays;
  }
  return in(locale, getProvider(locale));
}

Hier hilft wieder einmal das Decorator Design-Pattern. Damit ist es möglich eine existierende Klasse ohne Vererbung mit neuen Funktionalitäten auszustatten. Im Beitrag Guard-Decorator wurde z.B. die Auslagerung von Prüfungen in Decorator Implementierungen vorgestellt.

Das bisherige Ergebnis vom Typ Holiday wird mit einer weiteren Implementierung von Holiday dekoriert, die sich um die Übersetzung kümmert.

public static Holidays in(Locale locale, Locale language) {
  MapHolidays holidays = map.get(requireNonNull(locale));
  if (holidays != null) {
    return new LocalizedHolidays(holidays, language);
  }
  return new LocalizedHolidays(in(locale, getProvider(locale)), language);
}

Die Klasse LocalizedHolidays wird mit dem gewünschten Locale für die Sprache initialisiert und lädt die entsprechende ResourceBundle Instanz.

@Override
public Optional<String> getHoliday(LocalDate date) {
  return wrapped.getHoliday(date).map(bundle::getString);
}

Die Methode getHoliday nimmt das Ergebnis der dekorierten Instanz entgegen und verwendet es als Key für das ResourceBundle. Da der Rückgabewert der Methode ein Optional ist, kann direkt dessen map Methode genutzt werden um die Übersetzung zu bestimmen.

Bei der getHolidays Method ist die Sache etwas komplizierter, denn hier wird nicht ein einzelner Feiertag abgefragt, sondern alle Feiertage des Jahres.

Eine klassische Fehlentwicklung in der Programmierung ist das Umschaufeln von Datenstrukturen. In vielen Anwendung finden sich Methoden, die Werte aus einer Collection umgeformt in eine neue Collection eingefügen. Dabei werden immer wieder neue Objekte erzeugt und die Attribute des einen Types teilweise identisch, teilweise verändert kopiert. Die Attribute werden meist nur kopiert, weil die Konstruktoren der Zielklassen dies verlangen und nicht, weil diese Daten benötigt werden. Auch hier eignet sich das Decorator Pattern um unnötiges Kopieren zu vermeiden.

@Override
public Map<LocalDate, String> getHolidays(int year) {
  return Collections.unmodifiableMap(new LocalizedMap(wrapped.getHolidays(year), bundle));
}

Dieses Mal wird ein Decorator für eine Map verwendet. Damit niemand versucht in diese Map zu schreiben, wird sie mit Collections.unmodifiableMap davor geschützt.

private static class LocalizedMap extends AbstractMap<LocalDate, String> {
  ...
  private class LocalizedEntry extends SimpleImmutableEntry<LocalDate, String> {
    private LocalizedEntry(LocalDate key, String value) {
      super(key, value);
    }

    @Override
    public String getValue() {
      return bundle.getString(super.getValue());
    }
  }

  @Override
  public int size() { return wrapped.size(); }

  @Override
  public String get(Object key) {
    String value = wrapped.get(key);
    return value == null ? null : bundle.getString(value);
  }

  @Override
  public Set<Entry<LocalDate, String>> entrySet() {
    return wrapped.entrySet().stream().map(e -> new LocalizedEntry(e.getKey(), e.getValue()))
        .collect(Collectors.toSet());
  }
}

Damit die Implementierung kurz bleibt, erweitert die LocalizedMap die AbstractMap aus der Java Standard API. Diese erwartet eine Implementierung der entrySet Methode. Mit ihrer Hilfe lassen sich viele Methoden einer Map realisieren.

Diese Methoden in der AbstractMap müssen nicht performant sein. Aus diesem Grund sind manche Methoden wie size und get hier zusätzlich überschrieben worden. Sie holen sich ihre Informationen direkt aus der dekorierten Map und gehen nicht den Umweg über das Entry-Set.

Fazit

Damit ist die Implementierung einer verbesserten Internationalisierung auch schon beendet. Es hat sich gezeigt, dass der Einsatz des Decorator Design Pattern, keine große Änderungen in der Gesamtimplementierung erfordert. Nun ist nur noch zu prüfen, ob die Übersetzungen stimmen.