Skip to content

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,实现自定义类型转换。

基于 VitePress 构建