你好,我是看山。
咱们今天一起来聊聊 SpringBoot 中的国际化组件 MessageSource。
先看一下类图:
从类图可以看到,Spring 内置的MessageSource
有三个实现类:
在 SpringBoot 中,默认创建 ResourceBundleMessageSource 实例实现国际化输出。标准的配置通过MessageSourceProperties
类注入:
MessageFormat.format
函数对国际化信息格式化,如果注入参数,输出结果是经过格式化的。比如MessageFormat.format("Hello, {0}!", "Kanshan")
输出结果是“Hello, Kanshan!”。该参数控制的是,当输入参数为空时,是否还是使用MessageFormat.format
函数对结果进行格式化,默认是 false;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=[简体中文] 己字号
一个定义了六个配置文件:
从上面配置文件的命名可以看出,都是以 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"
]
从上面的结果可以看出:
zh-Hans
,所以结果是简体中文优先;我们在 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 有三种实现,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
的使用。这里做一下预告,下一章我们会从源码角度分析MessageSourc
e 的实现类ResourceBundleMessageSource
和ReloadableResourceBundleMessageSource
的执行逻辑;然后我们自定义扩展,从 Nacos 中读取配置内容,实现更加灵活的配置。
本文中的实例已经传到 GitHub,关注公众号「看山的小屋」,回复spring
获取源码。
青山不改,绿水长流,我们下次见。
你好,我是看山。游于码界,戏享人生。如果文章对您有帮助,请点赞、收藏、关注。我还整理了一些精品学习资料,关注公众号「看山的小屋」,回复“资料”即可获得。
个人主页:https://www.howardliu.cn
个人博文:《SpringBoot 手册》:国际化组件 MessageSource
CSDN 主页:https://kanshan.blog.csdn.net/
CSDN 博文:《SpringBoot 手册》:国际化组件 MessageSource