DoneSpeak

Springboot国际化消息和源码解读

字数: 2.6k时长: 12 min
2021/12/26 Share

写在前面

在REST接口的实现方案中,后端可以仅仅返回一个code,让前端根据code的内容做自定义的消息提醒。当然,也有直接显示后端返回消息的方案。在后端直接返回消息的方案中,如果要提供多个不同语种国家使用,则需要做国际化消息的实现。

1
2
3
4
5
400 BAD_REQUEST
{
"code": "user.email.token",
"message": "The email is token."
}

实现的目标:

  1. validation的error code可以按照不同语言响应;

  2. 自定义error code可以按照不同语言响应;

  3. 指定默认的语言;

版本说明

1
2
3
spring-boot: 2.1.6.RELEASE
sping: 5.1.8.RELEASE
java: openjdk 11.0.13

初始化项目

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>java

添加如下的Controller类和User实体类,其中的ServiceException是一个自定义的异常类,该异常会带上业务处理过程中的异常码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Validated
@RestController
@RequestMapping("/user")
public class UserController {

private static final String TOKEN_EMAIL = "token@example.com";

@PostMapping
public User createUser(@RequestBody @Valid User user) {
if (TOKEN_EMAIL.equalsIgnoreCase(user.getEmail())) {
String message = String.format("The email %s is token.", user.getEmail());
throw new ServiceException("user.email.token", message);
}
return user;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public class User {
@NotNull
@Length(min = 5, max = 12)
@Pattern(regexp = "^r.*", message = "{validation.user.username}")
private String username;

@NotNull
@Email
private String email;

@Range(min = 12, message = "{validation.user.age}")
private int age;
}

Validation中实现国际化信息

基本实现

在默认的SpringBoot配置中,只要在请求中增加Accept-Language的Header即可实现Validation错误信息的国际化。如下的请求会使用返回中文的错误信息。

1
2
3
4
5
6
7
8
curl --location --request POST 'http://localhost:8080/user' \
--header 'Accept-Language: zh-CN' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "xx",
"email": "token@example.com",
"age": 24
}

对于自定义的message,如validation.user.username,则只需要在resource目录下创建一个basename为ValidationMessages的国际化信息文件即可实现。

1
2
3
4
└── resources
├── ValidationMessages.properties
├── ValidationMessages_zh_CN.properties
└── application.yml
1
2
validation.user.age=用户的年龄应该大于{min}.
validation.user.username=用户名应该以r字母开始

国家化信息文件文件名定义规则:

1
2
3
basename_<language>_<country_or_region>.properties
ValidationMessages_en.properties
ValidationMessages_zh_CN.properties

更多的文件,可以到hibernate-validator的github仓库查看(文末会给出链接)。

LocaleResolver解析locale信息

在spring-framework的spring-webmvc/.../i18n中,可以找到如下三种不同的LocaleResolver的实现:

  • FixedLocaleResolver:

    • 固定local,不做国际化更改。

    • 不可动态更改local,否则抛出UnsupportedOperationException

  • AcceptHeaderLocaleResolver:

    • 读取request的header中的Accept-Language来确定local的值。

    • 不可动态更改local,否则抛出UnsupportedOperationException

  • CookieLocaleResolver:

    • 将local信息保存到cookie中,可配合LocaleChangeInterceptor使用
  • SessionLocaleResolver:

    • 将local信息保存到session中,可配合LocaleChangeInterceptor使用

默认使用AcceptHeaderLocaleResolver

设置指定固定的locale

通过指定spring.mvc.locale-resolver=fixed可以使用固定的locale。如果项目只有一种语言可以做该指定,以免客户端没有配置Accept-Language的header而出现多种不同的语言。

1
2
3
4
spring:
mvc:
locale-resolver: fixed
locale: en

在spring-boot的spring-boot-autoconfig/.../web/serlet中可以找到如下的配置,该配置指定了localeResolver的配置方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// spring-boot: spring-boot-autoconfig/.../web/serlet
package org.springframework.boot.autoconfigure.web.servlet;

public class WebMvcAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "spring.mvc", name = "locale")
public LocaleResolver localeResolver() {
if (this.mvcProperties.getLocaleResolver() == WebMvcProperties.LocaleResolver.FIXED) {
return new FixedLocaleResolver(this.mvcProperties.getLocale());
}
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setDefaultLocale(this.mvcProperties.getLocale());
return localeResolver;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package org.springframework.boot.autoconfigure.web.servlet;

@ConfigurationProperties(prefix = "spring.mvc")
public class WebMvcProperties {
...
/**
* Locale to use. By default, this locale is overridden by the "Accept-Language"
* header.
*/
private Locale locale;
/**
* Define how the locale should be resolved.
*/
private LocaleResolver localeResolver = LocaleResolver.ACCEPT_HEADER;
...
}

通过Url指定Locale

通过LocaleChangeInterceptor获取请求中的lang参数来设定语言,并将locale保存到Cookie中,请求了一次之后,相同的请求就无需再带上该lang参数,即可使用原本已经设定的locale。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
@ConditionalOnProperty(prefix = "spring.mvc", name = "customer-locale-resolver", havingValue = "cookie")
public class MvcConfigurer implements WebMvcConfigurer {

@Bean
public LocaleResolver localeResolver(@Value("${spring.mvc.locale:null}") Locale locale) {
CookieLocaleResolver resolver = new CookieLocaleResolver();
if (locale != null) {
resolver.setDefaultLocale(locale);
}
return resolver;
}

@Bean
public LocaleChangeInterceptor localeInterceptor() {
LocaleChangeInterceptor localeInterceptor = new LocaleChangeInterceptor();
localeInterceptor.setParamName("lang");
return localeInterceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeInterceptor());
}
}
1
2
3
4
5
6
7
curl --location --request POST 'http://localhost:8080/user?lang=en' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "rxdxxxx",
"email": "token@example.com",
"age": 24
}'

之后的请求只要带上含有设置的cookie即可。

1
2
3
4
5
6
7
8
curl --location --request POST 'http://localhost:8080/user' \
--header 'Content-Type: application/json' \
--header 'Cookie: org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE=zh-CN' \
--data-raw '{
"username": "rxdxxxx",
"email": "token@example.com",
"age": 24
}'

自定义Error Code实现国际化信息

在前文给出的代码中,当email为`token@example.com时,抛出邮箱已被占用的异常。自定义异常中的code不是通过validator校验,所以不能通过validator自动处理。需要通过MessageSource`来将定义的code转化为message,可以将MessageSource理解为一个key-value结构的类。

resources目录下创建目录i18n,然后创建以messages为basename的国际化信息文件。如下面的目录结构。

1
2
3
4
5
6
7
8
└── resources
├── ValidationMessages.properties
├── ValidationMessages_zh_CN.properties
├── application.yml
└── i18n
├── messages.properties
├── messages_en.properties
└── messages_zh_CN.properties

message_zh_CN.properties

1
user.email.token=邮箱已存在

application.yml中添加如下配置,即可让springboot生成含有i18n/messages中的properties的配置了。默认情况下,spring.messages.basename=messages,也就是可以直接在resources目录下创建需要的messages国际化信息文件。这里为了把i18n配置都放到一个目录下才做了修改,也可以不修改。

1
2
3
spring:
messages:
basename: i18n/messages

定义ApiExceptionHandler捕获指定的异常,并在其中的code转化为message。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package io.github.donespeak.springbootsamples.i18n.support;

import io.github.donespeak.springbootsamples.i18n.core.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.LocaleResolver;

import javax.servlet.http.HttpServletRequest;
import java.util.Locale;

@Slf4j
@RestControllerAdvice
public class ApiExceptionHandler {
// 用于获取当前的locale
private LocaleResolver localeResolver;
// 含有配置的code-message对
private MessageSource messageSource;

public ApiExceptionHandler(LocaleResolver localeResolver, MessageSource messageSource) {
this.localeResolver = localeResolver;
this.messageSource = messageSource;
}

@ExceptionHandler(ServiceException.class)
public ResponseEntity<Object> handleServiceException(ServiceException ex, HttpServletRequest request, WebRequest webRequest) {
HttpHeaders headers = new HttpHeaders();
HttpStatus status = HttpStatus.BAD_REQUEST;
// 获取当前请求的locale
Locale locale = localeResolver.resolveLocale(request);
log.info("the local for request is {} and the default is {}", locale, Locale.getDefault());
// 将code转化为message
String message = messageSource.getMessage(ex.getCode(), null, locale);
ApiError apiError = new ApiError(ex.getCode(), message);
log.info("The result error of request {} is {}", request.getServletPath(), ex.getMessage(), ex);
return new ResponseEntity(apiError, headers, status);
}
}

code转化为国际化信息的message也就配置完成了。如果对于其他的在ResponseEntityExceptionHandler中定义的Exception也需要做国际化信息转化的话,也可以按照上面处理ServiceException的方法进行定义即可。

源码探究

按照上面的代码操作,已经可以解决需要实现的功能了。这部分将会讲解国际化信息的功能涉及到的源码部分。

Springboot如何获取messages文件生成MessageSource

在Springboot的spring-boot-autoconfig中配置MessageSource的Properties类为MessageSourceProperties,该类默认指定了basename为messages。

1
2
3
4
5
6
7
8
package org.springframework.boot.autoconfigure.context;

public class MessageSourceProperties {
...
// 默认值
private String basename = "messages";
...
}

下面的MessageSourceAutoConfiguration为配置MessageSource的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package org.springframework.boot.autoconfigure.context;

public class MessageSourceAutoConfiguration {
...
@Bean
public MessageSource messageSource(MessageSourceProperties properties) {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
if (StringUtils.hasText(properties.getBasename())) {
// 设置 MessageSource的basename
messageSource.setBasenames(StringUtils
.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(properties.getBasename())));
}
if (properties.getEncoding() != null) {
messageSource.setDefaultEncoding(properties.getEncoding().name());
}
messageSource.setFallbackToSystemLocale(properties.isFallbackToSystemLocale());
Duration cacheDuration = properties.getCacheDuration();
if (cacheDuration != null) {
messageSource.setCacheMillis(cacheDuration.toMillis());
}
messageSource.setAlwaysUseMessageFormat(properties.isAlwaysUseMessageFormat());
messageSource.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage());
return messageSource;
}
...
}

Validation如何使用到ValidationMessages

springboot的spring-boot-autoconfigure/.../validation/ValidationAutoConfiguration为Validator的配置类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package org.springframework.boot.autoconfigure.validation;

/**
* @since 1.5.0
*/
@Configuration
@ConditionalOnClass(ExecutableValidator.class)
@ConditionalOnResource(resources = "classpath:META-INF/services/javax.validation.spi.ValidationProvider")
@Import(PrimaryDefaultValidatorPostProcessor.class)
public class ValidationAutoConfiguration {
// 创建Validator
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@ConditionalOnMissingBean(Validator.class)
public static LocalValidatorFactoryBean defaultValidator() {
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
// 提供message和LocaleResolver
MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
return factoryBean;
}

@Bean
@ConditionalOnMissingBean
public static MethodValidationPostProcessor methodValidationPostProcessor(Environment environment,
@Lazy Validator validator) {
MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
// proxy-target-class="true" 则使用cglib2
boolean proxyTargetClass = environment.getProperty("spring.aop.proxy-target-class", Boolean.class, true);
processor.setProxyTargetClass(proxyTargetClass);
processor.setValidator(validator);
return processor;
}
}

这里重点关注一下MessageInterpolatorFactory类,该类最后会创建一个org.hibernate.validator.messageinterpolatio.ResourceBundleMessageInterpolator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package org.springframework.boot.validation;

public class MessageInterpolatorFactory implements ObjectFactory<MessageInterpolator> {

private static final Set<String> FALLBACKS;

static {
Set<String> fallbacks = new LinkedHashSet<>();
fallbacks.add("org.hibernate.validator.messageinterpolation" + ".ParameterMessageInterpolator");
FALLBACKS = Collections.unmodifiableSet(fallbacks);
}

@Override
public MessageInterpolator getObject() throws BeansException {
try {
// 这里提供默认的MessageInterpolator,获取到ConfigurationImpl
// 最终得到 org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator
return Validation.byDefaultProvider().configure().getDefaultMessageInterpolator();
}
catch (ValidationException ex) {
MessageInterpolator fallback = getFallback();
if (fallback != null) {
return fallback;
}
throw ex;
}
}
...
}

ResourceBundleMessageInterpolatorAbstractMessageInterpolator的一个子类,该类定义了validation messages文件的路径,其中org.hibernate.validator.ValidationMessages为hibernate提供的,而用户自定义的为ValidationMessages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package org.hibernate.validator.messageinterpolation;

public abstract class AbstractMessageInterpolator implements MessageInterpolator {
/**
* The name of the default message bundle.
*/
public static final String DEFAULT_VALIDATION_MESSAGES = "org.hibernate.validator.ValidationMessages";

/**
* The name of the user-provided message bundle as defined in the specification.
*/
public static final String USER_VALIDATION_MESSAGES = "ValidationMessages";

/**
* Default name of the message bundle defined by a constraint definition contributor.
*
* @since 5.2
*/
public static final String CONTRIBUTOR_VALIDATION_MESSAGES = "ContributorValidationMessages";

...

}

在Springboot 2.6.0及之后,对validation进行了修改。如果需要使用,则需要按照如下的方式引入:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>2.6.2</version>
</dependency>

此外,还对MessageInterpolatorFactory进行了修改,允许设置外部的MessageSource。还增加了一个MessageSourceMessageInterpolator来整合messageSource和MessageInterpolator。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

public class MessageInterpolatorFactory implements ObjectFactory<MessageInterpolator> {
...
private final MessageSource messageSource;

public MessageInterpolatorFactory() {
this(null);
}

/**
* Creates a new {@link MessageInterpolatorFactory} that will produce a
* {@link MessageInterpolator} that uses the given {@code messageSource} to resolve
* any message parameters before final interpolation.
* @param messageSource message source to be used by the interpolator
* @since 2.6.0
*/
public MessageInterpolatorFactory(MessageSource messageSource) {
// 允许使用外部的messageSource
this.messageSource = messageSource;
}

@Override
public MessageInterpolator getObject() throws BeansException {
MessageInterpolator messageInterpolator = getMessageInterpolator();
if (this.messageSource != null) {
return new MessageSourceMessageInterpolator(this.messageSource, messageInterpolator);
}
return messageInterpolator;
}

private MessageInterpolator getMessageInterpolator() {
try {
return Validation.byDefaultProvider().configure().getDefaultMessageInterpolator();
}
catch (ValidationException ex) {
MessageInterpolator fallback = getFallback();
if (fallback != null) {
return fallback;
}
throw ex;
}
}
...
}

MessageSourceMessageInterpolator会优先使用messageSource处理,再通过messageInterpolator处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.springframework.boot.validation;

class MessageSourceMessageInterpolator implements MessageInterpolator {
...
@Override
public String interpolate(String messageTemplate, Context context) {
return interpolate(messageTemplate, context, LocaleContextHolder.getLocale());
}

@Override
public String interpolate(String messageTemplate, Context context, Locale locale) {
// 优先通过messageSource替换占位符,在通过messageInterpolator做处理
String message = replaceParameters(messageTemplate, locale);
return this.messageInterpolator.interpolate(message, context, locale);
}
...
}

如需了解更多的信息,可以去查看org.springframework.validation.beanvalidation。LocalValidatorFactoryBean的源码。

参考

CATALOG
  1. 1. 写在前面
    1. 1.1. 初始化项目
  2. 2. Validation中实现国际化信息
    1. 2.1. 基本实现
    2. 2.2. LocaleResolver解析locale信息
    3. 2.3. 设置指定固定的locale
    4. 2.4. 通过Url指定Locale
  3. 3. 自定义Error Code实现国际化信息
  4. 4. 源码探究
    1. 4.1. Springboot如何获取messages文件生成MessageSource
    2. 4.2. Validation如何使用到ValidationMessages
  5. 5. 参考