Пошагавая инструкция как настроить интернационализацию в приложении на 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}. Возможно, это тоже можно настроить, но это выходит за пределы статьи.