Spring 国际化(i18n):MessageSource 详解
你有没有想过:一个面向全球用户的应用,怎么支持中文、英文、日文等多语言?
用户注册时:
- 中文用户看到:「注册成功,请查收邮件」
- 英文用户看到:「Registration successful, please check your email」
- 日文用户看到:「登録成功、メールをご確認ください」
总不能写一堆 if-else 吧?
Spring 的国际化(i18n)机制,就是来解决这个问题的。
i18n 的整体架构
┌─────────────────────────────────────────────────────────────────────────┐
│ Spring i18n 架构 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ messages.properties(默认语言) │
│ messages_zh.properties(中文) │
│ messages_en.properties(英文) │
│ messages_ja.properties(日文) │
│ │ │
│ ▼ │
│ MessageSource │
│ │ │
│ ▼ │
│ LocaleResolver(解析用户 Locale) │
│ │ │
│ ▼ │
│ Locale(语言环境) │
│ │
└─────────────────────────────────────────────────────────────────────────┘MessageSource 接口
接口定义
java
public interface MessageSource {
// 获取消息,参数使用 {0}, {1}, ...
String getMessage(String code, Object[] args, String defaultMessage, Locale locale);
// 获取消息,没有找到时抛出 NoSuchMessageException
String getMessage(String code, Object[] args, Locale locale) throws NoSuchMessageException;
// 获取消息,使用 DefaultMessageSourceResolvable
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}使用示例
java
@Service
public class MessageService {
@Autowired
private MessageSource messageSource;
public void greet(Locale locale) {
// 获取消息
String greeting = messageSource.getMessage("greeting", null, locale);
System.out.println(greeting); // 输出对应语言版本
}
public void greetWithArgs(Locale locale) {
// 带参数的消息
String message = messageSource.getMessage(
"user.welcome", // 消息代码
new Object[]{"张三", "VIP"}, // 参数
locale
);
System.out.println(message); // 输出: 欢迎 VIP 用户 张三
}
}配置文件
命名规范
messages.properties # 默认语言(必须)
messages_zh.properties # 简体中文
messages_zh_CN.properties # 中国大陆(可选)
messages_en.properties # 英文
messages_ja.properties # 日文资源文件内容
properties
# messages.properties(英文 - 默认)
greeting=Hello
user.welcome=Welcome, {0}!
user.register.success=Registration successful
user.register.fail=Registration failed: {0}
validation.email.required=Email is required
validation.email.invalid=Email format is invalid
error.notfound=Resource not found
# messages_zh.properties(中文)
greeting=你好
user.welcome=欢迎, {0}!
user.register.success=注册成功
user.register.fail=注册失败:{0}
validation.email.required=邮箱不能为空
validation.email.invalid=邮箱格式不正确
error.notfound=资源不存在
# messages_ja.properties(日文)
greeting=こんにちは
user.welcome={0}さん、いらっしゃいませ!
user.register.success=登録成功
user.register.fail=登録失敗:{0}
validation.email.required=メールアドレスは必須です
validation.email.invalid=メールアドレスの形式が不正です
error.notfound=リソースが見つかりません文件存放位置
src/main/resources/
├── messages.properties
├── messages_zh.properties
├── messages_en.properties
├── messages_ja.properties
└── validation.properties # 专门的校验消息@MessageSource 注入
基本注入
java
@Service
public class UserService {
@Autowired
private MessageSource messageSource;
public void register(User user) {
try {
userRepository.save(user);
} catch (Exception e) {
String message = messageSource.getMessage(
"user.register.fail",
new Object[]{e.getMessage()},
LocaleContextHolder.getLocale()
);
throw new BusinessException(message);
}
}
}通过构造器注入(推荐)
java
@Service
public class UserService {
private final MessageSource messageSource;
public UserService(MessageSource messageSource) {
this.messageSource = messageSource;
}
public String getMessage(String code, Object[] args) {
return messageSource.getMessage(
code,
args,
LocaleContextHolder.getLocale()
);
}
}@RequestMapping 中使用 i18n
Controller 中的消息
java
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private MessageSource messageSource;
@GetMapping("/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.findById(id);
if (user == null) {
String message = messageSource.getMessage(
"error.notfound",
null,
LocaleContextHolder.getLocale()
);
return Result.error(404, message);
}
return Result.success(user);
}
@PostMapping
public Result<Void> createUser(@RequestBody UserDTO dto, Locale locale) {
try {
userService.create(dto);
String message = messageSource.getMessage(
"user.create.success",
null,
locale
);
return Result.success(201, message);
} catch (ValidationException e) {
return Result.error(400, e.getMessage());
}
}
}异常处理中的消息
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@Autowired
private MessageSource messageSource;
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Result<Void> handleNotFound(UserNotFoundException ex, Locale locale) {
String message = messageSource.getMessage(
"user.notfound",
new Object[]{ex.getUserId()},
locale
);
return Result.error(404, message);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleValidation(MethodArgumentNotValidException ex, Locale locale) {
String message = messageSource.getMessage(
"validation.failed",
null,
locale
);
return Result.error(400, message);
}
}LocaleResolver 详解
常见的 LocaleResolver
| 类型 | 说明 |
|---|---|
| AcceptHeaderLocaleResolver | 从 HTTP Accept-Language 头解析(默认) |
| FixedLocaleResolver | 使用固定的 Locale |
| SessionLocaleResolver | 从 Session 中获取 Locale |
| CookieLocaleResolver | 从 Cookie 中获取 Locale |
配置 LocaleResolver
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LocaleInterceptor())
.addPathPatterns("/**");
}
}
// 自定义语言切换拦截器
public class LocaleInterceptor extends HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String lang = request.getParameter("lang");
if (lang != null && !lang.isEmpty()) {
Locale locale = Locale.forLanguageTag(lang);
LocaleContextHolder.setLocale(locale);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
LocaleContextHolder.resetLocaleContext();
}
}手动设置 Locale
java
@Service
public class LocaleService {
public void changeLocale(String language, HttpServletRequest request, HttpServletResponse response) {
Locale locale = Locale.forLanguageTag(language);
// 方式一:存储到 Session
HttpSession session = request.getSession();
session.setAttribute("org.springframework.web.servlet.LocaleResolver.LOCALE", locale);
// 方式二:存储到 Cookie
Cookie cookie = new Cookie("locale", language);
cookie.setMaxAge(3600 * 24 * 365);
response.addCookie(cookie);
LocaleContextHolder.setLocale(locale);
}
}Validation 与 i18n
校验消息国际化
java
// messages.properties
javax.validation.constraints.NotBlank.message=不能为空
javax.validation.constraints.Size.message=长度必须在 {min} 到 {max} 之间
javax.validation.constraints.Email.message=邮箱格式不正确
javax.validation.constraints.Min.message=最小值为 {value}
javax.validation.constraints.Max.message=最大值为 {value}
// 自定义校验消息
com.example.validation.constraints.StrongPassword.message=密码必须包含大小写字母、数字和特殊字符分组校验消息
java
// messages.properties
validation.user.create={0} 校验组消息
validation.user.update={0} 更新校验组消息
// 代码中使用
@NotBlank(message = "{validation.user.create}")
private String username;@Validated 分组
java
// 校验分组
public interface Create {}
public interface Update {}
public class UserDTO {
@NotBlank(groups = Create.class, message = "{user.username.required}")
@Size(groups = {Create.class, Update.class}, min = 3, max = 20)
private String username;
}
// Controller
@PostMapping
public Result<Void> create(@Validated(Create.class) @RequestBody UserDTO dto) {
// 只校验 Create 组的验证
}
@PutMapping("/{id}")
public Result<Void> update(@Validated(Update.class) @RequestBody UserDTO dto) {
// 只校验 Update 组的验证
}ReloadableResourceBundleMessageSource
配置热加载
java
@Configuration
public class MessageSourceConfig {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource =
new ReloadableResourceBundleMessageSource();
// 设置基础名称(不需要加语言后缀)
messageSource.setBasenames(
"classpath:messages",
"file:/opt/config/messages"
);
// 设置默认编码
messageSource.setDefaultEncoding("UTF-8");
// 设置缓存时间(-1 表示不缓存,生产环境不推荐)
messageSource.setCacheSeconds(3600);
// 设置回退到默认语言
messageSource.setUseCodeAsDefaultMessage(true);
return messageSource;
}
}properties 文件热加载
java
@Configuration
public class HotReloadConfig {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource source =
new ReloadableResourceBundleMessageSource();
source.setBasename("classpath:messages");
source.setDefaultEncoding("UTF-8");
// 每 60 秒检查文件更新
source.setCacheSeconds(60);
return source;
}
}Thymeleaf 中的 i18n
模板中使用消息
html
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- 使用 th:text 获取国际化消息 -->
<h1 th:text="#{greeting}">默认消息</h1>
<!-- 带参数的消息 -->
<p th:text="#{user.welcome(${user.name})}">欢迎</p>
<!-- 选择消息(根据参数选择) -->
<p th:text="#{user.count(${count})}">
<span th:text="#{user.count.zero}">没有用户</span>
<span th:text="#{user.count.one}">一个用户</span>
<span th:text="#{user.count.other}">{0} 个用户</span>
</p>
</body>
</html>properties 中的复数形式
properties
# 使用 {0} 表示参数
user.count.zero={0} 个用户
user.count.one={0} 个用户
user.count.other={0} 个用户
# Thymeleaf 使用 select
user.status.active=激活
user.status.inactive=未激活前端语言切换
JavaScript 获取国际化消息
java
@RestController
@RequestMapping("/api/i18n")
public class I18nController {
@Autowired
private MessageSource messageSource;
@GetMapping("/messages")
public Map<String, String> getMessages(Locale locale) {
String[] codes = {
"greeting",
"user.register.success",
"user.register.fail"
};
Map<String, String> messages = new HashMap<>();
for (String code : codes) {
messages.put(code, messageSource.getMessage(code, null, locale));
}
return messages;
}
}返回所有消息
java
@RestController
@RequestMapping("/api/i18n")
public class I18nController {
@Autowired
private MessageSource messageSource;
@GetMapping("/messages")
public Map<String, String> getAllMessages(
@RequestParam(defaultValue = "zh") String lang,
@RequestParam(defaultValue = "messages") String basename) {
Locale locale = Locale.forLanguageTag(lang);
ResourceBundle bundle = ResourceBundle.getBundle(basename, locale);
Map<String, String> messages = new HashMap<>();
bundle.keySet().forEach(key ->
messages.put(key, bundle.getString(key))
);
return messages;
}
}Spring Boot 中的 i18n
自动配置
Spring Boot 自动配置了 MessageSource:
yaml
spring:
messages:
basename: messages # 基础名称(逗号分隔)
encoding: UTF-8 # 编码
cache-duration: -1 # 缓存时间(-1 表示永久)
fallback-to-system-locale: true # 回退到系统语言自定义配置
java
@Configuration
public class CustomI18nConfig {
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
source.setBasenames("messages", "validation", "errors");
source.setDefaultEncoding("UTF-8");
return source;
}
}常见问题
1. 消息找不到
java
// 默认消息作为回退
String message = messageSource.getMessage(
"unknown.code", // 不存在的代码
null, // 参数
"默认消息", // 默认消息
locale
);2. 参数索引错误
properties
# 正确:{0}, {1}, {2} 从 0 开始
user.welcome={0} 欢迎回来!
error.param={0} 参数 {1} 不能为 {2}
# 错误:不要使用 {1} 而没有 {0}3. Locale 为 null
java
// 确保 Locale 不为 null
String message = messageSource.getMessage(
"code",
null,
LocaleContextHolder.getLocale() // 可能是 null!
);
// 解决方案:使用默认值
public String getMessage(String code, Locale locale) {
Locale currentLocale = locale != null ? locale : Locale.getDefault();
return messageSource.getMessage(code, null, currentLocale);
}面试核心问题
Q1:Spring i18n 的核心接口?
MessageSource:负责从资源文件加载消息,支持国际化。
LocaleResolver:解析用户的语言环境。
Q2:MessageSource 的常见实现?
| 实现 | 说明 |
|---|---|
| ResourceBundleMessageSource | 基于 ResourceBundle |
| ReloadableResourceBundleMessageSource | 支持热加载 |
| DelegatingMessageSource | 委托给其他 MessageSource |
Q3:如何获取国际化消息?
java
@Autowired
private MessageSource messageSource;
// 使用 Locale
String message = messageSource.getMessage("code", args, locale);下节预告:Spring 类型转换 —— 深入理解 PropertyEditor、Converter 和 Formatter,实现自定义类型转换。
