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

(Просмотрено 26 раз, 1 раз за сегодня)
Вы можете оставить комментарий, или Трекбэк с вашего сайта.

Оставить комментарий