Internationalisierung mit Java ResourceBundle und Kompilierabhängigkeiten

Keine Kommentare

Wie in vielen anderen Projekt mussten wir auch in meinem letzten Projekt einen Mechanismus zur Internationalisierung und Lokalisierung umsetzen. Wir starteten mit dem klassischem Konzept der Java ResourceBundles. Wie immer passten jedoch nach einigen Wochen die definierten Schlüssel in den Property-Dateien nicht mehr 100% zu den tatsächlich verwendeten im Projekt. Dies ist denke ich ein häufiges Problem im Zusammenhang mit Refactoring.

Inspiriert vom Internationalisierungs-Feature in Google’s Web Toolkit wollten wir eine Lösung erstellen, die Kompilierabhängigkeiten bietet und somit die Weiterentwicklung und kommende Refactorings erleichtert. GWT nutzt einen eigenen Compiler um den Client-Javascript-Code zu erzeugen. Als Ergebnis gibt es ein eigenes Kompilat für jede definierte Locale. Im Client wird dann die gewünschte Locale ausgewählt, z.B. basierend auf der Browser-Standard-Locale. Als Entwickler muss man dazu nur das Messages-Interface implementieren und in der Anwendung nutzen. Danach kann man alle Vorteile von normalem Java-Code nutzen, z.B. die Java-Referenz-Suche in der Entwicklungsumgebung. Zusätzlich schlägt der GWT-Compile fehl, wenn Übersetzungen in der Property-Dateien fehlen.

Unser Ziel: Statt

Messages.getString("example");

wollen wir

Messages.get().example();

aufrufen können.


Ein Java Proxy und ein Junit-Test zum Ersatz des GWT-Compilers ist alles, was wir dazu benötigen. Gar nicht schwer…

Wahrscheinlich haben Sie ein ResourceBundle mit einigen Texten definiert. Und vielleicht speichern Sie schon die Locale in einer ThreadLocal-Variable. Dies ist ein üblicher Ansatz, um die Locale-Informationen des aktuellen Thread zu speichern. Wir nutzen dazu einen einfachen ServletFilter und speichern die Locale im LocaleContextHolder von Spring. Dieser wird auch von Spring MVC oder Spring Security genutzt und passt perfekt für unsere Bedürfnisse. Falls Sie kein Spring nutzen, ist eine solche ThreadLocal-Variable aber auch schnell selbst implementiert.

Falls Sie wie beschrieben arbeiten, könnte Ihre Lösung zum Zugriff auf die Texte wie folgt aussehen:

   public final class Messages {
      ...
      public static String getString(String key) {
         ResourceBundle.getBundle(BUNDLE_NAME, LocaleContextHolder.getLocale()).getString(key)
      }
      ...
}

Als ersten Schritt wollen wir nun eine Art Fehlererkennung zur Kompilierzeit herstellen. Dazu erstellen wir ein Interface und definieren für jeden Text eine Methode.

public interface OurProjectMessages() {
   String example();
}

Und in unserer Message-Implementierung liefern wir das Interface ausimplementiert von einem Java Proxy. Zusätzlich verhindern wir den Zugriff auf die unsichere Methode getString(String key) und machen diese private.

public final class Messages {
   ...
   private static OurProjectMessages messages = (OurProjectMessages) Proxy.newProxyInstance(//
      OurProjectMessages.class.getClassLoader(),//
      new Class[] { OurProjectMessages.class }, //
      new MessageResolver());
 
   private static class MessageResolver implements InvocationHandler {
      @Override
      public Object invoke(Object proxy, Method method, Object[] args) {
         return Messages.getString(method.getName());
      }
   }
 
   public static OurProjectMessages get() {
      return messages;
   }
 
   private static String getString(String key) {
      ResourceBundle.getBundle(BUNDLE_NAME, LocaleContextHolder.getLocale()).getString(key)
   }
   ...
}

Fertig – Nun können wir auf unsere Messages wie im oberstem Codebeispiel gezeigt zugreifen (Messages.get().example();). Dies ist wirklich eine Erleichterung und hilft, den Überblick über die verwendeten Texte zu behalten. Aber dies ist nur die halbe Miete. Wir können noch immer vergessen neue Texte in den Property-Dateien zu definieren oder alte, nicht mehr benutzte Texte zu entfernen.

Die Lösung dazu ist die Implementierung eines JUnit-Tests. Der Test wird regelmäßig durch unseren Jenkins im Rahmen von continuous integration durchgeführt und färbt unsere Builds rot, sobald jemand unseren Text-Übersetzungen zu wenig Aufmerksamkeit geschenkt hat. Es gibt Tests in beide Richtungen – Überflüssige Texte in der Properties und fehlende Textübersetzungen:

   @Test
   public void shouldHaveInterfaceMethodForAllMessages() {
      ...
   }
   @Test
   public void shouldHaveMessagesForAllInterafaceMethods() {
      ...
   }
   ...

Die Tests liefern bei einem Fehler gut lesbare Fehlermeldungen – zum Beispiel:
...AssertionError: No translations for [messages_en.properties#example]
or
...AssertionError: No interface method for : [messages_en.properties#exampleNotExisting]

Die Implementierungs-Details sind im Demo-Projekt zu finden.

Im Blog beschrieben ist nur die einfachste Variante. Bei Interesse sollten Sie einen Blick auf das Demo-Project werfen. Dort finden Sie weitere Implementierungs-Details, z.B. die Behandlung von parameterisierten Texten Message.get().example("2","2011-31-01"); oder den Zugriff auf Anzeigetexten zu Enums Message.getEnumText(SomeEnum.EXAMPLE);. Das Ziel des Demo-Projekts war es, das Projekt so klein wie möglich zu halten. Daher sind einige Funktionen „per Hand“ programmiert, für die wir normalerweise Frameworks einsetzen. Die Stellen sind im Code dokumentiert.

Download Demo-Projekt

Tags

Daniel Reuter

Daniel gehört seit April 2009 zum Team der codecentric. Seine Schwerpunkte liegen in der Entwicklung von Enterprise-Anwendungen im Versicherungsumfeld. Daniel ist Allroundcodehacker, Architekturgedankenpfleger, Sauberkeitsprogrammierenthusiast, Teamzusammenarbeitshelfer und Versicherungsfachdomänenfreund.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.