Blog-Archiv

Montag, 26. August 2019

JSF Internationalization

Internationalization is important for applications that are used all over the world. In this Blog I'd like to present a way how this can be done with JSF.

Application Screenshots

Here are screenshots how the example application will look like. It features a language dropdown-list that translates the whole UI into the selected language when changed, without triggering a submit. Available languages are restricted to English, French and German in this example.

After choosing English:

Mind that the text in "Your Name" was not erased by the page-translation.
Mind further that the labels inside the language dropdown have been translated too.

After choosing French:

When clicking "Submit":

We see that the language setting survived the current HTTP request, because the URL changed, and this means the follower page was displayed through faces-redirect=true, so the page was not built as response to the first POST request that submitted the language.

File Structure

This is a Maven project, built in Eclipse like described in my first JSF-Blog. We don't need JPA here.

Following files can be copied unchanged into this new jsfI18n project:

  • pom.xml (just replace the Maven artifactId by jsfI18n)
  • ApplicationFacesConfig.java
  • beans.xml
  • web.xml

Sources

JSF Configuration

The JSF faces-config.xml in src/main/webapp/WEB-INF/ looks like this now:

<?xml version="1.0"?>
<faces-config
    xmlns="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-facesconfig_2_3.xsd"
    version="2.3">

  <application>
    <resource-bundle>
      <!-- filename without _language and .properties extensions -->
      <base-name>messages</base-name> 
      <!-- name of the globally available variable -->
      <var>translations</var>
    </resource-bundle>
    
    <locale-config>
      <default-locale>de</default-locale>
        
      <supported-locale>de</supported-locale>
      <supported-locale>fr</supported-locale>
      <supported-locale>en</supported-locale>
    </locale-config>
    
  </application>

</faces-config>

<resource-bundle>

The <resource-bundle> element inside <application> must give the path and basename of the property-files (e.g. messages_de.properties). If the files were in a sub-folder, e.g. src/main/resources/i18n/bundles/, this would be <base-name>i18n.bundles.messages</base-name>.

The <resource-bundle> also must give the name of the global variable that should hold all properties as key/value map. In this case the variable name is "translations", we will meet this again in XHTML views where it is used as translations['key'] or translations.key.

You could also load the bundle directly in an XHTML view:

<h:head>
  <f:loadBundle basename="messages" var="translations"/>
</h:head>

This may be the better solution for a big application where loading all resources initially would require lots of time and memory. Thus holding them per XHTML view is recommended, although text resources should be shared and reused. Unfortunately traditional property files do not provide an include-mechanism.

<locale-config>

The <locale-config> element contains languages supported by the application, and the fallback language (default-locale) in case the browser doesn't send one in HTTP header. In Java you can access this by FacesContext.getCurrentInstance().getApplication().getDefaultLocale() and FacesContext.getCurrentInstance().getApplication().getSupportedLocales().

The JSF view language will be the one the browser sends. (The browser defaults to the operating-system's language if not configured explicitly.) That means, I will see an English UI in case my browser's language is English, even when the default-locale was set to "de" like here. When we want the language to be the configured default-locale, we need to tell the JSF XHTML views to set their locale from some Java bean that we must provide. But let's look at the text resource bundles before.

Translations

Here are the resource bundle files, located in the src/main/resources directory. They are "traditional" property-files, in ISO-8859-1 encoding.

The left-side key will be used by the developer in the JSF XHTML view (e.g. "Your_Name"), the user will see the translation on the right side (e.g. "Votre nom").

messages.properties (English, default)

Your_Name = Your Name
Your_Language = Your Language
Language.de = German
Language.en = English
Language.fr = French
submit = Submit

Hello = Hello
with_language = with language

messages_de.properties (German)

Your_Name = Dein Name
Your_Language = Deine Sprache
Language.de = Deutsch
Language.en = Englisch
Language.fr = Französisch
submit = Absenden

Hello = Hallo
with_language = mit Sprache

messages_fr.properties (French)

Your_Name = Votre nom
Your_Language = Votre langue
Language.de = Allemand
Language.en = Anglais
Language.fr = Français
submit = Soumettre

Hello = Bonjour
with_language = avec langue

In Java, you can load resource bundles by calling ResourceBundle.getBundle("messages").

Java

This is located in src/main/java/ in a package fri/jsf/, to be created.

We need a session-scoped Java bean that gives us access to a language setting, here in the form of a String like "en" for English.

UserSettings.java

package fri.jsf;

import java.io.Serializable;
import java.util.*;
import javax.enterprise.context.SessionScoped;
import javax.faces.context.FacesContext;
import javax.inject.Named;

@Named
@SessionScoped
public class UserSettings implements Serializable
{
    private Locale locale;
    private List<String> languages;
    
    public String getLanguage() {
        return getLocale().getLanguage();
    }
    public void setLanguage(String language) {
        locale = Locale.forLanguageTag(language);
    }
    public List<String> getPossibleLanguages() {
        if (languages == null)    {
            languages = new ArrayList<>();
            Iterator<Locale> supportedLocales = FacesContext.getCurrentInstance().getApplication().getSupportedLocales();
            while (supportedLocales.hasNext())
                languages.add(supportedLocales.next().getLanguage());
        }
        return languages;
    }

    private Locale getLocale()    {
        if (locale == null)
            locale = FacesContext.getCurrentInstance().getApplication().getDefaultLocale();
            // in real life this should come from database
        return (locale != null) ? locale : Locale.getDefault();
    }
}

The @Named annotation is new in JSF 2.3, it was @ManagedBean in older versions. Mind that this bean is session-scoped and thus needs to be serializable. Would it contain fields, also these needed to be serializable, recursively.

The initial language comes from JSF FacesContext, and also the list of possible languages, both refer to <locale-config> in faces-config.xml.

The bean exposes the language as string taken from the private property locale, and it stores any change to that very property. Thus the language setting gets lost when the session ends.

XHTML

Here are the JSF XHTML views, located in src/main/webapp/, using the UserSettings Java bean as userSettings, which is a naming convention (→ first letter lowered) when the bean has no explicit name in its @Named annotation:

i18n.xhtml

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="http://xmlns.jcp.org/jsf/html"
      xmlns:f="http://xmlns.jcp.org/jsf/core">

    <h:head>
        <title>JSF Internationalization</title>
        <f:view locale="#{userSettings.language}"/>
    </h:head>
    
    <h:body>
        <h:form>
            <h:panelGrid columns="2">
                <h:outputLabel id="langLabel" for="lang">#{translations.Your_Language}</h:outputLabel>
                <h:selectOneMenu id="lang" value="#{userSettings.language}">
                    <f:selectItems
                        value="#{userSettings.possibleLanguages}" 
                        var="thisLanguage"
                        itemValue="#{thisLanguage}"
                        itemLabel="#{translations['Language.' += thisLanguage]}"
                    />
                    <f:ajax render="langLabel lang nameLabel submitButton" />
                    <!-- perform AJAX call when this field changes,
                        send values of fields with ids listed in "execute",
                        on response update fields with ids listed in "render"  -->
                </h:selectOneMenu>
                
                <h:outputLabel id="nameLabel" for="name">#{translations['Your_Name']}</h:outputLabel>
                <h:inputText id="name" value="#{userName}" />
            </h:panelGrid>
            
            <h:commandButton id="submitButton" value="#{translations['submit']}" 
                    action="i18n-response?faces-redirect=true"/>            
        </h:form>
        
    </h:body>
</html>

Following is the response view, named in action attribute of the command-button:

i18n-response.xhtml

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="http://xmlns.jcp.org/jsf/html"
      xmlns:f="http://xmlns.jcp.org/jsf/core">
      
    <h:head>
        <title>JSF Internationalization Done!</title>
        <f:view locale="#{userSettings.language}"/>
    </h:head>
    
    <h:body>
        #{translations['Hello']} 
        #{userName} 
        #{translations['with_language']} 
        #{translations['Language.'.concat(userSettings.language)]}!
    </h:body>

</html>

The answer to the question how the view knows about the language setting in UserSettings is in the <f:view/> element:

<f:view locale="#{userSettings.language}"/>

That's the way how you can explicitly set a language, directly in the view. (But you'd have to do this in any page, thus I think a JSF lifecycle listener would be a better solution.)

How to Implement Translations

JSF translations are coded in the view, and normally are quite simple. Just access the global map with a language-neutral key ('Your_Language' is in messages*.properties):

<h:outputLabel ....>#{translations['Your_Language']}</h:outputLabel>

This is the same as:

<h:outputLabel ....>#{translations.Your_Language}</h:outputLabel>

JSF expressions are normally enclosed in #{}, called deferred evaluation syntax. If you use ${}, called immediate evaluation syntax, it gets processed by the JSP engine, not the JSF lifecycle. Both forms can be XML-element content as well as XML-attribute content.

Sometimes translations get tricky and hard to understand. Look at the language-labels, and how they are built together using the JSF expression language (EL).

<f:selectItems
    value="#{userSettings.possibleLanguages}" 
    var="language"
    itemValue="#{language}"
    itemLabel="#{translations['Language.'.concat(language)]}"
/>

The <f:selectItems> element is nested inside the <h:selectOneMenu> and determines how to process the list-return of the UserSettings.getPossibleLanguages() call done in value. It defines a variable (var) named "language" which will always hold the current list item. Then it defines that the value of any item (itemValue) will be what is in the variable named "language". Finally the label (itemLabel) represents what the user will see inside the dropdown list:

The expression #{translations['Language.'.concat(language)]} first reads the variable "language", resulting e.g. in "en". Then it executes the concat() function of the String object 'Language.', passing the "language" value as parameter. Result is "Language.en", which is then used as key of the "translations" map. Look at the text resource bundle and you will find "Language.en" on the left side.

Isn't there a more readable way to solve this? I don't know any. The needed identifier is known at runtime only and must be built together by applying some rule.

Here is a variant, but even less readable:

<f:selectItems
    ....
    itemLabel="#{translations['Language.' += language]}"
/>

The += operator is in JSF EL what + is for String-context in Java, it concatenates left and right side.

How to Parameterize Text

Assumed the welcome-message is completely packed into a property like this:

userWelcome = Hello {0} with language {1}!

Then you can display it in the JSF view like this:

<h:outputFormat value="#{msgs.userWelcome}">
  <f:param value="#{userName}"/>
  <f:param value="#{translations['Language.'.concat(userSettings.language)]}"/>
</h:outputFormat>

Output would be the same as shown in the screenshot at start of this article.

How to Update the UI without Submit

Beside form submits, an HTML page can update itself using AJAX (Asynchronous JavaScript XML). The JSF <f:ajax> API allows a list of element-ids that should trigger the AJAX-call (inside "execute" attribute), and a list of element-ids that are updated on return of the call (inside "render" attribute). Both lists are space-separated.

<f:ajax render="langLabel lang nameLabel submitButton" />

I left out the execute attribute because the default trigger is the parent element, which is the targeted dropdown-menu. All elements having an id in "lang langLabel nameLabel submitButton" would be updated when the AJAX call returns.

In real world there would be much more UI-labels to be translated, so this list would be quite long. As it is not possible to use * or any wildcards in the "render" attribute, this solution is a maintenance problem!

Scoped Variables

This is not about translations any more now, it's about a variable scoping problem. Look at the result page text substitutions in i18n-response.xhtml:

    <h:body>
        #{translations['Hello']} 
        #{userName} 
        #{translations['with_language']} 
        #{translations['Language.'.concat(userSettings.language)]}!
    </h:body>

Where is the name "FritzTheCat" from the text field? It is referenced as #{userName} but the welcome says "Bonjour avec langue Français" - no "FritzTheCat"!
This name got lost because the variable userName is request-scoped. When you remove the faces-redirect=true directive in i18n.xhtml, the name would be there, because the first HTTP POST request would build the response.

How to fix this: either create a property "name" in UserSettings.java and use it as userSettings.name in XHTML, or create/use some other bean for the name that is not request-scoped.

Conclusion

I found it easy to internationalize a JSF application. Nevertheless some translation code tends to become unreadable. Immediate translation via AJAX may be a maintenance problem, but it is not really needed.




Keine Kommentare: