大家好,我是程序员天天困。
想象一个很常见的场景:团队把 App 推向东南亚市场,海外用户点登录失败,屏幕上弹出来的提示却是中文的「用户名或密码错误」。开发在本地测试全绿,翻出代码一看——错误文案直接写死在 Java 类里。要改文案?重新打包部署吧。
说真的,只要业务有出海计划,后端迟早要碰 Spring Boot i18n。这篇文章就把这套东西从头到尾讲清楚。点个收藏,我们开始。
一、i18n 是什么,为什么后端也需要它
i18n(internationalization,国际化):单词 internationalization 首尾字母 i 和 n 之间有 18 个字母,所以简写为 i18n。指的是在产品设计阶段就让代码具备适配多种语言和地区的能力,而不是事后硬改。你可以理解为「给软件预留多语言插槽」。
和它一起出现的还有 l10n(localization,本地化):i18n 是搭好插槽,l10n 是往插槽里填具体某种语言的翻译和格式(日期、货币、数字符号)。
很多人觉得 i18n 是前端的事,后端返回错误码、前端自己映射文案就行。这种做法在纯 App 场景能跑,但一旦对接第三方回调、开放平台 API、服务端推送消息(邮件、短信、Webhook),文案是从后端直接发出去的,前端根本接不住。
我列几个后端必须做 i18n 的典型场景:
- 开放 API:第三方开发者调用你的接口,错误信息得是对方能看懂的语言。
- 服务端推送:注册邮件、登录验证码、订单状态短信,这些文案由后端拼接。
- 统一错误码体系:后端维护错误码和消息模板,多端共用,避免 iOS、Android、Web 各翻译一遍还对不上。
- 日志与审计:部分合规场景要求操作日志按用户地区语言留痕。
说白了,只要文案是从你的服务进程里产生并发给用户的,后端就绕不开 i18n。
二、Spring Boot i18n 的三大核心组件
Spring Boot 处理多语言消息就靠三样东西,记住「查字典」这个类比就够了:
| 组件 | 角色 | 字典类比 |
|---|---|---|
MessageSource |
消息源,加载并管理多语言资源文件 | 字典本身 |
LocaleResolver |
区域解析器,决定当前请求用哪种语言 | 判断读者要查哪种语言版本 |
LocaleChangeInterceptor |
语言切换拦截器,从请求参数里切换语言 | 读者翻字典前指定语言 |
MessageSource(消息源):Spring 定义的一个接口,负责根据消息键(key)和区域(Locale)解析出对应文案。Spring Boot 通过 MessageSourceAutoConfiguration 自动装配它,你只需要告诉它资源文件在哪、用什么编码。
LocaleResolver(区域解析器):决定「这次请求到底用什么语言」的策略组件。它可以从 HTTP 头、Cookie、Session 甚至固定值里解析出一个 Locale 对象。DispatcherServlet 处理请求时会调用它,把结果存进 LocaleContextHolder,后续整条链路都能取到。
LocaleChangeInterceptor(语言切换拦截器):一个可选的拦截器,允许通过请求参数(比如 ?lang=en_US)动态切换语言。它本质上是调用 LocaleResolver.setLocale() 把新语言写回 Cookie 或 Session。
整个流程其实就是三步:请求进来 → LocaleResolver 判断语言 → MessageSource 按 key + Locale 查文案。没有任何魔法。

三、从零搭建一个多语言项目
光说不练假把式。下面基于 Spring Boot 3.x 搭一个最小可用的多语言后端,用到的代码都是标准写法。
1、准备资源文件
在 src/main/resources/ 下建一个 i18n/ 目录,放四份资源文件:
src/main/resources/
└── i18n/
├── messages.properties # 默认兜底
├── messages_zh_CN.properties # 简体中文
├── messages_en_US.properties # 英语(美国)
└── messages_ja_JP.properties # 日语(日本)
messages.properties 是默认文件,当请求的语言找不到对应翻译时,会回退到这里。这个文件必须存在,否则启动和运行时都会有警告。
每份文件内容长这样(默认文件建议用英文兜底),key 保持一致,value 按语言填:
messages.properties:
user.welcome=Welcome
user.login.fail=Invalid username or password
user.notfound=User not found: {0}
messages_zh_CN.properties:
user.welcome=欢迎回来
user.login.fail=用户名或密码错误
user.notfound=用户不存在:{0}
messages_en_US.properties:
user.welcome=Welcome back
user.login.fail=Invalid username or password
user.notfound=User not found: {0}
messages_ja_JP.properties:
user.welcome=おかえりなさい
user.login.fail=ユーザー名またはパスワードが正しくありません
user.notfound=ユーザーが見つかりません:{0}
{0} 是占位符,运行时可以用参数替换,后面会演示。
2、配置 application.yml
spring:
messages:
# 资源文件基础名,不要写 .properties 后缀
basename: i18n/messages
encoding: UTF-8
# 资源文件缓存秒数;开发时设为 0 方便热更新,生产给 3600
cache-duration: 3600
# 找不到对应语言的资源文件时,不回退到系统 Locale,直接用 messages.properties
fallback-to-system-locale: false
basename 支持配置多个,用逗号分隔,比如 i18n/messages,i18n/errors。一般项目一个就够。
3、配置 LocaleResolver 和拦截器
新建一个配置类:
@Configuration
public class I18nConfig implements WebMvcConfigurer {
// Bean 名必须叫 localeResolver,Spring 按此名查找
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
resolver.setCookieMaxAge(Duration.ofDays(30));
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return resolver;
}
// 支持通过 ?lang=en_US 切换语言
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}
这里有个坑:Bean 名必须是 localeResolver。Spring 在 DispatcherServlet 里按这个名字去容器里找,你要是写成 cookieLocaleResolver(),它找不到就会回退到默认的 AcceptHeaderLocaleResolver,配置直接失效。
4、封装一个消息工具类
直接在业务代码里每次注入 MessageSource 再传三个参数有点啰嗦,封装一下:
@Component
public class MessageSourceUtils {
private final MessageSource messageSource;
public MessageSourceUtils(MessageSource messageSource) {
this.messageSource = messageSource;
}
/**
* 取当前请求 Locale 对应的文案
*/
public String get(String key, Object... args) {
return messageSource.getMessage(key, args, LocaleContextHolder.getLocale());
}
/**
* 指定 Locale 取文案(后台任务、异步线程里用)
*/
public String get(String key, Locale locale, Object... args) {
return messageSource.getMessage(key, args, locale);
}
}
LocaleContextHolder 里的 Locale 是 DispatcherServlet 在请求进来时通过 LocaleResolver 设进去的,整条请求链路都能取。但要注意:异步线程里它是空的,因为 LocaleContextHolder 底层用的是 ThreadLocal。异步场景必须显式传 Locale,这也是我留第二个重载方法的原因。
5、在 Controller 里使用
@RestController
@RequestMapping("/api/users")
public class UserController {
private final MessageSourceUtils messageSource;
public UserController(MessageSourceUtils messageSource) {
this.messageSource = messageSource;
}
@GetMapping("/welcome")
public Result<String> welcome() {
return Result.ok(messageSource.get("user.welcome"));
}
@GetMapping("/{id}")
public Result<UserVO> getUser(@PathVariable Long id) {
UserVO user = userService.getById(id);
if (user == null) {
// 只抛错误码和参数,文案由全局异常处理器统一翻译
throw new BusinessException("user.notfound", id);
}
return Result.ok(user);
}
}
启动项目,默认请求(Cookie 没设置时走 defaultLocale=zh_CN):
curl http://localhost:8080/api/users/welcome
返回:欢迎回来
通过参数切到英文:
curl "http://localhost:8080/api/users/welcome?lang=en_US"
返回:Welcome back
切到日文:
curl "http://localhost:8080/api/users/welcome?lang=ja_JP"
返回:おかえりなさい
?lang=en_US 第一次请求时,拦截器会把 Locale 写进名为 lang 的 Cookie,之后不带参数也会记住语言,30 天有效。
6、全局异常处理里用 i18n
更常见的做法是业务代码只抛错误码,文案翻译交给全局异常处理器:
@RestControllerAdvice
public class GlobalExceptionHandler {
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result<Void>> handleBusiness(BusinessException e) {
// 优先用错误码当 key 去资源文件查;查不到用异常自带的默认消息
String message = messageSource.getMessage(
e.getCode(),
e.getArgs(),
e.getDefaultMessage(),
LocaleContextHolder.getLocale()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Result.fail(e.getCode(), message));
}
}
这样业务层只管抛 new BusinessException("user.notfound", id),文案怎么显示交给统一的处理器。错误码直接作为资源文件的 key,规范且好维护。
四、四种 LocaleResolver 怎么选
Spring 内置了四种 LocaleResolver,各有适用场景,别一上来就抄 Cookie 方案。
| 实现 | 语言来源 | 持久化 | 适用场景 |
|---|---|---|---|
AcceptHeaderLocaleResolver |
请求头 Accept-Language |
无(每次按头解析) | 纯 API 服务、对接第三方、无登录态 |
CookieLocaleResolver |
指定 Cookie | 浏览器端持久 | 前后端分离、未登录也要记语言 |
SessionLocaleResolver |
HttpSession | 会话级 | 传统服务端渲染、登录后定语言 |
FixedLocaleResolver |
写死一个 Locale | 永不变 | 测试、强制单语言的内部系统 |
我的选型建议很直接:
- 纯后端 API,不维护用户语言偏好:用默认的
AcceptHeaderLocaleResolver,啥都不用配。客户端(浏览器、App)会自动带Accept-Language头。 - 需要记住用户选择,且未登录也要生效:用
CookieLocaleResolver。这也是最通用的方案。 - 登录后由用户中心统一下发语言:用
SessionLocaleResolver或干脆自定义一个从用户信息里读 Locale 的解析器。 - 想同时支持 Cookie 和 Header 兜底,可以自定义
LocaleResolver,先读 Cookie,Cookie 没有再回退到Accept-Language头。
注意 AcceptHeaderLocaleResolver 不支持通过拦截器切换语言——它的 setLocale() 方法会直接抛 UnsupportedOperationException,因为 HTTP 头是客户端发的,服务端改不了。如果你需要 ?lang=xxx 切换,必须换成 Cookie 或 Session 方案。
五、踩坑记录与最佳实践
实际项目里有几个高频坑,挑最容易踩的说。
1)编码问题:properties 文件乱码
这是最经典的坑。Java 9 之前 JDK 读取 .properties 文件默认按 ISO-8859-1 编码,中文只能写成 \uXXXX 转义;Java 9 起 PropertyResourceBundle 默认改成了 UTF-8,但老项目、老服务器上的乱码问题大多源自这。Spring Boot 3.x 要求 JDK 17+,默认已经是 UTF-8,但显式配置一下更稳妥:
spring:
messages:
encoding: UTF-8
另外,IDEA 里也要把 properties 文件的编码对齐(Settings → Editor → File Encodings → Default encoding for properties files 选 UTF-8;老项目如果还在用 ISO-8859-1,可以勾选 Transparent native-to-ascii conversion,IDEA 会自动在编辑时显示原文、保存时写转义码)。文件本身的编码和 Spring 配置里声明的编码要一致,一头 UTF-8 一头 ISO-8859-1 必乱。

2)key 的命名要规范
别图省事用 msg1、msg2 这种名字。推荐按「模块.场景.含义」分层:
user.login.fail=用户名或密码错误
user.register.email.duplicate=该邮箱已被注册
order.pay.timeout=支付超时,请重试
system.error.internal=系统繁忙,请稍后再试
好处是 IDE 里搜 user. 就能把用户模块所有文案找出来,翻译人员也好按文件分工。
3)用占位符而不是字符串拼接
不同语言语序不一样。中文说「用户 123 不存在」,日语可能是「ユーザー 123 が見つかりません」,数字位置不同。用 {0} 占位符由 MessageFormat 去拼,能保证语序正确:
user.notfound=用户不存在:{0}
messageSource.get("user.notfound", id);
千万别自己用 String.format 或字符串 + 拼,翻译的时候语序全乱。
4)找不到 key 怎么办
MessageSource.getMessage() 默认找不到 key 会抛 NoSuchMessageException。生产环境因为漏配一个翻译就 500 不值得。前面全局异常处理器里用的四参版本:
messageSource.getMessage(code, args, defaultMessage, locale);
第三个参数 defaultMessage 就是兜底文案,查不到 key 时返回它,不会抛异常。建议线上统一用这个版本。
可能有人会问:前端已经做了 i18n,后端还有必要做吗?
看你的文案在哪产生。如果所有提示都是前端本地映射,后端只返回错误码,那后端确实可以不做;但邮件、短信、第三方回调、文件导出这些由后端直接产出文案的场景,后端不做就没法多语言。两者不是互斥的,是分工问题。
六、小结
Spring Boot i18n 没有任何黑魔法,本质就是「按 Locale 查 properties 文件」。记住三个要点:
- MessageSource 管字典——配置 basename 和编码,资源文件按
messages_{locale}.properties命名。 - LocaleResolver 定语言——纯 API 用 Header,要记住偏好就用 Cookie,别让 Bean 名写错。
- 业务层只抛错误码——文案翻译交给全局异常处理器,用默认消息兜底,避免漏配翻译导致 500。
Spring Boot i18n 代码不复杂,真正的工作量在翻译和 key 的规范管理上。建议项目初期就把 i18n 搭好,别等出海了再回头改硬编码字符串——那时候散落各处的文案能改到你怀疑人生。
我是程序员天天困,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:踩过最离谱的 i18n 坑是什么?