Introduction

Every web application should be internationalized, or at least be ready for it. Internationalization (i18n) means that the content of the site will change according to the locale of the user visiting it, where locale stands for "language and regional settings".

These are the aspects of i18n that should be considered:

  • language user choice

  • search engine indexing

  • static text

  • database text fields

  • enum values

Language user choice

Browsers send the user system language to the server at each request. Actually, some browsers allow users to override the system language. Still, that’s a very inconvenient way to choose language. You should also give the user a way to choose a different language for your site using something like a dropdown menu.

Search engine indexing

The chosen language should be reflected in the url for a number of reasons:

  • better search engine indexing

  • bookmarking

One way to put the language in the url would be to add a request parameter like ?lang=fr; another option is to add the language code in the servlet path, like example.com/fr/home. The Yada Framework supports both.

You can also use a full Locale code instead of the language code, like fr_CA for french in Canada.

Immutable (static) text

A typical web page usually shows some text that is statically typed, i.e. not added via a CMS backend but typed directly into the HTML code. This may be a title, a menu entry, an image caption, or a description. All this statically typed text can’t be used in a i18n application: the text must change according to the user language.

Database text fields

Traditional applications with just one language store a Java String attribute into a single table column in the database. With i18n applications, where a Java attribute can have as many values as the languages that are allowed, the single-column approach can’t work anymore. There are many solutions to this problem. The Yada Framework stores a localized String attribute into a dedicated table, where a row holds the value for a single instance in a single language.

For example, consider a Product with a non-localized "name" and a localized "description". The Database model could be like the following:

Table 1. Product
ID NAME

1

Mercury Ball

2

Jupiter Ball

Table 2. Product_description
PRODUCT_ID DESCRIPTION LOCALE

1

Small inflatable ball

en_GB

1

Piccola palla gonfiabile

it_IT

2

Big inflatable ball

en_GB

2

Grande palla gonfiabile

it_IT

Enum values

TO BE CONTINUED...

Initial language choice

When a user loads the site for the first time, the initial language will be chosen from the accept-language header sent by the browser. This is implemented in Spring’s CookieLocaleResolver but overridden in YadaWebConfig so that all values from the header are considered in order to find the first language chosen by the user that has also been configured in the webapp. If no values from the header are acceptable, the default configured language will be used. Configuration example:

	<i18n localePathVariable="true">
      <locale default="true">en</locale>
      <locale>ar</locale>
      <locale>de</locale>
      <locale>fr</locale>
   	</i18n>

On the following requests, the standard Spring cookie is used to determine the previously chosen locale.

Configuring "language in the path"

URL Example:

http://www.example.com/it/myHome

Java code

If your project does not use YadaSecurity, change WebApplicationInitializer in order to add a servlet filter:

@Override
protected Filter[] getServletFilters() {
      // Locale in the path
      // See https://stackoverflow.com/a/23847484/587641
      return new Filter[] { new DelegatingFilterProxy("yadaLocalePathVariableFilter") };
}

The above adds a filter to the Spring servlet engine. It is not needed when using YadaSecurity because the same is already done in net.yadaframework.security.SecurityWebApplicationInitializer.

The "language in the path" functionality is implemented in YadaLocalePathVariableFilter, YadaLocalePathChangeInterceptor, YadaLinkBuilder

Application configuration

Edit the conf.webapp.prod.xml configuration file (were 'prod' is the environment code for "production") adding a section like the following:

<i18n localePathVariable="true">
      <locale>it</locale>
      <locale default="true">en</locale>
      <locale>de</locale>
      <locale>es</locale>
      <locale>fr</locale>
      <locale>ru</locale>
</i18n>

Note the use of the "default" attribute, that selects the default language as explained later.

Other than just the language, you can use a full locale code though this is rarely needed:

<i18n localePathVariable="true">
      <locale>it_IT</locale>
      <locale default="true">en_GB</locale>
      <locale>en_US</locale>
      <locale>es_ES</locale>
      <locale>fr_FR</locale>
      <locale>fr_CA</locale>
</i18n>

You can also configure a country to be added to the locale after the request has been received. This way you can still use just the language code in the url but receive a full Locale in the java @Controller:

<i18n localePathVariable="true">
      <locale country="IT">it</locale>
      <locale country="GB" default="true">en</locale>
      <locale country="DE">de</locale>
      <locale country="ES">es</locale>
      <locale country="FR">fr</locale>
      <locale country="RU">ru</locale>
</i18n>

Using "language in the path"

Java

Language on redirect

When returning a redirect string, the language path should be present: /fr/products. The method YadaWebUtil.redirectString() can add the needed language to the url, and also any parameters (see javadoc):

return YadaWebUtil.redirectString("/products", locale, "id", "172");

The YadaWebUtil.redirectString() returns the "redirect:" prefix too. In order to create a string without that prefix, use YadaWebUtil.enhanceUrl().

URL with no language

The default language is also needed when someone types just the server address without path from a browser in a language that is not in the configuration. In such case, the default language should be used:

@RequestMapping("/")
public String home(Model model, HttpServletRequest request, Locale locale) {
        if (YadaLocalePathChangeInterceptor.localePathRequested(request)) {
                // Language was in the url
                return home(model, request);
        }
        // Language was not in the url
        String currentLanguage = locale.getLanguage();
        if (!config.getLocaleStrings().contains(currentLanguage)) {
                // Not a configured locale - use the default one
                Locale defaultLocale = config.getDefaultLocale();
                if (defaultLocale==null) {
                        // Default locale was not configured - use english
                        defaultLocale = Locale.ENGLISH;
                }
                currentLanguage = defaultLocale.getLanguage();
        }
        return "redirect:/" + currentLanguage + "/home"; // Moved temporarily
The default language redirect should be implemented in YadaLocalePathVariableFilter

HTML

The standard Thymeleaf @{url} syntax has been retrofitted to automatically handle language in the path: the current locale will be added at the start of every url, so @{/home} becomes /de/home for example.

A language menu can be easily implemented with code like this:

<select id="langmenu">
      <option value="en" th:selected="${#locale.language=='en'}">EN</option>
      <option value="it" th:selected="${#locale.language=='it'}">IT</option>
</select>

Javascript

The language in the path variable can be changed via javascript using

yada.changeLanguagePathVariable(locale);

where "locale" is the ISO2 locale code. This code could be called when choosing from the list of languages:

$("#langmenu").change(function() {
      var locale = $(this).val();
      yada.changeLanguagePathVariable(locale);
});

Configuring "language request parameter"

URL Example:

http://www.example.com/myHome?lang=it

This is easier to configure because you don’t need to change the Java code. The application configuration is the same but you need to set localePathVariable="false".

Check that this stil works and what it does. I think YadaWebUtil.enhanceUrl() doesn't work properly

Coding with i18n text

Static text

To implement localized static text just use the standard Spring "MessageSource" concept: store all text in different message.properties files, indexed by a key.

The Yada Framework expects message source files to be in the WEB-INF/messages folder, with a file name in the messages[_<lang>].properties format. Example:

messages_de.properties
messages_fr.properties
messages_ru.properties
messages.properties

Each file stores the text of a different language. You don’t need to add them all immediately: start from the default language in messages.properties then add the translations when they become needed. The default language can be any language that you consider to be the "base" language: all keys that are not found in a specific language are searched in the default one; when not found, the key is shown as text.

The content of the file is in the standard Java "MessageFormat" format:

<key> = <value>

Example:

validation.empty = This value can't be empty
validation.password.length = Password can''t be shorter than {0} characters and longer than {1}
files.total = There {0,choice,0#are no files|1#is one file|1<are {0,number,integer} files}.

In particular:

  • {0} and {1} are ways of passing parameters

  • when a parameter is specified, a single quote must be escaped by another single quote

  • there’s a powerful way of specifying variations like singular/plural (choice format)

In production, files are reloaded every 600 seconds (10 minutes) to pick up changes.

The Message Source configuration is implemented in YadaAppConfig.messageSource()

Usage with Thymeleaf

The syntax to show a localized string in Thymeleaf is #{<key>}. Example:

<p th:text="#{validation.empty}">Any placeholder text here will be overwritten</p>

See the Thymeleaf docs for more details.

Emails

Email templates can use the same message properties of HTML files, or be saved in separate files, one per language. See Internationalization in the email chapter.

Usage in Java

To get the localized text in java you first autowire a MessageSource bean, then use the getMessage() method:

@Autowired private MessageSource messageSource;

public String someMethod(Locale locale) {
  String msg1 = messageSource.getMessage("validation.empty", null, locale);
  String msg2 = messageSource.getMessage("validation.password.length", new Object[]{5, 10}, locale);

Database fields

The Yada Framework uses the table-per-attribute approach to multivalue string attributes. An @Entity with a localized string attribute can be defined with a Map<Locale, String> so that values are related to their locale:

@ElementCollection
@Column(length=8192)
@MapKeyColumn(name="locale", length=32)
@CollectionTable(
	uniqueConstraints = @UniqueConstraint(columnNames={"MyEntityName_id", "locale"})
)
private Map<Locale, String> description = new HashMap<>();

The uniqueContratints (optional) annotation ensures that there can’t be two values for a given locale. The "MyEntityName_id" value should be the actual column name in the ElementCollection table: it usually is the name of the Entity followed by "_id" with a first capital letter.

To retrieve the value in a specific locale, use YadaUtil.getLocalValue(). This will return the value in the specified locale or null. If a default locale has been configured (see Application configuration above) then the default locale will be tried before returning null. This is useful when all locales have the same value and you only want to set it once: the value for the default language will be "inherited" by all current and future configured languages.

String productDesc = YadaUtil.getLocalValue(product.getDescription(), locale);
String productDesc = YadaUtil.getLocalValue(product.getDescription()); // Use current locale

It can be very convenient to add to the entity a method that retrieves the value in the current locale (the locale of the current request):

@Entity
public class Product {
  ...
  @ElementCollection
  @Column(length=8192)
  @MapKeyColumn(name="locale", length=32)
  @CollectionTable(
    uniqueConstraints = @UniqueConstraint(columnNames={"Product_id", "locale"})
  )
  private Map<Locale, String> description = new HashMap<>();

  ...
  public String getDescriptionLocal() {
    return YadaUtil.getLocalValue(description);
  }

This allows for a simple use in Thymeleaf:

<p th:text="${product.descriptionLocal}">Some description</p>

Be careful that Maps are lazy by default, so the localized value won’t be returned outside of a transaction. The solution is to either prefetch the map in the DAO (most efficient solution) or to eagerly load it (simpler implementation).

Prefetching in the DAO can be done by simply calling a .size() or by using YadaUtil.prefetchLocalizedStrings() and similar methods:

public Product findProduct(Long id) {
	Product product = em.find(Product.class, id);
	// Either call .size()
	product.getDescription().size();
	// Or prefetch all localized strings via reflection
	YadaUtil.prefetchLocalizedStrings(product, Product.class);
	return product;
}

In order to eagerly load the attribute, use FetchType.EAGER together with FetchMode.SELECT:

@ElementCollection(fetch = FetchType.EAGER)
@Fetch(FetchMode.SELECT)
@Column(length=8192)
@MapKeyColumn(name="locale", length=32)
@CollectionTable(
	uniqueConstraints = @UniqueConstraint(columnNames={"Product_id", "locale"})
)
private Map<Locale, String> description = new HashMap<>();
not using FetchMode.SELECT may result in a cross join that loads a huge amount of values into memory, possibly causing an OutOfMemory exception!