跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Javajava

Spring 国际化原理与实战:MessageSource、LocaleResolver、LocaleContextHolder、MessageFormat

Spring 国际化的核心是把文案从代码中拆出来,先通过 LocaleResolver 确定请求语言,再借助 LocaleContextHolder 在当前线程保存 Locale,最后由 MessageSource 按 code 和 Locale 加载资源文件,并通过 MessageFormat 填充占位符参数。文章对比了 ResourceBundleMessageSource 与 ReloadableResourceBundleMessageSource,也说明了 AcceptHeaderLocaleResolver、SessionLocaleResolver、CookieLocaleResolver 各自适合的场景,并给出 Spring Boot 最小配置示例,帮助把多语言能力跑通。

MqEngine发布于 2026/6/300 浏览
Spring 国际化原理与实战:MessageSource、LocaleResolver、LocaleContextHolder、MessageFormat

Spring 国际化原理与实战:MessageSource、LocaleResolver、LocaleContextHolder、MessageFormat

Spring 国际化到底解决什么问题

企业项目里,国际化通常不是'做个英文版页面'这么简单。提示语、校验信息、异常文案、邮件模板,都会跟着语言环境变化。把这些内容直接写进代码,后面补语言的时候就会很难受:改一处要扫一遍,漏一个字符串就成了线上 bug。

Spring 的做法比较朴素,也比较耐用:把文本从代码里拆出去,根据请求里的语言环境去加载对应文案。真正干活的就四个东西:MessageSource、LocaleResolver、LocaleContextHolder 和 MessageFormat。这几个组件配合起来,能把'查语言、找文本、填参数'这条链路跑通。

核心思路:先拿到 Locale,再找对应消息

Spring 国际化的关键不是'翻译',而是'按上下文选文案'。流程其实很直白:请求进来后,先确定当前 Locale,再根据这个 Locale 去消息文件里找对应的 code,最后把占位符参数填进去。

简化成一条链路就是这样:

客户端请求 -> LocaleResolver 解析 Locale -> LocaleContextHolder 保存 Locale -> MessageSource 按 Locale 取消息 -> MessageFormat 填充参数 -> 返回文本

这里的重点是消息和代码分离。配置文件维护文案,业务代码只关心 code。这么做的好处很实际:新增语言时不用改逻辑,只补资源文件就行。

MessageSource:真正负责找消息的组件

MessageSource 是 Spring 国际化里最核心的接口。它不负责判断语言,也不负责保存上下文,只做一件事:按 code 和 Locale 取出对应的消息。

public interface MessageSource {
    String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
    String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
    String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}

平时业务里最常用的是第二个方法,带默认值。生产环境里我也更倾向于用它,至少能避免某个 code 拼错时直接把请求打挂。至于第三个方法,更多是框架内部在用,比如校验错误对象那类场景。

常见实现:ResourceBundleMessageSource 和 ReloadableResourceBundleMessageSource

Spring 提供了两个最常见的实现类,差别不算复杂,但选错了会影响维护方式。

实现类原理优点缺点适用场景
ResourceBundleMessageSource基于 JDK ResourceBundle 加载 classpath 下的 .properties轻量、启动快不支持热加载;只认 classpath 资源小项目、文案很少改的场景
ReloadableResourceBundleMessageSource扩展版,支持文件系统/URL 资源支持热加载;可加缓存策略;资源路径更灵活启动略慢,配置更多中大型项目、文案会调整的场景

如果项目里国际化文案会频繁改,我一般会直接上 ReloadableResourceBundleMessageSource。多一点配置,换来的是后面少重启几次。

一个比较实用的配置
@Bean
public MessageSource messageSource() {
    ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    messageSource.setBasename("classpath:i18n/messages");
    messageSource.setDefaultEncoding("UTF-8");
    messageSource.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
    messageSource.setCacheSeconds(3600);
    messageSource.setUseCodeAsDefaultMessage(true);
    return messageSource;
}

几个配置项里,UTF-8 基本是必配项,不然中文很容易乱码。setUseCodeAsDefaultMessage(true) 也挺实用,至少排查时能直接看出是不是没命中资源文件。多模块项目里,我更建议把消息拆成多个文件,比如 common、validation、error,别把所有内容堆进一个 messages.properties 里,后期会很乱。

LocaleResolver:决定当前请求用哪种语言

LocaleResolver 的作用是解析当前请求的语言环境。它负责'从哪儿拿 Locale',而不是'怎么翻译'。Spring 常用的实现有三种,适合的场景差别挺明显。

AcceptHeaderLocaleResolver

它从请求头 Accept-Language 里读语言信息。浏览器、前端框架、移动端 App,默认都会带这类头。

@Bean
public LocaleResolver localeResolver() {
    AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
    resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
    resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
    return resolver;
}

这种方式配置最省事,但代价也很清楚:语言切换依赖客户端请求头,服务端没法单独改语言。适合不需要用户手动切换的场景。

SessionLocaleResolver

如果语言切换要跟着用户会话走,用这个更合适。切换一次,在当前 Session 里一直有效。

@Bean
public LocaleResolver localeResolver() {
    SessionLocaleResolver resolver = new SessionLocaleResolver();
    resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
    resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
    return resolver;
}

@GetMapping("/switchLocale")
public String switchLocale(@RequestParam String lang, HttpSession session) {
    Locale locale = switch (lang) {
        case "en" -> Locale.US;
        case "zh" -> Locale.SIMPLIFIED_CHINESE;
        default -> Locale.SIMPLIFIED_CHINESE;
    };
    session.setAttribute(SessionLocaleResolver.LOCALE_SESSION_ATTRIBUTE_NAME, locale);
    return "Locale switched to: " + lang;
}

它的问题也很现实:依赖 Session。单体应用没什么,分布式场景里如果 Session 没共享,切换语言这件事就会变得不稳定。

CookieLocaleResolver

如果你希望语言偏好能跨会话保存,Cookie 会更顺手。

@Bean
public LocaleResolver localeResolver() {
    CookieLocaleResolver resolver = new CookieLocaleResolver();
    resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
    resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
    resolver.setCookieName("lang");
    resolver.setCookieMaxAge(60 * 60 * 24 * 7);
    resolver.setCookiePath("/");
    return resolver;
}

@GetMapping("/switchLocale")
public String switchLocale(@RequestParam String lang, HttpServletResponse response) {
    Locale locale = switch (lang) {
        case "en" -> Locale.US;
        case "zh" -> Locale.SIMPLIFIED_CHINESE;
        default -> Locale.SIMPLIFIED_CHINESE;
    };
    ((CookieLocaleResolver) localeResolver()).setLocale(null, response, locale);
    return "Locale switched to: " + lang;
}

它的优点是持久化,缺点也很直接:用户禁用 Cookie 时就不工作了。面向 C 端 Web 应用时,这个方案通常比 Session 更稳一点。

三者可以简单记成这样:请求头适合'随请求变化',Session 适合'会话内保持',Cookie 适合'跨会话记住偏好'。

特性AcceptHeaderLocaleResolverSessionLocaleResolverCookieLocaleResolver
数据来源HTTP 请求头SessionCookie
持久化否会话内可跨会话
是否支持手动切换否是是
适用场景前端控制语言后台系统C 端 Web 应用

LocaleContextHolder:把 Locale 放到当前线程里

LocaleContextHolder 是个工具类,底层基于 ThreadLocal。它存在的意义很简单:让你在 Controller、Service、异常处理器里都能拿到当前请求的 Locale,不用层层往下传。

public abstract class LocaleContextHolder {
    private static final ThreadLocal<LocaleContext> localeContextHolder = new NamedThreadLocal<>("LocaleContext");
    private static final ThreadLocal<LocaleContext> inheritableLocaleContextHolder = new NamedInheritableThreadLocal<>("LocaleContext");

    public static Locale getLocale() {
        LocaleContext localeContext = getLocaleContext();
        return (localeContext != null ? localeContext.getLocale() : null);
    }

    public static void setLocale(@Nullable Locale locale) {
        setLocale(locale, false);
    }

    public static void resetLocaleContext() {
        localeContextHolder.remove();
        inheritableLocaleContextHolder.remove();
    }
}

这套机制没什么玄学,就是线程隔离。一个请求线程一个 Locale,互不影响。需要注意的是,异步线程默认拿不到父线程的上下文,如果你要把 Locale 带进子线程,就得用可继承的模式。

获取当前语言环境时,通常这样写就够了:

Locale currentLocale = LocaleContextHolder.getLocale();

在 Web 场景下,Spring 会帮你把 Locale 放好。非 Web 场景,比如定时任务、普通异步任务,就别默认指望它会自动存在,通常得自己处理。

MessageFormat:负责把参数填进消息里

MessageFormat 是 JDK 自带的,Spring 直接拿来用。它处理的是 {0}、{1} 这类占位符,把动态参数塞到消息模板中。

基础形式很简单:

service.call.timeout=调用{0}服务超时,超时时间:{1}ms
String message = messageSource.getMessage("service.call.timeout", new Object[]{"user-service", 5000}, Locale.SIMPLIFIED_CHINESE);
// 输出:调用 user-service 服务超时,超时时间:5000ms

它也支持日期、数字这类格式化:

user.register.time=用户{0}注册时间:{1,date,yyyy-MM-dd HH:mm:ss}
order.amount=订单{0}的金额:{1,number,currency},创建时间:{2,date,long}
String zhMessage = messageSource.getMessage(
    "order.amount",
    new Object[]{"ORD123456", 999.99, new Date()},
    Locale.SIMPLIFIED_CHINESE
);

String enMessage = messageSource.getMessage(
    "order.amount",
    new Object[]{"ORD123456", 999.99, new Date()},
    Locale.US
);

这里有几个坑挺常见。一个是占位符索引从 0 开始,参数数量不够会直接报错。另一个是消息文本里如果真要出现 { 或 },要记得转义,不然它会被当成格式符号解析。数字和日期的输出也会跟 Locale 走,这点倒是省心,不用自己拼格式。

Web 场景下的完整执行链路

把这四个组件串起来,流程其实不复杂:

请求进入 DispatcherServlet
  ↓
LocaleResolver 解析 Locale
  ↓
LocaleContextHolder 保存当前线程的 Locale
  ↓
业务代码调用 MessageSource.getMessage
  ↓
MessageSource 按 Locale 加载资源文件
  ↓
MessageFormat 处理占位符
  ↓
返回多语言文本

这条链路里,LocaleResolver 只管'从哪儿来',LocaleContextHolder 只管'先存起来',MessageSource 负责'按 code 找文案',MessageFormat 负责'把参数补进去'。职责分得很清楚,所以调试时也不难定位问题。

最小 Demo:跑通 Spring 国际化

先准备一个最基础的 Spring Boot 项目,JDK 17+、Spring Boot 3.x、Maven 或 Gradle 都可以。

配置类
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.List;
import java.util.Locale;

@Configuration
public class I18nConfig {
    @Bean
    public ReloadableResourceBundleMessageSource messageSource() {
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setBasename("classpath:i18n/messages");
        messageSource.setDefaultEncoding("UTF-8");
        messageSource.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
        messageSource.setCacheSeconds(0);
        messageSource.setUseCodeAsDefaultMessage(true);
        return messageSource;
    }

    @Bean
    public LocaleResolver localeResolver() {
        AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
        resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
        resolver.setSupportedLocales(List.of(Locale.SIMPLIFIED_CHINESE, Locale.US));
        return resolver;
    }
}
消息文件

resources/i18n/messages_zh_CN.properties:

hello.world=你好,世界!
user.name.empty=用户名不能为空
user.age.range=年龄必须在{0}到{1}之间

resources/i18n/messages_en_US.properties:

hello.world=Hello, World!
user.name.empty=Username cannot be empty
user.age.range=Age must be between {0} and {1}
Controller
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Locale;

@RestController
@RequestMapping("/i18n")
public class I18nController {
    private final MessageSource messageSource;

    public I18nController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @GetMapping("/hello")
    public String hello() {
        Locale currentLocale = LocaleContextHolder.getLocale();
        return messageSource.getMessage("hello.world", null, currentLocale);
    }

    @GetMapping("/age")
    public String ageRange() {
        Locale currentLocale = LocaleContextHolder.getLocale();
        return messageSource.getMessage("user.age.range", new Object[]{18, 60}, currentLocale);
    }
}
验证方式
curl -H "Accept-Language: zh-CN" http://localhost:8080/i18n/hello
curl -H "Accept-Language: zh-CN" http://localhost:8080/i18n/age
curl -H "Accept-Language: en-US" http://localhost:8080/i18n/hello
curl -H "Accept-Language: en-US" http://localhost:8080/i18n/age
curl http://localhost:8080/i18n/hello

默认语言没带请求头时会走 LocaleResolver 的默认值,所以还是中文。

为什么 bean 名必须叫 messageSource

这个点很容易被忽略,但线上真的会踩。Spring 的一些自动配置默认会去找名字叫 messageSource 的 Bean。如果你自己起了别的名字,框架可能就拿不到它,最后退回到默认的 DelegatingMessageSource,结果就是只返回 code,不会做真正的国际化解析。

也就是说,名字不是随便取的。想省事,直接叫 messageSource;真要改名,就得把相关依赖都显式接上。

结语

Spring 国际化这套机制不复杂,真正重要的是把职责分开:LocaleResolver 负责找语言,LocaleContextHolder 负责临时保存,MessageSource 负责找文案,MessageFormat 负责填参数。把这条链路理顺以后,后面的校验消息、异常提示、接口返回文案都能沿着同一套方式做下去。

目录

  1. Spring 国际化原理与实战:MessageSource、LocaleResolver、LocaleContextHolder、MessageFormat
  2. Spring 国际化到底解决什么问题
  3. 核心思路:先拿到 Locale,再找对应消息
  4. MessageSource:真正负责找消息的组件
  5. 常见实现:ResourceBundleMessageSource 和 ReloadableResourceBundleMessageSource
  6. 一个比较实用的配置
  7. LocaleResolver:决定当前请求用哪种语言
  8. AcceptHeaderLocaleResolver
  9. SessionLocaleResolver
  10. CookieLocaleResolver
  11. LocaleContextHolder:把 Locale 放到当前线程里
  12. MessageFormat:负责把参数填进消息里
  13. Web 场景下的完整执行链路
  14. 最小 Demo:跑通 Spring 国际化
  15. 配置类
  16. 消息文件
  17. Controller
  18. 验证方式
  19. 为什么 bean 名必须叫 messageSource
  20. 结语
  • 免费图片AI生成工具免费生成了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 免费图片视频在线生成30秒,将你的创意变成现实开始设计
  • X/Twitter免费视频下载器免登陆无限额度免费视频解析下载了解详情
  • 100+免费在线小游戏爽一把
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • C++ list 容器的用法与简化实现
  • OpenClaw 飞书机器人部署记录
  • StyleSelectorXL:在 SDXL 里管理 77 种绘画风格
  • Windows 11 下配置 CUDA 版 llama.cpp 本地聊天
  • 通义万相 2.1 的架构、能力与落地观察
  • 金仓 KES V9 兼容 MongoDB 的运维替代思路
  • 双指针滑动窗口:4 道经典题的思路拆解
  • Qwen3-Embedding-4B 本地部署:llama.cpp 与 vLLM 集成
  • C++ 继承机制详解
  • TurboQuant 与 RWKV-6:大模型部署的两条降本路线
  • 用 Python 生成 Node.js 项目结构的桌面工具
  • NewStar CTF Web 题解整理
  • Mac Mini M4 本地部署 AI 模型:Ollama 与 Stable Diffusion 实操
  • 大语言模型原理、应用与演进路线
  • ES6 的三个常用新特性:进制、Symbol 和 Class
  • Spring Boot Web 后端开发核心注解
  • ThinkPHP 8 多应用架构搭建与落地要点
  • MySQL 用 ON DUPLICATE KEY UPDATE 做 Upsert
  • 用 MCP 扩展 Chrome DevTools 做 JS 逆向
  • YOLOv8 无人机道路病害识别的工程落地思路

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online