☰
Spring Boot国际化配置实战:从MessageSource到前后端多语言切换
2026/10/6 5:06:36 网站建设 项目流程

做后端时间长了,你会发现一个很有意思的现象:很多项目一开始只做中文,等产品想出海,或者公司接了个外企单子,最头疼的不是改业务逻辑,而是把界面和提示文字一层层挖出来翻译。Spring Boot国际化配置(i18n)就是专门解决这个问题的。它不复杂,核心就是利用MessageSource机制,根据客户端传来的语言标识自动选择对应的提示文案。这篇文章我把自己实际用到的完整步骤、配置参数、踩过的坑,包括Vue前后端分离怎么配合,一次性总结清楚。适合正在做多语言需求的后端开发,也适合刚接触Spring Boot、想搞懂国际化原理的初学者。

我最早接触国际化是给一个面向东南亚的SaaS项目做多语言,刚开始以为只是把文案抽出来放properties文件而已,结果翻车翻得厉害:中文乱码、语言切换不生效、单体资源文件上千行没法维护。后来慢慢理清了一套稳妥的配置方式,从ResourceBundle原理到动态语言包方案,踩了不少雷。这篇内容不是官方文档的翻译版,而是我实际跑通过的记录,从基础到进阶都有,保证能落地。

1. 先理清Spring Boot国际化的整体思路

1.1 国际化解决的痛点和核心概念

国际化,也叫i18n(internationalization的缩写,首字母i加末字母n中间其实有18个字母),指的是同一个应用能根据访问者所在的语言区域,展示不同语言的内容。它和我们常说的"翻译"有一点本质区别:翻译是上线前把文案死写进去,国际化则是一套运行时动态匹配语言的能力。

这里绕不开一个核心类:java.util.Locale。所有语言、区域、编号规则都通过Locale表达,比如Locale.CHINA、Locale.US,字符串形式就是zh_CN、en_US。前端传一个请求头Accept-Language: zh-CN,或者URL上挂?lang=en_US,后端拿到这个Locale去查对应语言的文案,查不到就回退到默认语言。这个过程和"一个服务员根据客人说哪种方言,就用哪种方言回话"很像,只不过Spring Boot把"方言字典"做成了properties资源文件。

所以国际化的底层就三件事:定义一套Key、为每种语言提供Key对应的文案、在请求进来时根据Locale找到正确的文案。Spring Boot用MessageSource接口把这套逻辑规范了起来,我们只需要往IOC容器里放一个MessageSource实现,业务代码就能在任意地方调用messageSource.getMessage("key", args, locale)拿到文案。

1.2 方案选型:MessageSource接口下的三种实现

MessageSource接口本身不干活,真正干活的是它的实现类。Spring Boot默认注入的是ResourceBundleMessageSource,这个类的底层基于JDK的ResourceBundle机制,会把messages_zh_CN.properties当成一颗资源树加载到内存,然后在请求时按Locale去树上摘文案。

除了ResourceBundleMessageSource,还会看到另外两个实现:

实现类特点适用场景
ResourceBundleMessageSource默认实现,一次性加载,性能好,但修改资源文件后需要重启生产环境、资源文件稳定
ReloadableResourceBundleMessageSource支持定时刷新缓存,开发环境改完不用重启本地开发、资源文件频繁调整
StaticMessageSource只能通过编程方式追加文案,无法读properties单元测试、少量硬编码消息

Spring Boot自动配置默认使用的是ResourceBundleMessageSource,所以你在application.properties里配置spring.messages.basename即可。我个人的习惯是开发阶段临时用一个ReloadableResourceBundleMessageSource的Bean覆盖掉默认配置,配合IDE的自动编译,改文案就能实时生效,非常省心。生产环境一定切回默认实现,省掉无谓的刷新开销。

1.3 资源文件目录结构与basename配置

大部分项目的资源文件都放在src/main/resources下,默认基名messages。如果你只有中英文,最简洁的目录结构就是:

resources/ ├── messages.properties ├── messages_zh_CN.properties └── messages_en_US.properties

messages.properties是默认语言包,messages_zh_CN.properties是简体中文,messages_en_US.properties是美式英语。这套命名规则不是Spring Boot造的,而是JDKResourceBundle的标准规则:basename_language_COUNTRY.properties。

spring.messages.basename可以指定一个或多个基名。如果项目文案比较多,不想全堆在一个文件里,可以按模块拆,例如:

spring: messages: basename: messages, i18n/error, i18n/email encoding: UTF-8

这里i18n/error对应的是resources/i18n/error.properties、resources/i18n/error_zh_CN.properties。拆分文件的好处是不同团队维护不同模块,但代价是查找key时要多翻几个文件。我建议团队初期按模块拆,等KEY规范成熟后再考虑整合到数据库或配置中心。

2. 核心配置与消息编码的实操细节

2.1 资源文件命名规则和编码陷阱

资源文件的命名是第一个大坑。Locale的写法必须是语言_国家的标准格式,比如zh_CN、en_US、ja_JP,不能乱写zh_cn、en-us。虽然ResourceBundle对大小写有兼容,但为了在Spring Boot里能正确匹配,最好严格遵守标准。

更麻烦的是编码。Java的properties文件早期只支持ISO-8859-1,所以所有非英文字符都要转成Unicode编码。后来JDK9开始支持UTF-8,但很多坑都出在历史项目或Windows环境上。Spring Boot从2.x开始默认读取配置文件的编码是UTF-8,但如果你在IDEA里没有把File Encoding全部设置为UTF-8,properties文件在保存时可能被转成GBK或乱码,导致运行起来中文全部变成问号。

我的做法是四步走:

  1. IDEA设置里把Global Encoding、Project Encoding、Default encoding for properties files全部设为UTF-8。
  2. 在application.yml里显式声明spring.messages.encoding: UTF-8。
  3. 如果使用Maven构建,注意pom.xml里的project.build.sourceEncoding和project.reporting.outputEncoding都要设成UTF-8。
  4. IDE在保存messages_zh_CN.properties时,选择"转成Native2ASCII"或者干脆让编辑器按UTF-8显示,Maven打包时用native2ascii-maven-plugin做转换,确保最后target/classes里的文件是标准格式。

2.2 MessageSource.getMessage的三种使用姿势

配置好资源文件后,业务里怎么用才是关键。MessageSource的getMessage方法主要有三种重载形式,分别应对不同场景。

第一种最基础,只有一个code:

String msg = messageSource.getMessage("user.register.success", null, locale);

当你的文案里没有占位符时,args传null就可以。注意如果这个key在资源文件里不存在,方法会直接抛NoSuchMessageException,除非传入defaultMessage。

第二种带参数占位符:

String msg = messageSource.getMessage("user.welcome", new Object[]{userName, points}, locale);

对应的messages.properties里要写user.welcome=欢迎您,{0}!当前积分:{1}。这里的花括号占位符由MessageFormat处理,{0}、{1}会自动替换成args数组里的元素。

第三种带默认值:

String msg = messageSource.getMessage("user.title", null, "默认标题", locale);

当key不存在时,返回兜底的默认值,不会抛异常。这个方式非常适合做兼容处理:比如某个新功能还没来得及翻译所有语言,可以先用默认语言兜底,而不是让用户看到一个刺眼的错误页面。

2.3 占位符参数与格式化消息

占位符不只是简单替换,它背后是java.text.MessageFormat的完整格式语法。比如你可以指定参数类型、数字格式、日期格式,复杂的业务文案也能一套语言一份。

举个例子,订单状态提示可以写成:

order.status={0, choice, 0#订单已取消|1#订单已支付|1<订单已发货}

{0, choice, ...}是MessageFormat的ChoiceFormat语法。当{0}等于0时输出"订单已取消",等于1时输出"订单已支付",大于1时输出"订单已发货"。这个机制在做多语言时非常有用,因为不同语言对单复数的表达完全不同,英文要区分one/other,中文不需要,用ChoiceFormat就能优雅适配。

还有一个容易踩的坑:MessageFormat中单引号是转义字符。如果你在文案里写英文的I'm,必须写成I''m,否则解析时可能会出现MessageFormat抛出格式错误,或者文案里的单引号被莫名其妙吃掉。我在生产环境就遇到过用户反馈提示语多了一个空格,排查半天发现是文案里一个单引号没转义。所以资源文件里涉及引号、大括号时,一定要多想一层。

2.4 LocaleResolver解析流程

Spring MVC在请求处理时会先经过LocaleResolver,解析出当前请求的Locale,然后绑定到LocaleContextHolder上。这样Controller方法里可以直接注入一个Locale参数,或者通过LocaleContextHolder.getLocale()在任何地方拿到当前请求的Locale。

默认的LocaleResolver是AcceptHeaderLocaleResolver,它只看浏览器请求头Accept-Language。也就是说,你只配置了资源文件但不配置任何LocaleResolver,语言切换完全由浏览器决定,用户没法在页面上手动切换。

所以要支持手动切换语言,至少要换掉默认的LocaleResolver。常见方案是SessionLocaleResolver或者CookieLocaleResolver。比如:

@Bean public LocaleResolver localeResolver() { SessionLocaleResolver resolver = new SessionLocaleResolver(); resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE); return resolver; }

配合拦截器可以实现在链接上加?locale=en_US参数来切换语言,Spring Boot自带LocaleChangeInterceptor,只需要注册到WebMvc配置里:

@Configuration public class I18nConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); interceptor.setParamName("lang"); registry.addInterceptor(interceptor); } }

这样用户访问/any/path?lang=en_US,请求进来时拦截器会把语言设置为英文,同时存到Session或Cookie里,之后所有请求都沿用这个语言,体验很自然。

3. 从零搭建一个支持中英文切换的Spring Boot项目

3.1 项目依赖与基础配置

我们跑一个最小可用的demo,Spring Boot版本用2.7或者3.x都行,核心依赖只需要一个web启动器:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

然后配置application.yml:

spring: messages: basename: messages encoding: UTF-8 cache-duration: 3600 fallback-to-system-locale: true

这里cache-duration是资源文件的缓存秒数,默认值按版本略有差异。生产环境可以设大一点,比如3600秒;开发环境设成0甚至-1(不缓存)更舒服。唯一要注意的是ReloadableResourceBundleMessageSource在Spring Boot的自动配置里也会遵守这个参数,如果发现改了文件不生效,先看一眼是不是缓存时间太长。

3.2 创建中英文资源文件

创建三个文件。默认语言文件messages.properties,我一般让它和中文内容一致,并在文件头写清这是默认文案:

user.register.success=用户注册成功 user.welcome=欢迎您,{0}!当前积分:{1} order.status={0, choice, 0#订单已取消|1#订单已支付|1<订单已发货}

然后是纯英文文件messages_en_US.properties:

user.register.success=User registered successfully user.welcome=Welcome, {0}! Current points: {1} order.status={0, choice, 0#Order cancelled|1#Order paid|1<Order shipped}

如果你需要繁体中文,就是messages_zh_TW.properties,内容用繁体字;如果只有大陆业务,光是messages.properties加一个messages_en_US.properties就够用了。记住没有messages_zh_CN.properties也是没问题的,因为messages.properties默认就充当了中文角色。

3.3 通过Controller暴露多语言接口

写一个入口Controller,手动解析请求参数里的lang,然后传给MessageSource:

@RestController public class I18nController { private final MessageSource messageSource; public I18nController(MessageSource messageSource) { this.messageSource = messageSource; } @GetMapping("/i18n/message") public Map<String, String> getMessage( @RequestParam(defaultValue = "zh_CN") String lang, @RequestParam(defaultValue = "Lily") String name) { Locale locale = Locale.forLanguageTag(lang.replace('_', '-')); String welcome = messageSource.getMessage("user.welcome", new Object[]{name, 100}, locale); String orderStatus = messageSource.getMessage("order.status", new Object[]{0}, locale); return Map.of("welcome", welcome, "orderStatus", orderStatus); } @GetMapping("/i18n/locale") public String currentLocale() { Locale locale = LocaleContextHolder.getLocale(); return locale.toLanguageTag(); } }

这里有一个小细节:Locale.forLanguageTag("zh-CN")标准写法是带短横线,但是我们的资源文件名是下划线zh_CN,所以先把参数里的下划线替换成短横线,再交给Locale.forLanguageTag解析,Spring Boot底层会按Locale.toLanguageTag()的规则找到对应的messages_zh_CN.properties。

如果你想使用前面配置的LocaleChangeInterceptor,接口里就不需要传lang参数了,因为拦截器已经把语言写进了LocaleContextHolder:

@GetMapping("/i18n/context") public String contextMessage() { return messageSource.getMessage("user.register.success", null, LocaleContextHolder.getLocale()); }

但实际项目里,我还是推荐用CookieLocaleResolver而不是Session。原因很简单,Session在微服务和分布式环境里不友好,Cookie天然跨请求传递,后端只负责写一个lang=en_US的Cookie,前端每次请求自动带上,简单粗暴且可靠。

3.4 接入前端页面实测效果

我用一个最简单的静态页面来演示。在src/main/resources/static/index.html里略作修改,放一个下拉框和展示结果:

<select id="lang"> <option value="zh_CN">中文</option> <option value="en_US">English</option> </select> <button onclick="loadMessage()">加载文案</button> <div id="result"></div> <script> function loadMessage() { const lang = document.getElementById('lang').value; fetch('/i18n/message?lang=' + lang + '&name=张三') .then(res => res.json()) .then(data => { document.getElementById('result').innerText = data.welcome + ' | ' + data.orderStatus; }); } </script>

启动应用,访问http://localhost:8080,切换语言后点击按钮,就能看到接口返回的文案跟着语言走。这是最直观的验证方式,能确认资源文件、Locale解析、Controller三部分都正常。

如果项目前端用的是Vue,其实思路类似:后端只需要提供一个查询语言包的接口,前端把返回的键值对缓存到Vuex或Pinia里,配合vue-i18n的mergeLocaleMessage做动态合包。这样后端负责数据,前端负责呈现,互不干扰。

4. 常见问题与排查避坑实录

4.1 中文乱码:三大编码坑点

乱码是国际化最常见的翻车现场。优先级最高的坑是IDEA的Properties文件编码。我检查过很多小伙伴的代码,代码逻辑完全正确,但messages_zh_CN.properties在IDEA里显示正常,一跑起来全乱,最后发现是IDEA右下角显示的是GBK编码保存的,文件本身已经坏了。解决方案还是那四步:IDEA的File Encoding全部设UTF-8、Maven的project.build.sourceEncoding=UTF-8、Spring Boot配置spring.messages.encoding=UTF-8、必要时用native2ascii插件做兼容。

第二个坑是Maven资源插件在打包时如果做了过滤或转码,可能把properties内容弄坏。我建议这里不启用Maven的<filtering>,除非你有明确的占位符替换需求。

第三个坑是数据库里的字符集。如果你的文案已经不走properties文件了,而是从MySQL表里读取,记得建表字段用utf8mb4,连接参数加characterEncoding=utf8,否则从数据库查出来就是乱码,和Spring Boot没半毛钱关系。

4.2 切换后一直走默认语言:Locale失效排查

明明在页面上加了?lang=en_US,接口也拿到了lang参数,但返回的还是中文。这个问题我排查过三种原因:

第一种,项目里没有注册LocaleResolver的Bean。Spring Boot默认走AcceptHeaderLocaleResolver,如果你只在Controller里通过@RequestParam lang手动转Locale,那某个地方如果直接用LocaleContextHolder.getLocale(),拿到的还是浏览器语言。不要混着用,要么全手动传locale,要么全走LocaleResolver+Interceptor。

第二种,自己写的LocaleResolverBean没有生效。Spring Boot会读取容器里的LocaleResolver类型的Bean,如果你在某个配置类里写了多个LocaleResolver的@Bean方法,或者配置类没有扫描到,就会静默使用默认的。排查办法很简单,加一个日志输出拿到实际Bean类型:

@PostConstruct public void printResolver() { LocaleResolver resolver = applicationContext.getBean(LocaleResolver.class); System.out.println(resolver.getClass()); }

第三种,LocaleChangeInterceptor的paramName和前端传参不一致。如果用?lang=en_US,那么setParamName("lang");如果用?locale=en_US,就得改成"locale"。这个配置非常隐蔽,错了也不报错,就是切换无效。

4.3 资源文件找不到或加载失败又没报错

ResourceBundleMessageSource如果找不到基名,不会在启动时报错,只会在运行时抛NoSuchMessageException。如果你把basename写成了i18n/messages,但文件实际放在resources/messages.properties,那么Spring Boot会静默使用空消息源,所有getMessage请求都会找默认值,找不到就抛异常。

更隐蔽的是target目录没更新。你改了messages_en_US.properties,但IDEA没有重新编译,target/classes里的旧文件还在。这种情况重启没用,要mvn clean compile强制清理。所以遇到改了文案不生效,第一件事不是看代码,而是看target/classes下的资源文件是不是最新版本。

还有一个常见错误是resources目录下同时存在messages.properties和messages_zh_CN.properties,但getMessage("hello", ...)在无Locale参数时,Spring Boot会用默认Locale来判断。如果默认Locale是en_US,而messages_en_US.properties里没有hello,同时messages.properties里有,那么会得到NoSuchMessageException吗?不会,它会回退到messages.properties。但前提是fallback-to-system-locale=true。我建议这个配置一直保持true,让默认语言文件作为最后一道保险。

4.4 参数占位符和复数形式的坑

MessageFormat的占位符功能很强,但语法也严格。一个常见错误是文案里有普通的{和}字符,比如JSON模板{"name":"测试"},直接放到properties里,MessageFormat解析会把它当成占位符,解析失败时抛IllegalArgumentException。解决方案是外面用单引号包住普通花括号:json.template={''"name"'':''测试''},或者干脆把这类模板拆分拼接,不放进MessageFormat处理。

复数形式也容易让人懵。ChoiceFormat的语法里竖线和井号配合,英文规则可以写:

item.count={0} item(s)

但如果你用choice,就要注意1<表示大于1,而不是大于等于1。我自己排查过一个问题:英文下文案变成"1 orders",因为choice条件写了1<{0} items,而实际传入的数字是1,结果走到了0#单数分支以外的默认分支。正确写法是:

item.count={0, choice, 0#No items|1#One item|1<{0} items}

这里1<{0}表示当数量大于1时输出多条,数字1正好落在1#One item分支,没有歧义。

4.5 校验注解的国际化消息

如果你用了@NotNull、@Size这类Bean Validation注解,它们的消息提示也可以国际化。默认情况下,校验框架会从ValidatorMessages.properties里找默认消息,但我们可以用资源文件覆盖。

比较规范的做法是创建ValidationMessages.properties(这是Hibernate Validator的默认messages基名,要放在classpath根路径),例如:

user.name.notnull=用户名不能为空 user.name.size=用户名长度必须在{min}到{max}之间

实体类里写:

public class UserDTO { @NotNull(message = "{user.name.notnull}") @Size(min = 2, max = 10, message = "{user.name.size}") private String name; }

注意@Size里的min和max是在运行时由校验框架填充进{min}、{max}占位符的,这个不需要我们处理。如果你想按照Locale切换,Spring Boot的LocalValidatorFactoryBean会自动使用LocaleContextHolder里的Locale,所以前面配置好LocaleResolver后,校验消息天然支持多语言。

如果用了Spring Boot 3.x或者Jakarta Validation,流程一样,只是ValidationMessages.properties这个基名依然是默认命名。我在项目里见过有人手动把ValidationMessages.properties改成messages.properties并以为能被读取,结果校验消息永远不生效,最后才发现Validator不会去找spring.messages.basename,它只认自己的固定名字。

5. 进阶扩展:动态国际化与多端多语言

5.1 数据库驱动的动态语言包

当产品语种越来越多、运营天天改文案时,properties文件就hold不住了。你不想每次改文案都重新发版,最直接的办法是把语言包挪到数据库,做一个管理后台给运营自己编辑。

具体实现有两种路子。一种是在原有的ResourceBundleMessageSource外面包一层,请求时先查缓存,缓存没有再去数据库。另一种是直接实现Spring提供的AbstractMessageSource抽象类,只实现resolveCode(code, locale)方法即可:

@Component("messageSource") public class DatabaseMessageSource extends AbstractMessageSource { @Autowired private MessageRepository messageRepository; private final Map<String, MessageFormat> cache = new ConcurrentHashMap<>(); public void clearCache() { cache.clear(); } @Override protected MessageFormat resolveCode(String code, Locale locale) { String key = code + "_" + locale.toLanguageTag(); return cache.computeIfAbsent(key, k -> { String content = messageRepository.findContentByCodeAndLocale(code, locale); return content != null ? createMessageFormat(content, locale) : null; }); } }

注意,如果拿不到当前Locale的文案,resolveCode返回null,框架会自动去父MessageSource(通常是默认语言文件)继续找,这个兜底逻辑不用我们操心。唯一要注意的是数据库连接查询会多次命中,所以一定设计好缓存更新机制:运营后台改完文案后,调用clearCache()清掉缓存,否则用户看到的一直是旧数据。

这套方案在小团队里性价比很高。我做过一个管理端,表结构非常简单:

language: varchar(10) -- en_US / zh_CN code: varchar(64) -- user.welcome content: varchar(2000) -- 文案内容 key: varchar(64) -- 唯一索引 (language, code)

后台提供一个查询接口返回全量语言包,前端可以在登录时一次性拉取到本地,之后切换语言甚至可以做到无延迟。如果将来超过几十个语种,再考虑换成Redis缓存加配置中心。

5.2 前后端分离项目如何配合

现在很多项目是Spring Boot只做API,前端Vue,甚至还有App、小程序多个端。这个时候国际化配置要分清边界:后端负责业务数据和状态码提示,前端负责界面静态文案。两者不能混成一锅粥。

我的建议是,Spring Boot后端至少做好两件事:

第一,接口返回的是业务数据和异常码,异常码对应的文案由前端根据当前语言去映射。比如后端返回error.user.not.found,前端根据用户当前语言显示"用户不存在"或者"User not found"。这是最干净的方案,后端不需要感知前端语言,也方便多个前端共用一套接口。

第二,如果部分文案必须由后端渲染(比如Excel导出、PDF、邮件模板),那后端必须提供一个类似/i18n/bundle?lang=en_US的接口,返回该语言下全量可用的Key-Value。前端或定时任务拿到这包数据后,存到本地或服务端缓存里。注意这个接口要加缓存控制,最好用ETag或版本号,避免每次都全量传输。

如果你用的是vue-i18n,可以在应用启动时异步加载这个接口的数据,然后mergeLocaleMessage合并到语言包里。切换语言时,先判断本地有没有目标语言包,没有就向后端拉取,拉到了再切,这样体验比刷新页面好很多。

5.3 国际化在日志、异常、邮件模板中的延伸

国际化不只服务页面展示。我在实际项目里,还会用到这三类场景。

异常消息:BizException通常包含一个errorCode,但组装给用户看的message时不要硬编码中文,而是用messageSource.getMessage(errorCode, args, locale)动态生成。这样统一异常处理器上所有错误提示都可以跟着语言走,App端和Web端传不同的Accept-Language也能得到各自语言的消息。

日志输出:日志里不建议直接用国际化消息动态拼装,因为日志是给开发人员看的,不需要本地化,反而要保留原始业务含义。如果确实需要记录用户当前语言,可以在日志上下文里放一个Locale信息,方便复现"为什么这个用户看到的是英文文案"。

邮件模板:如果用Thymeleaf做邮件模板,可以在模板里使用#{user.welcome}直接引用Spring MessageSource。但要注意邮件模板的Locale需要从用户信息里获取,而不是从当前请求上下文拿,因为发邮件的场景通常没有活跃请求。我会在Service层显式传user.getLocale()给模板引擎,避免邮件语言跟随管理后台操作者的语言跑偏。

收个尾

做多语言项目最大的体会是:国际化不只是翻译文案,而是一种隔离业务语言和展示语言的架构思维。你可以先从最简单的properties文件起步,等文案规模大了再迁移到数据库或配置中心。最值得提前设计的不是语言种类,而是你代码里的key命名规范,像user.register.success这种层级风格,比src.title好维护太多。这套Spring Boot国际化配置方案我已经在多个项目里跑过,从单体到微服务都够用,关键是先把MessageSource、LocaleResolver、ResourceBundle这三个底子吃透,后面的动态化、自动化和多端扩展都水到渠成。如果你们项目正好也在踩国际化的坑,欢迎在评论区聊聊你的实际场景。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询