Spring boot + i18n + Thymeleaf
Пошагавая инструкция как настроить интернационализацию в приложении на Spring. Пишу эту статью потому что там много нюансов, чтобы их самому не забыть). Исходные данные:
Пустой проект на Java 11, созданный при помощи https://start.spring.io/ с зависимостями Spring Web, Thymeleaf.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>ru.knastnt</groupId>
<artifactId>spring-i18n</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-i18n</name>
<description>Spring boot + I18N + Thymeleaf</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Для запуска интернационализации на проекте Spring Boot достаточно создать бандлы (файлы .properties) и объявить Bean — MessageSource.
Создадим пару файлов:
1 | src\main\resources\locale\messages\app.properties |
1 | src\main\resources\locale\messages\app_ru.properties |
со следующим содержимым соответственно:
1 2 | registration.label = Sign Up<br /> login.label=Sign In |
1 2 | registration.label = Регистрация<br /> login.label=Вход |
Чтобы быстренько увидить работающую интернационализацию, тупо создадим src\main\resources\templates\index.html и запихнем туда чё-нить интернациональное:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
хэлоу
<p th:text="#{registration.label}"></p>
</body>
</html>
Это не требует дополнительной настройки, т.к. согласно поставляемой автоконфигурации, thymeleaf будет искать свои шаблоны именно в этой директории. И index.html будет использоваться при открытии корня сайта.
Теперь создадим класс с конфигурацией
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
@Configuration
public class I18NConfig {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setCacheSeconds(5); //refresh cache once per 5 sec
messageSource.setDefaultEncoding("UTF-8");
messageSource.setFallbackToSystemLocale(false);
messageSource.setBasenames("classpath:locale/messages/app");
return messageSource;
}
}
На этом базовая интернационализация будет работать. Самое время запустить проект и проверить чё там.
Если у Вас есть англоязычный браузер, то в нём будет соответственно англоязычная локаль нашего приложения. Я поставил себе в Firefox плагин Quick Locale Switcher.
В результате если в запросе пользователя хеадер Accept-Language соответствует русской локали (ru), то будут использоваться значения из locale\messages\app_ru.properties, если Accept-Language передаёт локаль отличную от русской, то используется файл locale\messages\app.properties.
Путь к этим бандл файлам задается методом messageSource.setBasenames.
Установка setFallbackToSystemLocale(false) нужна чтобы при отсутствии бандла для запрашиваемой пользователем локали, использовался app.properties, а не бандл системной локали (насколько я понял, — локали сервера. поправьте если не прав)
Метод messageSource.setCacheSeconds дает нам возможность установить время обновления бандлов. Т.е. мы можем на работающем приложении править текст бандлов и они будут перечитываться снова и снова согласно установленному времени в секундах.
Автоматическое определение локали работает потому что Spring Boot создал бин класса AcceptHeaderLocaleResolver, который и реализует указанную логику.
Изменение локали пользователем
Теперь нам надо дать пользователю возможность самому менять язык системы. Делать это мы будем с помощью передачи дополнительного GET параметра, например, ?lang=en к любому url адресу приложения. Для этого нам нужно переопределить бин LocaleResolver применив реализацию SessionLocaleResolver. Вообще говоря у LocaleResolver есть несколько реализаций:
Можете прочитать про них отдельно.
Добавляем в наш конфигурационный класс (I18NConfig) новый бин:
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
...
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver slr = new SessionLocaleResolver();
//Назначает локаль по умончанию, которая используется когда к сесии не прикреплена никакая локаль.
//Если не назначать локаль по умолчанию, то локаль будет назначена согласно Accept-Language хэдера запроса
//slr.setDefaultLocale(Locale.forLanguageTag("ru"));
return slr;
}
Итак, сейчас мы имеем такое же поведение как при AcceptHeaderLocaleResolver, но теперь к конкретной сессии можно насильно назначить какую-то локаль, отличную от передаваемого Accept-Header. Если на момент назначения локали сессия не поднята (отсутствует кука JSESSIONID), то она будет автоматически поднята и кука появится. При сбросе сессии, соответственно, назначенная локаль сбрасывается.
Теперь нужно реальзовать возможность назначения произвольной локали для сессии. Для этого определим бин перехватчика LocaleChangeInterceptor:
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
...
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}
здесь с помощью метода setParamName устанавнивается наименование того самого GET параметра используемого для смены локали.
Однако же этого не достаточно, т.к. этот перехватчик нужно ещё и зарезистрировать. Предлагаю наш класс I18NConfig унаследовать от WebMvcConfigurerAdapter, переопределить метод addInterceptors, в котором и произвести регистрацию. По итогу наш класс I18NConfig должен выглядеть следующим образом:
package ru.knastnt.spring.i18n;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
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;
@Configuration
public class I18NConfig extends WebMvcConfigurerAdapter {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver slr = new SessionLocaleResolver();
//Назначает локаль по умончанию, которая используется когда к сесии не прикреплена никакая локаль.
//Если не назначать локаль по умолчанию, то локаль будет назначена согласно Accept-Language хэдера запроса
//slr.setDefaultLocale(Locale.forLanguageTag("ru"));
return slr;
}
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setCacheSeconds(5); //refresh cache once per 5 sec
messageSource.setDefaultEncoding("UTF-8");
messageSource.setFallbackToSystemLocale(false);
messageSource.setBasenames("classpath:locale/messages/app");
return messageSource;
}
}
Дополнительно
Если не изменяется локаль на странице входа (Spring Security)
Если у Вас страница входа настроена таким образом:
http.authorizeRequests()
...
.formLogin().loginPage("/login").permitAll()
, то при такой настройке url с GET параметрами (например, /login?lang=en) не проходят цепочку фильтрации SpringSecurity и происходит редирект на /login. Чтобы это победить, используйте отдельную конструкцию antMatchers, т.к. он допускает использование GET параметров в URL:
http.authorizeRequests()
...
.antMatchers(... , "/login", ...).permitAll()
.and()
.formLogin().loginPage("/login").permitAll()
Интернационализация Spring Validator
Вы можете интернационализировать сообщения валидатора установив значение message таким образом:
@Pattern(regexp = "^\\+[0-9]{11,16}$", message = "{err.incorphone}")
, где err.incorphone — значение в бандлах.
Для этого в классе I18NConfig объявим ещё один бин, который позволит нам сделать это:
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
...
@Bean
public LocalValidatorFactoryBean validator(MessageSource messageSource) {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messageSource);
return bean;
}
Интернационализованное сообщение будет выводиться, например, при передаче объекта в метод если он помечен аннотацией @Valid, например:
@PostMapping("/reg")
public void trytoreg(@Valid User user, BindingResult bindingResult) {
//TODO
}
Однако, при выводе эксепшена в стак трейс, интернационализованные значения сообщений подхватываться не будут. Вместо них Вы увидете свой {err.incorphone}. Возможно, это тоже можно настроить, но это выходит за пределы статьи.