Internationalization is not the most loved topic for Java developers. This is merely because it makes code less readable and introduces the need to work with property files and constants. As a result there is no static typing in that area and we are in need to get supported by tools, such as IDEs. In this blog post I want to show how this can be elegantly solved with Xtend.
I18n - The JDK Way
The JDK offers well working facilities for externalizing messages and formatting values such as dates and currencies in a proper localized way. For externalizing messages the class ResourceBundle
is your friend, as it not only reads and parses property files but also supports flexible way of how property values are looked up based on Locales. The other interesting bit is the MessageFormat
hierarchy which contains strategies to turn data types into localized strings.
The provided functionality is very powerful but enforces a cumbersome programming model. The standard way is to have a bunch of property files and whenever you need to get the contained values you use a ResourceBundle
which has to be created like this:
ResourceBundle messages = ResourceBundle.getBundle("MyMessages", localeToUse);
Now you obtain the messages using the property keys, which are just strings. To minimize very likely spelling problems and it is good practice to have a declaring all the keys as constants. Then you only have to declare them once in the code and once for each property file.
public class MyMessagesKeys { public final static GREETING = "greeting"; ... }
Clients can access the values using the constants :
messages.getString(MyMessagesKeys.MY_KEY);
A common requirement and feature is to have variables in the messages which should provided by the program. These arguments can even be of different types, where the MessageFormat
types come in. But again the Java compiler doesn't know if there are any arguments, nor how many and of which types they are. So you are back in untyped land and will learn about your errors only at runtime.
Let's see how we can improve the API for the JDK classes, such that we get rid of all the duplication and weakly typed access.
DRY With Xtend
Xtend is not a language which is meant to be used instead of Java, but is an extension to Java. You should use it in places where Java just doesn't cut it, like in this example.
In the following we're going to built a statically typed API to be used from Java by means of an active annotation. The basic idea is, that we make the compiler turn a constant containing a message into a Java facade and a corresponding properties file. Consider the following declaration of messages:
@Externalized class MyMessages { val GREETING = "Hello {0}!" val DATE_AND_LOCATION = "Today is {0,date} and you are in {1}" }
This is all the developer should need to declare. Now we implement an active annotation, that turns the field declarations into static methods. It even adds typed arguments for the placeholders in the messages. That is the generated Java provides the following sigatures:
// Generated Java signatures /** * Hello {0}! */ public static String GREETING(String arg0) { ... } /** * Today is {0,date} and you are in {1} */ public static String DATE_AND_LOCATION(Date arg0, String arg1) { ... }
The actual values are written to a *.properties
file and the implementation of the static methods use a ResourceBundler
to look them up. So in case you need a locale-specific translation, you only need to add an additional *.properties file. Even that could be easily validated for miss-spelled or non declared keys. But I'll leave this feature as an exercise for other people :-).
Java clients can now access the messages in a statically typed way, and if you are working within Eclipse you even get the default message displayed when hovering over a property.
Building The @Externalized Annotation
Developing the active annotation is relatively easy. First you need to create a separate Java project. Although an active annotation is just library, it has to sit in an upstream project or jar, i.e. cannot be used within the same project. We would run into chicken 'n egg problems during compilation if that was allowed.
In that project create a new Xtend file and name it Externalized.xtend
. We first declare the annotation and annotate it with @Active
so the compiler is aware of it being an active annotation. We also have to provide a processor class which is executed by the compiler:
@Active(ExternalizedProcessor) annotation Externalized {} class ExternalizedProcessor extends AbstractClassProcessor { override doTransform(MutableClassDeclaration clazz, extension TransformationContext ctx) { // To be implemented ... } override doGenerateCode(ClassDeclaration clazz, extension CodeGenerationContext ctx) { // To be implemented ... } }
As you can see two callback methods have been overridden, which correspond to certain phases in the compiler. The first phase we participate in is doTransform
in which the annotated classes can be mutated. The second phase doGenerateCode
allows us to write to the file system. We will later write the properties files during this phase. The doGenerateCode
phase has been introduced in version 2.4.3, so make sure you have updated accordingly if you want to use this.
Step 1: Transforming The Fields Into Methods
We want to turn all field declarations into static methods, so we first iterate over the declared fields of the given class.
override doTransform(MutableClassDeclaration clazz, extension TransformationContext ctx) { for (field : clazz.declaredFields) { // get the actual value of the field val initializer = field.initializerAsString // create a message format object for that value val msgFormat = try { new MessageFormat(initializer) } catch(IllegalArgumentException e) { field.initializer.addError("invalid format : " + e.message) new MessageFormat("") } // check the syntax and report back in case of problems val formats = msgFormat.formatsByArgumentIndex if(msgFormat.formats.length != formats.length) { field.initializer.addWarning('Unused placeholders. They should start at index 0.') } // add a method using the field's name clazz.addMethod(field.simpleName) [ // return type is always string and the method is static returnType = string static = true // add parameters for the given arguments in the message format object formats.forEach [ format, idx | addParameter("arg" + idx, switch format { NumberFormat: primitiveInt DateFormat: Date.newTypeReference() default: string }) ] // add the value as a comment, for documentation purpose docComment = initializer // add the actual body. It's generated Java code. val params = parameters body = [ ''' try { String msg = RESOURCE_BUNDLE.getString("«field.simpleName»"); «IF formats.length > 0» msg = «toJavaCode(MessageFormat.newTypeReference)» .format(msg,«params.map[simpleName].join(",")»); «ENDIF» return msg; } catch («toJavaCode(MissingResourceException.newTypeReference)» e) { // TODO error logging return "«initializer»"; } '''] ] } // now that we have the methods we can just remove the fields, // as they are no longer needed clazz.declaredFields.forEach[remove] // add a static ResourceBundle to be used from the methods we just created. clazz.addField("RESOURCE_BUNDLE") [ static = true final = true type = ResourceBundle.newTypeReference initializer = ['''ResourceBundle.getBundle("«clazz.qualifiedName»")'''] ] }
Now we have successfully transformed an @Externalized
class written in Xtend into a Java class containing corresponding static methods.
Step 2: Generating The Properties File
The last thing we need to do now is to write the values of the fields into the properties file. For this we participate in the code generation phase by overriding doGenerateCode
. While during doTransform
we got a mutable Java representation of the original sources, in this phase we get the unmodifiable original source passed in.
override doGenerateCode(ClassDeclaration clazz, extension CodeGenerationContext ctx) { // obtain the target folder for the given compilation unit val targetFolder = clazz.compilationUnit.filePath.targetFolder // compute the path for the properties file val file = targetFolder.append(clazz.qualifiedName.replace('.', '/') + ".properties") // write the contents to the file file.contents = ''' «FOR field : clazz.declaredFields» «field.simpleName» = «field.initializerAsString» «ENDFOR» ''' }
Check Out The New Release
The described example is included in the "active annotations examples" coming with latest release.