写在后面
在 REST 接口的实现计划中,后端能够仅仅返回一个code
,让前端依据 code 的内容做自定义的音讯揭示。当然,也有间接显示后端返回音讯的计划。在后端间接返回音讯的计划中,如果要提供多个不同语种国家应用,则须要做国际化音讯的实现。
400 BAD_REQUEST
{
"code": "user.email.token",
"message": "The email is token."
}
实现的指标:
- validation 的 error code 能够依照不同语言响应;
- 自定义 error code 能够依照不同语言响应;
- 指定默认的语言;
版本阐明
spring-boot: 2.1.6.RELEASE
sping: 5.1.8.RELEASE
java: openjdk 11.0.13
初始化我的项目
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>java
增加如下的 Controller 类和 User 实体类,其中的 ServiceException
是一个自定义的异样类,该异样会带上业务处理过程中的异样码。
@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;
}
}
@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 错误信息的国际化。如下的申请会应用返回中文的错误信息。
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
的国际化信息文件即可实现。
└── resources
├── ValidationMessages.properties
├── ValidationMessages_zh_CN.properties
└── application.yml
validation.user.age= 用户的年龄应该大于{min}.
validation.user.username= 用户名应该以 r 字母开始
国家化信息文件文件名定义规定:
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
。
- 读取 request 的 header 中的
-
CookieLocaleResolver
:- 将 local 信息保留到 cookie 中,可配合
LocaleChangeInterceptor
应用
- 将 local 信息保留到 cookie 中,可配合
-
SessionLocaleResolver
:- 将 local 信息保留到 session 中,可配合
LocaleChangeInterceptor
应用
- 将 local 信息保留到 session 中,可配合
默认应用AcceptHeaderLocaleResolver
。
设置指定固定的 locale
通过指定 spring.mvc.locale-resolver=fixed
能够应用固定的 locale。如果我的项目只有一种语言能够做该指定,免得客户端没有配置 Accept-Language
的 header 而呈现多种不同的语言。
spring:
mvc:
locale-resolver: fixed
locale: en
在 spring-boot 的 spring-boot-autoconfig/.../web/serlet
中能够找到如下的配置,该配置指定了 localeResolver 的配置形式。
// 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;
}
}
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。
@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());
}
}
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 即可。
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 的国际化信息文件。如上面的目录构造。
└── resources
├── ValidationMessages.properties
├── ValidationMessages_zh_CN.properties
├── application.yml
└── i18n
├── messages.properties
├── messages_en.properties
└── messages_zh_CN.properties
message_zh_CN.properties
user.email.token= 邮箱已存在
在 application.yml
中增加如下配置,即可让 springboot 生成含有 i18n/messages
中的 properties 的配置了。默认状况下,spring.messages.basename=messages
,也就是能够间接在 resources
目录下创立须要的 messages
国际化信息文件。这里为了把 i18n 配置都放到一个目录下才做了批改,也能够不批改。
spring:
messages:
basename: i18n/messages
定义 ApiExceptionHandler
捕捉指定的异样,并在其中的 code 转化为 message。
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。
package org.springframework.boot.autoconfigure.context;
public class MessageSourceProperties {
...
// 默认值
private String basename = "messages";
...
}
上面的 MessageSourceAutoConfiguration
为配置 MessageSource 的实现。
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 的配置类。
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
。
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;
}
}
...
}
ResourceBundleMessageInterpolator
是 AbstractMessageInterpolator
的一个子类,该类定义了 validation messages 文件的门路,其中 org.hibernate.validator.ValidationMessages
为 hibernate 提供的,而用户自定义的为ValidationMessages
。
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 进行了批改。如果须要应用,则须要依照如下的形式引入:
<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。
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 解决。
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
的源码。
参考
- 本文我的项目源码
- source code of Spring Boot’s Auto-configuration class for MessageSource – Github
- Spring Boot internationalization: Step-by-step – Lokalise Blog
- ValidationMessages files in hibernate-validator repository
- Internationalization – springboot docs