在SpringBoot中使用MessageSource

在SpringBoot中使用MessageSource

几个说明

  1. properties配置文件中,spring.messages.basename必须要加classpath前缀。如 spring.messages.basename=classpath:i18n/messages
  2. 必须要手动配置MessageSource,springboot不会自动配置之
  3. 如果使用MessageSource.getMessage()方法,第一个参数的引用形式为"code",而不是"{code}"或者"${code}"。如messageSource.getMessage("test.msg", null, Locale.getDefault());
  4. 在配置LocalValidatorFactoryBean之后,才可以在javax.validation.constraints包下的注解(@Size@NotNull...)下的message属性中使用"{code}"的形式声明校验提示信息。如 @NotNull(message = "{leftTime.not.null}")
  5. springMVC的locale配置和JVM的locale配置不一样,在application.properties中配置的spring.mvc.locale=zh_CN实际上配置的是WebMvcProperties,在获取消息时,locale信息应该使用webMvcProperties.getLocale()1获取而不是使用Locale.getDefault()获取。

MessageSource is a powerful feature available in Spring applications. This helps application developers handle various complex scenarios with writing much extra code, such as environment-specific configuration, internationalization or configurable values.

One more scenario could be modifying the default validation messages to more user-friendly/custom messages.

In this tutorial, we'll see how to configure and manage custom validation MessageSource in the application using Spring Boot.

2 引入Maven依赖 #

Let's start with adding the necessary Maven dependencies:

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

You can find the latest versions of these libraries over on Maven Central.

3 自定义校验信息示例 #

Let's consider a scenario where we have to develop an application that supports multiple languages. If the user doesn't provide the correct details as input, we'd like to show error messages according to the user's locale.

Let's take an example of a Login form bean:

 1public class LoginForm {
 2
 3    // 注意此处的语法,为"{}"形式,在spring项目中,是无法通过ctrl+鼠标左键定位到配置文件的
 4    // 若去除大括号,则可以通过ctrl+鼠标左键定位到配置的值
 5    @NotEmpty(message = "{email.notempty}")
 6    @Email
 7    private String email;
 8
 9    @NotNull
10    private String password;
11
12    // standard getter and setters
13}

Here we've added validation constraints that verify if an email is not provided at all, or provided, but not following the standard email address style.

To show custom and locale-specific message, we can provide a placeholder as mentioned for the @NotEmpty annotation.

The email.notemptyproperty will be resolved from a properties files by the MessageSource configuration.

4 配置MessageSource #

An application context delegates the message resolution to a bean with the exact name messageSource.

ReloadableResourceBundleMessageSource is the most common MessageSource implementation that resolves messages from resource bundles for different locales:

 1@Bean
 2public MessageSource messageSource() {
 3    ReloadableResourceBundleMessageSource messageSource
 4      = new ReloadableResourceBundleMessageSource();
 5    // 如果使用ReloadableResourceBundleMessageSource,classpath前缀必不可少
 6    // classpath前缀告诉ReloadableResourceBundleMessageSource从classpath中获取配置
 7    messageSource.setBasename("classpath:messages");
 8    messageSource.setDefaultEncoding("UTF-8");
 9    return messageSource;
10}

Here, it's important to provide the basename as locale-specific file names will be resolved based on the name provided.

4.1 关于MessageSource的自动配置 #

实际上,Spring Boot可以自动配置MessageSourece,不过,想要成功配置,有2个条件:

  1. Spring Boot自动配置实际上使用的是ResourceBundleMessageSourece,不同于ReloadableResourceBundleMessageSource
  2. 你无需再配置别名为"messageSource"的Bean,也就是说上述的配置必须忽略掉

不妨看看MessageSource自动配置相关的类,具体内容在org.springframework.boot.autoconfig.context.MessageSourceAutoConfiguration.java类中:

1@Configuration(proxyBeanMethods = false)
2@ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT)
3@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
4@Conditional(ResourceBundleCondition.class)
5@EnableConfigurationProperties
6public class MessageSourceAutoConfiguration {
7
8	//...
9}

注意该自动配置类上的2个注解:

  • @ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT)

    这个注解说明的就是,如果你没有配置messageSource,那么SpringBoot(可能)会自动为你配置

  • @Conditional(ResourceBundleCondition.class)

    这是一个条件化注入,条件在ResourceBundleCondition.class中定义。通过名字就知道,Spring Boot自动配置使用的是ResourceBundleMessageSourece

ResourceBundleCondition.classMessageSourceAutoConfiguration.class的内部类,以下是其内容:

 1@Override
 2public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
 3	String basename = context.getEnvironment().getProperty("spring.messages.basename", "messages");
 4	ConditionOutcome outcome = cache.get(basename);
 5	if (outcome == null) {
 6		outcome = getMatchOutcomeForBasename(context, basename);
 7		cache.put(basename, outcome);
 8	}
 9	return outcome;
10}
11
12private ConditionOutcome getMatchOutcomeForBasename(ConditionContext context, String basename) {
13	ConditionMessage.Builder message = ConditionMessage.forCondition("ResourceBundle");
14	for (String name : StringUtils.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(basename))) {
15		for (Resource resource : getResources(context.getClassLoader(), name)) {
16			if (resource.exists()) {
17				return ConditionOutcome.match(message.found("bundle").items(resource));
18			}
19		}
20	}
21	return ConditionOutcome.noMatch(message.didNotFind("bundle with basename " + basename).atAll());
22}
23
24// basename不需要classpath前缀,它总是从classpath中获取资源
25private Resource[] getResources(ClassLoader classLoader, String name) {
26	String target = name.replace('.', '/');
27	try {
28		return new PathMatchingResourcePatternResolver(classLoader)
29				.getResources("classpath*:" + target + ".properties");
30	}
31	catch (Exception ex) {
32		return NO_RESOURCES;
33	}
34}

我们只需要关注getResources方法,可以看到,其自动补全了classpath前缀,因此,ResourceBundleMessageSourece总是从classpath中获取资源的。

如果这两个条件都满足,那么SpringBoot会自动使用ResourceBundleMessageSourece配置MessageSource。

4.2 RBMS和RRBMS #

  • RBMS: ResourceBundleMessageSource
  • RRBMS: ReloadableResourceBundleMessageSource

在本文的 文首,标注了几个实践时需要注意的点,现在看来,前2点都是错误的表述,因为当时实践时使用的是ReloadableResourceBundleMessageSourece,并且没有搞清楚Spring Boot自动配置MessageSource的条件。

关于这2个“MessageSource”的区别,github上有一个经典的 issue,描述的问题是如果不使用classpath前缀,前者可以读取消息,后者不能读取消息。spring开发人员的回复一针见血:

I assume your resource bundle files live in the classpath? There is an important difference between ResourceBundleMessageSource and ReloadableResourceBundleMessageSource: The former always loads resource bundles from the classpath (since that is all that standard java.util.ResourceBundle is capable of), whereas the latter loads resource bundle files through the ApplicationContext's ResourceLoader. If your context is a ClassPathXmlApplicationContext, you won't notice a difference - but if it is a WebApplicationContext, it will try to find the files in the WAR directory structure when not using a prefix. So it would simply not find your files because it is looking in the wrong location.

If my assumption is correct, the following quick fix will allow your messages to be found in their existing location when switching to ReloadableResourceBundleMessageSource:

<property name="basename" value="classpath:messages">

However, since classpath resources will be cached by the ClassLoader, ReloadableResourceBundleMessageSource's refreshing is likely to not actually work in that case. So I'd rather recommend specifying something like the following, operating against an expanded WAR directory structure where WEB-INF resources can be refreshed from the file system:

<property name="basename" value="WEB-INF/messages"/>

回复指出了ResourceBundleMessageSourece和ReloadableResourceBundleMessageSourece最重要的区别:

  • ResourceBundleMessageSourece总是从classpath中加载资源
  • ReloadableResourceBundleMessageSourece 则从ApplicationContext's ResourceLoader中加载资源

除此之外,二者还有一些其他的区别:

  • ResourceBundleMessageSourece只能读取properties配置文件,而ReloadableResourceBundleMessageSourece还可以读取xml配置文件
  • ReloadableResourceBundleMessageSourece可以从任意位置2读取配置文件
  • 从名字来看,Reloadable是可以动态加载配置文件的,事实上也确实如此,它有一个属性cacheSeconds,用来设置缓存配置文件的时间间隔:
    • 默认值是 -1,意味着不动态加载配置文件
    • 如果配置值为0,那么每次获取消息时就会检查配置文件的改动,这个配置值要慎用
    • 如果配置为其他正整数,则会在固定间隔后检查配置文件改动

5 配置LocalValidatorFactoryBean #

为了在javax.validation.constraints包下注解(@NotEmpty@NotNull等)的校验中使用messageResource,还需要配置LocalValidatorFactoryBean

To use custom name messages in a properties file like we need to define a LocalValidatorFactoryBean and register the messageSource:

1@Bean
2public LocalValidatorFactoryBean getValidator() {
3    LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
4    bean.setValidationMessageSource(messageSource());
5    return bean;
6}

However, note that if we had already extended the WebMvcConfigurerAdapter, to avoid having the custom validator ignored, we'd have to set the validator by overriding the getValidator() method from the parent class.

Now we can define a property message like:

“email.notempty=<Custom_Message>”

instead of

“javax.validation.constraints.NotEmpty.message=<Custom_message>”

6 国际化properties文件 #

The final step is to create a properties file in the src/main/resources directory with the name provided in the basename in step 4:

6.1 messages.properties #

1email.notempty=Please provide valid email id.

Here we can take advantage of internationalization along with this. Let's say we want to show messages for a French user in their language.

In this case, we have to add one more property file with the name the messages_fr.properties in the same location (No code changes required at all):

6.2 messages_fr.properties #

1email.notempty=Veuillez fournir un identifiant de messagerie valide.

7 结论 #

In this article, we covered how the default validation messages can be changed without modifying the code if the configuration is done properly beforehand.

We can also leverage the support of internationalization along with this to make the application more user-friendly.


8 使用并解析message #

前文介绍了如何使用MessageResource进行参数校验时的国际化信息展现,最后补充如何在其他部分展现国际化的信息,最显著的一个使用场景就是错误消息的展现。

配置好messages.properties文件之后,我们可以定义一个错误信息的枚举类:

1#  messages.properties
2satisfied.resource.not.found=要处理的资源不存在
3unknown.error=未知错误
4
5#  other promote messages
6no.specific.id.resource=对应id的资源不存在
 1@Getter
 2public enum ReqState {
 3    RESPONSE_ADVICE_ERROR(500_08, "response.advice.error"),
 4    SATISFIED_RESOURCE_NOT_FOUND(500_09,"satisfied.resource.not.found"),
 5
 6    UNKNOWN_ERROR(600_00, "unknown.error");
 7
 8    private int code;
 9    private String message;
10
11    ReqState(int code, String message) {
12        this.code = code;
13        this.message = message;
14    }
15}

和在@NotEmpty注解中使用方式不一样,这里只需要以字符串的形式直接引用即可。当然,这个消息还需要解析(实际上消息是以key-value的形式配置的,以key的形式引用,而要以value的形式呈现,在多语言的环境,可以实现"一次引用,多种呈现”的目的),解析的方式也很简单:

1@Autowired
2MessageResource messageSource;
3
4messageSource.getMessage("unknown.error", null, LocaleContextHolder.getLocale()))

如果此处像文章开头说的那样,使用webMvcProperties.getLocale()的话,在获取HTTP Header设置的Loacle时有些问题。此处使用了LocaleContextHolder.getLocale(),LocaleContextHolder可以灵活地获取每一次Servlet请求的Locale信息。

我们不妨看看WebMvcProperties类的Locale域:

1/**
2* Locale to use. By default, this locale is overridden by the "Accept-Language" header.
3*/
4private Locale locale;

注意到,可以通过设置HTTP请求头的方式来设置Locale信息。

实际上,测试发现,通过设置Accept-Language请求头,配合使用LocaleContextHolder.getLocale()获取Locale信息,可以实现国际化效果,而使用webMvcProperties.getLocale()无法总是正确获取请求头设置的Locale信息。

还有一点就是,LocaleContextHolder是通过静态方法获取的Locale信息,相较于webMvcProperties的实例方法,免去了注入WebMvcProperties的麻烦。

8.1 LocaleContextHolder和Accept-Language #

现在我们知道,可以通过LocaleContextHolder和设置Accept-Language头动态获取请求的Locale信息,那么我们可以在控制器中 这样使用Locale信息

 1@Controller
 2public class WifeController {
 3    @Autowired
 4    private MessageSource msgSrc;
 5
 6    @RequestMapping(value = "/wife/mood")
 7    public String readWife(Model model, @RequestParam("whatImDoing") String iAm) {
 8        // 获取Locale信息
 9        Locale loc = LocaleContextHolder.getLocale();
10        if(iAm.equals("playingXbox")) {
11            model.addAttribute( "statusTitle", msgSrc.getMessage("mood.angry", null, loc) );
12            model.addAttribute( "statusDetail", msgSrc.getMessage("mood.angry.xboxdiatribe", null, loc) );
13        }
14        return "moodResult";
15    }
16}

不过,在每个控制器里都需要获取一次Loacle信息,这样的方式似乎有点繁琐。那么是否可以简单一点呢?显然是可以的。

springMvc v3.2.x doc 17.3.3中定义了控制器方法支持的参数:

...

java.util.Locale for the current request locale, determined by the most specific locale resolver available, in effect, the configured LocaleResolver in a Servlet environment.

...

也就是说,Locale可以直接作为参数被HTTP请求传递进来。因此,可以这样改造上述控制器:

1@RequestMapping(value = "/wife/mood")
2public String readWife(Model model, @RequestParam("whatImDoing") String iAm, Locale loc) {
3    if(iAm.equals("playingXbox")) {
4        model.addAttribute( "statusTitle", msgSrc.getMessage("mood.angry", null, loc) );
5        model.addAttribute( "statusDetail", msgSrc.getMessage("mood.angry.xboxdiatribe", null, loc) );
6    }
7    return "moodResult";
8}

这样简洁多了,SpringMvc简直太聪明了!等等,通过spring.mvc.locale=zh_CN或通过Accept-Language: en;q=0.7,zh-TW;q=0.8,zh-CN;q=0.7这样的形式配置MVC context的Locale信息还是有点麻烦,并且这样的话,前端每次请求都需要手动设置(校验)请求头,麻烦!

默认情况下,浏览器发起请求的Accept-Language是根据用户语言设置的。

文章到此,我们已经可以通过配置WebMvcProperties和设置Accept-Language请求头来设置Spring MVC Context的Locale信息;并且通过LocaleContextHolder.getLocale()方法或者直接在控制器中传递Locale参数的形式获取Locale信息。

8.2 Locale Resolver #

这样看来,国际化的配置还是不够灵活,配置文件的加载以及请求头的设置这两种方法都略显笨重。

去找找文档看看其他的思路吧:

当请求进入到控制器时,DispatcherServlet会寻找locale resolver,并使用其设置Locale。使用RequestContext.getLocale()方法总是可以获取到Locale信息:

1@GetMapping("/resolver/locale")
2public ReqResult<?> locale(HttpServletRequest request) {
3    // 构建RequestContext
4    RequestContext rc = new RequestContext(request);
5    log.info("locale: {}", rc.getLocale());
6    return ReqResult.ok(rc.getMessage("http.ok"), rc.getLocale());
7}

这个控制器可能的返回结果为:

 1{
 2    "code": 20000,
 3    "msg": "success",
 4    "data": "en"
 5}
 6{
 7    "code": 20000,
 8    "msg": "成功",
 9    "data": "zh_CN"
10}

RequestContext可以很方便的获取请求中包含的信息,可能的参数绑定(校验)错误等,还能直接获取Spring Message,很强大。

注意到,ServletRequest也有一个getLocale()方法,那么,我们直接从Request中获取Locale不是很方便么?就像这样:

1 @GetMapping("/request/locale")
2public ReqResult<?> locale(HttpServletRequest request, HttpServletResponse response){
3    // TODO why this method always return client default locale?
4    return ReqResult.ok(request.getLocale());
5}

哈哈。似乎一切都完美。不过,注意看ServletRequest.getLocale()文档你就会发现问题:

Returns the preferred Locale that the client will accept content in, based on the Accept-Language header. If the client request doesn't provide an Accept-Language header, this method returns the default locale for the server.

也就是说,从request中获取的并不是获取的Spring MVC Context当前使用的Locale信息。这一点在使用了LocaleChangeInterceptor之后,更能够得到 证明

除了RequestContext的方式之外,还可以通过配置拦截器、通过特定的条件(比如请求参数)来更改Locale。

文档提到了几种不同的LocaleResolver

  • AcceptHeaderLocaleResolver

    这个locale resolver已经在前文讨论过了,通过设置HTTP Header的Accept-Language请求头可以设置SpringMvc Context的Locale信息。这个resolver在前文就已经试验过了。

  • CookieLocaleResolver

    这个locale resolver检查cookie中是否声明了Locale信息,如果有,则使用之。

  • SessionLocaleResolver

    这个locale resolver可以从当前请求的HttpSession中获取Locale和TimeZone信息。由于和Session相关,故在切换Locale时没有cookie灵活,只有session关闭之后Locale配置才能重新设置。

  • LocaleChangeInterceptor

    这是推荐使用的方式,通过拦截器+请求参数实现国际化。

8.3 通过LocaleChangeInterceptor实现国际化 #

以下两篇文章分别使用xml和java Bean的方式配置了LocaleChangeInterceptor,通过地址栏参数展现国际化信息:

参考配置地址:

不妨看看LocaleChangeInterceptor是如何工作的:

 1@Override
 2public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
 3        throws ServletException {
 4    // 从请求路径中获取Locale参数
 5    String newLocale = request.getParameter(getParamName());
 6    if (newLocale != null) {
 7        if (checkHttpMethod(request.getMethod())) {
 8            // locale resovler
 9            LocaleResolver localeResolver = RequestContextUtils.getLocaleResolver(request);
10            if (localeResolver == null) {
11                throw new IllegalStateException(
12                        "No LocaleResolver found: not in a DispatcherServlet request?");
13            }
14            try {
15                // 设置Locale信息
16                localeResolver.setLocale(request, response, parseLocaleValue(newLocale));
17            }
18            catch (IllegalArgumentException ex) {
19                if (isIgnoreInvalidLocale()) {
20                    if (logger.isDebugEnabled()) {
21                        logger.debug("Ignoring invalid locale value [" + newLocale + "]: " + ex.getMessage());
22                    }
23                }
24                else {
25                    throw ex;
26                }
27            }
28        }
29    }
30    // Proceed in any case.
31    return true;
32}

可以看到,LocaleChangeInterceptor的工作方式比较简单:

  1. 路径参数中获取Locale参数配置
  2. 获取LocaleResolver
  3. 利用LocaleResolver重新设置步骤1中获取的Locale配置

这里有一个重点:LocaleResolver。如果不在项目中显示的配置LocaleResolver,那么此拦截器获取到的实例是AcceptHeaderLocaleResolver,这很致命:

1// AcceptHeaderLocaleResolver.java
2@Override
3	public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) {
4		throw new UnsupportedOperationException(
5				"Cannot change HTTP accept header - use a different locale resolution strategy");
6	}

因为AcceptHeaderLocaleResolversetLocale()方法直接抛出异常,导致Locale信息无法被设置。

所以,如果使用LocaleChangeInterceptor,那么必须要显式配置一个LocalResolver,可以是SessionLocaleResolver或者CookieLocaleResolver

1@Bean
2public SessionLocaleResolver localeResolver() {
3    SessionLocaleResolver sessionLocaleResolver = new SessionLocaleResolver();
4    // 配置默认Locale
5    sessionLocaleResolver.setDefaultLocale(locale);
6    return sessionLocaleResolver;
7}

这样,保证即使不传递路径国际化参数,也能使用默认的Locale配置。

现在,我们再回头看看从HttpServletRequest中获取当前MVC Context 的Locale信息失败的原因:

  1. LocaleChangeInterceptor不与AcceptHeaderLocaleResolver兼容
  2. HttpServletRequest从Accept-Language中获取Locale配置,否则返回服务器默认Locale信息

这应该比较好理解了,即使设置了Accept-language,这个设置也不能被配置了LocaleChangeInterceptor的mvc容器采纳。

9 参考 #


  1. LocaleContextHolder是它的完美替代。 ↩︎

  2. 从文档和一些其他的资料来看,RRBMS是可以从任意位置读取配置文件的,不过笔者并没有实践这一说法。 ↩︎