Internationalization, frequently abbreviated i18n, is the process of designing an application that can be translated into various languages.
This generally involves placing translations in properties bundles with their names suffixed with the target locale, for instance, the messages_en.properties
, messages_en_US.properties
, and messages_fr.properties
files.
The correct property bundle is resolved by trying the most specific locale first and then falling back to the less specific ones.
For U.S English, if you try to get a translation from a bundle named x
, the application would first look in the x_en_US.properties
file, then the x_en.properties
file, and finally, the x.properties
file.
The first thing we will do is translate our error messages into French. To do this, we will rename our existing messages.properties
file to messages_en.properties
.
We will also create a second bundle named messages_fr.properties
:
Size.profileForm.twitterHandle=Veuillez entrer votre identifiant Twitter Email.profileForm.email=Veuillez spécifier une adresse mail valide NotEmpty.profileForm.email=Veuillez spécifier votre adresse mail PastLocalDate.profileForm.birthDate=Veuillez donner votre vraie date de naissance NotNull.profileForm.birthDate=Veuillez spécifier votre date de naissance typeMismatch.birthDate = Date de naissance invalide.
By default, Spring Boot uses a fixed LocaleResolver
interface. The LocaleResolver
is a simple interface with two methods:
public interface LocaleResolver { Locale resolveLocale(HttpServletRequest request); void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale); }
Spring provides a bunch of implementations of this interface, such as FixedLocaleResolver
. This local resolver is very simple; we can configure the application locale via a property and cannot change it once it is defined. To configure the locale of our application, let's add the following property to our application.properties
file:
spring.mvc.locale=fr
This will add our validation messages in French.
If we take a look at the different LocaleResolver
interfaces that are bundled in Spring MVC, we will see the following:
FixedLocaleResolver
: This fixes the locale defined in configuration. It cannot be changed once fixed.CookieLocaleResolver
: This allows the locale to be retrieved and saved in a cookie.AcceptHeaderLocaleResolver
: This uses the HTTP header sent by the user's browser to find the locale.SessionLocaleResolver
: This finds and stores the locale in an HTTP session.These implementations cover a number of use cases, but in a more complex application one might implement LocaleResolver
directly to allow more complex logic such as fetching the locale from the database and falling back to browser locale, for instance.
In our application, the locale is linked to the user. We will save their profile in session.
We will allow the user to change the language of the site using a small menu. That's why we will use the SessionLocaleResolver
. Let's edit WebConfiguration
once more:
package masterSpringMvc.config; import masterSpringMvc.date.USLocalDateFormatter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; import org.springframework.web.servlet.i18n.SessionLocaleResolver; import java.time.LocalDate; @Configuration public class WebConfiguration extends WebMvcConfigurerAdapter { @Override public void addFormatters(FormatterRegistry registry) { registry.addFormatterForFieldType(LocalDate.class, new USLocalDateFormatter()); } @Bean public LocaleResolver localeResolver() { return new SessionLocaleResolver(); } @Bean public LocaleChangeInterceptor localeChangeInterceptor() { LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor(); localeChangeInterceptor.setParamName("lang"); return localeChangeInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(localeChangeInterceptor()); } }
We declared a LocaleChangeInterceptor
bean as a Spring MVC interceptor. It will intercept any request made to Controller
and check for the lang
query parameter. For instance, navigating to http://localhost:8080/profile?lang=fr
would cause the locale to change.
Spring MVC Interceptors can be compared to Servlet filters in a web application. Interceptors allow custom preprocessing, skipping the execution of a handler, and custom post-processing. Filters are more powerful, for example, they allow for exchanging the request and response objects that are handed down the chain. Filters are configured in a web.xml
file, while interceptors are declared as beans in the application context.
Now, we can change the locale by entering the correct URL ourselves, but it would be better to add a navigation bar allowing the user to change the language. We will modify the default layout (templates/layout/default.html
) to add a drop-down menu:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no"/> <title>Default title</title> <link href="/webjars/materializecss/0.96.0/css/materialize.css" type="text/css" rel="stylesheet" media="screen,projection"/> </head> <body> <ul id="lang-dropdown" class="dropdown-content"> <li><a href="?lang=en_US">English</a></li> <li><a href="?lang=fr">French</a></li> </ul> <nav> <div class="nav-wrapper indigo"> <ul class="right"> <li><a class="dropdown-button" href="#!" data-activates="lang-dropdown"><i class="mdi-action-language right"></i> Lang</a></li> </ul> </div> </nav> <section layout:fragment="content"> <p>Page content goes here</p> </section> <script src="/webjars/jquery/2.1.4/jquery.js"></script> <script src="/webjars/materializecss/0.96.0/js/materialize.js"></script> <script type="text/javascript"> $(".dropdown-button").dropdown(); </script> </body> </html>
This will allow the user to choose between the two supported languages.
The last thing we need to do in order to have a fully bilingual application is to translate the titles and labels of our application. To do this, we will edit our web pages and use the th:text
attribute, for instance, in profilePage.html
:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorator="layout/default"> <head lang="en"> <title>Your profile</title> </head> <body> <div class="row" layout:fragment="content"> <h2 class="indigo-text center" th:text="#{profile.title}">Personal info</h2> <form th:action="@{/profile}" th:object="${profileForm}" method="post" class="col m8 s12 offset-m2"> <div class="row"> <div class="input-field col s6"> <input th:field="${profileForm.twitterHandle}" id="twitterHandle" type="text" th:errorclass="invalid"/> <label for="twitterHandle" th:text="#{twitter.handle}">Twitter handle</label> <div th:errors="*{twitterHandle}" class="red-text">Error</div> </div> <div class="input-field col s6"> <input th:field="${profileForm.email}" id="email" type="text" th:errorclass="invalid"/> <label for="email" th:text="#{email}">Email</label> <div th:errors="*{email}" class="red-text">Error</div> </div> </div> <div class="row"> <div class="input-field col s6"> <input th:field="${profileForm.birthDate}" id="birthDate" type="text" th:errorclass="invalid"/> <label for="birthDate" th:text="#{birthdate}" th:placeholder="${dateFormat}">Birth Date</label> <div th:errors="*{birthDate}" class="red-text">Error</div> </div> </div> <div class="row s12 center"> <button class="btn indigo waves-effect waves-light" type="submit" name="save" th:text="#{submit}">Submit <i class="mdi-content-send right"></i> </button> </div> </form> </div> </body> </html>
The th:text
attribute will replace the contents of a HTML element with an expression. Here, we use the #{}
syntax, which indicates we want to display a message coming from a property source like messages.properties
.
Let's add the corresponding translations to our English bundle:
NotEmpty.profileForm.tastes=Please enter at least one thing profile.title=Your profile twitter.handle=Twitter handle email=Email birthdate=Birth Date tastes.legend=What do you like? remove=Remove taste.placeholder=Enter a keyword add.taste=Add taste submit=Submit
Now to the French ones:
NotEmpty.profileForm.tastes=Veuillez saisir au moins une chose profile.title=Votre profil twitter.handle=Pseudo twitter email=Email birthdate=Date de naissance tastes.legend=Quels sont vos goûts ? remove=Supprimer taste.placeholder=Entrez un mot-clé add.taste=Ajouter un centre d'intérêt submit=Envoyer
Some of the translations are not used yet, but will be used in just a moment. Et voilà! The French market is ready for the Twitter search flood.
We now want the user to enter a list of "tastes", which are, in fact, a list of keywords we will use to search tweets.
A button will be displayed, allowing our user to enter a new keyword and add it to a list. Each item of this list will be an editable input text and will be removable thanks to a remove button:
Handling list data in a form can be a chore with some frameworks. However, with Spring MVC and Thymeleaf it is relatively straightforward, when you understand the principle.
Add the following lines in the profilePage.html
file right below the row containing the birth date, and just over the submit button:
<fieldset class="row"> <legend th:text="#{tastes.legend}">What do you like?</legend> <button class="btn teal" type="submit" name="addTaste" th:text="#{add.taste}">Add taste <i class="mdi-content-add left"></i> </button> <div th:errors="*{tastes}" class="red-text">Error</div> <div class="row" th:each="row,rowStat : *{tastes}"> <div class="col s6"> <input type="text" th:field="*{tastes[__${rowStat.index}__]}" th:placeholder="#{taste.placeholder}"/> </div> <div class="col s6"> <button class="btn red" type="submit" name="removeTaste" th:value="${rowStat.index}" th:text="#{remove}">Remove <i class="mdi-action-delete right waves-effect"></i> </button> </div> </div> </fieldset>
The purpose of this snippet is to iterate over the tastes
variable of our LoginForm
. This can be achieved with the th:each
attribute, which looks a lot like a for…in
loop in java.
Compared to the search result loop we saw earlier, the iteration is stored in two variables instead of one. The first one will actually contain each row of the data. The rowStat
variable will contain additional information on the current state of the iteration.
The strangest thing in the new piece of code is:
th:field="*{tastes[__${rowStat.index}__]}"
This is quite a complicated syntax. You could come up with something simpler on your own, such as:
th:field="*{tastes[rowStat.index]}"
Well, that wouldn't work. The ${rowStat.index}
variable, which represents the current index of the iteration loop, needs to be evaluated before the rest of the expression. To achieve this, we need to use preprocessing.
The expression surrounded by double underscores will be preprocessed, which means that it will be processed before the normal processing phase, allowing it to be evaluated twice.
There are two new submit buttons on our form now. They all have a name. The global submit button we had earlier is called save
. The two new buttons are called addTaste
and removeTaste
.
On the controller side, this will allow us to easily discriminate the different actions coming from our form. Let's add two new actions to our ProfileController
:
@Controller public class ProfileController { @ModelAttribute("dateFormat") public String localeFormat(Locale locale) { return USLocalDateFormatter.getPattern(locale); } @RequestMapping("/profile") public String displayProfile(ProfileForm profileForm) { return "profile/profilePage"; } @RequestMapping(value = "/profile", params = {"save"}, method = RequestMethod.POST) public String saveProfile(@Valid ProfileForm profileForm, BindingResult bindingResult) { if (bindingResult.hasErrors()) { return "profile/profilePage"; } System.out.println("save ok" + profileForm); return "redirect:/profile"; } @RequestMapping(value = "/profile", params = {"addTaste"}) public String addRow(ProfileForm profileForm) { profileForm.getTastes().add(null); return "profile/profilePage"; } @RequestMapping(value = "/profile", params = {"removeTaste"}) public String removeRow(ProfileForm profileForm, HttpServletRequest req) { Integer rowId = Integer.valueOf(req.getParameter("removeTaste")); profileForm.getTastes().remove(rowId.intValue()); return "profile/profilePage"; } }
We added a param
parameter to each of our post actions to differentiate them. The one we had previously is now bound to the save
parameter.
When we click on a button, its name will automatically be added to the form data sent by the browser. Note that we specified a particular value with the remove button: th:value="${rowStat.index}"
. This attribute will indicate which value the associated parameter should specifically take. A blank value will be sent if this attribute is not present. This means that when we click on the remove button, a removeTaste
parameter will be added to the POST
request, containing the index of the row we would like to remove. We can then get it back into the Controller
with the following code:
Integer rowId = Integer.valueOf(req.getParameter("removeTaste"));
The only downside with this method is that the whole form data will be sent every time we click on the button, even if it is not strictly required. Our form is small enough, so a tradeoff is acceptable.
That's it! The form is now complete, with the possibility of adding one or more tastes.