How to Improve Localized String Formatting in Java

Java localization often involves many stringly typed things where one stray character can break an otherwise simple operation. This tutorial demonstrates how to improve correctness in localized applications.

Steps

  1. Check out the base project

    Follow the steps in How to Set Up, Compile and Run an AutoRes.uk Project

  2. Inspect the localized resource bundle

    The files that match src/main/resources/com/example/i18n*.properties form a resource bundle. English (root), german (de), french (fr) and italian (it) are included in the project.

  3. Start with a traditional resource bundle

    This uses the ResourceBundle type.

    Replace the contents of Hello.java with:

    package com.example;
    
    import java.util.Locale;
    import java.util.ResourceBundle;
    
    public class Hello {
      public static void main( String[] args ) {
        Locale[] locales = {Locale.ROOT, Locale.FRENCH, Locale.GERMAN, Locale.ITALIAN};
        for (Locale l : locales) {
          ResourceBundle bundle = ResourceBundle.getBundle("com.example.i18n", l);
          String msg = bundle.getString("hello-world");
          System.out.println(l + "=" + msg);
        }
      }
    }

    Compile and run the application:

    ./mvnw --quiet clean compile exec:java

    The localized messages will be printed to the console.

  4. Make the bundle less stringly typed

    Bundles can have a lot of properties. If a property name is misspelled or the property is missing then null is returned.

    Annotate the class with @uk.autores.Keys("i18n.properties").

    Replace ResourceBundle.getBundle("com.example.i18n", l); with ResourceBundle.getBundle(I18N.bundle(), l);.

    Replace bundle.getString("hello-world"); with bundle.getString(I18N.HELLO_WORLD);.

    Recompile and run the application.

    Keys are now verified during compilation.

  5. Add some string composition

    A common requirement in localization is to compose strings from variables. This is often done using MessageFormat. The root property is iso-80000-defines=ISO 80000 defines {0} to be {1,number} bytes..

    Replace the contents of Hello.java with:

    package com.example;
    
    import uk.autores.Keys;          
    import java.text.MessageFormat;
    import java.util.Locale;
    import java.util.ResourceBundle;
    
    @Keys("i18n.properties")
    public class Hello {
      public static void main(String[] args) {
        Locale[] locales = {Locale.ROOT, Locale.FRENCH, Locale.GERMAN, Locale.ITALIAN};
        for (Locale l : locales) {
          printBytesPerUnit(l, "GB", 1024*1024*1024);
        }
      }
     
      private static void printBytesPerUnit(Locale l, String unit, int bytes) {
        ResourceBundle bundle = ResourceBundle.getBundle(I18N.bundle(), l);
        String pattern = bundle.getString(I18N.ISO_80000_DEFINES);
        Object[] params = {unit, bytes};
        MessageFormat mf = new MessageFormat(pattern, l);
        String msg = mf.format(params);
        System.out.println(l + "=" + msg);
      }
    }

    Compile and run the application:

    ./mvnw --quiet clean compile exec:java

    The localized messages will be printed to the console.

  6. Generate a localized message method

    Replace the contents of Hello.java with:

    package com.example;
    
    import uk.autores.Messages;
    import java.util.Locale;
    
    @Messages("i18n.properties")
    public class Hello {
      public static void main(String[] args) {
        Locale[] locales = {Locale.ROOT, Locale.FRENCH, Locale.GERMAN, Locale.ITALIAN};
        for (Locale l : locales) {
          String msg = I18N.iso80000Defines(l, "GB", 1024*1024*1024);
          System.out.println(l + "=" + msg);
        }
      }
    }

    Compile and run the application:

    ./mvnw --quiet clean compile exec:java

    The localized messages will be printed to the console.

  7. Add a new property

    Add the following to i18n.properties. Do not edit the other properties files.

    hello=Hello {0}!

    Compile the application:

    ./mvnw clean compile

    Compilation fails because the new property is missing from the localized files. This feature lets developers guard against missing translations.

    Alter the annotation to be:

    @Messages(value = "i18n.properties",
              missingKey = Severity.WARN)

    Recompile the application.

    Compilation succeeds, with warnings. This allows development to continue while waiting for new translations. missingKey should be set to ERROR before release.

Tradeoffs

The goal of the library is to provide convenience and correctness. However, light testing with the Java Microbenchmark Harness suggests performance benefits.

The benchmark result below compares using ResourceBundle & MessageFormat versus @Messages to format the iso-80000-defines property.

Benchmark      Mode  Cnt       Score      Error  Units
Hello.bundle  thrpt   25  131580.518 ± 1640.646  ops/s
Hello.method  thrpt   25  261516.097 ± 6170.135  ops/s

The generated method is about twice as fast based on throughput.

In the generated method the format template is analysed at compile time. The runtime code is composed of simpler concatenation operations. Buffer sizes can be estimated ahead of time to reduce resizing.

Potential downsides are increased compilation times and greater class file overheads.