跳到主要内容
Spring 国际化原理与实战:MessageSource、LocaleResolver、LocaleContextHolder、MessageFormat | 极客日志
Java java
Spring 国际化原理与实战:MessageSource、LocaleResolver、LocaleContextHolder、MessageFormat Spring 国际化的核心是把文案从代码中拆出来,先通过 LocaleResolver 确定请求语言,再借助 LocaleContextHolder 在当前线程保存 Locale,最后由 MessageSource 按 code 和 Locale 加载资源文件,并通过 MessageFormat 填充占位符参数。文章对比了 ResourceBundleMessageSource 与 ReloadableResourceBundleMessageSource,也说明了 AcceptHeaderLocaleResolver、SessionLocaleResolver、CookieLocaleResolver 各自适合的场景,并给出 Spring Boot 最小配置示例,帮助把多语言能力跑通。
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 常用的实现有三种,适合的场景差别挺明显。
它从请求头 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.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 负责填参数。把这条链路理顺以后,后面的校验消息、异常提示、接口返回文案都能沿着同一套方式做下去。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online