国际化 MessageSource - 消息体系、LocaleResolver 与多语言实战
概述
Spring Framework 提供了完善的国际化(i18n)支持,核心围绕 MessageSource 接口体系构建。通过 MessageSource,应用可以按 Locale 解析消息文本,实现多语言界面、多语言异常提示等场景。本文将深入分析 MessageSource 的接口设计、核心实现类、Locale 解析机制,并结合源码剖析消息解析流程,最后通过电商国际站的实战案例展示完整的多语言解决方案。
一、MessageSource 接口体系
1.1 MessageSource 接口
MessageSource 是 Spring 国际化消息解析的顶层接口,定义了获取消息的基本方法:
package org.springframework.context;
public interface MessageSource {
String getMessage(String code, Object[] args, String defaultMessage, Locale locale);
String getMessage(String code, Object[] args, Locale locale) throws NoSuchMessageException;
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}三个方法的核心区别:
| 方法 | 行为 |
|---|---|
getMessage(code, args, defaultMessage, locale) | 找不到消息时返回默认值 |
getMessage(code, args, locale) | 找不到消息时抛出 NoSuchMessageException |
getMessage(resolvable, locale) | 接受 MessageSourceResolvable 对象,可封装多个 code + 默认值 |
1.2 HierarchicalMessageSource 接口
HierarchicalMessageSource 扩展了 MessageSource,增加了父子层级结构。当子 MessageSource 中找不到消息时,会委托给父 MessageSource 查找。
package org.springframework.context;
public interface HierarchicalMessageSource extends MessageSource {
void setParentMessageSource(MessageSource parent);
MessageSource getParentMessageSource();
}这种层级设计类似于 ClassLoader 的委派模型,广泛用于框架内部消息的合并处理。
1.3 AbstractMessageSource 抽象类
AbstractMessageSource 实现了 HierarchicalMessageSource,提供了模板方法式的消息解析骨架,是开发者扩展自定义 MessageSource 的基类。
package org.springframework.context.support;
public abstract class AbstractMessageSource extends MessageSourceSupport
implements HierarchicalMessageSource {
@Nullable
private MessageSource parentMessageSource;
@Nullable
private MessageFormatFactory messageFormatFactory;
private boolean alwaysUseMessageFormat = false;
private boolean fallbackToSystemLocale = true;
// ... 模板方法核心实现
}关键设计点:
getMessage()模板方法:定义了消息解析的标准流程resolveCode()抽象方法:子类需实现具体的消息查找逻辑messageFormat缓存:对解析到的带参数的消息进行格式化缓存MessageSourceSupport基类:提供MessageFormat的创建和参数处理等公共能力
二、MessageSource 核心实现类
2.1 ResourceBundleMessageSource
基于 JDK 的 java.util.ResourceBundle 实现,通过 ResourceBundle 加载 classpath 下的 .properties 文件。
配置示例:
@Configuration
public class MessageSourceConfig {
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
// 设置资源文件基名(可设置多个,用逗号分隔)
source.setBasename("messages");
// 默认编码 UTF-8
source.setDefaultEncoding("UTF-8");
// 是否回退到系统 Locale
source.setFallbackToSystemLocale(false);
// 缓存时长(秒),-1 表示永久缓存
source.setCacheSeconds(-1);
return source;
}
}文件结构:
src/main/resources/
├── messages.properties # 默认(通常为英文)
├── messages_zh_CN.properties # 简体中文
├── messages_zh_TW.properties # 繁体中文
├── messages_ja_JP.properties # 日文
└── messages_fr_FR.properties # 法文原理: 内部委托 ResourceBundle 加载,利用 JDK 的 ResourceBundle.Control 进行 Locale 匹配。生成环境推荐设置为 cacheSeconds(-1) 避免反复加载。
2.2 ReloadableResourceBundleMessageSource
相比 ResourceBundleMessageSource,该类支持:
- 热重载:可配置
cacheSeconds,超过缓存时间后重新读取文件 - 多种文件路径:支持 classpath、文件系统、Spring Resource 路径
- 更灵活的编码处理:不依赖 JDK
ResourceBundle的文件编码限制
配置示例:
@Bean
public ReloadableResourceBundleMessageSource messageSource() {
ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
// 支持 classpath: 和 file: 前缀
source.setBasename("classpath:messages");
source.setDefaultEncoding("UTF-8");
// 开发环境设置短缓存时间,生产环境设置 -1 禁用缓存
source.setCacheSeconds(3600);
// 设置文件扩展名(默认 .properties)
source.setFileEncodings(Collections.singletonMap("zh_CN", "UTF-8"));
return source;
}资源文件加载路径:
// 从 classpath 根目录加载
source.setBasename("classpath:messages");
// 从 classpath 子目录加载
source.setBasename("classpath:i18n/messages");
// 从文件系统绝对路径加载
source.setBasename("file:/etc/app/i18n/messages");
// 多个 basename
source.setBasenames("classpath:messages", "classpath:validation/errors");2.3 两者对比
| 特性 | ResourceBundleMessageSource | ReloadableResourceBundleMessageSource |
|---|---|---|
| 底层机制 | JDK ResourceBundle | Spring 自实现 Properties 加载 |
| 路径支持 | 仅 classpath | classpath、file、URL 等 |
| 热加载 | 不支持(需重启) | 支持(cacheSeconds 控制) |
| 编码处理 | 依赖 JDK,默认 ISO-8859-1 | 支持显式设置编码 |
| 性能 | 较高(JDK 原生) | 略低(需属性文件解析) |
| 使用建议 | 生产环境稳定场景 | 开发环境或需要热更新的场景 |
2.4 StaticMessageSource
一个简单的内存 MessageSource 实现,主要用于测试和框架内部使用。
public class StaticMessageSource extends AbstractMessageSource {
private final Map<String, String> messages = new LinkedHashMap<>();
// 手动注册消息
public void addMessage(String code, Locale locale, String msg) {
messages.put(code + "_" + locale.toString(), msg);
}
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
return messages.get(code + "_" + locale.toString());
}
}使用场景:
// 单元测试中使用
StaticMessageSource ms = new StaticMessageSource();
ms.addMessage("order.notfound", Locale.CHINA, "订单不存在");
ms.addMessage("order.notfound", Locale.US, "Order not found");
assertEquals("订单不存在", ms.getMessage("order.notfound", null, Locale.CHINA));
assertEquals("Order not found", ms.getMessage("order.notfound", null, Locale.US));2.5 DelegatingMessageSource
一个空实现的 MessageSource,内部委托给父 MessageSource。Spring 内部使用,作为 ApplicationContext 的默认 MessageSource。
public class DelegatingMessageSource extends AbstractMessageSource {
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
return null; // 始终返回 null,完全依赖父 MessageSource
}
@Override
protected MessageFormat resolveCode(String code, Locale locale) {
return null;
}
}当 ApplicationContext 没配置自定义 MessageSource Bean 时,Spring 会注册一个 DelegatingMessageSource 实例,这样所有 getMessage() 调用都会向上委托到父容器。
三、国际化文件解析
3.1 文件命名规则
Spring 遵循 JDK ResourceBundle 的 Locale 匹配规则:
basename[_language][_country][_variant].properties匹配优先级(以 messages_zh_Hans_CN 为例):
1. messages_zh_Hans_CN.properties # 完全匹配
2. messages_zh_Hans.properties # 匹配语言+脚本
3. messages_zh_CN.properties # 匹配语言+国家
4. messages_zh.properties # 仅匹配语言
5. messages.properties # 默认文件(fallback)
6. 系统 Locale 文件 # 如果 fallbackToSystemLocale=true3.2 属性文件示例
messages.properties(默认 - 英文):
order.notfound=Order not found
order.amount.invalid=Invalid order amount: {0}
order.shipped.confirm=Order {0} has been shipped to {1}
validation.required={0} is required
validation.length.between={0} must be between {1} and {2} characters
error.system=System error, please contact supportmessages_zh_CN.properties(简体中文):
order.notfound=订单不存在
order.amount.invalid=无效的订单金额:{0}
order.shipped.confirm=订单 {0} 已发货至 {1}
validation.required={0} 不能为空
validation.length.between={0} 的长度必须在 {1} 到 {2} 之间
error.system=系统错误,请联系技术支持3.3 消息参数插值
Spring 使用 java.text.MessageFormat 进行参数格式化,支持 {0}、{1} 等占位符,也支持格式化样式。
// 基本参数替换
String msg = messageSource.getMessage("order.shipped.confirm",
new Object[]{"20240715001", "上海市浦东新区"},
Locale.CHINA);
// 输出:订单 20240715001 已发货至 上海市浦东新区
// 带格式化的参数
messageSource.getMessage("validation.length.between",
new Object[]{"用户名", 6, 20},
Locale.CHINA);
// 输出:用户名的长度必须在 6 到 20 之间
// 带 MessageFormat 样式的参数(属性文件中定义)
// 属性:welcome.msg=Welcome {0,choice,0#|0<Mr. {1}}
// 注意实际使用时应避免在属性值中直接嵌入复杂 MessageFormat 模式格式化样式说明:
# 数字格式化
product.price=The price is {0,number,currency}
# 输出:The price is ¥128.00
# 日期格式化
order.date=Order date: {0,date,long}
# 输出:Order date: 2024年7月15日
# 选择格式化
items.count={0,choice,0#no items|1#1 item|1<{0} items}
# 输出:no items / 1 item / 5 items3.4 通过 ApplicationContext 获取 MessageSource
Spring 的 ApplicationContext 实现了 MessageSource 接口,可以直接通过上下文获取消息:
@Service
public class NotificationService {
@Autowired
private ApplicationContext context;
public String getLocalizedMessage(String code, Locale locale) {
// ApplicationContext 直接调用 getMessage()
return context.getMessage(code, null, locale);
}
}或者直接注入 MessageSource:
@Service
public class OrderService {
@Autowired
private MessageSource messageSource;
public String getErrorMessage(String code, Locale locale) {
return messageSource.getMessage(code, null, "未知错误", locale);
}
}3.5 MessageSourceResolvable 接口
MessageSourceResolvable 允许将多个可能的 code 和默认值封装为一个对象:
public interface MessageSourceResolvable {
String[] getCodes();
Object[] getArguments();
String getDefaultMessage();
}使用示例:
// 错误码回退机制:依次尝试 codes 中的每个 code
MessageSourceResolvable resolvable = new DefaultMessageSourceResolvable(
new String[]{"order.refund.error.vip", "order.refund.error", "error.unknown"},
new Object[]{"20240715001"},
"处理失败,请重试"
);
String msg = messageSource.getMessage(resolvable, locale);
// 按数组顺序查找 messages,找到了就返回,都找不到则返回默认值四、LocaleResolver 接口体系
4.1 LocaleResolver 接口
LocaleResolver 决定当前请求使用哪个 Locale:
package org.springframework.web.servlet;
public interface LocaleResolver {
Locale resolveLocale(HttpServletRequest request);
void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale);
}Spring MVC 通过 DispatcherServlet 自动调用 resolveLocale(),将解析到的 Locale 绑定到当前请求。
DispatcherServlet 中的默认配置:
// DispatcherServlet.properties 中定义的默认实现
org.springframework.web.servlet.LocaleResolver=\
org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver4.2 AcceptHeaderLocaleResolver
根据 HTTP 请求头的 Accept-Language 字段解析 Locale。无需显式配置,客户端浏览器自动发送。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
// 设置默认 Locale
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
// 限制支持的 Locale 列表(可选)
resolver.setSupportedLocales(Arrays.asList(
Locale.SIMPLIFIED_CHINESE, Locale.US, Locale.JAPAN
));
return resolver;
}
}HTTP 请求示例:
GET /api/orders/1001 HTTP/1.1
Accept-Language: zh-CN,zh;q=0.9,en;q=0.84.3 CookieLocaleResolver
将用户的语言偏好存储在 Cookie 中,实现持久化。
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver();
// Cookie 名称
resolver.setCookieName("LANG");
// Cookie 过期时间(秒),-1 表示关闭浏览器后过期
resolver.setCookieMaxAge(86400 * 30); // 30 天
// Cookie 路径
resolver.setCookiePath("/");
// Cookie 的 HttpOnly 标志
resolver.setCookieHttpOnly(true);
// 默认 Locale
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return resolver;
}切换语言 Controller:
@Controller
public class LanguageController {
@GetMapping("/change-language")
public String changeLanguage(@RequestParam("lang") String lang,
HttpServletRequest request,
HttpServletResponse response) {
Locale locale = new Locale(lang);
// 通过 LocaleResolver 设置 Locale 到 Cookie
LocaleResolver localeResolver = RequestContextUtils.getLocaleResolver(request);
if (localeResolver != null) {
localeResolver.setLocale(request, response, locale);
}
// 重定向回来源页
String referer = request.getHeader("Referer");
return "redirect:" + (referer != null ? referer : "/");
}
}4.4 SessionLocaleResolver
将 Locale 存储在用户 Session 中。
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver resolver = new SessionLocaleResolver();
// Session 属性名(可选,默认 SessionLocaleResolver.LOCALE_SESSION_ATTRIBUTE_NAME)
resolver.setLocaleAttributeName("CURRENT_LOCALE");
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return resolver;
}4.5 FixedLocaleResolver
始终使用固定的 Locale,不支持切换。适用于单语言站点。
@Bean
public LocaleResolver localeResolver() {
FixedLocaleResolver resolver = new FixedLocaleResolver(Locale.CHINA);
return resolver;
}4.6 四种 LocaleResolver 对比
| 实现类 | 存储位置 | 变更持久化 | 适用场景 |
|---|---|---|---|
| AcceptHeaderLocaleResolver | HTTP 请求头 | 不持久化 | 浏览器自动协商、API 端 |
| CookieLocaleResolver | 浏览器 Cookie | 持久化(按配置) | Web 用户可自选语言 |
| SessionLocaleResolver | HttpSession | 会话级别 | 需登录的系统 |
| FixedLocaleResolver | 固定值 | 不支持变更 | 内部系统、单语言站点 |
五、LocaleContextHolder
5.1 原理概述
LocaleContextHolder 通过 ThreadLocal 将 Locale 绑定到当前线程,使得不直接访问 HttpServletRequest 的层(如 Service、Repository 层)也能获取当前线程的 Locale。
5.2 LocaleContext 结构
package org.springframework.context.i18n;
public final class LocaleContextHolder {
private static final ThreadLocal<LocaleContext> localeContextHolder =
new NamedThreadLocal<>("LocaleContext");
private static final ThreadLocal<LocaleContext> inheritableLocaleContextHolder =
new NamedInheritableThreadLocal<>("LocaleContext");
// 设置 LocaleContext
public static void setLocaleContext(@Nullable LocaleContext localeContext) {
setLocaleContext(localeContext, false);
}
// 获取当前线程的 Locale
public static Locale getLocale() {
LocaleContext localeContext = getLocaleContext();
return localeContext != null ? localeContext.getLocale() : null;
}
// 设置时可指定是否可继承给子线程
public static void setLocaleContext(@Nullable LocaleContext localeContext, boolean inheritable) {
if (localeContext == null) {
resetLocaleContext();
} else {
if (inheritable) {
inheritableLocaleContextHolder.set(localeContext);
localeContextHolder.remove();
} else {
localeContextHolder.set(localeContext);
inheritableLocaleContextHolder.remove();
}
}
}
}5.3 与 DispatcherServlet 的联动
DispatcherServlet 在处理请求时,通过 LocaleContextHolder 绑定 Locale:
// DispatcherServlet 中的处理(简化)
protected void doService(HttpServletRequest request, HttpServletResponse response) {
// ... 省略其他逻辑
// 从 LocaleResolver 获取 Locale 并绑定到 LocaleContextHolder
LocaleContext localeContext = new SimpleLocaleContext(request.getLocale());
LocaleContextHolder.setLocaleContext(localeContext, true); // inheritable = true
try {
// ... 执行请求处理
} finally {
// 请求结束后清理 ThreadLocal,防止内存泄漏
LocaleContextHolder.resetLocaleContext();
}
}5.4 在 Service 层使用
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private MessageSource messageSource;
public String getLocalizedMessage(String code, Object[] args) {
// 无需传递 Locale 参数,从 ThreadLocal 自动获取
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, args, locale);
}
// 或者更简洁的方式:直接调用
public String getErrorDescription(OrderError error) {
// 如果 LocaleContextHolder 已经绑定了 Locale
return messageSource.getMessage(error.getCode(), error.getArgs(),
error.getDefaultMessage(), LocaleContextHolder.getLocale());
}
}5.5 手动绑定 LocaleContext
在非 Web 环境(如消息队列消费者、定时任务)中手动绑定:
@Service
public class ReportGenerationService {
public void generateReport(String type, Locale locale) {
try {
// 手动绑定 LocaleContext
LocaleContextHolder.setLocale(new SimpleLocaleContext(locale));
// 后续调用都能获取到正确的 Locale
String title = messageSource.getMessage("report.title." + type, null,
LocaleContextHolder.getLocale());
// ... 生成报告
} finally {
// 务必清理
LocaleContextHolder.resetLocaleContext();
}
}
}5.6 异步任务中的传递
对于 @Async 异步方法,由于线程池复用了线程,需要使用 LocaleContextHolder.setLocaleContext(_, inheritable=true) 或手动传递:
@Async
public CompletableFuture<String> processAsync(String code, LocaleContext context) {
try {
// 手动设置父线程的 LocaleContext
LocaleContextHolder.setLocaleContext(context);
return CompletableFuture.completedFuture(
messageSource.getMessage(code, null, LocaleContextHolder.getLocale())
);
} finally {
LocaleContextHolder.resetLocaleContext();
}
}六、源码分析:AbstractMessageSource.getMessage() 解析流程
Spring 5.3.x 中 AbstractMessageSource 的消息解析核心流程,以 getMessage(code, args, locale) 为例:
6.1 入口方法
// AbstractMessageSource.java (Spring 5.3.x)
@Override
public final String getMessage(String code, @Nullable Object[] args, Locale locale)
throws NoSuchMessageException {
String msg = getMessageInternal(code, args, locale);
if (msg != null) {
return msg;
}
// 找不到消息时抛出异常
throw new NoSuchMessageException(code, locale);
}6.2 内部解析流程:getMessageInternal()
@Nullable
protected String getMessageInternal(@Nullable String code, @Nullable Object[] args,
@Nullable Locale locale) {
// 步骤1:如果未传入 Locale,从 LocaleContextHolder 获取
if (locale == null) {
locale = LocaleContextHolder.getLocale();
}
// 步骤2:如果仍然没有 Locale,使用默认
if (locale == null) {
locale = Locale.getDefault();
}
// 步骤3:解析消息文本(抽象方法,由子类实现)
String msg = resolveCodeString(code, locale);
// 步骤4:如果当前 MessageSource 未找到,委托给父 MessageSource
if (msg == null) {
if (this.parentMessageSource != null) {
return this.parentMessageSource.getMessage(code, args, locale);
}
return null; // 没有父 MessageSource,返回 null
}
// 步骤5:对包含参数的消息进行格式化
if (args != null && args.length > 0) {
MessageFormat messageFormat = resolveMessageFormat(msg, locale);
if (messageFormat != null) {
synchronized (messageFormat) {
msg = messageFormat.format(args);
}
}
}
return msg;
}6.3 解析消息文本:resolveCodeString()
@Nullable
private String resolveCodeString(String code, Locale locale) {
// 检查是否启用 alwaysUseMessageFormat 模式
if (this.alwaysUseMessageFormat) {
// 获取 MessageFormat 对象,同时提取原始消息文本
MessageFormat messageFormat = resolveCode(code, locale);
if (messageFormat != null) {
return messageFormat.format(new Object[0]); // 空参数格式化
}
} else {
// 默认路径:直接解析为字符串
return resolveCodeWithoutArguments(code, locale);
}
return null;
}6.4 子类实现的关键抽象方法
AbstractMessageSource 定义了两个供子类实现的抽象方法:
// 方式一:返回消息文本字符串
@Nullable
protected abstract String resolveCodeWithoutArguments(String code, Locale locale);
// 方式二:返回 MessageFormat 对象(带缓存的)
@Nullable
protected abstract MessageFormat resolveCode(String code, Locale locale);6.5 ResourceBundleMessageSource 的实现
// ResourceBundleMessageSource.java (简化)
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
// 从 ResourceBundle 缓存中获取
ResourceBundle bundle = getResourceBundle(locale);
if (bundle != null) {
return getStringOrNull(bundle, code);
}
return null;
}
// 获取 ResourceBundle 的完整逻辑
protected ResourceBundle getResourceBundle(Locale locale) {
// 尝试候选 Locale 列表
for (Locale candidate : getCandidateLocales(locale)) {
ResourceBundle bundle = getBundle(candidate);
if (bundle != null && bundle.containsKey(code)) {
return bundle;
}
}
return null;
}
// 候选 Locale 的计算逻辑
protected List<Locale> getCandidateLocales(Locale locale) {
// 返回 Locale 匹配链,例如 locale=zh_CN 时返回:
// [zh_CN, zh, 默认Locale, 空Locale]
// 由 ResourceBundle 标准机制决定
return ResourceBundleUtils.getCandidateLocales(locale);
}6.6 ReloadableResourceBundleMessageSource 的实现
// ReloadableResourceBundleMessageSource.java (简化)
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
// 检查缓存是否过期
PropertiesHolder propHolder = getMergedProperties(locale);
return propHolder.getProperty(code);
}
// 获取合并后的属性集
private PropertiesHolder getMergedProperties(Locale locale) {
// 1. 获取该 Locale 对应的属性文件
// 2. 检查缓存时间戳
// 3. 如果缓存过期,重新加载文件
// 4. 与 base 属性合并(子 Locale 的覆盖 base 的)
// 5. 返回合并后的 PropertiesHolder
return mergedProperties;
}
// 热加载核心:检查缓存时间
protected boolean isCacheRefreshRequired(long lastModified) {
if (this.cacheMillis < 0) {
return false; // 永久缓存
}
return (System.currentTimeMillis() - lastModified >= this.cacheMillis);
}6.7 完整流程图
getMessage(code, args, locale)
│
├─ locale == null ? → LocaleContextHolder.getLocale()
│
├─ resolveCodeString(code, locale)
│ │
│ ├─ alwaysUseMessageFormat ?
│ │ ├─ true → resolveCode(code, locale).format({})
│ │ └─ false → resolveCodeWithoutArguments(code, locale)
│ │
│ └─ 子类实现(Redis/DB/Properties 等数据源)
│
├─ msg == null ?
│ ├─ parentMessageSource != null ? → 委托父 MessageSource
│ └─ 返回 null
│
├─ args != null && args.length > 0 ?
│ ├─ true → MessageFormat.format(args)
│ └─ false → 直接返回 msg
│
└─ 返回最终消息字符串七、实战案例:电商国际站多语言方案
7.1 业务场景
一个跨境电商站点同时服务中美日用户,需要:
- 前端页面文案国际化
- 后端异常消息中英文切换
- App 端通过请求头传递语言偏好
- 管理后台固定中文
7.2 资源文件组织
resources/
├── i18n/
│ ├── messages.properties # 默认(英文)
│ ├── messages_zh_CN.properties # 简体中文
│ ├── messages_ja_JP.properties # 日文
│ ├── validation.properties # 校验错误消息
│ ├── validation_zh_CN.properties
│ ├── email/
│ │ ├── order-confirmation.properties
│ │ └── order-confirmation_zh_CN.properties
│ └── error/
│ ├── error.properties
│ └── error_zh_CN.properties7.3 配置类
@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 核心 MessageSource 配置
*/
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource =
new ReloadableResourceBundleMessageSource();
messageSource.setBasenames(
"classpath:i18n/messages",
"classpath:i18n/validation",
"classpath:i18n/email/order-confirmation",
"classpath:i18n/error/error"
);
messageSource.setDefaultEncoding("UTF-8");
messageSource.setFallbackToSystemLocale(false);
// 开发环境 10 秒刷新,生产环境 -1(永久缓存)
messageSource.setCacheSeconds(environment.acceptsProfiles("dev") ? 10 : -1);
return messageSource;
}
/**
* LocaleResolver:App 端读取 Accept-Language 头,
* Web 端用 Cookie 持久化用户选择
*/
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver();
resolver.setCookieName("SHOP_LANG");
resolver.setCookieMaxAge(86400 * 365); // 一年
resolver.setCookiePath("/");
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return resolver;
}
/**
* 拦截器:从 App 请求头获取语言并覆盖
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LocaleChangeInterceptor())
.addPathPatterns("/api/**"); // 仅 API 接口
}
}7.4 App 端语言头拦截器
当 App 端通过 X-Language 自定义请求头发送语言偏好时,通过拦截器动态切换:
@Component
public class AppLocaleInterceptor implements HandlerInterceptor {
private static final String APP_LANG_HEADER = "X-Language";
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String lang = request.getHeader(APP_LANG_HEADER);
if (StringUtils.hasText(lang)) {
// 解析语言头,例如 "zh-CN,zh;q=0.9"
Locale locale = Locale.lookup(
Locale.LanguageRange.parse(lang),
Arrays.asList(Locale.SIMPLIFIED_CHINESE, Locale.US, Locale.JAPAN)
);
if (locale != null) {
// 通过 LocaleContextHolder 绑定到当前线程
LocaleContextHolder.setLocale(locale);
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
@Nullable Exception ex) {
// 清理 ThreadLocal,防止内存泄漏
LocaleContextHolder.resetLocaleContext();
}
}注册拦截器:
@Configuration
public class LocaleConfig implements WebMvcConfigurer {
@Autowired
private AppLocaleInterceptor appLocaleInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(appLocaleInterceptor)
.addPathPatterns("/api/**");
}
}7.5 统一异常处理中的国际化
@RestControllerAdvice
public class GlobalExceptionHandler {
@Autowired
private MessageSource messageSource;
// 业务异常处理
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResult<Void>> handleBusinessException(BusinessException ex) {
// 从 LocaleContextHolder 获取当前请求的 Locale
Locale locale = LocaleContextHolder.getLocale();
// 解析国际化错误消息
String message = messageSource.getMessage(
ex.getErrorCode(),
ex.getArgs(),
ex.getDefaultMessage(),
locale
);
return ResponseEntity.badRequest()
.body(ApiResult.error(ex.getErrorCode(), message));
}
// 参数校验异常处理
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<Void>> handleValidation(MethodArgumentNotValidException ex) {
Locale locale = LocaleContextHolder.getLocale();
List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
String firstError = fieldErrors.stream()
.map(fe -> messageSource.getMessage(fe, locale))
.findFirst()
.orElse("Validation failed");
return ResponseEntity.badRequest()
.body(ApiResult.error("VALIDATION_ERROR", firstError));
}
// 系统异常兜底
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResult<Void>> handleUnknown(Exception ex) {
Locale locale = LocaleContextHolder.getLocale();
String message = messageSource.getMessage(
"error.system", null, "Internal server error", locale
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResult.error("SYSTEM_ERROR", message));
}
}7.6 自定义异常中的消息编码
public class BusinessException extends RuntimeException {
private final String errorCode;
private final Object[] args;
private final String defaultMessage;
public BusinessException(String errorCode, Object[] args, String defaultMessage) {
super(errorCode);
this.errorCode = errorCode;
this.args = args;
this.defaultMessage = defaultMessage;
}
// 工厂方法:创建带参数的国际化异常
public static BusinessException of(String errorCode, Object... args) {
return new BusinessException(errorCode, args, null);
}
public static BusinessException of(String errorCode, String defaultMessage, Object... args) {
return new BusinessException(errorCode, args, defaultMessage);
}
// getters...
public String getErrorCode() { return errorCode; }
public Object[] getArgs() { return args; }
public String getDefaultMessage() { return defaultMessage; }
}7.7 Service 层使用
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private MessageSource messageSource;
@Override
public OrderVO getOrderDetail(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> BusinessException.of(
"order.notfound",
new Object[]{orderId},
"Order not found: " + orderId
));
// 获取本地化消息
Locale locale = LocaleContextHolder.getLocale();
String statusDesc = messageSource.getMessage(
"order.status." + order.getStatus(),
null,
locale
);
return OrderVO.builder()
.id(order.getId())
.statusDesc(statusDesc)
.build();
}
@Override
public void cancelOrder(Long orderId, String reason) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> BusinessException.of("order.notfound", orderId));
// 检查订单状态
if (order.getStatus() == OrderStatus.SHIPPED) {
throw BusinessException.of(
"order.shipped.cannot.cancel",
new Object[]{orderId, LocaleContextHolder.getLocale().toLanguageTag()},
"Shipped order cannot be cancelled"
);
}
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
// 发送国际化邮件通知
Locale userLocale = LocaleContextHolder.getLocale();
emailService.sendOrderCancelledEmail(order, userLocale);
}
@Override
public List<String> getLocalizedStatusList() {
// 获取多语言订单状态列表
return Arrays.asList(OrderStatus.values())
.stream()
.map(status -> messageSource.getMessage(
"order.status." + status.name(),
null,
status.name(),
LocaleContextHolder.getLocale()
))
.collect(Collectors.toList());
}
}7.8 前端 JS 配合
后端提供一个 API 接口一次性返回所有前端需要的国际化消息:
@RestController
@RequestMapping("/api/i18n")
public class I18nController {
@Autowired
private MessageSource messageSource;
/**
* 返回前端需要的所有国际化消息
* 前端按需加载,缓存到 localStorage
*/
@GetMapping("/messages")
public Map<String, String> getMessages(
@RequestHeader("Accept-Language") String acceptLanguage) {
// 解析 Locale
Locale locale;
try {
locale = Locale.lookup(
Locale.LanguageRange.parse(acceptLanguage),
Arrays.asList(Locale.SIMPLIFIED_CHINESE, Locale.US, Locale.JAPAN)
);
} catch (Exception e) {
locale = Locale.US;
}
// 前端需要的所有消息 key
List<String> frontendKeys = Arrays.asList(
"nav.home", "nav.orders", "nav.cart",
"order.status.CREATED", "order.status.PAID",
"order.status.SHIPPED", "order.status.DELIVERED",
"button.submit", "button.cancel", "button.confirm",
"message.loading", "message.success", "message.error",
"error.system", "error.network", "error.timeout"
);
Map<String, String> messages = new LinkedHashMap<>();
for (String key : frontendKeys) {
messages.put(key, messageSource.getMessage(
key, null, key, locale
));
}
return messages;
}
/**
* 获取当前支持的语言列表
*/
@GetMapping("/locales")
public List<LocaleInfo> getSupportedLocales() {
return Arrays.asList(
new LocaleInfo("zh-CN", "简体中文"),
new LocaleInfo("en-US", "English"),
new LocaleInfo("ja-JP", "日本語")
);
}
record LocaleInfo(String code, String displayName) {}
}7.9 测试验证
@SpringBootTest
@AutoConfigureMockMvc
class I18nIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void testChineseErrorMessage() throws Exception {
// App 端发送中文头
mockMvc.perform(get("/api/orders/99999")
.header("X-Language", "zh-CN")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("订单不存在"));
}
@Test
void testEnglishErrorMessage() throws Exception {
// App 端发送英文头
mockMvc.perform(get("/api/orders/99999")
.header("X-Language", "en-US")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Order not found"));
}
@Test
void testJapaneseErrorMessage() throws Exception {
mockMvc.perform(get("/api/orders/99999")
.header("X-Language", "ja-JP")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("注文が見つかりません"));
}
@Test
void testValidationError() throws Exception {
// 提交空的订单创建请求,触发校验错误
String requestBody = "{}";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody)
.header("X-Language", "zh-CN"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("订单金额不能为空"));
}
}八、最佳实践与注意事项
8.1 性能优化
- 缓存策略
// 生产环境:永久缓存
messageSource.setCacheSeconds(-1);
// 开发环境:短缓存实现热加载
messageSource.setCacheSeconds(10);避免大量小文件:将相关消息合并到较少的 properties 文件中,减少 IO 操作
使用 basename 分组:按模块拆分 basename,而不是全部放在一个文件
8.2 编码问题
// 务必使用 UTF-8 编码
messageSource.setDefaultEncoding("UTF-8");
// 或者使用 Unicode 转义(native2ascii)
// order.notfound=\u8ba2\u5355\u4e0d\u5b58\u5728Spring Boot 中的便捷配置:
# application.properties
spring.messages.basename=messages,i18n/validation,i18n/error/error
spring.messages.encoding=UTF-8
spring.messages.cache-duration=3600
spring.messages.fallback-to-system-locale=false
spring.messages.always-use-message-format=false8.3 ThreadLocal 清理
// 异步处理时务必清理
try {
LocaleContextHolder.setLocale(locale);
// ... 业务逻辑
} finally {
LocaleContextHolder.resetLocaleContext();
}
// 或在拦截器中统一清理
@Override
public void afterCompletion(...) {
LocaleContextHolder.resetLocaleContext();
}8.4 自定义 MessageSource
当需要从数据库或 Redis 读取国际化消息时,可实现自定义 MessageSource:
@Component
public class DatabaseMessageSource extends AbstractMessageSource {
@Autowired
private I18nMessageRepository repository;
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
// 从数据库查询
return repository.findMessage(code, locale.toLanguageTag());
}
@Override
protected MessageFormat resolveCode(String code, Locale locale) {
String msg = resolveCodeWithoutArguments(code, locale);
if (msg != null) {
return createMessageFormat(msg, locale);
}
return null;
}
}8.5 Spring Boot 自动配置
Spring Boot 通过 MessageSourceAutoConfiguration 自动配置 MessageSource,优先级规则:
- 用户显式定义
MessageSourceBean → 完全接管 - 用户配置
spring.messages.*属性 → Boot 自动配置ResourceBundleMessageSource - 无任何配置 → 默认
messages基名
# 使用 Spring Boot 自动配置
spring.messages.basename=messages,i18n/validation
spring.messages.encoding=UTF-8
spring.messages.cache-duration=1h
spring.messages.always-use-message-format=false总结
Spring Framework 的 MessageSource 体系提供了从接口设计到多数据源实现的完整国际化能力:
- 接口层:
MessageSource→HierarchicalMessageSource→AbstractMessageSource,层次清晰,便于扩展 - 实现层:
ResourceBundleMessageSource和ReloadableResourceBundleMessageSource覆盖了静态文件和热加载两种场景 - Locale 解析层:
LocaleResolver的四种实现覆盖了 Web 端、API 端、固定语言的各类需求 - 上下文绑定:
LocaleContextHolder通过 ThreadLocal 解耦了 Locale 获取与业务代码 - 源码设计:模板方法模式、委派模式、缓存策略等多处经典设计值得深入学习
理解 MessageSource 的全貌,不仅是掌握国际化配置,更是学习 Spring 框架在抽象与扩展之间平衡设计的绝佳范例。