Spring MessageSource 国际化消息解析流程

本文基于若依3.9.2、SpringBoot3版本。

Spring MessageSource 国际化消息解析流程

把提示文案写进代码,改一个字就要重新编译发版;想支持多语言,还得逐个改造散落在各处的字符串。国际化的第一步是把文案从代码里搬出去:代码里只保留一个消息键,具体文案维护在资源文件里,运行时按语言环境查找并填充参数。

若依的这套接入非常薄:一份资源文件、一行 yml 配置、一个静态工具类,Web 层再加一个配置类,其余全部交给 Spring 的 MessageSource 机制完成。

本文沿一次 MessageUtils.message() 调用展开,依次看资源文件的组织方式、MessageSource 的来源、Locale 的确定过程,以及占位符填充和缺键时的行为。

一条消息的完整解析链路

以密码错误次数超限的提示为例,从调用到最终文案的链路如下:

text 复制代码
MessageUtils.message("user.password.retry.limit.exceed", 5, 10)
  │
  ├─ SpringUtils.getBean(MessageSource.class)     // 从容器取消息源
  ├─ LocaleContextHolder.getLocale()              // 取当前线程的语言环境
  │
  └─ messageSource.getMessage(code, args, locale) // 交给 Spring 解析
        ├─ 按 locale 加载 Bundle:messages_zh_CN → messages_zh → messages
        ├─ 取出模板:密码输入错误{0}次,帐户锁定{1}分钟
        └─ MessageFormat:用 args 按位置填充 {0}、{1}
             得到 "密码输入错误5次,帐户锁定10分钟"

这条链路涉及四个组件,各自的职责如下:

位置 组件 职责
ruoyi-admin/src/main/resources/i18n/messages.properties 消息资源文件 以 key=value 保存文案,支持 {0}、{1} 占位符
application.yml 的 spring.messages.basename 配置项 告诉 Spring Boot 资源基名是 i18n/messages
Spring Boot MessageSourceAutoConfiguration 自动配置 条件装配 ResourceBundleMessageSource,Bean 名为 messageSource
ruoyi-common/.../utils/MessageUtils.java 静态门面 从容器取 MessageSource,用当前线程的 Locale 翻译消息

MessageUtils 只负责「取 MessageSource + 取当前 Locale」两件事,真正的查找、回退、占位符格式化都在 Spring 内部完成。下面按链路顺序逐段展开。

消息资源文件

资源文件位于 ruoyi-admin/src/main/resources/i18n/messages.properties,随 ruoyi-admin 打包进应用的 BOOT-INF/classes/i18n/ 目录。文件是 UTF-8 编码的普通 properties,# 开头为注释:

properties 复制代码
#错误消息
user.jcaptcha.error=验证码错误
user.jcaptcha.expire=验证码已失效
user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定{1}分钟

##文件上传消息
upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!

键名采用「模块.场景」的点分命名,如 user.jcaptcha.error、upload.filename.exceed.length。命名只是约定,没有任何编译期检查------拼错键名、删掉键、改了键忘记改代码,都只能到运行时才暴露。

命名规则与回退链

Java 的 ResourceBundle 按「基名 + 语言 + 国家」查找资源文件。基名是 yml 里配置的 i18n/messages,请求的 Locale 不同,候选文件的顺序也不同:

文件名 适用的 Locale
messages_zh_CN.properties zh_CN,语言 + 国家
messages_zh.properties zh,语言级
messages.properties 所有,基础文件,也是最终兜底

以 zh_CN 为例,候选顺序是 messages_zh_CN → messages_zh → messages,找不到的层级自动落到基础文件。键的查找也沿这条链向上回退:如果 messages_en.properties 里缺某个键,会取基础文件中的同名键,所以英文包可以只翻译一部分,缺的部分显示中文。

若依仓库里只有 messages.properties 一份文件,没有 _zh、_en 变体。这带来一个直接结果:任何 Locale 最终都会落到基础文件,切换语言不会改变任何一条消息的文本。这套机制的价值目前体现在文案集中维护上,多语言能力处于预留状态。

编码与缓存

编码与缓存涉及两个配置项:

  • Spring Boot 3 的 spring.messages.encoding 默认是 UTF-8,中文可以直接写在 properties 里;如果不经过 Boot、手动创建 ResourceBundleMessageSource,默认编码是 ISO-8859-1,中文会变成乱码
  • spring.messages.cache-duration 未设置时表示永久缓存已加载的 Bundle,运行期修改 messages.properties 不会生效,必须重启应用

MessageSource 的来源

若依没有手写 MessageSource Bean,完全依赖 Spring Boot 的 MessageSourceAutoConfiguration。它的装配逻辑可以拆成三层:

  1. 类上标注 @ConditionalOnMissingBean(name = "messageSource"),用户自定义同名 Bean 时自动配置让位
  2. 启动时检查 spring.messages.basename 对应的基础文件 <基名>.properties 是否在 classpath 上(不检查 messages_xx.properties 这类语言变体),任意一个基名的基础文件存在就匹配
  3. 匹配后注册一个名为 messageSource 的 ResourceBundleMessageSource,把基名、编码、缓存时长、回退策略等属性灌进去

若依在 application.yml 中只配置了基名一项:

yaml 复制代码
spring:
  messages:
    basename: i18n/messages

如果条件不匹配------比如资源文件不在 classpath 上------Boot 不会注册自动配置的 ResourceBundleMessageSource,容器只会保留上下文兜底的空 DelegatingMessageSource(Bean 名同样是 messageSource)。这时 MessageUtils 仍能按类型取到 Bean,但消息解析会失败------MessageUtils 用的三参重载直接抛 NoSuchMessageException,带 defaultMessage 的四参重载返回默认消息。基础文件 messages.properties 必须存在,原因就在这里:装配条件只检查 basename + ".properties"。

相关配置项及默认值如下:

属性 默认值 作用
spring.messages.basename messages 资源基名,可逗号分隔多个,按声明顺序查找
spring.messages.encoding UTF-8 读取 properties 的字符编码
spring.messages.cache-duration 未设置 Bundle 缓存时长,未设置表示永久缓存
spring.messages.fallback-to-system-locale true 请求 Locale 没有对应文件时,是否再尝试 JVM 默认 Locale
spring.messages.use-code-as-default-message false 找不到键时返回键本身,还是抛异常

基名的使用有两个细节:

  • 基名可以写多个,ResourceBundleMessageSource 按声明顺序逐个查找,先找到键的生效,若依只用了一个
  • 同名基名不要分散在多个 jar 里。ResourceBundle 通过类加载器按 classpath 顺序查找,同名的 messages.properties 只会加载到最先出现的那一份,其余被静默忽略。把资源文件集中放在可启动的 ruoyi-admin 模块,而不是可复用的 ruoyi-common,避免的就是这种冲突

MessageUtils:静态门面

工具类的代码只有一个方法:

java 复制代码
public static String message(String code, Object... args) {
    MessageSource messageSource = SpringUtils.getBean(MessageSource.class);
    return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
}

它做三件事:按类型从容器取 MessageSource、取当前线程的 Locale、把两者交给 Spring 的 getMessage。

用静态方法而不是依赖注入,是由调用方的结构决定的。这套机制的主要消费者是异常类:BaseException 的子类在业务代码里用 new 创建,是普通 Java 对象,没有机会接受 Spring 注入,而它们的 getMessage() 又要按 code 翻译消息。静态门面加 SpringUtils 是让「非容器对象」访问容器服务的入口------SpringUtils 实现 BeanFactoryPostProcessor,执行时机早于普通 Bean 实例化,因此 MessageUtils 在 Bean 的构造方法、@PostConstruct 里被调用时也能正常取到容器。

MessageUtils 不在字段里缓存 MessageSource,每次调用都走一遍 getBean。静态字段的初始化时机不可控,可能早于容器就绪;单例的 getBean 主要是一次缓存命中,成本可以忽略,换来的是时序上的安全。

分层上,MessageUtils 放在 ruoyi-common,只依赖 spring-context 提供的 MessageSource 和 LocaleContextHolder,不依赖 Spring MVC;LocaleResolver、LocaleChangeInterceptor 这些 Web 组件放在 ruoyi-framework。common 模块的异常体系因此不必引入 MVC 依赖就能完成消息翻译。

getMessage 的两个细节:缺键与占位符

缺键抛异常,而不是回退

MessageSource 有三个 getMessage 重载,MessageUtils 用的是不带 defaultMessage 的三参数版本:

java 复制代码
String getMessage(String code, Object[] args, Locale locale) throws NoSuchMessageException;

键不存在时,这个重载直接抛 NoSuchMessageException,不返回 null 也不返回键名。spring.messages.use-code-as-default-message 默认是 false,正好对应这个行为。

对异常体系的影响要看 BaseException.getMessage() 的写法:

java 复制代码
@Override
public String getMessage() {
    String message = null;
    if (!StringUtils.isEmpty(code)) {
        message = MessageUtils.message(code, args);
    }
    if (message == null) {
        message = defaultMessage;
    }
    return message;
}

defaultMessage 只在 code 为空时生效;code 非空但键不存在时,MessageUtils 直接抛异常,走不到 message == null 的兜底。这个异常发生在 getMessage() 被调用的时刻------通常是日志打印或全局异常处理器组装响应的时候,结果是一个漏配的键会把原始业务异常替换成 NoSuchMessageException,排查时看到的堆栈与真正的错误无关。使用这套体系时,每个 code 都要在资源文件里有对应条目;需要兜底文案时应改用带 defaultMessage 的四参数重载,自己取 MessageSource 调用。

MessageFormat 填充占位符

args 参数最终交给 JDK 的 MessageFormat 处理,规则有四条:

  • {0}、{1} 按位置取 args 数组元素,索引从 0 开始;user.password.retry.limit.exceed 的两个参数就是重试次数和锁定分钟数
  • MessageFormat 把单引号 ' 当转义符,文案里要出现字面量单引号需写成 '';若依现有文案没有单引号,没有遇到这个问题
  • args 为 null 或空数组时,消息原样返回,不进入 MessageFormat 解析
  • 数字、日期参数会按 Locale 做区域化格式化,当前参数都是整数,差异不明显

占位符只认 {0}、{1} 这样的数字索引。资源文件里的键 length.not.valid=长度必须在{min}到{max}个字符之间 用的是 {min}、{max} 命名占位符,一旦带参数调用,MessageFormat 在构造阶段就会抛 IllegalArgumentException。从形式看,这个键是给 Bean Validation 校验消息用的------校验器用自己的插值机制填充这类命名参数------但代码里没有任何校验注解引用它,当前是一条死键,不能用 MessageUtils.message("length.not.valid", args) 的方式消费。消息键本身不携带格式语义,格式由消费它的解析器决定。

Locale 的确定

messageSource.getMessage 的第三个参数决定读哪份资源文件。MessageUtils 取的是 LocaleContextHolder.getLocale()------Spring 的线程级 Locale 上下文,内部用 ThreadLocal 保存。

DispatcherServlet 继承的 FrameworkServlet.processRequest 在处理请求的一开始,就从 LocaleResolver 取一个 LocaleContext 绑定进 LocaleContextHolder,请求结束时在 finally 里清理。这个上下文是延迟解析的,getLocale() 每次调用都会经由它重新读取数据源(SessionLocaleResolver 读 Session)。业务代码执行时上下文一定已经就绪。没有上下文的场景------过滤器层、异步线程、定时任务------getLocale() 返回 Locale.getDefault(),即 JVM 默认语言。

若依的 LocaleResolver 由 framework 模块的 I18nConfig 提供,注册的是 SessionLocaleResolver,默认语言固定为简体中文:

java 复制代码
@Bean
public LocaleResolver localeResolver() {
    SessionLocaleResolver slr = new SessionLocaleResolver();
    // 默认语言
    slr.setDefaultLocale(Constants.DEFAULT_LOCALE);
    return slr;
}

SessionLocaleResolver 从 Session 属性里取语言,取不到时返回 defaultLocale。因为默认值被写死为 zh_CN,它不会退到请求的 Accept-Language 头,浏览器语言偏好完全不起作用。I18nConfig 同时注册了 LocaleChangeInterceptor,接受 ?lang=en_US 参数把新语言写进 Session。SessionLocaleResolver 返回的 LocaleContext 是延迟解析的,getLocale() 每次都重新读 Session,而拦截器在 Controller 之前就执行了,所以当次请求中拦截器之后的代码(Controller、Service、MessageUtils)立即使用新 Locale,不需要等下一次请求。仓库里没有英文资源文件,即使手动带上 lang 参数,文案也不会有任何变化。

消息机制的使用方

除 BaseException 子类通过 getMessage() 间接翻译外,直接调用 MessageUtils 的有四个类:SysLoginService、UserDetailsServiceImpl、SysRegisterService、LogoutSuccessHandlerImpl,全部集中在登录、注册、退出链路。覆盖范围如下:

位置 是否走 i18n
登录失败、验证码校验失败的提示 是,异常类 code 驱动
注册的用户名/密码校验提示 否,SysRegisterService 硬编码中文,仅验证码异常走 code
登录成功、退出成功、注册成功的日志 是,直接调用 MessageUtils
文件大小、文件名长度异常 是,FileException 子类 code 驱动
权限不足、认证失败、参数校验、Excel 表头、前端界面 否,硬编码中文

登录日志的异步写入有一个实现细节:

java 复制代码
AsyncManager.me().execute(
    AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match"))
);

MessageUtils.message(...) 在请求线程里先执行,得到的已经是中文字符串,再作为参数传给异步任务。异步线程没有 LocaleContextHolder 上下文,如果在任务体里才去翻译 code,拿到的是 JVM 默认语言;跨线程传递的应该是已翻译的文本,而不是待翻译的 code。

思考

静态门面与依赖注入的取舍 :调用方里有一大类是 new 出来的异常对象,它们不可能成为 Spring Bean,消息又必须在 getMessage() 里按 code 翻译,静态门面是让非容器对象访问容器服务的折中方案,同时把 i18n 依赖限制在 common 模块内部(只依赖 spring-context,不依赖 MVC)。代价是依赖被隐藏:单元测试里只要触发了消息解析,就必须准备容器和 MessageSource Bean。更理想的设计是异常只携带 code、由统一异常处理层集中翻译,但那样异常在非 Web 场景下拿不到可读消息。若依选择了使用成本最低、耦合最隐晦的方案,对框架代码可以接受,业务代码不值得模仿。

缺键时 fail-fast 与回退的取舍 :当前实现是隐式 fail-fast------三参数 getMessage 缺键抛异常,而且抛在 getMessage() 被日志或异常处理器调用的时刻,容易把原始异常掩盖成二次异常。对用户可见的错误提示,可用性优先,应该给默认消息,不能让「少了一行翻译」导致整个错误响应失败;对开发期,则可以用 use-code-as-default-message=true 或启动时校验键集合,尽早暴露拼写问题。两种策略适用于不同环境,关键是不要开发期没有校验、运行期直接抛异常,把配置错误变成线上故障。

相关推荐
念何架构之路1 小时前
go-grpc逻辑架构图
开发语言·后端·golang
博.闻广见1 小时前
第10篇:Agent系统的性能优化 —— 缓存、压缩与动态模型路由
java·人工智能·后端·spring·agent
打工仔折腾 AI1 小时前
极空间部署Portainer实战:绕过Socket权限、解决9000端口冲突与固定内网访问
人工智能·后端·python·性能优化
Irene19911 小时前
Flink 学习日志 01:前端转大数据的实战避坑指南
java·jvm·flink
Thomas.Sir1 小时前
第1课:SpringBoot 4.x 快速上手:新特性、环境准备与第一个HelloWorld程序
java·spring boot
king13598745501 小时前
LiteRT Tensor API:C++ 编写端侧模型图,XNNPACK、GPU 与 NPU 怎么选?
开发语言·c++·人工智能·chatgpt·架构·aigc
geovindu1 小时前
rust: Template Method Pattern(续)
开发语言·后端·设计模式·rust·行为模式·板方法模式
每天一道题2 小时前
从单体到微服务:Service、Instance 与服务拆分到底是什么关系?
后端·微服务·云原生·架构
尹玉玺2 小时前
从原子残片到天道公理:以德谟克利特思想全貌,重新定界西方唯物认知的历史层级与边界
人工智能·算法·架构