Spring 国际化原理与实战:MessageSource、LocaleResolver、LocaleContextHolder、MessageFormat
Spring 国际化到底解决什么问题
企业项目里,国际化通常不是'做个英文版页面'这么简单。提示语、校验信息、异常文案、邮件模板,都会跟着语言环境变化。把这些内容直接写进代码,后面补语言的时候就会很难受:改一处要扫一遍,漏一个字符串就成了线上 bug。
Spring 的做法比较朴素,也比较耐用:把文本从代码里拆出去,根据请求里的语言环境去加载对应文案。真正干活的就四个东西:MessageSource、LocaleResolver、LocaleContextHolder 和 MessageFormat。这几个组件配合起来,能把'查语言、找文本、填参数'这条链路跑通。
核心思路:先拿到 Locale,再找对应消息
Spring 国际化的关键不是'翻译',而是'按上下文选文案'。流程其实很直白:请求进来后,先确定当前 Locale,再根据这个 Locale 去消息文件里找对应的 code,最后把占位符参数填进去。
简化成一条链路就是这样:
客户端请求 -> LocaleResolver 解析 Locale -> LocaleContextHolder 保存 Locale -> MessageSource 按 Locale 取消息 -> MessageFormat 填充参数 -> 返回文本
这里的重点是消息和代码分离。配置文件维护文案,业务代码只关心 code。这么做的好处很实际:新增语言时不用改逻辑,只补资源文件就行。
MessageSource:真正负责找消息的组件
MessageSource 是 Spring 国际化里最核心的接口。它不负责判断语言,也不负责保存上下文,只做一件事:按 code 和 Locale 取出对应的消息。
public interface MessageSource {
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}
平时业务里最常用的是第二个方法,带默认值。生产环境里我也更倾向于用它,至少能避免某个 code 拼错时直接把请求打挂。至于第三个方法,更多是框架内部在用,比如校验错误对象那类场景。
常见实现:ResourceBundleMessageSource 和 ReloadableResourceBundleMessageSource
Spring 提供了两个最常见的实现类,差别不算复杂,但选错了会影响维护方式。
| 实现类 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| ResourceBundleMessageSource | 基于 JDK ResourceBundle 加载 classpath 下的 .properties | 轻量、启动快 | 不支持热加载;只认 classpath 资源 | 小项目、文案很少改的场景 |
| ReloadableResourceBundleMessageSource | 扩展版,支持文件系统/URL 资源 | 支持热加载;可加缓存策略;资源路径更灵活 | 启动略慢,配置更多 | 中大型项目、文案会调整的场景 |
如果项目里国际化文案会频繁改,我一般会直接上 ReloadableResourceBundleMessageSource。多一点配置,换来的是后面少重启几次。
一个比较实用的配置
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:i18n/messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
messageSource.setCacheSeconds(3600);
messageSource.setUseCodeAsDefaultMessage(true);
return messageSource;
}
几个配置项里,UTF-8 基本是必配项,不然中文很容易乱码。setUseCodeAsDefaultMessage(true) 也挺实用,至少排查时能直接看出是不是没命中资源文件。多模块项目里,我更建议把消息拆成多个文件,比如 common、validation、error,别把所有内容堆进一个 messages.properties 里,后期会很乱。
LocaleResolver:决定当前请求用哪种语言
LocaleResolver 的作用是解析当前请求的语言环境。它负责'从哪儿拿 Locale',而不是'怎么翻译'。Spring 常用的实现有三种,适合的场景差别挺明显。
AcceptHeaderLocaleResolver
它从请求头 Accept-Language 里读语言信息。浏览器、前端框架、移动端 App,默认都会带这类头。
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
return resolver;
}
这种方式配置最省事,但代价也很清楚:语言切换依赖客户端请求头,服务端没法单独改语言。适合不需要用户手动切换的场景。
SessionLocaleResolver
如果语言切换要跟着用户会话走,用这个更合适。切换一次,在当前 Session 里一直有效。
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver resolver = new SessionLocaleResolver();
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
return resolver;
}
@GetMapping("/switchLocale")
public String switchLocale(@RequestParam String lang, HttpSession session) {
Locale locale = switch (lang) {
case "en" -> Locale.US;
case "zh" -> Locale.SIMPLIFIED_CHINESE;
default -> Locale.SIMPLIFIED_CHINESE;
};
session.setAttribute(SessionLocaleResolver.LOCALE_SESSION_ATTRIBUTE_NAME, locale);
return "Locale switched to: " + lang;
}
它的问题也很现实:依赖 Session。单体应用没什么,分布式场景里如果 Session 没共享,切换语言这件事就会变得不稳定。
CookieLocaleResolver
如果你希望语言偏好能跨会话保存,Cookie 会更顺手。
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver();
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
resolver.setCookieName("lang");
resolver.setCookieMaxAge(60 * 60 * 24 * 7);
resolver.setCookiePath("/");
return resolver;
}
@GetMapping("/switchLocale")
public String switchLocale(@RequestParam String lang, HttpServletResponse response) {
Locale locale = switch (lang) {
case "en" -> Locale.US;
case "zh" -> Locale.SIMPLIFIED_CHINESE;
default -> Locale.SIMPLIFIED_CHINESE;
};
((CookieLocaleResolver) localeResolver()).setLocale(null, response, locale);
return "Locale switched to: " + lang;
}
它的优点是持久化,缺点也很直接:用户禁用 Cookie 时就不工作了。面向 C 端 Web 应用时,这个方案通常比 Session 更稳一点。
三者可以简单记成这样:请求头适合'随请求变化',Session 适合'会话内保持',Cookie 适合'跨会话记住偏好'。
| 特性 | AcceptHeaderLocaleResolver | SessionLocaleResolver | CookieLocaleResolver |
|---|---|---|---|
| 数据来源 | HTTP 请求头 | Session | Cookie |
| 持久化 | 否 | 会话内 | 可跨会话 |
| 是否支持手动切换 | 否 | 是 | 是 |
| 适用场景 | 前端控制语言 | 后台系统 | C 端 Web 应用 |
LocaleContextHolder:把 Locale 放到当前线程里
LocaleContextHolder 是个工具类,底层基于 ThreadLocal。它存在的意义很简单:让你在 Controller、Service、异常处理器里都能拿到当前请求的 Locale,不用层层往下传。
public abstract class LocaleContextHolder {
private static final ThreadLocal<LocaleContext> localeContextHolder = new NamedThreadLocal<>("LocaleContext");
private static final ThreadLocal<LocaleContext> inheritableLocaleContextHolder = new NamedInheritableThreadLocal<>("LocaleContext");
public static Locale getLocale() {
LocaleContext localeContext = getLocaleContext();
return (localeContext != null ? localeContext.getLocale() : null);
}
public static void setLocale(@Nullable Locale locale) {
setLocale(locale, false);
}
public static void resetLocaleContext() {
localeContextHolder.remove();
inheritableLocaleContextHolder.remove();
}
}
这套机制没什么玄学,就是线程隔离。一个请求线程一个 Locale,互不影响。需要注意的是,异步线程默认拿不到父线程的上下文,如果你要把 Locale 带进子线程,就得用可继承的模式。
获取当前语言环境时,通常这样写就够了:
Locale currentLocale = LocaleContextHolder.getLocale();
在 Web 场景下,Spring 会帮你把 Locale 放好。非 Web 场景,比如定时任务、普通异步任务,就别默认指望它会自动存在,通常得自己处理。
MessageFormat:负责把参数填进消息里
MessageFormat 是 JDK 自带的,Spring 直接拿来用。它处理的是 {0}、{1} 这类占位符,把动态参数塞到消息模板中。
基础形式很简单:
service.call.timeout=调用{0}服务超时,超时时间:{1}ms
String message = messageSource.getMessage("service.call.timeout", new Object[]{"user-service", 5000}, Locale.SIMPLIFIED_CHINESE);
// 输出:调用 user-service 服务超时,超时时间:5000ms
它也支持日期、数字这类格式化:
user.register.time=用户{0}注册时间:{1,date,yyyy-MM-dd HH:mm:ss}
order.amount=订单{0}的金额:{1,number,currency},创建时间:{2,date,long}
String zhMessage = messageSource.getMessage(
"order.amount",
new Object[]{"ORD123456", 999.99, new Date()},
Locale.SIMPLIFIED_CHINESE
);
String enMessage = messageSource.getMessage(
"order.amount",
new Object[]{"ORD123456", 999.99, new Date()},
Locale.US
);
这里有几个坑挺常见。一个是占位符索引从 0 开始,参数数量不够会直接报错。另一个是消息文本里如果真要出现 { 或 },要记得转义,不然它会被当成格式符号解析。数字和日期的输出也会跟 Locale 走,这点倒是省心,不用自己拼格式。
Web 场景下的完整执行链路
把这四个组件串起来,流程其实不复杂:
请求进入 DispatcherServlet
↓
LocaleResolver 解析 Locale
↓
LocaleContextHolder 保存当前线程的 Locale
↓
业务代码调用 MessageSource.getMessage
↓
MessageSource 按 Locale 加载资源文件
↓
MessageFormat 处理占位符
↓
返回多语言文本
这条链路里,LocaleResolver 只管'从哪儿来',LocaleContextHolder 只管'先存起来',MessageSource 负责'按 code 找文案',MessageFormat 负责'把参数补进去'。职责分得很清楚,所以调试时也不难定位问题。
最小 Demo:跑通 Spring 国际化
先准备一个最基础的 Spring Boot 项目,JDK 17+、Spring Boot 3.x、Maven 或 Gradle 都可以。
配置类
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.List;
import java.util.Locale;
@Configuration
public class I18nConfig {
@Bean
public ReloadableResourceBundleMessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:i18n/messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
messageSource.setCacheSeconds(0);
messageSource.setUseCodeAsDefaultMessage(true);
return messageSource;
}
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
return resolver;
}
}
消息文件
resources/i18n/messages_zh_CN.properties:
hello.world=你好,世界!
user.name.empty=用户名不能为空
user.age.range=年龄必须在{0}到{1}之间
resources/i18n/messages_en_US.properties:
hello.world=Hello, World!
user.name.empty=Username cannot be empty
user.age.range=Age must be between {0} and {1}
Controller
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Locale;
@RestController
@RequestMapping("/i18n")
public class I18nController {
private final MessageSource messageSource;
public I18nController(MessageSource messageSource) {
this.messageSource = messageSource;
}
@GetMapping("/hello")
public String hello() {
Locale currentLocale = LocaleContextHolder.getLocale();
return messageSource.getMessage("hello.world", null, currentLocale);
}
@GetMapping("/age")
public String ageRange() {
Locale currentLocale = LocaleContextHolder.getLocale();
return messageSource.getMessage("user.age.range", new Object[]{18, 60}, currentLocale);
}
}
验证方式
curl -H "Accept-Language: zh-CN" http://localhost:8080/i18n/hello
curl -H "Accept-Language: zh-CN" http://localhost:8080/i18n/age
curl -H "Accept-Language: en-US" http://localhost:8080/i18n/hello
curl -H "Accept-Language: en-US" http://localhost:8080/i18n/age
curl http://localhost:8080/i18n/hello
默认语言没带请求头时会走 LocaleResolver 的默认值,所以还是中文。
为什么 bean 名必须叫 messageSource
这个点很容易被忽略,但线上真的会踩。Spring 的一些自动配置默认会去找名字叫 messageSource 的 Bean。如果你自己起了别的名字,框架可能就拿不到它,最后退回到默认的 DelegatingMessageSource,结果就是只返回 code,不会做真正的国际化解析。
也就是说,名字不是随便取的。想省事,直接叫 messageSource;真要改名,就得把相关依赖都显式接上。
结语
Spring 国际化这套机制不复杂,真正重要的是把职责分开:LocaleResolver 负责找语言,LocaleContextHolder 负责临时保存,MessageSource 负责找文案,MessageFormat 负责填参数。把这条链路理顺以后,后面的校验消息、异常提示、接口返回文案都能沿着同一套方式做下去。


