本文基于若依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。它的装配逻辑可以拆成三层:
- 类上标注
@ConditionalOnMissingBean(name = "messageSource"),用户自定义同名 Bean 时自动配置让位 - 启动时检查
spring.messages.basename对应的基础文件<基名>.properties是否在 classpath 上(不检查messages_xx.properties这类语言变体),任意一个基名的基础文件存在就匹配 - 匹配后注册一个名为
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 或启动时校验键集合,尽早暴露拼写问题。两种策略适用于不同环境,关键是不要开发期没有校验、运行期直接抛异常,把配置错误变成线上故障。