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> < 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}. Возможно, это тоже можно настроить, но это выходит за пределы статьи.