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就是 keylocale就是当前语言环境。框架拿着这两个信息去资源文件里找中文环境就返回“欢迎回来”英文环境就返回“Welcome back”。所以整个国际化方案要解决的核心问题就两个第一当前用户是什么语言环境Locale 从哪来第二翻译表放在哪、怎么查MessageSource 怎么配。SpringMVC 把这两个问题拆成了两个组件LocaleResolver负责解决“语言环境从哪来”MessageSource负责解决“翻译表怎么查”。理解了这个拆分后面所有的配置就都有了落脚点。2.2 三种 LocaleResolver 的选型对比SpringMVC 内置了几个LocaleResolver实现选哪个直接决定了国际化的触发方式。基于浏览器请求这种方式对应的就是AcceptHeaderLocaleResolver。我把常见的几个放在一起对比一下方便你判断自己的项目该用哪个。实现类语言来源是否可手动切换典型场景AcceptHeaderLocaleResolver浏览器Accept-Language请求头否官网、默认语言策略SessionLocaleResolverSession 中存储的 Locale是需要用户手动切换语言CookieLocaleResolverCookie 中存储的 Locale是切换后长期记住偏好FixedLocaleResolver固定值否测试、单语言环境AcceptHeaderLocaleResolver是 SpringMVC 的默认实现也就是说你什么都不配它其实已经在工作了。这一点很多人不知道以为国际化必须显式配置LocaleResolver其实默认就是按浏览器请求头来的。只不过默认情况下它只认请求头一旦用户想手动切换语言就没辙了所以需要手动切换的场景才会换成 Session 或 Cookie 版本。选AcceptHeaderLocaleResolver的理由很直接用户无感知不需要任何交互。浏览器在每次请求时都会带上Accept-Language头里面按优先级列出了用户系统支持的语言比如zh-CN,zh;q0.9,en;q0.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_CNmessages_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;q0.9,en-US;q0.8,en;q0.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 codeuser.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.propertiesuser.welcome欢迎回来 user.welcome.name欢迎回来{0} common.submit提交 common.cancel取消messages_en_US.propertiesuser.welcomeWelcome back user.welcome.nameWelcome back, {0} common.submitSubmit common.cancelCancelmessages.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 MapString, Object greet(Locale locale) { MapString, 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;q0.9,zh;q0.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 时忘了加2Locale 解析结果是否正确在 Controller 里打印 locale 看3资源文件是否被加载看 basename 路径和文件位置4key 是否匹配检查拼写、大小写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没指定charsetUTF-8浏览器可能用错误编码解析。Spring Boot 里可以通过spring.http.encoding.charsetUTF-8和forcetrue来强制。实操心得排查乱码时先在 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然后配置多个 basenamesource.setBasenames(i18n/common/messages, i18n/user/messages, i18n/order/messages);这样每个模块的翻译由对应负责人维护减少冲突。缺点是配置稍微复杂一点但项目大了之后收益很明显。最后分享一个调试技巧。Spring Boot 的 Actuator 里有个/actuator/env端点能看到当前生效的配置。排查国际化问题时可以确认spring.messages.basename等配置有没有被正确加载。另外在 Controller 里临时加一个端点把当前 Locale、所有支持的 Locale、以及某个 key 在各语言下的取值都返回出来调试起来非常直观。这个端点上线前记得删掉或者加权限控制。国际化这东西配置本身不复杂难的是把每个环节的细节都照顾到。基于浏览器请求这种方式胜在简单无感适合大多数默认场景。真要做多语言切换再考虑 Session 或 Cookie 方案也不迟。把这条链路走通一次以后遇到类似需求就能直接复用了。