一个项目做到中后期,后端往往不怕业务逻辑有多复杂,最怕产品经理轻飘飘来一句:“咱们要支持中英文切换。” 这句话落到代码里,就是一套完整的 Spring 国际化(i18n)体系。我在四个项目里踩过 i18n 的坑,从最开始用 Map 硬编码文案,到后来规规矩矩用MessageSource + LocaleResolver把多语言做干净,才终于把这套机制吃透。
网上讲 i18n 的教程很多,但大多停在“怎么配三个 properties 文件”的层面,一旦涉及动态参数、热更新、数据库存文案、校验消息国际化,就会出现知识断层。这篇文章我就从原理层面把 Spring 国际化的核心盘一遍。无论你是刚接触 Spring Boot 的新手,还是已经写过几年代码但没系统梳理过 i18n 的开发者,这篇都能给你一个可落地的完整认知。
1. 从“支持中英文切换”这句需求说起
很多项目在初期根本不做多语言,所有提示语直接硬编码在 Java 代码里或者页面上。等客户突然提出海外版需求时,第一个排查点就是:全站到底散落了多少文案?如果当初写代码时没有统一出口,这个排查量能让你加班到怀疑人生。
Spring 国际化(i18n)要解决的,就是把“文案”和“代码”彻底分离。代码里只写一个逻辑上的文案标识(通常叫 code),比如login.title、order.paid、error.timeout,真正的文案内容放到 messages 资源文件里。用户请求过来时,系统根据用户的语言环境自动选择对应语言的文案,还支持把用户名、订单号、日期等动态值插进模板里。
要做到这件事,Spring 底层靠的是一套成熟组件:既有时区、语言、国家地区这样的基础数据,也有资源加载、请求拦截、格式化输出等机制。在企业级项目里,多语言开发从来不是“多准备几个 properties 文件”这么简单——文案要按模块拆分、参数要能动态填充、线上文案要能热更新、校验错误也要能跟着语言切换。所有这些需求,Spring i18n 其实都给出了标准解法,只是因为知识点分散,很少有人一次性讲透。
这篇文章适合谁?适合那些知道ResourceBundleMessageSource怎么配,但不清楚LocaleResolver和Interceptor到底谁先执行、MessageFormat的单引号为什么会把文案吞掉的开发者。也适合正在做企业级改造、准备把硬编码文案统一收口的团队。我会从组件清单、底层原理、实操代码、企业级扩展四个角度往下拆。
2. 四个核心组件全景:语言、资源、解析、格式化
Spring i18n 的复杂度不在代码量,而在概念链路长。你如果只知道配一个messageSourceBean,遇到“为什么请求头切换语言不生效”“为什么多参数消息格式化不对”就会抓瞎。把整条链路拉通看,核心就是 4 个组件。
2.1 Locale:只是“你是哪国人”的一个对象?
java.util.Locale看起来很简单,构造函数传两个字符串就行:new Locale("zh", "CN")、new Locale("en", "US")。第一个参数是 ISO 639 语言代码,第二个是 ISO 3166 国家/地区代码。语言代码决定语言,国家代码决定地区差异,比如同样是英语,en_US和en_GB在日期格式、拼写上都有区别。
但它在 i18n 里的角色不是“数据”,而是“方向标”。整个国际化链路的第一步,就是拿到当前请求的 Locale。这个 Locale 可以来自 HTTP 请求头的Accept-Language,可以来自 Session,也可以来自 Cookie,甚至可以是 URL 上的一个参数。谁来决定用哪个来源?这就是后来的 LocaleResolver 的事。Locale 本身只是载体,谁来封装、谁来传递、谁在最后把它翻译成具体文案,是后三个组件的分工。
2.2 MessageSource:真正的翻译仓库
MessageSource是 Spring 提供的一个顶级接口,核心方法长这样:
String getMessage(String code, Object[] args, Locale locale)code是文案标识,args是动态参数,locale是目标语言。方法语义很直白:给我一个标识、几个参数和一种语言,还你一段完整的本地化文案。
实现类里最常用的是ResourceBundleMessageSource和ReloadableResourceBundleMessageSource。前者基于 JDK 的ResourceBundle,只能加载 classpath 路径下的.properties文件;后者是 Spring 自己实现的,能加载任意 Spring 资源,还支持缓存失效时间,也就是热更新。还有一个很重要的点:ApplicationContext本身也实现了MessageSource接口,所以你在任意 Spring 容器里注入MessageSource,拿到的往往是容器配置的这个 Bean——这也是为什么很多人没主动配过 MessageSource,项目也能正常跑,因为 Spring Boot 有默认的配置。
2.3 LocaleResolver 与 LocaleChangeInterceptor:谁决定当前语言
MessageSource只负责查文案,但它必须知道“查哪种语言”。这个决定权在LocaleResolver手中。
LocaleResolver也是一个接口,核心是resolveLocale(HttpServletRequest request),返回当前请求应该使用的 Locale。Spring 提供三种现成实现:
AcceptHeaderLocaleResolver:从请求头Accept-Language里取语言,和浏览器语言设置直接挂钩。SessionLocaleResolver:从 Session 里取语言,会话内生效。CookieLocaleResolver:从 Cookie 里取语言,能在浏览器端持久化,下次访问仍然有效。
如果只是读取语言,有 Resolver 就够了。但用户通常需要手动切换语言,这时候就要靠LocaleChangeInterceptor。它是一个 Spring MVC 拦截器,会拦截请求中的某个指定参数(通常是lang),拿到后调用LocaleResolver.setLocale(...)把新语言存起来。
注意:拦截器只是更新 Resolver 里保存的这个值,真正让整个请求能在后续拿到这个 Locale 的,是另一个机制——LocaleContextHolder。它用线程本地变量保存当前请求的 Locale,Controller 层、Service 层根本不需要手动传 Locale,直接LocaleContextHolder.getLocale()就能拿到。
2.4 MessageFormat:参数的最后一棒
查到了文案模板,也拿到了动态参数,此时还不能直接把数组拼进字符串。模板里的占位符长这样:{0}、{1},甚至还有{0, date, full}这种带格式类型的写法,普通字符串拼接根本处理不了。Spring 用的是java.text.MessageFormat,它会解析模板里的占位符,把数组里的元素按索引填入,并且支持日期、数字、货币、复数的本地化格式化。
举个例子,模板"用户 {0} 上次登录时间:{1, date, full}",配合参数["zhangsan", new Date()],不同 Locale 下会得到不同的日期格式。英文环境输出 "User zhangsan last login time: Thursday, June 5, 2025",中文环境输出 "用户 zhangsan 上次登录时间:2025年6月5日星期四"。
2.5 一次完整请求的 i18n 调用链
把这四个组件串起来,一次请求的完整链路是这样:
- 浏览器发起请求,URL 上可能带
?lang=en_US; DispatcherServlet找到处理器链,执行LocaleChangeInterceptor.preHandle(),解析lang参数,调用LocaleResolver.setLocale()更新当前语言;- 当前语言被封装进
LocaleContext,绑定到LocaleContextHolder这个线程本地变量; - Controller 或 Service 调用
messageSource.getMessage("welcome", args, LocaleContextHolder.getLocale()); MessageSource根据 Locale 找到对应语言的资源文件,取出模板;MessageFormat将 args 注入模板占位符,做最终格式化;- 请求结束,
LocaleContextHolder被清理,避免线程池复用导致语言串台。
看到这里你会发现,i18n 没有很难的算法,难的是把每个环节的职责边界搞清楚。接下来我挑几个最核心的底层原理展开,因为这些原理直接决定了你排查问题的效率。
3. 底层原理拆解:一个请求如何完成国际化
3.1 资源 Bundle 的查找降级链
ResourceBundleMessageSource在加载文案时有一个“逐步降级”的查找顺序。假设你配置的 basename 是messages,请求的 Locale 是zh_CN,它会依次寻找:
messages_zh_CN.propertiesmessages_zh.propertiesmessages.properties- 都没有则抛
NoSuchMessageException
这个设计思路很清晰:国家不匹配时退回语言,语言不匹配时退回默认,默认也没有就报错。做国际化时,默认文件非常关键,它应该是最完整的文案兜底,绝不能留空。很多线上事故其实就是默认文件里缺了几条,导致用户直接看到异常堆栈。
实践上还有两个细节值得注意。第一,如果设置了setFallbackToSystemLocale(false),则查找不会退回服务器系统的语言环境,而是直接使用默认资源文件。第二,setUseCodeAsDefaultMessage(true)可以在文案缺失时把 code 本身作为提示返回,而不是抛异常。开发期可以开这个配置方便调试,但我个人建议更早把文案管理纳入规范,别依赖它兜底。
3.2 LocaleContext 与 LocaleContextHolder:语言怎么在代码里传递
很多 i18n 教程会把LocaleContextHolder.getLocale()当成一个黑盒来用,但理解它的实现能让很多疑难杂症瞬间清楚。
LocaleContextHolder内部维护了一个ThreadLocal<LocaleContext>。进入请求处理阶段时,Spring 会把当前解析出来的 Locale 封装成LocaleContext存进这个线程本地变量。这样一来,这个线程里运行的所有代码都能取到同一个 Locale,不需要在方法签名里层层传递。
ThreadLocal 的代价是必须清理。Spring MVC 在请求处理完会调用LocaleContextHolder.resetLocaleContext(),保证线程池复用时不会把上一个请求的语言带到下一个请求。如果你在自定义的异步任务、定时任务里也用到了 Locale,必须自己手动设置和清理:
LocaleContextHolder.setLocale(Locale.ENGLISH); try { // 执行需要国际化资源处理的业务 } finally { LocaleContextHolder.resetLocaleContext(); }这个问题在使用了线程池或者@Async的多语言项目里特别容易踩。主线程把语言设置好了,异步线程拿到的却是 null 或者默认值。遇到这类问题,先检查线程上下文有没有正确传递。
3.3 MessageSource 的体系结构
ApplicationContext本身实现了MessageSource接口,这有什么意义?意味着你在任意 Bean 里注入ApplicationContext,再调用context.getMessage(...),也能拿到国际化文案。
那 Spring 是怎么处理“你自己定义的 MessageSource Bean”和“容器内置的 MessageSource”这两个概念的?
在容器刷新时,Spring 会查找容器中是否存在一个名为messageSource的 Bean。如果有,就用它做整个容器的基础 MessageSource;如果没有,Spring 会创建一个DelegatingMessageSource作为兜底,并把父级 MessageSource 设为 parent。DelegatingMessageSource本身没有文案逻辑,只是把getMessage委托给 parent。这就是为什么 Spring Boot 项目里只用配置三个 properties 文件,不需要手动创建 Bean 也能生效——Spring Boot 的自动配置早已帮你装配好了默认的messageSource。
理解了这套结构,就能看明白一个实用场景:你可以用DelegatingMessageSource把多个 MessageSource 串成父子链。比如公共文案放父级,业务模块文案放子级,子级查不到时自动去父级找。这在大型多模块项目里特别有用。
3.4 回到源码调用链
再看一眼getMessage的调用链:
AbstractMessageSource.getMessage(...):翻译参数,最终调用resolveCode;ResourceBundleMessageSource.resolveCode():调用doGetResourceBundle()加载缓存中的ResourceBundle;ResourceBundle.getBundle():执行前面说的降级查找;- 找到模板后,用
createMessageFormat()创建MessageFormat并执行格式化。
如果你在代码里下断点,沿着这条链走一次,对 i18n 的理解会比读十篇博客都深。特别是ResourbleBundleMessageSource的缓存逻辑:JDK 的ResourceBundle自带进程内缓存,文件改了不重启,几乎不可能生效。这也是我为什么在企业级项目里强烈建议使用ReloadableResourceBundleMessageSource的原因。
4. 实操 Demo:一个支持中英双语切换的后端提示服务
原理说完,直接干活。下面这套 Demo 是我在项目中常用的模板,结构简单但五脏俱全,涵盖 Resource Bundle 配置、Interceptor 注册、Cookie 持久化和动态消息填充。
4.1 工程结构与依赖
我以一个普通的 Spring Boot 3 项目为例,只需要引入spring-boot-starter-web这一个依赖就够了。工程里需要创建三类文件:
- 配置类:注册
MessageSource、LocaleResolver、LocaleChangeInterceptor; - 资源文件:
messages.properties、messages_zh_CN.properties、messages_en_US.properties; - 业务代码:一个 Controller,调用
messageSource.getMessage(...)返回文案。
文件目录结构大概是:
src/main/resources/ ├── i18n/ │ ├── messages.properties │ ├── messages_zh_CN.properties │ └── messages_en_US.properties这里把资源文件放在i18n目录下,是为了和普通配置区分开,企业中好维护。
4.2 消息文件这样写
默认文件messages.properties,我建议直接用中文,因为项目的主要使用者是中文,默认文件应该符合绝大多数用户的习惯:
login.title=登录 login.welcome=欢迎,{0}! login.lastLogin=上次登录时间:{0, date, full} login.unread=你有{0}条未读消息中文版本messages_zh_CN.properties:
login.title=登录 login.welcome=欢迎,{0}! login.lastLogin=上次登录时间:{0, date, full} login.unread=你有{0}条未读消息英文版本messages_en_US.properties:
login.title=Login login.welcome=Welcome, {0}! login.lastLogin=Last login time: {0, date, full} login.unread=You have {0} unread messages细心的读者会发现中文默认版本和zh_CN版本完全一样。这很正常,默认文件的作用就是兜底,当语言既不是中文也不是英文时,至少还能看到中文提示。
4.3 配置类与拦截器
配置类里一次性把组件注册齐全:
@Configuration public class I18nConfig implements WebMvcConfigurer { @Bean public MessageSource messageSource() { ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasename("i18n/messages"); messageSource.setDefaultEncoding("UTF-8"); messageSource.setFallbackToSystemLocale(false); messageSource.setCacheSeconds(3600); return messageSource; } @Bean public LocaleResolver localeResolver() { CookieLocaleResolver resolver = new CookieLocaleResolver(); resolver.setCookieName("lang"); resolver.setCookieMaxAge(60 * 60 * 24 * 365); resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE); resolver.setCookiePath("/"); return resolver; } @Bean public LocaleChangeInterceptor localeChangeInterceptor() { LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); interceptor.setParamName("lang"); return interceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(localeChangeInterceptor()).addPathPatterns("/**"); } }几个关键点需要说明。
setBasename("i18n/messages")的路径是 classpath 下的相对路径,不需要加classpath:前缀。如果你有多个资源文件,可以用setBasenames("i18n/messages", "i18n/error")同时配置多个。
setCacheSeconds(3600)表示缓存 1 小时,这是企业级项目比较稳妥的配置。开发阶段想实时看到改动,可以临时改成 0,但不建议在生产环境关闭缓存。
选择CookieLocaleResolver而不是SessionLocaleResolver,核心原因是电商类项目的会话经常被网关、安全策略刷新,Session 里的语言设置偶尔会被清掉,Cookie 则能稳定地保存在浏览器端。如果内部管理系统没有这个问题,用SessionLocaleResolver也未尝不可。
4.4 业务接口与模板渲染
Controller 里调用MessageSource时,不需要手动传 Locale,直接在参数列表里加一个Locale locale,Spring MVC 会自动把当前请求的 Locale 绑定进来:
@RestController @RequestMapping("/api/i18n") public class I18nController { private final MessageSource messageSource; public I18nController(MessageSource messageSource) { this.messageSource = messageSource; } @GetMapping("/login") public Map<String, String> loginTip(@RequestParam String name, Locale locale) { String title = messageSource.getMessage("login.title", null, locale); String welcome = messageSource.getMessage("login.welcome", new Object[]{name}, locale); String lastLogin = messageSource.getMessage("login.lastLogin", new Object[]{new Date()}, locale); String unread = messageSource.getMessage("login.unread", new Object[]{3}, locale); return Map.of( "title", title, "welcome", welcome, "lastLogin", lastLogin, "unread", unread ); } }这里最值得注意的参数是login.lastLogin。模板里写的是{0, date, full},参数直接传Date对象,MessageFormat会根据当前 Locale 自动把它格式化成对应语言的完整日期格式。如果你不用这个特性,而是自己new SimpleDateFormat("yyyy-MM-dd"),那不同语言环境下的日期格式就完全一样了,国际化就不够彻底。
4.5 验证效果
启动项目后,直接访问:
GET http://localhost:8080/api/i18n/login?name=zhangsan&lang=zh_CN返回:
{ "title": "登录", "welcome": "欢迎,zhangsan!", "lastLogin": "上次登录时间:2025年6月5日星期四", "unread": "你有3条未读消息" }访问:
GET http://localhost:8080/api/i18n/login?name=zhangsan&lang=en_US返回:
{ "title": "Login", "welcome": "Welcome, zhangsan!", "lastLogin": "Last login time: Thursday, June 5, 2025", "unread": "You have 3 unread messages" }第一次访问时 URL 里的lang=zh_CN会通过LocaleChangeInterceptor写入 Cookie。之后再把 URL 里的lang参数去掉,只要浏览器 Cookie 还在,请求一样使用中文。如果你把 Cookie 里的lang改成en_US,下次访问就自动变成英文。
这一套组合拳,已经覆盖了绝大多数中小型项目的多语言需求。
5. 企业级进阶:热重载、数据库消息源、错误信息国际化
5.1 热重载选型:从 ResourceBundle 到 Reloadable
线上运营经常要做“临时文案调整”,尤其是电商大促、活动页提示语。如果文案还保存在ResourceBundleMessageSource里,每次改文字都要重新发布应用,运营效率会非常低。
ResourceBundleMessageSource之所以不能热更新,根因是 JDKResourceBundle的缓存机制。文件被读取后,ResourceBundle会在进程内长期驻留,外部改文件它感知不到。
ReloadableResourceBundleMessageSource就不一样。它把资源文件的内容解析成Properties对象,并受cacheSeconds(或cacheMillis)控制。只要缓存过期,下次getMessage会重新读取磁盘上的文件内容,实现秒级热更新。
我推荐生产项目的缓存时间设置在 30 到 60 秒之间。太短了高并发下频繁读磁盘,太长则失去了热更新的意义。用法上只需要把 Bean 的实现类换成ReloadableResourceBundleMessageSource,其余代码一概不用动。
5.2 自定义消息源,把文案放到数据库
当文案量超过几百条,或者运营希望完全不依赖发版就能管理文案时,properties 文件就不够用了。更合理的做法是让 MessageSource 直接读数据库。
Spring 给我们留好了扩展点,继承AbstractMessageSource,只关注一个核心方法resolveCode即可:
public class DatabaseMessageSource extends AbstractMessageSource { private final MessageDao messageDao; public DatabaseMessageSource(MessageDao messageDao) { this.messageDao = messageDao; } @Override protected MessageFormat resolveCode(String code, Locale locale) { String text = messageDao.findByCodeAndLocale(code, locale.toLanguageTag()); if (text == null) { text = messageDao.findByCodeAndLocale(code, "default"); } if (text == null) { return null; } return createMessageFormat(text, locale); } }把返回null这一点说明白:AbstractMessageSource会在拿到null时触发默认文件查找逻辑。所以数据库查不到文案时,让它可以继续回到本地 properties 文件找,这样既保留了数据库的灵活性,又有本地兜底,不会出现线上文案缺失就白屏的情况。
数据库消息源适合文案量大、更新频繁、且有内容管理后台支撑的团队。但它也会带来额外的查询压力和缓存设计问题,建议在数据库上层加一层本地缓存或 Redis 缓存,避免每次getMessage都查库。
5.3 参数校验消息的国际化
业务接口里最常见的文案其实是校验错误提示。比如@NotNull校验不通过时,Spring Boot 默认返回的是英文还是中文?不一定,取决于 Validator 配置。
如果你使用 Spring Boot Validation,可以通过配置一个LocalValidatorFactoryBean并注入 MessageSource,让校验消息也走上 i18n 通道:
@Bean public LocalValidatorFactoryBean validator(MessageSource messageSource) { LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); bean.setValidationMessageSource(messageSource); return bean; }然后在 messages 资源文件里配置 key,例如:
login.username.notBlank=用户名不能为空 login.password.size=密码长度必须在{0}到{1}之间属性上引用:
public class LoginRequest { @NotBlank(message = "{login.username.notBlank}") private String username; @Size(min = 6, max = 20, message = "{login.password.size}") private String password; }@NotBlank的 message 属性写成花括号包住的 key,Validator 就会去 MessageSource 里查真正的文案,而不是把字符串字面量当提示。这一步是企业级项目的标配,能让所有校验提示随语言环境一起切换。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
i18n 的坑翻来覆去就那么几个,我整理了一个速查表,覆盖我遇到过的绝大多数问题。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 中文乱码 | properties 文件编码不是 UTF-8,或者 MessageSource 没设置编码 | 统一 UTF-8,setDefaultEncoding("UTF-8")或spring.messages.encoding=UTF-8 |
| 切换语言完全不生效 | LocaleChangeInterceptor没注册,或paramName和 URL 参数不一致 | 检查拦截器是否addInterceptor,确认lang参数名 |
| 默认语言失效,跟着浏览器走 | 用了AcceptHeaderLocaleResolver,且没设置defaultLocale | 配置 Cookie/Session Resolver,显式设置默认 Locale |
| 打开页面突然中文变英文 | 浏览器识别Accept-Language导致 | 设默认 Locale,并考虑固定 Resolver 来源 |
{0}原样输出 | 调getMessage时没传 args 数组 | 传入new Object[]{...} |
| 文案里的单引号没显示一部分 | MessageFormat把单引号当转义字符 | 把don't写成don''t |
| 修改 properties 不生效 | 用了ResourceBundleMessageSource | 换成ReloadableResourceBundleMessageSource,配置缓存时间 |
| 异步线程里取不到 Locale | LocaleContextHolder未在线程内传递 | 手动setLocale并在 finally 清理 |
页面直接抛NoSuchMessageException | 文案缺失且未使用 code 兜底 | 配置setUseCodeAsDefaultMessage(true),或补全资源文件 |
6.2 几条亲测有效的排错经验
第一个经验:遇到乱码先查编译产物。Maven 或 Gradle 构建时有可能把 properties 文件按系统默认编码重新转码,导致你在 IDE 里看到是好的、部署后却乱码。确认pom.xml的project.build.sourceEncoding设置成 UTF-8,并检查 target 目录下的文件是否正常。
第二个经验:不要把 URL 参数lang和 properties 文件中的 Locale 表示弄混。URL 里写lang=en_US,文件的 baseName 后缀就是_en_US,大小写、下划线必须完全一致。Locale对象和字符串之间的转换最好统一封装工具方法,避免到处new Locale(lang)忽略国家代码。
第三个经验:MessageFormat的单引号问题值得单独拎出来警示。很多人第一次写login.unread=You have {0} unread messages没问题,但写login.tip=Don't forget {0}时,输出会变成Dont forget ...。这是因为MessageFormat把单引号当成“跳过模式解析”的开关。正确写法是双单引号:Don''t。这条规律在你自定义模板时要时刻记着。
第四个经验:如果公司有前端团队,后端最好只负责通过统一接口下发国际化文案的 code 和参数,页面文案渲染交给前端的 i18n 库,后端国际化只覆盖服务端错误、邮件、文件导出这类场景。别把所有文案都塞给后端,也不要把所有文案都塞给前端,划分一个清晰的边界,比什么都重要。
我自己踩得最深的一次坑,是某次线上文案临时要加一行版权信息。当时用的是ResourceBundleMessageSource,改完文件怎么刷新都不生效,最后只能重新发布。那之后我就养成了两个习惯:凡是文案可能被运营改动的,一律ReloadableResourceBundleMessageSource加短缓存;凡是代码里允许缺失提示的,一定开setUseCodeAsDefaultMessage(true)兜底。多语言这个东西,原理不复杂,但从细节里见真功夫。把组件职责理清楚,把底层机制弄明白,再配合一套普适的工程规范,企业级多语言开发完全可以做到又稳又灵活。