Spring Boot 国际化深挖:实现地道的 YAML 语言包支持
1. 为什么选择 YAML 而非 Properties?
在 Spring Boot 的国际化(i18n)实践中,虽然默认支持 .properties,但开发者往往更青睐 YAML:
- 中文友好:原生支持 UTF-8,无需繁琐的 Unicode 转义(如
\u4e2d\u6587)。 - 结构清晰:支持嵌套层级,方便管理成千上万条文案。
2. 核心痛点:消失的加载逻辑
如果你尝试继承 ReloadableResourceBundleMessageSource 并重写 loadProperties,你会发现该方法 从未被调用。
源码陷阱:硬编码后缀
通过调试 ReloadableResourceBundleMessageSource#refreshProperties 源码,可以发现原因:
Spring 内部在加载资源时,硬编码了只尝试拼接 .properties 和 .xml 后缀。即便你在配置中写了 messages.yml,它也会尝试去查找 messages.yml.properties。
尝试重写refreshProperties(),却发现该类中的 cachedProperties 和 resourceLoader 都是 private 权限,子类无法直接访问。如果强行重写,不仅需要大量反射,还会失去原生的缓存保护,导致每次请求都产生 IO 开销。
3. 终极方案:重写 doGetBundle
为了既能支持 YAML,又能完美复用 Spring 内置的缓存机制,我们转向了 ResourceBundleMessageSource,通过重写 doGetBundle 这一核心钩子函数来实现。
参考了 org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration中的实现ResourceBundleMessageSource。
第一步:创建 YAML 版 ResourceBundle
Java 的 ResourceBundle 是抽象类,我们需要一个包装类将 YAML 解析后的 Properties 封装进去。
import java.util.*;
public class YamlResourceBundle extends ResourceBundle {
private final Map<String, Object> metadata = new HashMap<>();
public YamlResourceBundle(Properties properties) {
// 将 Properties 平铺结构存入 Map (例如: user.login -> 登录)
properties.forEach((k, v) -> metadata.put(String.valueOf(k), v));
}
@Override
protected Object handleGetObject(String key) {
return metadata.get(key);
}
@Override
public Enumeration<String> getKeys() {
return Collections.enumeration(metadata.keySet());
}
}
第二步:自定义 MessageSource 实现
利用 YamlPropertiesFactoryBean 将 YAML 资源转换为 ResourceBundle。由于 ResourceBundleMessageSource 内部有 cachedBundle 缓存,这样写可以确保 每个 Locale 只解析一次文件。
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import java.util.*;
public class YamlResourceBundleMessageSource extends ResourceBundleMessageSource {
@Override
protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
// 1. 构建 YAML 文件名逻辑,例如: i18n/messages_zh_CN.yml
String language = locale.getLanguage();
String country = locale.getCountry();
StringBuilder sb = new StringBuilder(basename);
if (!language.isEmpty()) {
sb.append("_").append(language);
if (!country.isEmpty()) {
sb.append("_").append(country);
}
}
sb.append(".yml"); // 此处需要处理.yml和.yaml,也可以考虑.yAml/.YAMl/.yAML等各种xewg
// 2. 加载资源
Resource resource = new ClassPathResource(sb.toString());
if (resource.exists()) {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
factory.setResources(resource);
factory.afterPropertiesSet();
Properties props = factory.getObject();
return (props != null) ? new YamlResourceBundle(props) : null;
}
// 3. 兜底:如果没找到 yml,则回退到父类默认逻辑处理 properties/xml
return super.doGetBundle(basename, locale);
}
}
4. 自动配置注入
通过注入 MessageSourceProperties 来动态读取 application.yml 中的 spring.messages 配置,保持与原生 Spring Boot 配置的一致性。
@Configuration
public class I18nConfig {
@Bean("messageSource")
public MessageSource messageSource(MessageSourceProperties properties) {
YamlResourceBundleMessageSource ms = new YamlResourceBundleMessageSource();
// 读取配置中的 basename (如 i18n/messages)
if (StringUtils.hasText(properties.getBasename())) {
ms.setBasenames(StringUtils.commaDelimitedListToStringArray(
StringUtils.trimAllWhitespace(properties.getBasename())));
}
ms.setDefaultEncoding(properties.getEncoding().name());
ms.setFallbackToSystemLocale(properties.isFallbackToSystemLocale());
ms.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage());
return ms;
}
}
5. 总结与反思
在这次技术攻关中,我们经历了两个阶段:
- 硬攻阶段:试图重写
ReloadableResourceBundleMessageSource,但被私有变量和硬编码后缀挡住。 - 重构阶段:回归基础,重写
ResourceBundleMessageSource的doGetBundle扩展点。
结论:当框架的高级类由于权限限制难以扩展时,回归底层标准(如 ResourceBundle 模式)往往能找到更优雅、更地道的解法。
最后编辑:张三 更新时间:2026-01-21 23:48