Wednesday, January 16, 2013

i18n of a Play Framework 2.0.4 Java App / Website

This post shows how I internationalized the social surfing website and app surfr.co that is built with Play. Feel free to take from it what serves you, or comment on what could be done better.

My setup:
  • Play Framework 2.0.4 using Java
  • A standard website where the language code is in the url (example: /en/pagename), and ajax requests where it's not.
  • I'm only using 2-letter language codes like "en", no countries like "en_US".
What you need:
  • Basic knowledge of Play.
  • Small to medium-sized website/app. I don't recommend it for large sites.

Some things are a bit different for Play using Scala, or for Play version 2.1 (which is not released yet). But parts may still be of use for you.

The documenation is at http://www.playframework.org/documentation/2.0.4/JavaI18N

Step 1: Define the languages.

In conf/application.conf add/edit the variable:
application.langs="en,de,ru"

Note:
  • I had to put the values in double-quotes. No quotes as in the documentation failed.
  • Order the languages by priority.

Step 2: Add the texts

As in the documentation create the files conf/messages, conf/messages.de and conf/messages.ru.
Create the files as UTF-8 files so that any text works, not just western. And don't add the BOM because Play 2.0.4 can't handle such files. On Windows you can create the files with Notepad: "save as..." and choose UTF-8. 

Note: 
  • If a text is not translated to a certain language then it always falls back to the default language.
  • Use 2 single quotes instead of 1. Example: Sam''s Pizzaland. Standard Java message file rules apply.
  • It doesn't seem to be supported to separate content by file. All goes into the one messages file. As I said... small/medium sized sites only.

Step 3: Figure out that the default handling isn't suitable

Now you're ready to use the texts in Java code and in templates. 
Messages.get("my.message.key")
Messages.get(new Lang("en"), "my.message.key")
The problem: Either you pass the user's language around everywhere (no way), or you settle with the built-in language selection (maybe). That is: using the first language that the user's browser settings mention for which you have translations.
For me, that was not acceptable. My website lets the user change the language. Unfortunately, Play does not offer a simple way to overwrite the lookup of the default language. I read that it will be supported in version 2.1, and that there are easier ways for overriding in the Scala version. So here's what I did.

Step 4: Implement language selection and lookup

I intercept each request to figure out the best language.

Create a new packageless (ouch) class named Global (ouch).
public class Global extends GlobalSettings {
    @Override
    public Action onRequest(final Request request, Method actionMethod) {
        I18n.defineBestLang(request);
        return super.onRequest(request, actionMethod);
    }
}

Note: it should theoretically be possible to name the class differently and put it into a package by configuring application.global= in the application.conf file, but it did not work for me.

The defineBestLang() method goes by this priority:
  1. Read from the url. My web urls (not ajax urls) contain the language in the form of /en/pagename.
  2. Read from cookie. For the app I'm using a 'lang' cookie.
  3. The browser's language settings, just like default Play. Something like Lang.preferred(request.acceptLanguages())
  4. Hardcoded default language.

It then stores the information in a ThreadLocal. Yes it works, I'm using just 1 thread per request. You can also use it as a start and pass it around where needed.

At this point the language is always available in controllers, templates, services. 
My I18n class also has static methods for getting the language code, and String and Html text for a certain key for the "best" language.
In order to not include that I18n class in all templates, I added it to the Build.scala file: 
templatesImport += "mypackage.I18n"

Step 5: Use the text

In a template I can now instead of @Messages.get("my.message.key") just use @I18n.text("my.message.key") for a pure text or @I18n.html("my.message.key") for html formatting.

Step 6: Routing

The front page has 2 entries in the routes file. For example the front page:
GET     /                             controllers.ControllerFactory.getApplication.index()
GET     /$lang<[a-z]{2}>   controllers.ControllerFactory.getApplication.indexI18n(lang:String)

public Result index() {
    return i18nRedirect();
}
public Result indexI18n(String lang) {
    return ok(views.html.Surfrco.index.render());
}
protected Result i18nRedirect() {
    return redirect("/"+ I18n.code()+request().uri());
}

The guest's language is auto-detected, and he's redirected to his language. This way I'm not serving duplicate content on multiple URLs. Currently I'm doing the same with other content pages (2 routes, with and without language) but it's not really necessary as long as no one links there.

Step 7: Client side i18n

My server-side messages file contains all texts as used in Java code, plus static webpage content. The client side only needs a couple phrases in JavaScript. That's why I've decided against streaming the whole messages file to the client. Instead I've created 3 files (UTF-8 again) messages.en.js etc. and serve only the one to the client:

The file's content is of the form:
function initMessages(m) {
m['my.text.key']="My Text";
...
}
if (typeof srfMessages == 'undefined') srfMessages = {};
initMessages(srfMessages);

And elsewhere is a very simple function to retrieve texts:

function t(key) {
    return srfMessages[key];
}

Note that in contrast to the server side, there is no fallback here to the default language.

And that's how i18n works for surfr.co.

No comments:

Post a Comment