1. 为什么国际化这件事值得单独拎出来讲
做后端开发的人多少都碰过这样的需求:同一个页面,国内用户打开看到中文,海外用户打开看到英文,甚至有些面向多语言地区的产品还要支持日语、韩语、西班牙语。产品经理一句“加个多语言吧”,落到开发头上就是一套完整的国际化方案。SpringMVC 作为经典 Web 层框架,对国际化的支持其实相当成熟,但很多同行平时只停留在“配个 MessageSource 就完事了”的层面,真到线上出问题的时候才发现坑不少。
这篇内容聚焦的是国际化实现里最基础也最常用的一种方式:基于浏览器请求的国际化。说白了,就是让框架根据用户浏览器发过来的Accept-Language请求头,自动判断该给用户返回哪种语言的文案。这种方式的好处是零配置、零交互,用户不需要手动切换语言,打开页面就是自己系统语言对应的内容。适合什么场景?面向海外用户的官网、SaaS 产品的默认语言策略、以及那些不想在页面上放语言切换按钮的轻量级应用。
我这次把 SpringMVC 国际化的这条链路从头到尾捋一遍,包括LocaleResolver的选型逻辑、MessageSource的配置细节、资源文件的命名规则、编码坑、以及实际调试时怎么快速定位“为什么没生效”。内容会涉及不少配置代码和参数说明,但我会尽量用大白话把每一步的意图讲清楚,让刚接触国际化的同学也能跟着做出来,同时给有经验的同行补一些文档里不太会写的实操经验。
2. 国际化整体设计与核心思路拆解
2.1 国际化的本质:把“写死的文案”变成“可查表的键”
很多人第一次接触国际化会有点懵,觉得这是个很玄的东西。其实剥开来看,国际化的本质特别朴素:把代码里写死的中文字符串,替换成一个 key,然后根据当前的语言环境,去一张“翻译表”里查出对应的文案。
举个最直观的例子。以前你写:
model.addAttribute("welcome", "欢迎回来");国际化之后变成:
model.addAttribute("welcome", messageSource.getMessage("user.welcome", null, locale));这个user.welcome就是 key,locale就是当前语言环境。框架拿着这两个信息去资源文件里找,中文环境就返回“欢迎回来”,英文环境就返回“Welcome back”。所以整个国际化方案要解决的核心问题就两个:第一,当前用户是什么语言环境(Locale 从哪来);第二,翻译表放在哪、怎么查(MessageSource 怎么配)。
SpringMVC 把这两个问题拆成了两个组件:LocaleResolver负责解决“语言环境从哪来”,MessageSource负责解决“翻译表怎么查”。理解了这个拆分,后面所有的配置就都有了落脚点。
2.2 三种 LocaleResolver 的选型对比
SpringMVC 内置了几个LocaleResolver实现,选哪个直接决定了国际化的触发方式。基于浏览器请求这种方式,对应的就是AcceptHeaderLocaleResolver。我把常见的几个放在一起对比一下,方便你判断自己的项目该用哪个。
| 实现类 | 语言来源 | 是否可手动切换 | 典型场景 |
|---|---|---|---|
AcceptHeaderLocaleResolver | 浏览器Accept-Language请求头 | 否 | 官网、默认语言策略 |
SessionLocaleResolver | Session 中存储的 Locale | 是 | 需要用户手动切换语言 |
CookieLocaleResolver | Cookie 中存储的 Locale | 是 | 切换后长期记住偏好 |
FixedLocaleResolver | 固定值 | 否 | 测试、单语言环境 |
AcceptHeaderLocaleResolver是 SpringMVC 的默认实现,也就是说你什么都不配,它其实已经在工作了。这一点很多人不知道,以为国际化必须显式配置LocaleResolver,其实默认就是按浏览器请求头来的。只不过默认情况下它只认请求头,一旦用户想手动切换语言就没辙了,所以需要手动切换的场景才会换成 Session 或 Cookie 版本。
选AcceptHeaderLocaleResolver的理由很直接:用户无感知,不需要任何交互。浏览器在每次请求时都会带上Accept-Language头,里面按优先级列出了用户系统支持的语言,比如zh-CN,zh;q=0.9,en;q=0.8。框架解析这个头,取出最匹配的那个 Locale,整个流程对用户完全透明。
2.3 为什么默认方案反而容易被忽略
这里有个很有意思的现象:正因为AcceptHeaderLocaleResolver是默认的,反而导致很多人在排查国际化问题时绕了远路。他们花大量时间去配LocaleResolver的 Bean,结果发现配不配效果一样,然后又怀疑是不是MessageSource没生效。
我的经验是,排查国际化问题要按“Locale 是否正确 → 资源文件是否被加载 → key 是否匹配 → 编码是否正确”这个顺序来,而不是一上来就怀疑配置。因为默认的AcceptHeaderLocaleResolver大概率是正常工作的,问题往往出在后面几个环节。这个排查顺序后面我会专门用一节来讲。
另外要提醒一点:AcceptHeaderLocaleResolver支持通过setSupportedLocales()设置支持的语言列表。如果你不设置,它会接受浏览器传来的任意 Locale;如果设置了,浏览器请求的语言不在列表里,就会回退到默认 Locale。这个细节在实际项目里很重要,后面实操部分会展开。
3. 核心细节解析与实操要点
3.1 MessageSource 的配置与资源文件命名规则
MessageSource是国际化的翻译表管理器。SpringMVC 里最常用的是ResourceBundleMessageSource,它基于 JDK 的ResourceBundle机制来加载.properties文件。配置方式如下:
@Configuration public class I18nConfig { @Bean public MessageSource messageSource() { ResourceBundleMessageSource source = new ResourceBundleMessageSource(); source.setBasename("i18n/messages"); source.setDefaultEncoding("UTF-8"); source.setUseCodeAsDefaultMessage(true); return source; } }这里有几个参数必须说清楚,因为它们直接决定了国际化能不能正常工作。
setBasename("i18n/messages")指定了资源文件的基础名。框架会根据当前 Locale 去拼接具体的文件名。假设 Locale 是zh_CN,它会依次去找messages_zh_CN.properties、messages_zh.properties、messages.properties,找到哪个用哪个。这个逐级回退的机制非常关键,意味着你只需要为差异部分提供翻译,公共部分放在默认文件里就行。
setDefaultEncoding("UTF-8")是必须设置的。JDK 早期版本的ResourceBundle默认用 ISO-8859-1 读取 properties 文件,中文会变成乱码。虽然高版本 JDK 有所改善,但显式指定 UTF-8 是最稳妥的做法。我见过太多项目因为漏了这一行,导致中文文案全是问号。
setUseCodeAsDefaultMessage(true)这个参数建议打开。它的作用是:当某个 key 在所有资源文件里都找不到时,直接把 key 本身作为返回值,而不是抛NoSuchMessageException。这在开发阶段特别有用,页面上直接显示user.welcome这种 key,一眼就能看出哪个文案漏配了,比抛异常友好得多。
资源文件的命名规则总结成一张表:
| 文件名 | 对应 Locale | 说明 |
|---|---|---|
messages.properties | 默认(兜底) | 所有语言都找不到时的最后回退 |
messages_zh.properties | 中文(不限地区) | 匹配zh、zh_CN、zh_TW等 |
messages_zh_CN.properties | 简体中文 | 精确匹配zh_CN |
messages_en.properties | 英文 | 匹配en、en_US、en_GB等 |
messages_en_US.properties | 美式英文 | 精确匹配en_US |
注意:资源文件必须放在 classpath 下
basename指定的路径里。上面配的是i18n/messages,那文件就要放在src/main/resources/i18n/目录下。放错位置是新手最常见的错误之一。
3.2 AcceptHeaderLocaleResolver 的解析逻辑
AcceptHeaderLocaleResolver解析Accept-Language头的逻辑,比很多人想象的要复杂一点。浏览器传来的头可能是这样的:
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7这里的q是权重值,表示用户对该语言的偏好程度,范围 0 到 1,不写默认是 1。框架会按权重从高到低排序,然后逐个和supportedLocales做匹配。
匹配规则是这样的:如果supportedLocales里配了zh_CN,而浏览器首选是zh-CN,那就匹配成功。如果supportedLocales里只有zh,浏览器传的是zh_CN,框架会做一次“语言部分匹配”,也就是拿zh去匹配zh_CN的语言部分,也能成功。但如果supportedLocales里配的是zh_CN,浏览器传的是zh,那就匹配不上了,因为zh没有地区信息,无法确定是不是zh_CN。
这个细节在实际项目里经常踩坑。比如你的产品只支持简体中文,supportedLocales配了zh_CN,结果某个用户的浏览器只传了zh,那就匹配失败,回退到默认 Locale。解决办法是要么把zh也加进supportedLocales,要么干脆不设置supportedLocales,让它接受任意 Locale。
配置supportedLocales的代码:
@Bean public LocaleResolver localeResolver() { AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver(); resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE); resolver.setSupportedLocales(Arrays.asList( Locale.SIMPLIFIED_CHINESE, Locale.US )); return resolver; }setDefaultLocale指定了匹配失败时的兜底语言。这个一定要设,否则匹配失败时框架可能返回一个你意料之外的 Locale,导致文案显示异常。
3.3 在 Controller 和页面中如何使用
配置好之后,在 Controller 里获取当前 Locale 有两种方式。一种是方法参数直接注入:
@GetMapping("/home") public String home(Locale locale, Model model) { String welcome = messageSource.getMessage("user.welcome", null, locale); model.addAttribute("welcome", welcome); return "home"; }SpringMVC 会自动把LocaleResolver解析出来的 Locale 注入到方法参数里,非常方便。另一种是通过RequestContextUtils从请求里取:
Locale locale = RequestContextUtils.getLocale(request);如果用的是 JSP 或者 Thymeleaf 这类模板引擎,框架通常提供了更简洁的标签。比如 JSP 的<spring:message>标签,Thymeleaf 的#{...}表达式,都能直接根据当前 Locale 取文案,不需要在 Controller 里手动调getMessage。
<!-- JSP --> <spring:message code="user.welcome" /> <!-- Thymeleaf --> <p th:text="#{user.welcome}">默认文案</p>这里有个实操建议:页面上的静态文案尽量用模板标签,动态拼接的文案才在 Controller 里调getMessage。因为模板标签能自动跟随当前 Locale,代码也更干净。而像“欢迎回来,张三”这种带参数的文案,就需要在代码里处理,用getMessage的重载方法传参数:
Object[] args = {"张三"}; String msg = messageSource.getMessage("user.welcome.name", args, locale);对应的资源文件里写user.welcome.name=欢迎回来,{0},{0}会被参数替换掉。这种占位符机制在处理复数、性别等复杂语言规则时还能配合MessageFormat使用,不过那是更进阶的话题了。
4. 完整实操流程与关键环节实现
4.1 项目结构与依赖准备
先把项目骨架搭起来。这里以一个标准的 Spring Boot 项目为例,因为 Spring Boot 对 SpringMVC 的自动配置让国际化配置更简洁。如果你用的是传统 SpringMVC 项目,配置方式略有不同,但核心组件是一样的。
项目结构大致如下:
src/main/resources/ ├── i18n/ │ ├── messages.properties │ ├── messages_zh_CN.properties │ └── messages_en_US.properties └── application.yml依赖方面,Spring Boot 的spring-boot-starter-web已经包含了 SpringMVC 的全部能力,不需要额外引入国际化相关的包。如果你用的是传统项目,确保spring-webmvc在依赖里即可。
application.yml里可以配一下默认的 Locale 和资源文件路径,不过更推荐用 Java 配置类的方式,因为参数更直观:
spring: messages: basename: i18n/messages encoding: UTF-8 fallback-to-system-locale: falsefallback-to-system-locale: false这个配置值得说一下。默认情况下,如果找不到匹配的资源文件,Spring 会回退到 JVM 的默认 Locale。在服务器上,JVM 默认 Locale 可能是en_US或者别的什么,这会导致行为不可预测。设成false之后,找不到就老老实实用messages.properties兜底,行为更可控。
4.2 资源文件的编写与编码处理
资源文件的内容就是一堆 key-value。我建议按模块给 key 加前缀,比如user.、order.、common.,这样文件大了也好维护。
messages_zh_CN.properties:
user.welcome=欢迎回来 user.welcome.name=欢迎回来,{0} common.submit=提交 common.cancel=取消messages_en_US.properties:
user.welcome=Welcome back user.welcome.name=Welcome back, {0} common.submit=Submit common.cancel=Cancelmessages.properties作为兜底,可以放一份默认语言(比如中文):
user.welcome=欢迎回来 user.welcome.name=欢迎回来,{0} common.submit=提交 common.cancel=取消编码问题是这里最大的坑。.properties文件在 IDE 里默认可能是 ISO-8859-1 编码,你写的中文保存后可能就乱了。一定要确认 IDE 里 properties 文件的编码设置是 UTF-8。IntelliJ IDEA 里可以在Settings → Editor → File Encodings里把Properties Files的默认编码改成 UTF-8,并勾选Transparent native-to-ascii conversion。Eclipse 里也有类似的设置。
提示:如果实在搞不定编码,可以用 JDK 自带的
native2ascii工具把中文转成 Unicode 转义序列,虽然可读性差,但绝对不会乱码。不过现在主流做法还是直接 UTF-8,配合setDefaultEncoding使用。
4.3 配置类与 Controller 实现
完整的配置类:
@Configuration public class I18nConfig { @Bean public LocaleResolver localeResolver() { AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver(); resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE); resolver.setSupportedLocales(Arrays.asList( Locale.SIMPLIFIED_CHINESE, Locale.US )); return resolver; } @Bean public MessageSource messageSource() { ResourceBundleMessageSource source = new ResourceBundleMessageSource(); source.setBasename("i18n/messages"); source.setDefaultEncoding("UTF-8"); source.setUseCodeAsDefaultMessage(true); return source; } }Controller 里演示两种取文案的方式:
@RestController public class DemoController { @Autowired private MessageSource messageSource; @GetMapping("/greet") public Map<String, Object> greet(Locale locale) { Map<String, Object> result = new HashMap<>(); result.put("locale", locale.toString()); result.put("welcome", messageSource.getMessage("user.welcome", null, locale)); result.put("welcomeName", messageSource.getMessage( "user.welcome.name", new Object[]{"张三"}, locale)); return result; } }启动项目后,用不同的Accept-Language请求头访问/greet,就能看到返回的文案随语言变化。用 curl 测试:
curl -H "Accept-Language: en-US" http://localhost:8080/greet # 返回 {"locale":"en_US","welcome":"Welcome back","welcomeName":"Welcome back, 张三"} curl -H "Accept-Language: zh-CN" http://localhost:8080/greet # 返回 {"locale":"zh_CN","welcome":"欢迎回来","welcomeName":"欢迎回来,张三"}4.4 参数计算与 Locale 匹配过程实录
这里我把一次实际的 Locale 匹配过程完整记录一下,方便你理解框架内部到底做了什么。
假设supportedLocales配的是[zh_CN, en_US],defaultLocale是zh_CN。浏览器传来Accept-Language: en-GB,en;q=0.9,zh;q=0.8。
第一步,框架解析请求头,得到一个按权重排序的 Locale 列表:[en_GB, en, zh]。
第二步,拿en_GB去和supportedLocales匹配。en_GB和en_US语言部分都是en,但地区不同,精确匹配失败。框架会尝试语言部分匹配,也就是拿en去匹配en_US的语言部分en,匹配成功。所以最终 Locale 是en_US。
第三步,如果第二步没匹配上,就继续拿en去匹配,逻辑同上。再不行就拿zh去匹配zh_CN,匹配成功则用zh_CN。
第四步,如果所有候选都匹配失败,就用defaultLocale,也就是zh_CN。
这个过程中,en_GB最终匹配到en_US是很多人没想到的。因为框架做的是“语言部分匹配”,只要语言代码相同就算匹配。如果你希望en_GB和en_US显示不同文案,那就得把en_GB也加进supportedLocales,并提供messages_en_GB.properties。
5. 常见问题与排查技巧实录
5.1 国际化不生效的排查顺序
国际化不生效是最高频的问题。我总结了一个排查顺序,按这个顺序走,基本能定位到 90% 的问题。
| 排查步骤 | 检查内容 | 常见问题 |
|---|---|---|
| 1 | 请求头是否带Accept-Language | 用 Postman 或 curl 时忘了加 |
| 2 | Locale 解析结果是否正确 | 在 Controller 里打印 locale 看 |
| 3 | 资源文件是否被加载 | 看 basename 路径和文件位置 |
| 4 | key 是否匹配 | 检查拼写、大小写 |
| 5 | 编码是否正确 | 中文是否乱码 |
| 6 | 是否被其他配置覆盖 | 检查是否有多个 MessageSource Bean |
第一步特别容易被忽略。用浏览器访问时浏览器会自动带Accept-Language,但用 Postman 或者写单元测试时,如果不手动加这个头,框架拿到的就是空,直接走默认 Locale,看起来就像“国际化没生效”。
第二步,在 Controller 里把 locale 打印出来,是最快的定位手段。如果 locale 是zh_CN但你期望的是en_US,那问题就在LocaleResolver的匹配逻辑上,跟资源文件无关。
5.2 中文乱码的三种成因与解决
中文乱码是国际化的经典问题,成因主要有三种。
第一种是资源文件本身编码不对。文件在 IDE 里看着是中文,实际存的是 ISO-8859-1 字节。解决办法是把文件另存为 UTF-8,并确认 IDE 的 properties 编码设置。
第二种是MessageSource 没设 defaultEncoding。即使文件是 UTF-8,如果ResourceBundleMessageSource用默认编码去读,还是会乱。加上setDefaultEncoding("UTF-8")即可。
第三种是HTTP 响应编码不对。文案从后端传到前端,如果响应头里的Content-Type没指定charset=UTF-8,浏览器可能用错误编码解析。Spring Boot 里可以通过spring.http.encoding.charset=UTF-8和force=true来强制。
实操心得:排查乱码时,先在 Controller 里把
getMessage的结果打印到控制台。如果控制台就是乱码,那问题在后端;如果控制台正常但页面乱码,那问题在响应编码。这一步能快速缩小范围。
5.3 默认 Locale 与回退机制的坑
回退机制有两个容易踩的坑。
第一个坑是fallbackToSystemLocale。前面提过,默认是true,意味着找不到匹配资源时会用 JVM 默认 Locale。服务器上 JVM 默认 Locale 往往是en_US,这会导致本该显示中文的场景显示英文。建议显式设成false。
第二个坑是messages.properties的内容。很多人把它当成“英文版”,其实它是所有语言都匹配不上时的兜底。如果messages.properties里写的是英文,那中文用户在某些边界情况下会看到英文。我的做法是让messages.properties和默认语言(比如中文)保持一致,这样兜底结果符合预期。
还有一个隐蔽的坑:ResourceBundleMessageSource会缓存资源文件。开发阶段改了 properties 文件,不重启应用可能看不到变化。可以通过setCacheSeconds(0)关闭缓存,但生产环境不建议这么做,会影响性能。开发阶段用热部署工具或者手动重启更稳妥。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 所有语言都显示同一文案 | Locale 没解析成功 | 检查请求头和 LocaleResolver |
| 中文显示为问号 | 编码问题 | 检查文件编码和 defaultEncoding |
| 部分 key 显示为 key 本身 | 资源文件漏配 | 检查各语言文件是否都有该 key |
| 切换语言后没变化 | 缓存未刷新 | 重启应用或关闭缓存 |
| 英文环境显示中文 | 回退到了默认文件 | 检查 messages.properties 内容 |
| 特定地区语言不生效 | supportedLocales 未包含 | 补充对应 Locale |
这张表我建议直接贴在项目 wiki 里,团队里谁遇到国际化问题先查表,能省下不少沟通成本。
6. 一些文档里不会写的实操经验
先说一个我踩过的坑。有一次项目里国际化怎么都不生效,排查了半天,最后发现是团队里另一个同事在某个配置类里也定义了一个MessageSourceBean,两个 Bean 冲突,Spring 选了另一个没配basename的。这种问题特别隐蔽,因为不会报错,只是行为不符合预期。所以项目里 MessageSource 和 LocaleResolver 的 Bean 一定要保证全局唯一,最好集中在一个配置类里管理。
再说一个关于supportedLocales的经验。很多团队为了“严谨”,把支持的语言列表配得很死,结果用户浏览器传了个列表里没有的语言,直接回退到默认语言,用户体验很差。我的建议是:如果产品没有严格的地区区分需求,干脆不设supportedLocales,只设defaultLocale。这样框架会接受浏览器传来的任意 Locale,资源文件找不到就逐级回退,最终落到messages.properties,行为反而更符合直觉。
关于资源文件的组织,我倾向于按模块拆分多个 basename,而不是把所有 key 堆在一个文件里。比如i18n/user/messages、i18n/order/messages,然后配置多个 basename:
source.setBasenames("i18n/common/messages", "i18n/user/messages", "i18n/order/messages");这样每个模块的翻译由对应负责人维护,减少冲突。缺点是配置稍微复杂一点,但项目大了之后收益很明显。
最后分享一个调试技巧。Spring Boot 的 Actuator 里有个/actuator/env端点,能看到当前生效的配置。排查国际化问题时,可以确认spring.messages.basename等配置有没有被正确加载。另外,在 Controller 里临时加一个端点,把当前 Locale、所有支持的 Locale、以及某个 key 在各语言下的取值都返回出来,调试起来非常直观。这个端点上线前记得删掉或者加权限控制。
国际化这东西,配置本身不复杂,难的是把每个环节的细节都照顾到。基于浏览器请求这种方式,胜在简单无感,适合大多数默认场景。真要做多语言切换,再考虑 Session 或 Cookie 方案也不迟。把这条链路走通一次,以后遇到类似需求就能直接复用了。