SpringBoot 实战:国际化组件 MessageSource

你好,我是看山。

咱们今天一起来聊聊 SpringBoot 中的国际化组件 MessageSource。

初识 MessageSource

先看一下类图:

MessageSource 类图

从类图可以看到,Spring 内置的MessageSource有三个实现类:

  • ResourceBundleMessageSource:通过 JDK 提供的 ResourceBundle 加载资源文件;
  • ReloadableResourceBundleMessageSource:通过 PropertiesPersister 加载资源,支持 xml、properties 两个格式,优先加载 properties 格式的文件。如果同时存在 properties 和 xml 的文件,会只加载 properties 的内容;
  • StaticMessageSource:是手动注入国际化内容,相当于手写代码。因为比较简单,而且实际用处不大,所以暂时不做讨论。

在 SpringBoot 中,默认创建 ResourceBundleMessageSource 实例实现国际化输出。标准的配置通过MessageSourceProperties类注入:

  • basename:加载资源的文件名,可以多个资源名称,通过逗号隔开,默认是“messages”;
  • encoding:加载文件的字符集,默认是 UTF-8,这个不多说;
  • cacheDuration:文件加载到内存后缓存时间,默认单位是秒。如果没有设置,只会加载一次缓存,不会自动更新。这个参数在 ResourceBundleMessageSource、ReloadableResourceBundleMessageSource 稍微有些差异,会具体说下。
  • fallbackToSystemLocale:这是一个兜底开关。默认情况下,如果在指定语言中找不到对应的值,会从 basename 参数(默认是 messages.properties)中查找,如果再找不到可能直接返回或抛错。该参数设置为 true 的话,还会再走一步兜底逻辑,从当前系统语言对应配置文件中查找。该参数默认是 true;
  • alwaysUseMessageFormat:MessageSource 组件通过MessageFormat.format函数对国际化信息格式化,如果注入参数,输出结果是经过格式化的。比如MessageFormat.format("Hello, {0}!", "Kanshan")输出结果是“Hello, Kanshan!”。该参数控制的是,当输入参数为空时,是否还是使用MessageFormat.format函数对结果进行格式化,默认是 false;
  • useCodeAsDefaultMessage:当没有找到对应信息的时候,是否返回 code。也就是当找了所有能找的配置文件后,还是没有找到对应的信息,是否直接返回 code 值。默认是 false,即不返回 code,抛出NoSuchMessageException异常。

小试牛刀

从上面我们知道了一些简单的配置,但是还是没有办法知道 MessageSource 到底是什么,本节我们举个例子小试牛刀。

首先从https://start.spring.io/创建一个最少依赖spring-boot-starter-web的 SpringBoot 项目。

然后在 resources 目录下定义一组国际化配置文件,我们这里使用默认配置,所以 basename 是 messages:

## messages.properties
message.code1=[DEFAULT]code one
message.code2=[DEFAULT]code two
message.code3=[DEFAULT]code three
message.code4=[DEFAULT]code four
message.code5=[DEFAULT]code five
message.code6=[DEFAULT]code six

## messages_en.properties
message.code2=[en]code two

## messages_en_US.properties
message.code3=[en_US]code three

## messages_zh.properties
message.code4=[中文] 丁字号

## messages_zh_CN.properties
message.code5=[大陆区域中文] 戊字号

## messages_zh_Hans.properties
message.code6=[简体中文] 己字号

一个定义了六个配置文件:

  • messages.properties:默认配置文件
  • messages_en.properties:英文配置文件
  • messages_en_US.properties:英文美国配置文件
  • messages_zh.properties:中文配置文件
  • messages_zh_CN.properties:中文中国大陆区域配置文件
  • messages_zh_Hans.properties:简体中文配置文件

从上面配置文件的命名可以看出,都是以 basename 开头,后面跟上语系和地区,三个参数以下划线分隔。

可以支持的语言和国家可以从java.util.Locale查找。

最后我们定义一个 Controller 实验:

@RestController
public class HelloController {
    @Autowired
    private MessageSource messageSource;

    @GetMapping("m1")
    public List<String> m1(Locale locale) {
        final List<String> multi = new ArrayList<>();
        multi.add(messageSource.getMessage("message.code1", null, locale));
        multi.add(messageSource.getMessage("message.code2", null, locale));
        multi.add(messageSource.getMessage("message.code3", null, locale));
        multi.add(messageSource.getMessage("message.code4", null, locale));
        multi.add(messageSource.getMessage("message.code5", null, locale));
        multi.add(messageSource.getMessage("message.code6", null, locale));
        return multi;
    }
}

我们通过不同的请求查看结果:

### 默认
GET http://localhost:8080/m1
### 结果是:
[
  "[DEFAULT]code one",
  "[DEFAULT]code two",
  "[DEFAULT]code three",
  "[中文] 丁字号",
  "[大陆区域中文] 戊字号",
  "[简体中文] 己字号"
]

### local: en
GET http://localhost:8080/m1
Accept-Language: en
### 结果是:
[
  "[DEFAULT]code one",
  "[en]code two",
  "[DEFAULT]code three",
  "[DEFAULT]code four",
  "[DEFAULT]code five",
  "[DEFAULT]code six"
]

### local: en-US
GET http://localhost:8080/m1
Accept-Language: en-US
### 结果是:
[
  "[DEFAULT]code one",
  "[en]code two",
  "[en_US]code three",
  "[DEFAULT]code four",
  "[DEFAULT]code five",
  "[DEFAULT]code six"
]

### local: zh
GET http://localhost:8080/m1
Accept-Language: zh
### 结果是:
[
  "[DEFAULT]code one",
  "[DEFAULT]code two",
  "[DEFAULT]code three",
  "[中文] 丁字号",
  "[DEFAULT]code five",
  "[DEFAULT]code six"
]

### local: zh-CN
GET http://localhost:8080/m1
Accept-Language: zh-CN
### 结果是:
[
  "[DEFAULT]code one",
  "[DEFAULT]code two",
  "[DEFAULT]code three",
  "[中文] 丁字号",
  "[大陆区域中文] 戊字号",
  "[DEFAULT]code six"
]

从上面的结果可以看出:

  1. 默认情况下,HTTP 请求没有传语言,所以使用了系统语言组装,相当于传参是zh-Hans,所以结果是简体中文优先;
  2. HTTP 请求定义的语言越精确,匹配的内容越精确;
  3. 默认情况下,指定语言配置文件找不到,会一次向上查找,地区 > 国家 > 语言 > 默认。

带参数的国际化信息

我们在 message.properties 中添加一行配置:

message.multiVars=var1={0}, var2={1}

在刚才的 Controller 中增加一个请求:

@GetMapping("m2")
public List<String> m2(Locale locale) {
    final List<String> multi = new ArrayList<>();
    multi.add("参数为 null: " + messageSource.getMessage("message.multiVars", null, locale));
    multi.add("参数为空:" + messageSource.getMessage("message.multiVars", new Object[]{}, locale));
    multi.add("只传一个参数:" + messageSource.getMessage("message.multiVars", new Object[]{"第一个参数"}, locale));
    multi.add("传两个参数:" + messageSource.getMessage("message.multiVars", new Object[]{"第一个参数", "第二个参数"}, locale));
    multi.add("传超过两个参数:" + messageSource.getMessage("message.multiVars", new Object[]{"第一个参数", "第二个参数", "第三个参数"}, locale));
    return multi;
}

我们看看结果:

###
GET http://localhost:8080/m2
### 结果是:
[
  "参数为 null: var1={0}, var2={1}",
  "参数为空:var1={0}, var2={1}",
  "只传一个参数:var1=第一个参数,var2={1}",
  "传两个参数:var1=第一个参数,var2=第二个参数",
  "传超过两个参数:var1=第一个参数,var2=第二个参数"
]

我们可以看到,我们在配置文件中定义了带参数的配置信息,此时,我们可以不传参数、传少于指定数量的参数、传符合指定数量的参数、传超过指定数量的参数,都可以正常返回国际化信息。

此处可以理解为,MessageFormat.format执行过程是for-index循环,从配置值中找格式为{数字}的占位符,然后用对应下标的输入参数替换,如果属于参数没了,就保持原样。

找不到配置内容

如果我们的配置文件中没有配置或者对应语言及其父级都没有配置呢?

这个就要靠前面说的useCodeAsDefaultMessage配置了,如果为 true,就会返回输入的 code,如果为 false,就会抛出异常。默认是 false,所以如果找不到会抛异常。比如:

@GetMapping("m3")
public List<String> m3(Locale locale) {
    final List<String> multi = new ArrayList<>();
    multi.add("不存在的 code: " + messageSource.getMessage("message.notExist", null, locale));
    return multi;
}

这个时候我们执行 http 请求:

###
GET http://localhost:8080/m3
### 结果是:
{
  "timestamp": "2022-06-19T09:14:14.977+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/m3"
}

这是报错了,异常栈是:

org.springframework.context.NoSuchMessageException: No message found under code 'message.notExist' for locale 'zh_CN_#Hans'.
    at org.springframework.context.support.AbstractMessageSource.getMessage(AbstractMessageSource.java:161) ~[spring-context-5.3.20.jar:5.3.20]
    at cn.howardliu.effective.spring.springbootmessages.controller.HelloController.m3(HelloController.java:47) ~[classes/:na]
    ……此处省略

自定义 MessageSource

本文开头说过,MessageSource 有三种实现,Spring 默认使用了 ResourceBundleMessageSource,我们可以自定义使用 ReloadableResourceBundleMessageSource。

既然是在 SpringBoot 中,我们可以依靠 SpringBoot 的特性定义:

@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT)
@ConditionalOnProperty(name = "spring.messages-type", havingValue = "ReloadableResourceBundleMessageSource")
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@Conditional(ReloadResourceBundleCondition.class)
@EnableConfigurationProperties
public class ReloadMessageSourceAutoConfiguration {

    private static final Resource[] NO_RESOURCES = {};

    @Bean
    @ConfigurationProperties(prefix = "spring.messages")
    public MessageSourceProperties messageSourceProperties() {
        return new MessageSourceProperties();
    }

    @Bean
    public MessageSource messageSource(MessageSourceProperties properties) {
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        if (StringUtils.hasText(properties.getBasename())) {
            final String[] originBaseNames = StringUtils
                    .commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(properties.getBasename()));
            final String[] baseNames = new String[originBaseNames.length];
            for (int i = 0; i < originBaseNames.length; i++) {
                if (originBaseNames[i].startsWith("classpath:")) {
                    baseNames[i] = originBaseNames[i];
                } else {
                    baseNames[i] = "classpath:" + originBaseNames[i];
                }
            }
            messageSource.setBasenames(baseNames);
        }
        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;
    }

    protected static class ReloadResourceBundleCondition extends SpringBootCondition {
        private static final ConcurrentReferenceHashMap<String, ConditionOutcome> CACHE =
                new ConcurrentReferenceHashMap<>();

        @Override
        public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
            String basename = context.getEnvironment().getProperty("spring.messages.basename", "messages");
            ConditionOutcome outcome = CACHE.get(basename);
            if (outcome == null) {
                outcome = getMatchOutcomeForBasename(context, basename);
                CACHE.put(basename, outcome);
            }
            return outcome;
        }

        private ConditionOutcome getMatchOutcomeForBasename(ConditionContext context, String basename) {
            ConditionMessage.Builder message = ConditionMessage.forCondition("ResourceBundle");
            for (String name : StringUtils.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(basename))) {
                for (Resource resource : getResources(context.getClassLoader(), name)) {
                    if (resource.exists()) {
                        return ConditionOutcome.match(message.found("bundle").items(resource));
                    }
                }
            }
            return ConditionOutcome.noMatch(message.didNotFind("bundle with basename " + basename).atAll());
        }

        private Resource[] getResources(ClassLoader classLoader, String name) {
            String target = name.replace('.', '/');
            try {
                return new PathMatchingResourcePatternResolver(classLoader)
                        .getResources("classpath*:" + target + ".properties");
            } catch (Exception ex) {
                return NO_RESOURCES;
            }
        }

    }

}

我们可以看到,我们在执行messageSource.setBasenames(baseNames);的时候,baseNames中的值都是设置成classpath:开头的,这是为了使ReloadableResourceBundleMessageSource能够读取 CLASSPATH 下的配置文件。当然也可以使用绝对路径或者相对路径实现,这个是比较灵活的。

我们可以通过修改配置文件内容,查看变化,这里就不再赘述。纸上得来终觉浅,绝知此事要躬行。

文末总结

本文通过几个小例子介绍了MessageSource的使用。这里做一下预告,下一章我们会从源码角度分析MessageSource 的实现类ResourceBundleMessageSourceReloadableResourceBundleMessageSource的执行逻辑;然后我们自定义扩展,从 Nacos 中读取配置内容,实现更加灵活的配置。

本文中的实例已经传到 GitHub,关注公众号「看山的小屋」,回复spring获取源码。

青山不改,绿水长流,我们下次见。

推荐阅读


你好,我是看山。游于码界,戏享人生。如果文章对您有帮助,请点赞、收藏、关注。我还整理了一些精品学习资料,关注公众号「看山的小屋」,回复“资料”即可获得。

个人主页:https://www.howardliu.cn
个人博文:SpringBoot 实战:国际化组件 MessageSource
CSDN 主页:https://kanshan.blog.csdn.net/
CSDN 博文:SpringBoot 实战:国际化组件 MessageSource

👇🏻欢迎关注我的公众号「看山的小屋」,领取精选资料👇🏻

公众号:看山的小屋