
做后端时间长了你会发现一个很有意思的现象很多项目一开始只做中文等产品想出海或者公司接了个外企单子最头疼的不是改业务逻辑而是把界面和提示文字一层层挖出来翻译。Spring Boot国际化配置i18n就是专门解决这个问题的。它不复杂核心就是利用MessageSource机制根据客户端传来的语言标识自动选择对应的提示文案。这篇文章我把自己实际用到的完整步骤、配置参数、踩过的坑包括Vue前后端分离怎么配合一次性总结清楚。适合正在做多语言需求的后端开发也适合刚接触Spring Boot、想搞懂国际化原理的初学者。我最早接触国际化是给一个面向东南亚的SaaS项目做多语言刚开始以为只是把文案抽出来放properties文件而已结果翻车翻得厉害中文乱码、语言切换不生效、单体资源文件上千行没法维护。后来慢慢理清了一套稳妥的配置方式从ResourceBundle原理到动态语言包方案踩了不少雷。这篇内容不是官方文档的翻译版而是我实际跑通过的记录从基础到进阶都有保证能落地。1. 先理清Spring Boot国际化的整体思路1.1 国际化解决的痛点和核心概念国际化也叫i18ninternationalization的缩写首字母i加末字母n中间其实有18个字母指的是同一个应用能根据访问者所在的语言区域展示不同语言的内容。它和我们常说的翻译有一点本质区别翻译是上线前把文案死写进去国际化则是一套运行时动态匹配语言的能力。这里绕不开一个核心类java.util.Locale。所有语言、区域、编号规则都通过Locale表达比如Locale.CHINA、Locale.US字符串形式就是zh_CN、en_US。前端传一个请求头Accept-Language: zh-CN或者URL上挂?langen_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.propertiesmessages.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-8properties文件在保存时可能被转成GBK或乱码导致运行起来中文全部变成问号。我的做法是四步走IDEA设置里把Global Encoding、Project Encoding、Default encoding for properties files全部设为UTF-8。在application.yml里显式声明spring.messages.encoding: UTF-8。如果使用Maven构建注意pom.xml里的project.build.sourceEncoding和project.reporting.outputEncoding都要设成UTF-8。IDE在保存messages_zh_CN.properties时选择转成Native2ASCII或者干脆让编辑器按UTF-8显示Maven打包时用native2ascii-maven-plugin做转换确保最后target/classes里的文件是标准格式。2.2 MessageSource.getMessage的三种使用姿势配置好资源文件后业务里怎么用才是关键。MessageSource的getMessage方法主要有三种重载形式分别应对不同场景。第一种最基础只有一个codeString 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中单引号是转义字符。如果你在文案里写英文的Im必须写成Im否则解析时可能会出现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; }配合拦截器可以实现在链接上加?localeen_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?langen_US请求进来时拦截器会把语言设置为英文同时存到Session或Cookie里之后所有请求都沿用这个语言体验很自然。3. 从零搭建一个支持中英文切换的Spring Boot项目3.1 项目依赖与基础配置我们跑一个最小可用的demoSpring Boot版本用2.7或者3.x都行核心依赖只需要一个web启动器dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency然后配置application.ymlspring: 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.propertiesuser.register.successUser registered successfully user.welcomeWelcome, {0}! Current points: {1} order.status{0, choice, 0#Order cancelled|1#Order paid|1Order shipped}如果你需要繁体中文就是messages_zh_TW.properties内容用繁体字如果只有大陆业务光是messages.properties加一个messages_en_US.properties就够用了。记住没有messages_zh_CN.properties也是没问题的因为messages.properties默认就充当了中文角色。3.3 通过Controller暴露多语言接口写一个入口Controller手动解析请求参数里的lang然后传给MessageSourceRestController public class I18nController { private final MessageSource messageSource; public I18nController(MessageSource messageSource) { this.messageSource messageSource; } GetMapping(/i18n/message) public MapString, 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参数了因为拦截器已经把语言写进了LocaleContextHolderGetMapping(/i18n/context) public String contextMessage() { return messageSource.getMessage(user.register.success, null, LocaleContextHolder.getLocale()); }但实际项目里我还是推荐用CookieLocaleResolver而不是Session。原因很简单Session在微服务和分布式环境里不友好Cookie天然跨请求传递后端只负责写一个langen_US的Cookie前端每次请求自动带上简单粗暴且可靠。3.4 接入前端页面实测效果我用一个最简单的静态页面来演示。在src/main/resources/static/index.html里略作修改放一个下拉框和展示结果select idlang option valuezh_CN中文/option option valueen_USEnglish/option /select button onclickloadMessage()加载文案/button div idresult/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.sourceEncodingUTF-8、Spring Boot配置spring.messages.encodingUTF-8、必要时用native2ascii插件做兼容。第二个坑是Maven资源插件在打包时如果做了过滤或转码可能把properties内容弄坏。我建议这里不启用Maven的filtering除非你有明确的占位符替换需求。第三个坑是数据库里的字符集。如果你的文案已经不走properties文件了而是从MySQL表里读取记得建表字段用utf8mb4连接参数加characterEncodingutf8否则从数据库查出来就是乱码和Spring Boot没半毛钱关系。4.2 切换后一直走默认语言Locale失效排查明明在页面上加了?langen_US接口也拿到了lang参数但返回的还是中文。这个问题我排查过三种原因第一种项目里没有注册LocaleResolver的Bean。Spring Boot默认走AcceptHeaderLocaleResolver如果你只在Controller里通过RequestParam lang手动转Locale那某个地方如果直接用LocaleContextHolder.getLocale()拿到的还是浏览器语言。不要混着用要么全手动传locale要么全走LocaleResolverInterceptor。第二种自己写的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和前端传参不一致。如果用?langen_US那么setParamName(lang)如果用?localeen_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-localetrue。我建议这个配置一直保持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 MapString, 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?langen_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这三个底子吃透后面的动态化、自动化和多端扩展都水到渠成。如果你们项目正好也在踩国际化的坑欢迎在评论区聊聊你的实际场景。