Internationalisation (i18n) with Angular and Java Spring in SpringBot
The problem
This article will be exploring the process of adding i18n to a java Spring Boot and Angular application, as such, we will be making use of an application built with SpringBot.
The example application we will be using is the Learning Management System (LMS) - Example project.
What we want to achieve is the ability to support multiple languages application wide.
Some of the questions that we need to answer are:
- How is a language selected?
- Where are our translations stored and retrieved?
- How do we manage incomplete translations?
- How do we supply new translations?
Spring Boot server-side
Spring Boot provides some i18n support as part of the core framework namely through the use of messages
. More details can be found in the Spring Boot documentation. We will leverage this support in our own implementation.
How is the language selected?
One of the key advantages of making use of the inbuilt Spring Boot i18n support is that the solution to this question is already solved for us. Spring Boot will set the current locale of the application based upon the value of the Accept-Language
header in any given request.
The value of this header is often set by the users browser language value and, as such, is updated automatically. Spring Boot will attempt to match the value of this header with one of the known locales.
If you wish to change how this works, a custom locale resolver can be implemented. This will not be covered in this article but a good starting point can be found in the Spring documentation regarding Using locales.
Where are our translations stored and retrieved from?
To start with, we will be sourcing our translations from a resource bundle. A resource bundle is a collection of property files that follow a specific naming convention. These property files will present our various translations through the keys of the each property being used to reference the translated values.
For the purpose of this example, we will ensure that all our files have the following message defined:
invalid_credentials_error_description=The username/password combination is invalid.
In our case, we will be creating a resource bundle called messages
.
Creating a resource bundle
-
Create a directory under
serverside/src/main/resources/
calledi18n
. Your file structure should now look like the following:This directory will be the home to the contents of our bundle.
-
Create three new files inside of this directory. The first called
messages.properties
, the second calledmessages_en_AU.properties
and the thirdmessages_fr_FR.properties
. This will give us three languages that we can use.├── i18n │ ├── messages_en_AU.properties │ ├── messages_fr_FR.properties │ └── messages.properties
These message files follow the naming convention of
messages_<locale_code>.properties
. In our example, the filemessages_en_AU.properties
represents an Australian English locale. -
Add our first message to all of these files, feel free to offer up your own translations for each, for example:
messages.properties
invalid_credentials_error_description=The username/password combination is invalid.
_messages_frFR.properties
invalid_credentials_error_description=La combinaison nom d'utilisateur / mot de passe n'est pas valide.
-
Now that we have created our resource bundle files, we now need to map them to our bundle name. We achieve this by updating our properties file. To ensure our bundle is available in all application profiles, we will add the following lines into the
application.properties
file found atserverside\src\main\resources\application.properties
.# % protected region % [Add any additional properties here] on begin spring.messages.basename=i18n/messages # % protected region % [Add any additional properties here] end
The value of this option is the combination of the folder name (i18n) and the bundle name (messages). Once set, using some IDE’s our messages will now be considered as a resources bundle.
Loading our resource bundle into the application context
Now that we have created our resource bundle, the next step is to tell the application that it exists. There are a few small configuration changes that need to be made to our application to achieve this.
For this example we will be updating one of our custom error handlers to demonstrate how you would make use of our translations.
We will be using the CustomAuthenticationFailureHandler.java
file found at serverside/src/main/java/lmsspring/configs/security/handlers/CustomAuthenticationFailureHandler.java
.
-
Open up the file and activate the protected region called
Add any additional imports here
before adding the following imports:import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder;
-
Inject the
MessageSource
bean using the protected region calledAdd any additional class fields here
:@Autowired private MessageSource messageSource;
-
Now that we have access to our message source, we can override our default error messages. Replace our
error_description
contents with our locale resolved message using the following snippet.rootNode.put( "error_description", messageSource.getMessage("invalid_credentials_error_description", null, LocaleContextHolder.getLocale()) );
We are using our message key to reference our language which will automatically be resolved for us.
Please note: This example is not inside of a protected region and as such is used for demonstration purposes only. See Protected Regions for details.
Testing our locale resolver
To test our local resolver we are going to use a REST API tool called Insomnia to allow us to send HTTP requests to our server-side application. Feel free to use your own HTTP tool of choice.
-
Create a new POST request to our login endpoint.
-
Add the
Accept-Language
header, we will be turning this on and off to demonstrate the default messages vs our French message resolution. -
Trigger the request
We can see that our message response is being replaced with our translation.
Sourcing our messages from a database
So far we have been sourcing our messages from a resource bundle. This works well for most scenarios but has the drawback of having to redeploy our application every time we want to make a change to an existing translation or to add a new one.
One solution to this problem is sourcing our messages from a database.
To achieve this we will be creating three new files:
- Message entity,
- Message repository, and
- Custom message source
-
Update our model with a locale entity. This will supply the first two files for us.
Our entity should appear as follows:
Note: Make sure you allow the appropriate level of access to this entity in the security diagram. i.e Visitors should be able to read.
-
Add a new method to our
ApplicationLocaleRepository
class found atrepositories/ApplicationLocaleRepository.java
. We will add this to the protected regionAdd any additional class methods here
. The method will appear as follows:Optional<ApplicationLocaleEntity> findByKeyAndLocale(String key, String locale);
This method will allow us to query a message per locale.
- Create a new package called
i18n
under theconfigs
package. Inside of this package create a new class calledMessageSource
. -
Populate this message source with the following contents:
package lmsspring.configs.i18n; import lmsspring.repositories.ApplicationLocaleRepository; import org.checkerframework.checker.nullness.qual.NonNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.support.AbstractMessageSource; import org.springframework.stereotype.Component; import java.text.MessageFormat; import java.util.Locale; @Component("messageSource") public class MessageSource extends AbstractMessageSource { private final ApplicationLocaleRepository localeRepository; @Autowired public MessageSource(ApplicationLocaleRepository localeRepository) { this.localeRepository = localeRepository; } @Override protected MessageFormat resolveCode(@NonNull String key, @NonNull Locale locale) { var messageOpt = localeRepository.findByKeyAndLocale(key, locale.toLangageTag()); // The message may be missing from the database, we will want to handle this better for a production system var message = messageOpt.orElseThrow(); assert message.getValue() != null; return new MessageFormat(message.getValue(), locale); } }
This will allow us to fetch our messages from the database. We can pre-load default values by seeding data. See SpringBot Seeding Data for details.
Recommendation: Given that these messages will be queried often it is recommended to implement caching.
Angular client-side
Now that we have our messages being fetched on the server, we want translate our client-side application. Given that the client-side is our presentation layer, the internationalisation of our Angular app is arguable more important than our server.
Built in i18n
Angular comes with i18n support baked in that allows for message substitution at compile time. This functionality works in a similar way to how the server-side does with a set of messages for each translation desired.
To make use of the default i18n support we would complete the following steps:
-
Create two new files two files under a new directory called
i18n
withinclientside/src/assets
, once for English and one for French. -
Populate these files with our messages within a nested JSON structure. For example
The trick here, same as on the server-side, is to ensure that the keys match.
-
Now we can make use of these translations within our components.
For details please see the official Angular docs for i18n support as while this is a powerful tool that unlocks the power to translate our site into many different languages, it comes with one big drawback, it cannot be switched on the fly requiring us to produce version of the client-side available for each language we wish to support.
Dynamic i18n
What we want to achieve with our i18n as shown with our server-side implementation is flexibility, we want to be able to switch languages on the fly based upon input such as a users selection. To achieve this we need to expand beyond our baked-in support. To achieve this level of flexibility, we will be using a third party library called npx-translate
which provides us the ability to support many different locales at the same time.
To make use of npx-translate
you need two key packages:
* Core - [npx-translate/core](https://github.com/ngx-translate/core)
* A loader - for example, [npx-translate/http-loader](https://github.com/ngx-translate/http-loader)
Setup
- Install both packages:
yarn add @ngx-translate/core@12
yarn add @ngx-translate/http-loader@5
Please Note: These versions are specific to Angular 9
-
Update the
app.module.ts
found atclientside\src\app\app.module.ts
. Add the following into the protected regionAdd any additional imports here
:import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { HttpClient } from '@angular/common/http'; export function HttpLoaderFactory(http: HttpClient) { return new TranslateHttpLoader(http); }
-
In the same file, add the following into the protected region called
Add any additional module imports here
:TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, deps: [HttpClient] } })
Usage
For this example we will be customising the login component to have a language selector that allows us to switch between our two languages, French and English.
- Import our
TranslateModule
into ourlogin.tile.module.ts
file found atclientside/src/app/lib/tiles/login/login.tile.module.ts
by addingTranslateModule
to the protected region labelledAdd any additional module imports here
and adding the importimport { TranslateModule } from '@ngx-translate/core';
into the protected region labelledAdd any additional imports here
. -
Open
clientside\src\app\lib\tiles\login\login\login.component.ts
and add the following import into the protected region labelledAdd any additional imports here
:import { TranslateService } from '@ngx-translate/core';
-
Inject it using our constructor by adding the following into the protected region labelled
Add any additional constructor parameters here here
:public translate: TranslateService,
-
Within our constructor body add the following into the protected region labelled
Add any additional construct logic before the main body here
:NOTE: This could be added somewhere globally.
translate.addLangs(['en', 'fr']); translate.setDefaultLang('en'); const browserLang = translate.getBrowserLang(); translate.use(browserLang.match(/en|fr/) ? browserLang : 'en');
-
Finally, make use of the directive in our template by adding the following to the protected region labelled
Add additional content here above the login form
:<div> <h2>{{ 'page.login.title' | translate }}</h2> <label> {{ 'page.login.select' | translate }} <select #langSelect (change)="translate.use(langSelect.value)"> <option *ngFor="let lang of translate.getLangs()" [value]="lang" [selected]="lang === translate.currentLang">{{ lang }}</option> </select> </label> </div>
This will add a language selector and a second title at the top of our login page that looks like the following:
Database backed
Given that we now have two sets of translations, if we wished to make use of the server-side messages we could implement a custom translation loader to replace our http-loader
that we are currently using.
This translation loader is a class that implements the TranslateLoader
interface and our implementation could query our API for each message.
Conclusion
Internationalisation requires both server-side and client-side implementations to properly support a language. Key considerations when adding internationalisation are, maintainability, performance, and error handling. This the examples in this article only briefly touch on performance and error handling in favour of focusing on the maintainability of the locale sets.
While adding i18n support to your applications can be complex, hopefully this article has shown that it does not have to be.
Was this article helpful?