It was necessary to talk about validation before talking about internationalizing content and messages. With global and cloud-based services, supporting content in only one language is often not sufficient.
In this recipe, we provide an implementation that suits our design and therefore continue to meet our scalability standards for not relying on HTTP Sessions.
We will see how to define the MessageSource
beans in charge of fetching the most suited message for a given location. We will see how to serialize resource properties to make them available to the frontend. We will implement a dynamic translation of content on this frontend with AngularJS and angular-translate.
There is both backend and a frontend work in this recipe.
csm-core-config.xml
):<bean id="messageBundle" class="edu.zc.csm.core.i18n.SerializableResourceBundleMessageSource"> <property name="basenames" value="classpath:/META-INF/i18n/messages,classpath:/META-INF/i18n/errors"/> <property name="fileEncodings" value="UTF-8" /> <property name="defaultEncoding" value="UTF-8" /> </bean>
SerializableResourceBundleMessageSource
that gathers the resource files and extracts properties:/** * @author rvillars * {@link https://github.com/rvillars/bookapp-rest} */ public class SerializableResourceBundleMessageSource extends ReloadableResourceBundleMessageSource { public Properties getAllProperties(Locale locale) { clearCacheIncludingAncestors(); PropertiesHolder propertiesHolder = getMergedProperties(locale); Properties properties = propertiesHolder.getProperties(); return properties; } }
A newly created PropertiesController
exposes publicly (serializing) all the messages and errors for a specific location (here, just a language):
@RestController @ExposesResourceFor(Transaction.class) @RequestMapping(value="/properties") public class PropertiesController{ @Autowired protected SerializableResourceBundleMessageSource messageBundle; @RequestMapping(method = RequestMethod.GET, produces={"application/json; charset=UTF-8"}) @ResponseBody public Properties list(@RequestParam String lang) { return messageBundle.getAllProperties(new Locale(lang)); } }
A specific service layer has been built to easily serve messages and errors across controllers and services:
@Service @Transactional(readOnly = true) public class ResourceBundleServiceImpl implements ResourceBundleService { @Autowired protected SerializableResourceBundleMessageSource messageBundle; private static final Map<Locale, Properties> localizedMap = new HashMap<>(); @Override public Properties getAll() { return getBundleForUser(); } @Override public String get(String key) { return getBundleForUser().getProperty(key); } @Override public String getFormatted(String key, String... arguments) { return MessageFormat.format( getBundleForUser().getProperty(key), arguments ); } @Override public boolean containsKey(String key) { return getAll().containsKey(key); } private Properties getBundleForUser(){ Locale locale = AuthenticationUtil.getUserPrincipal().getLocale(); if(!localizedMap.containsKey(locale)){ localizedMap.put(locale, messageBundle.getAllProperties(locale)); } return localizedMap.get(locale); } }
ResourceBundleServiceImpl
service is injected in our WebContentInterceptor
CloudstreetApiWCI:
@Autowired protected ResourceBundleService bundle;
TransactionController
, for example, the bundle is targeted to extract error messages:if(!transaction.getUser().getUsername() .equals(getPrincipal().getUsername())){ throw new AccessDeniedException( bundle.get(I18nKeys.I18N_TRANSACTIONS_USER_FORBIDDEN) ); }
I18nKeys
is just a class that hosts resource keys as constants:public class I18nKeys { //Messages public static final String I18N_ACTION_REGISTERS = "webapp.action.feeds.action.registers"; public static final String I18N_ACTION_BUYS = "webapp.action.feeds.action.buys"; public static final String I18N_ACTION_SELLS = "webapp.action.feeds.action.sells"; ... }
index.jsp
:<script src="js/angular/angular-translate.min.js"></script> <script src="js/angular/angular-translate-loader-url.min.js"></script>
index.jsp
:cloudStreetMarketApp.config(function ($translateProvider) { $translateProvider.useUrlLoader('/api/properties.json'); $translateProvider.useStorage('UrlLanguageStorage'); $translateProvider.preferredLanguage('en'); $translateProvider.fallbackLanguage('en'); });
main_menu.js
). The user is loaded and the language is extracted from user object (defaulted to EN):cloudStreetMarketApp.controller('menuController', function ($scope, $translate, $location, modalService, httpAuth, genericAPIFactory) { $scope.init = function () { ... genericAPIFactory.get("/api/users/"+httpAuth.getLoggedInUser()+".json") .success(function(data, status, headers, config) { $translate.use(data.language); $location.search('lang', data.language); }); } ... }
stock-detail.html
file for example:<span translate="screen.stock.detail.will.remain">Will remain</span>
Another example from the index-detail.html
file is the following:
<td translate>screen.index.detail.table.prev.close</td>
In home.html
, you can find scope variables whose values are translated as follows:
{{value.userAction.presentTense | translate}}
Let's have a look at the backend changes. What you first need to understand is the autowired SerializableResourceBundleMessageSource
bean from which internationalized messages are extracted using a message key.
This bean extends a specific MessageSource
implementation. Several types of MessageSource
exist and it is important to understand the differences between them. We will revisit the way we extract a Locale from our users and we will see how it is possible to use a LocaleResolver
to read or guess the user language based on different readability paths (Sessions, Cookies, Accept header, and so on).
First of all, a MessageSource
is a Spring interface (org.sfw.context.MessageSource
). The MessageSource
objects are responsible for resolving messages from different arguments.
The most interesting arguments being the key of the message we want and the Locale
(language/country combination) that will drive the right language selection. If no Locale
is provided or if the MessageSource
fails to resolve a matching language/country file or message entry, it falls back to a more generic file and tries again until it reaches a successful resolution.
As shown here, MessageSource
implementations expose only getMessage(…)
methods:
public interface MessageSource { String getMessage(String code, Object[] args, String defaultMessage, Locale locale); String getMessage(String code, Object[] args, Locale locale) throws NoSuchMessageException; String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException; }
This lightweight interface is implemented by several objects in Spring (especially in context components). However, we are looking specifically for MessageSource
implementations and three of them in Spring 4+ particularly deserve to be mentioned.
This MessageSource
implementation accesses the resource bundles using specified basenames. It relies on the underlying JDK's ResourceBundle
implementation, in combination with the JDK's standard message-parsing provided by MessageFormat
(java.text.MessageFormat
).
Both the accessed ResourceBundle
instances and the generated MessageFormat
are cached for each message. The caching provided by ResourceBundleMessageSource
is significantly faster than the built-in caching of the java.util.ResourceBundle
class.
With java.util.ResourceBundle
, it's not possible to reload a bundle when the JVM is running. Because ResourceBundleMessageSource
relies on ResourceBundle
, it faces the same limitation.
In contrast to ResourceBundleMessageSource
, this class uses Properties
instances as custom data structure for messages. It loads them via a PropertiesPersister
strategy using Spring Resource objects.
This strategy is not only capable of reloading files based on timestamp changes, but also loads properties files with a specific character encoding.
ReloadableResourceBundleMessageSource
supports reloading of properties files using the cacheSeconds
setting and also supports the programmatic clearing of the properties cache.
The base names for identifying resource files are defined with the basenames
property (in the ReloadableResourceBundleMessageSource configuration). The defined base names follow the basic ResourceBundle
convention that consists in not specifying the file extension nor the language code. We can refer to any Spring resource location. With a classpath
: prefix, resources can still be loaded from the classpath, but cacheSeconds
values other than -1
(caching forever) will not work in this case.
We have implemented a specific controller that serializes and exposes the whole aggregation of our resource bundle properties-files (errors and message) for a given language passed in as a query parameter.
To achieve this, we have created a custom SerializableResourceBundleMessageSource
object, borrowed from Roger Villars, and its
bookapp-rest application (https://github.com/rvillars/bookapp-rest).
This custom MessageSource
object extends ReloadableResourceBundleMessageSource
. We have made a Spring bean of it with the following definition:
<bean id="messageBundle" class="edu.zc.csm.core.i18n.SerializableResourceBundleMessageSource"> <property name="basenames" value="classpath:/META-INF/i18n/messages,classpath:/META-INF/i18n/errors"/> <property name="fileEncodings" value="UTF-8" /> <property name="defaultEncoding" value="UTF-8" /> </bean>
We have specifically specified the paths to our resource files in the classpath. This can be avoided with a global resource bean in our context:
<resources location="/, classpath:/META-INF/i18n" mapping="/resources/**"/>
Note that Spring MVC, by default, expects the i18n resource files to be located in a /WEB-INF/i18n
folder.
In our application, in order to switch the Locale
to another language/country, we pass through the user preferences screen. This means that we somehow persist this information in the database. This makes easy the LocaleResolution
that is actually operated on the client side, reading the user data and calling the internationalized messages for the language preference asynchronously.
However, some other applications might want to operate LocaleResolution
on the server side. To do so, a LocaleResolver
bean must be registered.
LocaleResolver
is a Spring Interface (org.springframework.web.servlet. LocaleResolver
):
public interface LocaleResolver { Locale resolveLocale(HttpServletRequest request); void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale); }
There are four concrete implementations in Spring MVC (version four and above):
The AcceptHeaderLocaleResolver makes use of the Accept-Language
header of the HTTP request. It extracts the first Locale that the value contains. This value is usually set by the client’s web browser that reads it from OS configuration.
This resolver always returns a fixed default Locale with optionally a time zone. The default Locale is the current JVM's default Locale.
This resolver is the most appropriate when the application actually uses user sessions. It reads and sets a session attribute whose name is only intended for internal use:
public static final String LOCALE_SESSION_ATTRIBUTE_NAME = SessionLocaleResolver.class.getName() + ".LOCALE";
By default, it sets the value from the default Locale
or from the Accept-Language
header. The session may also optionally contain an associated time zone attribute. Alternatively, we may specify a default time zone.
The practice in these cases is to create an extra and specific web filter.
CookieLocaleResolver
is a resolver that is well suited to stateless applications like ours. The cookie name can be customized with the cookieName
property. If the Locale
is not found in an internally defined request parameter, it tries to read the cookie value and falls back to the Accept-Language
header.
The cookie may optionally contain an associated time zone value as well. We can still specify a default time zone as well.
We used angular-translate.js
to handle translations and to switch the user Locale from the client side. angular-translate.js
library is very complete and well documented. As a dependency, it turns out to be extremely useful.
The main points of this product are to provide:
MessageFormat.js
A quick overview of angular-translate is shown in this figure:
International resources are pulled down either dynamically from an API endpoint (as we did), either from static resource files published on the web application path. These resources for their specific Locale are stored on the client-side using LocalStorage
or using cookies
.
The stored data corresponds to a variable (UrlLanguageStorage
in our case) that is accessible and injectable in whatever module that may require translation capabilities.
As shown in the following examples, the translate
directive can be used to actually render translated messages:
<span translate>i18n.key.message</span> or <span translate=" i18n.key.message" >fallBack translation in English (better for Google indexes) </span>
Alternatively, we can use a predefined translate filter to translate our translation keys in the DOM, without letting any controller or service know of them:
{{data.type.type == 'BUY' ? 'screen.stock.detail.transaction.bought' : 'screen.stock.detail.transaction.sold' | translate}}
You can read more about angular-translate on their very well done documentation: