写在后面

在REST接口的实现计划中,后端能够仅仅返回一个code,让前端依据code的内容做自定义的音讯揭示。当然,也有间接显示后端返回音讯的计划。在后端间接返回音讯的计划中,如果要提供多个不同语种国家应用,则须要做国际化音讯的实现。

400 BAD_REQUEST{    "code": "user.email.token",    "message": "The email is token."}

实现的指标:

  1. validation的error code能够依照不同语言响应;
  2. 自定义error code能够依照不同语言响应;
  3. 指定默认的语言;

版本阐明

spring-boot: 2.1.6.RELEASEsping: 5.1.8.RELEASEjava: 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;    }}
@Datapublic 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>.propertiesValidationMessages_en.propertiesValidationMessages_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而呈现多种不同的语言。

spring:  mvc:    locale-resolver: fixed    locale: en

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

// spring-boot: spring-boot-autoconfig/.../web/serletpackage 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@RestControllerAdvicepublic 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;        }    }    ...}

ResourceBundleMessageInterpolatorAbstractMessageInterpolator的一个子类,该类定义了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