Spring Framework 7.0.9 深度剖析-11-资源加载与国际化
11.1 本章导读
Spring 框架把两件看起来毫不相干的事放在一起:资源加载(Resource 抽象与定位)与国际化(MessageSource 消息解析)。这两件事在源码层面其实是一条线:国际化消息文件本身就是资源,ReloadableResourceBundleMessageSource 直接复用 spring-core 的整个 Resource 抽象来定位 messages_zh_CN.properties 这类文件;而 Resource 抽象又是整个框架读取配置、扫描类路径、加载外部化配置的基础设施。理解了资源加载,国际化的"文件从哪里来"就解决了一半;反过来,国际化部分展示了 Resource 抽象在生产环境中最典型的落地方式——带时间戳轮询的热刷新。
本章围绕两组核心抽象展开:
第一组在 spring-core 模块,包路径 org.springframework.core.io 与 org.springframework.util:
Resource接口(spring-core/src/main/java/org/springframework/core/io/Resource.java)及其十余个实现:ClassPathResource、FileSystemResource、UrlResource、FileUrlResource、ByteArrayResource、InputStreamResource、PathResource(7.0 已标记废弃)、AbstractResource、AbstractFileResolvingResource。- 加载器体系:
ResourceLoader、DefaultResourceLoader、ResourcePatternResolver、PathMatchingResourcePatternResolver(spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java)。 - 通配符匹配:
AntPathMatcher(spring-core/src/main/java/org/springframework/util/AntPathMatcher.java)。 - 属性编辑器:
ResourceEditor、ResourceArrayPropertyEditor,负责把配置文件里的字符串位置描述转换成Resource对象。
第二组在 spring-context 模块,包路径 org.springframework.context 与 org.springframework.context.support:
MessageSource接口(spring-context/src/main/java/org/springframework/context/MessageSource.java)与HierarchicalMessageSource。- 抽象骨架:
MessageSourceSupport、AbstractMessageSource、AbstractResourceBasedMessageSource。 - 两个生产实现:
ResourceBundleMessageSource(基于 JDKResourceBundle)与ReloadableResourceBundleMessageSource(基于 SpringResource抽象,支持热刷新),以及默认空实现DelegatingMessageSource。 - 装配逻辑:
AbstractApplicationContext.initMessageSource。
前置知识:读者应当熟悉 java.net.URL/URLClassLoader 的基本行为、JDK ResourceBundle 的同名文件回退约定(baseName_lang_country_variant → baseName_lang → baseName)、java.text.MessageFormat 的 {0} 占位符语法,以及 Spring 容器 refresh() 的大致阶段划分(initMessageSource 发生在 BeanFactory 后置处理器注册之后、事件广播器初始化之前)。
读完本章你应当获得:能徒手画出从 ctx.getResources("classpath*:com/example/**/*.xml") 到最终 Resource[] 的完整展开流程;能解释为什么 InputStreamResource 第二次 getInputStream() 会抛 IllegalStateException;能讲清 ResourceBundleMessageSource 的默认编码为什么是 ISO-8859-1、中文为什么会乱码、怎么改;能说清楚 getMessage(code, args, locale) 一次调用背后经过了缓存、MessageFormat、父级委托共多少层。
11.2 核心概念
本节先建立词汇表。每个概念都先给出它在源码中的位置与一句话职责,后面的源码剖析节会逐个深入。
InputStreamSource(输入流来源)。spring-core/src/main/java/org/springframework/core/io/InputStreamSource.java。只有一个方法 InputStream getInputStream() throws IOException,且标注了 @FunctionalInterface。它是整个资源体系的根接口——任何"能产出一个 InputStream 的东西"都是 InputStreamSource。注释里特别强调:每次调用通常应当返回全新的流(fresh stream),并举 JavaMail 附件为例说明为什么需要可重复读。这一句话是理解后面 InputStreamResource 与 ByteArrayResource 设计差异的钥匙。
Resource(资源描述符)。Resource 接口继承 InputStreamSource,在"能读流"之上补充了元信息语义:是否存在(exists)、是否可读(isReadable)、是否持有已打开的流(isOpen)、是否是文件系统文件(isFile)、能否暴露为 URL/URI/File/Path、内容长度(contentLength)、最后修改时间(lastModified)、相对资源派生(createRelative)、文件名(getFilename)与描述串(getDescription)。注意 Resource 只是一个描述符句柄,注释明确指出:拿到一个 Resource 对象并不代表资源真实存在,必须调用 exists() 确认。
ResourceLoader(资源加载器)。spring-core/src/main/java/org/springframework/core/io/ResourceLoader.java。策略接口,核心方法是 Resource getResource(String location)。它定义了位置字符串到 Resource 句柄的分派协议:必须支持全限定 URL(如 file:C:/test.dat)、必须支持 classpath: 伪 URL、应当支持相对路径。常量 CLASSPATH_URL_PREFIX = ResourceUtils.CLASSPATH_URL_PREFIX,值就是 "classpath:"。
ResourcePatternResolver(资源模式解析器)。spring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java。继承 ResourceLoader,增加 Resource[] getResources(String locationPattern),专门处理带 Ant 风格通配符的位置模式,并定义了第二个伪前缀 CLASSPATH_ALL_URL_PREFIX = "classpath*:"。ApplicationContext 本身就实现了这个接口,所以容器既是加载器也是模式解析器。
PathMatcher(路径匹配器)。org.springframework.util.PathMatcher 接口,默认实现是 AntPathMatcher。注意版本事实:7.0.9 的 PathMatchingResourcePatternResolver 字段是 private PathMatcher pathMatcher = new AntPathMatcher();(见 PathMatchingResourcePatternResolver.java 第 259 行),资源通配解析仍走 Ant 风格匹配器,而不是 Web 层主推的 PathPatternParser——后者是为 HTTP URL 路由设计的,本章不展开。
MessageSource(消息源)。spring-context/src/main/java/org/springframework/context/MessageSource.java。国际化的策略接口,三个 getMessage 重载构成"三态":带默认消息的、不带默认消息的(找不到就抛 NoSuchMessageException)、以及接收 MessageSourceResolvable(多候选 code + 参数 + 默认消息的封装对象)的。
HierarchicalMessageSource(层级消息源)。增加 getParentMessageSource/setParentMessageSource,实现"自己找不到就问父容器"的委派链。Spring Web 容器父子上下文的消息解析就是靠它串起来的。
basename(基础名)。ResourceBundleMessageSource/ReloadableResourceBundleMessageSource 的核心配置项,遵循 JDK ResourceBundle 约定:不含扩展名与语言代码的逻辑名,如 "messages",运行时派生出 messages.properties、messages_zh_CN.properties 等物理文件名。AbstractResourceBasedMessageSource(spring-context/src/main/java/org/springframework/context/support/AbstractResourceBasedMessageSource.java)统一持有 basenameSet、defaultCharset、cacheMillis、defaultLocale 等配置。
这些概念之间的关系:ResourceLoader 产出单个 Resource,ResourcePatternResolver 产出 Resource[] 并内部委托 PathMatcher 做通配匹配;MessageSource 家族在 AbstractMessageSource 里固化了"三态 getMessage → getMessageInternal → resolveCode 模板方法"的骨架,两个具体实现分别对接 JDK ResourceBundle 与 Spring 自己的 Resource 体系。下面进入源码剖析。
11.3 源码剖析(上):Resource 抽象体系
11.3.1 InputStreamSource 与 Resource:接口契约的全部语义
资源体系的根是 InputStreamSource(spring-core/src/main/java/org/springframework/core/io/InputStreamSource.java),整个接口只有一个方法:
@FunctionalInterface
public interface InputStreamSource {
/**
* Return an {@link InputStream} for the content of an underlying resource.
* It is usually expected that every such call creates a <i>fresh</i> stream.
*/
InputStream getInputStream() throws IOException;
}
逐句读注释里的契约:"every such call creates a fresh stream"——每次调用都要产出新流。为什么这么苛刻?注释紧接着给出 JavaMail 的例子:邮件附件需要多次读取流内容(一次算 Content-Length、一次写 MIME 体),如果流只能读一次就没法用。这条契约直接推出本章后面一个著名的坑:InputStreamResource 的 getInputStream() 第二次调用抛 IllegalStateException,因为它明确表示"我是那个不能重复读的例外"。
Resource 接口(spring-core/src/main/java/org/springframework/core/io/Resource.java)在 InputStreamSource 之上扩展了 13 个方法。注意其中相当一部分是 default 方法,即接口本身就给了默认行为:
public interface Resource extends InputStreamSource {
boolean exists();
default boolean isReadable() {
return exists();
}
default boolean isOpen() {
return false;
}
default boolean isFile() {
return false;
}
URL getURL() throws IOException;
URI getURI() throws IOException;
File getFile() throws IOException;
default Path getFilePath() throws IOException { // @since 7.0
return getFile().toPath();
}
default ReadableByteChannel readableChannel() throws IOException {
return Channels.newChannel(getInputStream());
}
default byte[] getContentAsByteArray() throws IOException { // @since 6.0.5
return FileCopyUtils.copyToByteArray(getInputStream());
}
default String getContentAsString(Charset charset) throws IOException { // @since 6.0.5
return FileCopyUtils.copyToString(new InputStreamReader(getInputStream(), charset));
}
long contentLength() throws IOException;
long lastModified() throws IOException;
Resource createRelative(String relativePath) throws IOException;
@Nullable String getFilename();
String getDescription();
}
逐方法拆解语义:
exists():确定性存在检查。注释特意区分"handle 有效"与"资源物理存在"——getResource("classpath:xxx")永远返回非 null 的 handle,但exists()才知道文件在不在。isReadable()(5.1 修订):默认实现就是exists()。注释说"返回 false 是确定性的不可读",而返回 true 不代表读的时候一定不抛异常。isOpen():默认 false。为 true 意味着"这个Resource包着一个已经打开的流,不能重复读,读完必须关"——InputStreamResource是唯一返回 true 的主流实现。isFile():默认 false。7.0 在 javadoc 中增加了提示:对非默认文件系统,getFilePath()比getFile()更可靠。getURL()/getURI():不能解析为 URL/URI 时抛IOException子类。getFile():只对默认文件系统有效,资源在 jar 里时会抛FileNotFoundException——这是 classpath 资源的著名限制。getFilePath():7.0 新增的 default 方法,默认委托getFile().toPath(),让非默认文件系统(如 NIO FileSystemProvider)也能暴露 NIO Path。这是 7.0 相对旧版的一个小而重要的演进:FileSystemResource和PathResource覆盖了它,直接返回内部持有的Path,不再绕一圈File.toPath()。readableChannel()、getContentAsByteArray()、getContentAsString():都是基于getInputStream()的便捷 default 方法,6.0.5 引入后,业务代码不再需要自己写FileCopyUtils.copyToByteArray(resource.getInputStream())。
11.3.2 AbstractResource:模板基类的"默认行为矩阵"
AbstractResource(spring-core/src/main/java/org/springframework/core/io/AbstractResource.java)实现了上述契约的合理默认,子类只覆盖它真正关心的方法。它的存在价值可以用"默认行为矩阵"概括:
public abstract class AbstractResource implements Resource {
// 1) exists:先试 File 探测,不行再试"能否打开流"
@Override
public boolean exists() {
if (isFile()) {
try {
return getFile().exists();
}
catch (IOException ex) {
debug(() -> "Could not retrieve File for existence check of " + getDescription(), ex);
}
}
try {
getInputStream().close(); // 能打开并立即关掉,即视为存在
return true;
}
catch (Throwable ex) {
debug(() -> "Could not retrieve InputStream for existence check of " + getDescription(), ex);
return false;
}
}
@Override public boolean isReadable() { return exists(); }
@Override public boolean isOpen() { return false; }
@Override public boolean isFile() { return false; }
// 2) getURL/getFile 默认抛异常:我是个"抽象"资源,别指望我能解析成 File
@Override
public URL getURL() throws IOException {
throw new FileNotFoundException(getDescription() + " cannot be resolved to URL");
}
@Override
public URI getURI() throws IOException {
URL url = getURL();
try {
return ResourceUtils.toURI(url);
}
catch (URISyntaxException ex) {
throw new IOException("Invalid URI [" + url + "]", ex);
}
}
@Override
public File getFile() throws IOException {
throw new FileNotFoundException(getDescription() + " cannot be resolved to absolute file path");
}
// ……
}
exists() 的设计值得细看:两段式探测。第一段 isFile() 为 true 就直接 getFile().exists(),零开销;否则兜底策略是"尝试 getInputStream(),能打开就算存在,打开后立刻 close()"。catch 的是 Throwable 而不只是 IOException——因为某些 ClassLoader 实现(比如 jar 包内资源在极端情况下)打开流时可能抛运行时异常,这里一律视为"不存在",只打 debug 日志。这就是为什么 ByteArrayResource 直接覆盖 exists() 恒返回 true,而不必走这套流探测。
另一个容易踩坑的默认实现是 contentLength():
@Override
public long contentLength() throws IOException {
InputStream is = getInputStream();
try {
long size = 0;
byte[] buf = new byte[256];
int read;
while ((read = is.read(buf)) != -1) {
size += read;
}
return size;
}
finally {
try { is.close(); }
catch (IOException ex) { /* debug */ }
}
}
它是把整个流读一遍来数字节数。注释明确警告:对 InputStreamResource 的子类,强烈建议覆盖此方法——因为流读完就没了。这就是"InputStreamResource 不能重复读"陷阱的源码出处之一:调用方只要触发了一次 contentLength(),流就被消费掉了。
其余默认行为:lastModified() 通过 getFileForLastModifiedCheck()(默认即 getFile())拿 File 时间戳;createRelative() 默认抛异常;getFilename() 默认返回 null;equals()/hashCode() 基于 getDescription() 字符串;toString() 直接返回 description。这套默认值的含义是:一个最小实现的 Resource 只需要实现 getInputStream() 和 getDescription(),其余方法按语义需要逐个覆盖。
11.3.3 十种实现速览与对比表
7.0.9 的 org.springframework.core.io 包下共有十种具体资源(不含 VFS 相关与描述辅助类)。先用表格建立全局图景,后面逐类深入:
| 实现类 | 定位方式 | 可重复读 | isOpen | getFile() | 典型用途 |
|---|---|---|---|---|---|
ClassPathResource | ClassLoader/Class 资源查找 | 是(每次新开流) | false | 仅当落在文件系统时成功 | 读 classpath 下的配置 |
FileSystemResource | java.io.File 或 NIO Path | 是 | false | 恒成功 | 读写磁盘任意文件 |
UrlResource | 任意 URL(http/jar/file/…) | 是 | false | 仅 file: 协议成功 | 通用 URL 资源 |
FileUrlResource | file: URL 的 UrlResource 子类 | 是 | false | 成功且缓存 File | file: 位置的可写资源 |
ByteArrayResource | 内存 byte[] | 是(每次 new ByteArrayInputStream) | false | 不支持 | 邮件附件、内存内容 |
InputStreamResource | 已打开的 InputStream/InputStreamSource | 否 | true | 不支持 | 一次性流式输入 |
PathResource | NIO Path(7.0 已 @Deprecated(forRemoval=true)) | 是 | false | path.toFile() | 旧代码迁移至 FileSystemResource |
VfsResource | JBoss VFS 虚拟文件 | 是 | false | 委派 VFS | JBoss 容器内部 |
DescriptiveResource | 仅描述串,无真实内容 | — | false | 抛异常 | 占位、错误描述 |
ModuleResource | 模块化系统的 java.lang.module 资源 | 是 | false | 不支持 | JPMS 模块内资源 |
下文重点剖析前六种生产中最常用的实现。
11.3.4 ClassPathResource:ClassLoader 与 Class 双路径解析
ClassPathResource(spring-core/src/main/java/org/springframework/core/io/ClassPathResource.java)继承自 AbstractFileResolvingResource,是日常开发中最常打交道的资源类型。它持有四个不可变字段:
private final String path; // 用户原始路径(clean 后)
private final String absolutePath; // 解析到 classpath 根的绝对路径
private final @Nullable ClassLoader classLoader;
private final @Nullable Class<?> clazz;
双路径模型:要么用 ClassLoader.getResource(String) 定位,要么用 Class.getResource(String) 定位。两个构造入口(ClassPathResource.java 第 84-125 行):
public ClassPathResource(String path, @Nullable ClassLoader classLoader) {
Assert.notNull(path, "Path must not be null");
String pathToUse = StringUtils.cleanPath(path);
if (pathToUse.startsWith("/")) {
pathToUse = pathToUse.substring(1); // ClassLoader API 不接受前导斜杠
}
this.path = pathToUse;
this.absolutePath = pathToUse;
this.classLoader = (classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader());
this.clazz = null;
}
public ClassPathResource(String path, @Nullable Class<?> clazz) {
Assert.notNull(path, "Path must not be null");
this.path = StringUtils.cleanPath(path);
String absolutePath = this.path;
if (clazz != null && !absolutePath.startsWith("/")) {
// 相对路径:拼上类所在包路径
absolutePath = ClassUtils.classPackageAsResourcePath(clazz) + "/" + absolutePath;
}
else if (absolutePath.startsWith("/")) {
absolutePath = absolutePath.substring(1);
}
this.absolutePath = absolutePath;
this.classLoader = null;
this.clazz = clazz;
}
注意两种入口对"前导斜杠"的处理完全不同,这是 Class.getResource 与 ClassLoader.getResource 语义差异的直接投影:
- ClassLoader 模式:
ClassLoader.getResource("com/foo/Bar.class")永远以 classpath 根为基准,前导斜杠是非法的(JDK 文档明确说 leading slash 不被接受),所以构造时一律substring(1)剥掉。 - Class 模式:
Class.getResource("a.txt")是相对于该 Class 所在包解析的,而Class.getResource("/a.txt")才是相对 classpath 根。所以构造器里:路径不以/开头 → 拼成类包路径/路径;以/开头 → 剥掉斜杠按绝对路径处理。
实际定位逻辑集中在 resolveURL()(第 173-190 行):
protected @Nullable URL resolveURL() {
try {
if (this.clazz != null) {
return this.clazz.getResource(this.path);
}
else if (this.classLoader != null) {
return this.classLoader.getResource(this.absolutePath);
}
else {
return ClassLoader.getSystemResource(this.absolutePath);
}
}
catch (IllegalArgumentException ex) {
// Should not happen according to the JDK's contract:
// see https://github.com/openjdk/jdk/pull/2662
return null;
}
}
逐行:clazz 优先;其次 classLoader;最后兜底 ClassLoader.getSystemResource。catch IllegalArgumentException 是 OpenJDK 一个历史 bug 的防御(注释引用了 openjdk/jdk#2662,某些类加载器在路径含非法字符时本不该抛却抛了),直接降级为"找不到"。
getInputStream()(第 200-215 行)是对称的三段式:
@Override
public InputStream getInputStream() throws IOException {
InputStream is;
if (this.clazz != null) {
is = this.clazz.getResourceAsStream(this.path);
}
else if (this.classLoader != null) {
is = this.classLoader.getResourceAsStream(this.absolutePath);
}
else {
is = ClassLoader.getSystemResourceAsStream(this.absolutePath);
}
if (is == null) {
throw new FileNotFoundException(getDescription() + " cannot be opened because it does not exist");
}
return is;
}
关于"流不关闭"的注意:这个流是 ClassLoader 打开的,ClassPathResource 自己不持有也不关闭它——关闭责任在调用方(try-with-resources)。这不是疏漏,而是 InputStreamSource 契约的必然:每次 getInputStream() 都开新流,资源本身无状态、无句柄可关。
其余覆盖点:exists() 直接复用 resolveURL() != null(一次 ClassLoader.getResource 哈希查找,比 AbstractResource 的"打开流探测"便宜得多);isReadable() 先 resolveURL() 再调父类 checkReadable(url);createRelative() 用 StringUtils.applyRelativePath 拼路径后按原模式(clazz 或 classLoader)重建;equals() 除了 absolutePath 还比较 getClassLoader()——同一个路径、不同 ClassLoader 的两个 ClassPathResource 不相等,因为它们解析到的可能是两个 jar 里的同名文件。
11.3.5 AbstractFileResolvingResource:把 URL 资源"翻译"成文件
ClassPathResource 和 UrlResource 都继承 AbstractFileResolvingResource(spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java)。这个中间类解决一个共同问题:拿到 URL 之后,怎么回答"存在吗""多大""什么时候改的"?它的 exists() 是全框架对外部资源做存在性探测的范本(第 49-121 行):
@Override
public boolean exists() {
try {
URL url = getURL();
if (ResourceUtils.isFileURL(url)) {
// Proceed with file system resolution
return getFile().exists();
}
else {
URLConnection con = url.openConnection();
customizeConnection(con);
HttpURLConnection httpCon = (con instanceof HttpURLConnection huc ? huc : null);
if (httpCon != null) {
httpCon.setRequestMethod("HEAD");
int code = httpCon.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
return true;
}
else if (code == HttpURLConnection.HTTP_NOT_FOUND) {
return false;
}
else if (code == HttpURLConnection.HTTP_BAD_METHOD) {
// 服务器不支持 HEAD,退回 GET 再判一次
con = url.openConnection();
customizeConnection(con);
// ……重新判 OK/404
}
}
if (con instanceof JarURLConnection jarCon) {
JarFile jarFile = jarCon.getJarFile();
try {
return (jarCon.getEntryName() == null || jarCon.getJarEntry() != null);
}
finally {
if (!jarCon.getUseCaches()) {
jarFile.close();
}
}
}
else if (con.getContentLengthLong() > 0) {
return true;
}
if (httpCon != null) {
httpCon.disconnect();
return false;
}
else {
getInputStream().close(); // 终极兜底:能开流就算存在
return true;
}
}
}
catch (IOException ex) {
return false;
}
}
这段代码是 Spring 对"异构协议存在性探测"的完整答案,分四条路径:
- file: 协议:直接转 File 探测,最快。
- http/https: 协议:发 HEAD 请求(
setRequestMethod("HEAD"))。200 → 存在;404 → 不存在;405(BAD_METHOD)→ 服务器不允许 HEAD,退回普通连接再判一次。这段 405 回退逻辑是为了兼容不规范的静态服务器。 - jar: 协议:不看 Content-Length(jar 条目没有可靠长度头),而是
jarCon.getJarEntry() != null——查 jar 里这个 entry 在不在。注意 finally 里只有getUseCaches()==false时才jarFile.close(),否则 JVM 的 jar 缓存自己管理句柄。 - 其他协议 / 兜底:Content-Length > 0 视为存在;HTTP 之外的协议最后试"打开流再关掉"。
contentLength() 与 lastModified() 是同款思路:file: 直接拿 File.length()/时间戳;HTTP 走 HEAD 读 Content-Length/Last-Modified 响应头;lastModified 对 jar: URL 还会先 extractArchiveURL 抽出外层 jar 文件本身的时间戳(jar 内 entry 的 mtime 就是 jar 文件的 mtime,这是 AbstractFileResolvingResource.getFileForLastModifiedCheck 的逻辑,见第 223-235 行)。
customizeConnection(URLConnection) 钩子(第 363-368 行)做两件事:ResourceUtils.useCachesIfNecessary(con)(6.2.10 引入,对 jar 之外的连接默认关闭 JVM 的 URLConnection 缓存),以及向下转型 HttpURLConnection 调可覆盖的 customizeConnection(HttpURLConnection) 钩子——子类可以在这里设超时、加请求头。UrlResource 就覆盖它来塞 Basic 认证头。
VFS(JBoss 虚拟文件系统)的处理用内部类 VfsResourceDelegate 延迟持有,避免编译期对 JBoss API 的硬依赖——url.getProtocol().startsWith("vfs") 时才反射式地建 VfsResource,这是一种经典的可选依赖隔离手法。
11.3.6 FileSystemResource:File 与 NIO Path 的双实现
FileSystemResource(spring-core/src/main/java/org/springframework/core/io/FileSystemResource.java)直接继承 AbstractResource(而不是 AbstractFileResolvingResource),实现 WritableResource 接口——这是它与 ClassPathResource 的本质区别:它知道自己就是磁盘上的一个文件,所以敢于 isFile() 恒 true、敢于提供 getOutputStream()。
三个核心字段(第 65-69 行):
private final String path;
private final @Nullable File file;
private final Path filePath;
7.0.9 提供四个构造器(String 路径、File、Path、FileSystem+String),前两个构造器都会同时算出 file 和 filePath;后两个只保留 filePath,全程走 NIO.2 API。这是"双实现"的含义:持有 File 的走老式 java.io,只持有 Path 的走新式 java.nio.file。
关键方法节选(第 168-198 行):
@Override
public boolean exists() {
return (this.file != null ? this.file.exists() : Files.exists(this.filePath));
}
@Override
public InputStream getInputStream() throws IOException {
try {
return Files.newInputStream(this.filePath);
}
catch (NoSuchFileException ex) {
throw new FileNotFoundException(ex.getMessage());
}
}
注意 getInputStream() 统一走 Files.newInputStream(this.filePath)——即使是 File 构造的实例,内部也先 toPath() 转成 Path 再开流。这是 Spring 5.1 之后全面 NIO.2 化的结果。NoSuchFileException 被转译成 FileNotFoundException,保持与 Resource 接口异常契约一致。
可写能力来自 WritableResource:getOutputStream() 开 Files.newOutputStream,writableChannel() 开 FileChannel.open(..., StandardOpenOption.WRITE)。contentLength() 对 File 是 file.length()、对 Path 是 Files.size(),且都处理了"返回 0 但其实文件不存在"的歧义(FileSystemResource.java 第 330-347 行:length == 0L && !file.exists() 时抛 FileNotFoundException)。
createRelative 的坑点(javadoc 反复强调):相对路径是用 StringUtils.applyRelativePath(this.path, relativePath) 拼的——new FileSystemResource("C:/dir1") 加相对路径 "dir2" 得到的是 C:/dir2(同级目录),而不是 C:/dir1/dir2;想要"嵌套到根目录下",必须构造时传带尾斜杠的路径 "C:/dir1/"。这个语义在三个构造器的 javadoc 里各重复了一遍,说明它历史上坑过很多人。7.0 还提供了 PathResource 作为"createRelative 永远嵌套"的替代品——但下面会看到它已经被废弃了。
getURI() 有一个 7.0 仍在维护的特殊处理(第 259-277 行):Path 构造的实例返回 URI 时,对 file: 协议做 new URI(scheme, uri.getPath(), null) 规范化,注释引用 issue #29275——这是为了规避 Windows 路径中 # 等字符被误当 URI fragment 的问题。
11.3.7 UrlResource:协议委托的通用 URL 资源
UrlResource(spring-core/src/main/java/org/springframework/core/io/UrlResource.java)继承 AbstractFileResolvingResource,是"给我一个 URL,其余不管"的通用实现。三个字段(第 57-72 行):
private final @Nullable URI uri; // 优先用 URI 构造(6.1 起)
private final URL url; // 实际访问用
private volatile @Nullable String cleanedUrl; // 延迟清洗,用于 equals/hashCode
volatile @Nullable Boolean useCaches; // 6.2.10 引入
getInputStream()(第 244-257 行)极其朴素:url.openConnection() → customizeConnection(con) → con.getInputStream();失败时若是 HttpURLConnection 先 disconnect() 再抛——避免连接悬挂。
真正体现设计的是 customizeConnection 的覆盖(第 260-267 行):
@Override
protected void customizeConnection(URLConnection con) throws IOException {
super.customizeConnection(con);
String userInfo = this.url.getUserInfo();
if (userInfo != null) {
String encodedCredentials = Base64.getUrlEncoder().encodeToString(userInfo.getBytes());
con.setRequestProperty(AUTHORIZATION, "Basic " + encodedCredentials);
}
}
URL 里可以直接嵌 user:password@host,Spring 自动转成 HTTP Basic 认证头。这是一个很少有人知道但极其实用的能力——内网 HTTP 配置中心拉配置时,http://admin:secret@config-server/messages.properties 这种写法是生效的。
createRelative(String)(第 333-353 行)委托 ResourceUtils.toRelativeURL(this.url, relativePath),先剥掉相对路径开头的 /,再按 URL 语义拼接。getFilename() 优先用 URI.getPath()(已解码、分隔符统一),否则对 URL.getPath() 做 URLDecoder.decode(filename, UTF_8)——处理 URL 编码过的中文文件名。
构造器有个 6.1 的行为变化值得记录(第 112-120 行):优先 ResourceUtils.toURI(cleanedPath) 再 uri.toURL(),只有 URISyntaxException/IllegalArgumentException 时才退回老式 new URL(path)。为什么?因为老式 URL(String) 对非法字符(如裸空格)更宽容但也更不可预测,URI 构造会做严格编码。
11.3.8 FileUrlResource:file: 协议的可写特化
FileUrlResource(spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java)只有约 117 行,是 UrlResource 的子类,同时实现 WritableResource。它存在的唯一理由(javadoc 明说):DefaultResourceLoader 解析到 file: URL 位置时要返回一个可以向下转型为 WritableResource 的类型。核心代码:
public class FileUrlResource extends UrlResource implements WritableResource {
private volatile @Nullable File file;
@Override
public File getFile() throws IOException {
File file = this.file;
if (file != null) {
return file;
}
file = super.getFile();
this.file = file;
return file;
}
@Override
public OutputStream getOutputStream() throws IOException {
return Files.newOutputStream(getFile().toPath());
}
// isWritable / writableChannel / createRelative ……
}
注意 volatile File file 的缓存设计:getFile() 每次调用都触发一次 URL→File 的解析是有开销的(ResourceUtils.getFile 内部要做协议校验与路径转换),解析一次后缓存到 volatile 字段。用 volatile 而不是 synchronized,因为这个字段是"幂等赋值、读到哪个都对"——多线程并发解析出两个 File 对象也无所谓,File 对象本身不可变。
11.3.9 ByteArrayResource 与 InputStreamResource:内存两端
ByteArrayResource(spring-core/src/main/java/org/springframework/core/io/ByteArrayResource.java)是 AbstractResource 的最简实现之一:
public class ByteArrayResource extends AbstractResource {
private final byte[] byteArray;
private final String description;
@Override public boolean exists() { return true; }
@Override
public long contentLength() {
return this.byteArray.length; // 数组长度,零开销
}
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(this.byteArray); // 每次新流,可重复读
}
@Override
public byte[] getContentAsByteArray() throws IOException {
int length = this.byteArray.length;
byte[] result = new byte[length];
System.arraycopy(this.byteArray, 0, result, 0, length); // 返回拷贝
return result;
}
}
对照 AbstractResource 的"默认矩阵":它覆盖了 exists(恒 true,数组总在内存里)、contentLength(直接取长度,避免读流)、getInputStream。getContentAsByteArray() 特意做一次 arraycopy 拷贝返回——避免调用方拿到内部数组的引用后篡改数据。equals() 用 Arrays.equals 逐字节比较,而不是 ==。
InputStreamResource(spring-core/src/main/java/org/springframework/core/io/InputStreamResource.java)是反面。它包装一个已经打开的流(或一个延迟提供流的 InputStreamSource lambda),核心就一个布尔标记:
public class InputStreamResource extends AbstractResource {
private final InputStreamSource inputStreamSource;
private final String description;
private final Object equality;
private boolean read = false;
@Override public boolean exists() { return true; }
@Override public boolean isOpen() { return true; } // 全体系主流实现中唯一
@Override
public InputStream getInputStream() throws IOException, IllegalStateException {
if (this.read) {
throw new IllegalStateException("InputStream has already been read (possibly for early content length " +
"determination) - do not use InputStreamResource if a stream needs to be read multiple times");
}
this.read = true;
return this.inputStreamSource.getInputStream();
}
}
三个设计决策点:
isOpen()恒 true:这是它对InputStreamSource契约的正式宣告——我持有的是已打开的资源,调用方读完必须关。read标记 +IllegalStateException:第二次取流直接报错,错误信息还贴心地提示"可能是被 contentLength 提前消费了"。- 6.1.7 起支持
InputStreamSourcelambda 构造:new InputStreamResource(() -> openMyStream())——把"取流动作"延迟到第一次getInputStream(),比直接传已构造好的流生命周期更短、更安全。
javadoc 的 NOTE 段落是本类最重要的文档:它不提供独立的 contentLength()——继承的 AbstractResource.contentLength() 会把流读完来数字节,读完流就废了。Spring Web 的资源渲染器甚至专门对这个类做特判,跳过 contentLength 探测。
11.3.10 PathResource:7.0 被标记废弃的 NIO 替代品
PathResource(spring-core/src/main/java/org/springframework/core/io/PathResource.java)在 7.0 的类声明上赫然写着:
@Deprecated(since = "7.0", forRemoval = true)
public class PathResource extends AbstractResource implements WritableResource {
它是 4.0 引入的纯 NIO Path 资源,行为与 FileSystemResource(Path) 的差别仅在 createRelative:PathResource 用 path.resolve(relativePath),相对路径永远嵌套在根下(Paths.get("C:/dir1/") + "dir2" → C:/dir1/dir2);而 FileSystemResource 按字符串拼接规则处理。7.0 把二者合并:废弃 PathResource,统一用 FileSystemResource。读者在新代码里不应再使用 PathResource,旧代码迁移目标就是 new FileSystemResource(path)。这是"版本事实以仓库为准"的典型例子——网上大量资料还在把 PathResource 作为推荐写法。
11.3 源码剖析(中):资源加载器与字符串→Resource 的转换
11.3.11 ResourceLoader:位置字符串的分派协议
ResourceLoader 接口(spring-core/src/main/java/org/springframework/core/io/ResourceLoader.java)只有两个方法:
public interface ResourceLoader {
String CLASSPATH_URL_PREFIX = ResourceUtils.CLASSPATH_URL_PREFIX; // "classpath:"
Resource getResource(String location);
@Nullable ClassLoader getClassLoader();
}
getResource 的 javadoc 规定了分派协议:必须支持全限定 URL(file:C:/test.dat)、必须支持 classpath: 伪 URL(classpath:test.dat)、应当支持相对文件路径(WEB-INF/test.dat,这一条留给 ApplicationContext 实现去扩展)。同时再次强调:返回的 handle 不代表资源存在。
DefaultResourceLoader(spring-core/src/main/java/org/springframework/core/io/DefaultResourceLoader.java)是这套协议的标准实现,也是 AbstractApplicationContext 的父类之一——所以 ApplicationContext 天然就是一个 ResourceLoader。它的 getResource(第 145-172 行)是本章最该背下来的一段代码:
@Override
public Resource getResource(String location) {
Assert.notNull(location, "Location must not be null");
// 1) 自定义协议解析器优先(可覆盖一切默认规则)
for (ProtocolResolver protocolResolver : getProtocolResolvers()) {
Resource resource = protocolResolver.resolve(location, this);
if (resource != null) {
return resource;
}
}
// 2) 以 "/" 开头:走子类覆盖的 getResourceByPath(Web 容器的 ServletContext 资源)
if (location.startsWith("/")) {
return getResourceByPath(location);
}
// 3) classpath: 前缀:ClassPathResource
else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader());
}
// 4) 否则尝试当 URL 解析
else {
try {
// Try to parse the location as a URL...
URL url = ResourceUtils.toURL(location);
return (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url));
}
catch (MalformedURLException ex) {
// No URL -> resolve as resource path.
return getResourceByPath(location);
}
}
}
分派顺序逐段解读:
- ProtocolResolver 钩子最先跑。
protocolResolvers是一个LinkedHashSet,用户可通过addProtocolResolver注册自定义协议处理器。注释明说"Any such resolver will be invoked ahead of this loader's standard resolution rules. It may therefore also override any default rules"——这是框架留给"ftp:"、"s3:" 这类自定义协议的扩展点。 /开头:交给protected Resource getResourceByPath(String path),默认实现是new ClassPathContextResource(path, getClassLoader())(第 185-187 行)。注意这个设计:ClassPathXmlApplicationContext等子类可以覆盖它,让/WEB-INF/foo.xml解析成ServletContextResource。Web 环境的XmlWebApplicationContext就是这么做的。classpath:前缀:剥掉前缀,包上ClassPathResource。getClassLoader()的兜底逻辑(第 96-98 行):字段非 null 用字段,否则ClassUtils.getDefaultClassLoader()——即线程上下文类加载器优先。- URL 尝试:
ResourceUtils.toURL(location)尝试把字符串当 URL 解析。解析成功后还有第二次分派:isFileURL(url)(协议是file)→ 返回FileUrlResource(可写、可向下转型 WritableResource);否则UrlResource。解析失败(MalformedURLException)不等于错误——裸路径如config.properties构不成 URL,这时候落回getResourceByPath,按 classpath 相对路径处理。
这个"try URL,失败就按路径"的兜底,是 new ClassPathXmlApplicationContext("app-context.xml") 能直接用裸文件名启动的原理。
另外两个字段值得一提:resourceCaches(第 56 行,Map<Class<?>, Map<Resource, ?>>)是给框架内部 ASM 元数据读取用的按类型缓存(getResourceCache(Class)),5.0 引入;clearResourceCaches() 用于容器刷新时清空。
11.3.12 ResourceEditor:配置字符串到 Resource 的 PropertyEditor
Spring 的 XML/注解配置里写 <property name="template" value="classpath:mail/notify.vm"/>,这个 value 字符串是怎么变成 Resource 类型属性的?答案是 ResourceEditor(spring-core/src/main/java/org/springframework/core/io/ResourceEditor.java)——一个 java.beans.PropertyEditor:
public class ResourceEditor extends PropertyEditorSupport {
private final ResourceLoader resourceLoader;
private @Nullable PropertyResolver propertyResolver;
private final boolean ignoreUnresolvablePlaceholders;
@Override
public void setAsText(String text) {
if (StringUtils.hasText(text)) {
String locationToUse = resolvePath(text).trim();
setValue(this.resourceLoader.getResource(locationToUse));
}
else {
setValue(null);
}
}
protected String resolvePath(String path) {
if (this.propertyResolver == null) {
this.propertyResolver = new StandardEnvironment();
}
return (this.ignoreUnresolvablePlaceholders ? this.propertyResolver.resolvePlaceholders(path) :
this.propertyResolver.resolveRequiredPlaceholders(path));
}
}
流程:配置字符串进来 → resolvePath 先做 ${...} 占位符替换(从 Environment 取属性,替换不了默认忽略)→ 交给 resourceLoader.getResource() 按上一节的分派协议产出 Resource。默认构造器用 new DefaultResourceLoader() 兜底;但在 ApplicationContext 里,ApplicationContextAware 体系会把当前容器自己注入进来——所以 classpath: 前缀解析时用的 ClassLoader 就是容器的 ClassLoader,而不是线程上下文 ClassLoader。
反向 getAsText():把 Resource 转回 URL 外部形式;转不出 URL 就返回 null,表示"这个资源没有合适的文本表示"。
11.3.13 ResourceArrayPropertyEditor:Resource[] 的批量转换
对应 Resource[] 类型属性的编辑器是 ResourceArrayPropertyEditor(spring-core/src/main/java/org/springframework/core/io/support/ResourceArrayPropertyEditor.java)。它和 ResourceEditor 的关键差别:委托的是 ResourcePatternResolver 而不是 ResourceLoader,所以配置里可以写通配符:
@Override
public void setAsText(String text) {
String pattern = resolvePath(text).trim();
String[] locationPatterns = StringUtils.commaDelimitedListToStringArray(pattern);
if (locationPatterns.length == 1) {
setValue(getResources(locationPatterns[0]));
}
else {
Resource[] resources = Arrays.stream(locationPatterns).map(String::trim)
.map(this::getResources).flatMap(Arrays::stream).toArray(Resource[]::new);
setValue(resources);
}
}
两个能力:逗号分隔多模式("classpath:a.xml,file:b.xml");每个模式走 getResources 展开成 Resource[] 后拍平合并。setValue(Object) 覆盖还能处理传入 Collection/数组的情况——遍历元素,String 当模式解析、Resource 原样保留,用 LinkedHashSet 去重。这就是 Spring Boot 中 @Value("classpath:db/migration/*.sql") Resource[] 这类注入的底层转换器。
11.4 源码剖析(下):通配符解析 PathMatchingResourcePatternResolver
11.4.1 ResourcePatternResolver 接口与 classpath*: 前缀
ResourcePatternResolver(spring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java)继承 ResourceLoader,只加一个方法和一个常量:
public interface ResourcePatternResolver extends ResourceLoader {
/** "classpath*:" —— 检索类路径/模块路径下所有同名资源 */
String CLASSPATH_ALL_URL_PREFIX = "classpath*:";
Resource[] getResources(String locationPattern) throws IOException;
}
classpath*: 与 classpath: 的差别是面试经典题,接口 javadoc 写得很明白:classpath: 只返回第一个匹配(ClassLoader.getResource 语义),而 classpath*: 返回所有 jar / 目录下同名资源(ClassLoader.getResources 语义)。6.0 起语义再扩展:模块路径(JPMS module path)也纳入搜索范围。
11.4.2 PathMatchingResourcePatternResolver:骨架与缓存字段
PathMatchingResourcePatternResolver(spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java)是 ResourcePatternResolver 的唯一生产实现,1300+ 行,是本章最复杂的类。先看它的字段区(第 257-268 行):
private final ResourceLoader resourceLoader;
private PathMatcher pathMatcher = new AntPathMatcher(); // 默认 Ant 匹配器
@Nullable
private Boolean useCaches;
private final Map<String, Resource[]> rootDirCache = new ConcurrentHashMap<>();
private final Map<String, NavigableSet<String>> jarEntriesCache = new ConcurrentHashMap<>();
private volatile @Nullable Set<ClassPathManifestEntry> manifestEntriesCache;
版本事实先说清:默认 PathMatcher 就是 AntPathMatcher(第 259 行),不是 PathPatternParser。可以通过 setPathMatcher 换掉。三个缓存各有分工:
rootDirCache:把"某个根目录模式串"解析出的Resource[]缓存下来(同一个 jar/目录被多个子模式复用时,不必反复getResources(rootDirPath))。jarEntriesCache:jar URL → 该 jar 全部 entry 名的排序集(TreeSet)。一个 jar 的 entry 列表是全量遍历后排序存的,后续同 jar 的其他模式匹配只需tailSet区间查找——这是 jar 内通配能快起来的关键。manifestEntriesCache:解析java.class.path系统属性与 jar 的Class-Pathmanifest 头得到的 jar 根列表,volatile 延迟初始化。
静态块(第 241-254 行)还有一个彩蛋:探测 Equinox OSGi(WebSphere 6.1 时代的遗留),反射找 org.eclipse.core.runtime.FileLocator.resolve,找到就存为 equinoxResolveMethod。这是为了在老式 OSGi 容器里把 bundle: URL 解析成真实 jar URL。
11.4.3 getResources:总入口分派
getResources(String locationPattern)(第 365-397 行)是总入口:
@Override
public Resource[] getResources(String locationPattern) throws IOException {
Assert.notNull(locationPattern, "Location pattern must not be null");
if (locationPattern.startsWith(CLASSPATH_ALL_URL_PREFIX)) {
// a class path resource (multiple resources for same name possible)
String locationPatternWithoutPrefix = locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length());
// Search the module path first.
Set<Resource> resources = findAllModulePathResources(locationPatternWithoutPrefix);
// Search the class path next.
if (getPathMatcher().isPattern(locationPatternWithoutPrefix)) {
// a class path resource pattern
Collections.addAll(resources, findPathMatchingResources(locationPattern));
}
else {
// all class path resources with the given name
Collections.addAll(resources, findAllClassPathResources(locationPatternWithoutPrefix));
}
return resources.toArray(EMPTY_RESOURCE_ARRAY);
}
else {
// war: 协议特殊处理:只在 "*/" 分隔符之后才算模式
int prefixEnd = (locationPattern.startsWith("war:") ? locationPattern.indexOf("*/") + 1 :
locationPattern.indexOf(':') + 1);
if (getPathMatcher().isPattern(locationPattern.substring(prefixEnd))) {
// a file pattern
return findPathMatchingResources(locationPattern);
}
else {
// a single resource with the given name
return new Resource[] {getResource(locationPattern)};
}
}
}
分派逻辑:
classpath*:开头:先剥前缀;先查模块路径(findAllModulePathResources,6.0 新增),再查类路径。类路径这边再分两路:剥掉前缀后的串若含通配符(isPattern)→findPathMatchingResources(展开根目录后逐个匹配);否则 →findAllClassPathResources(直接ClassLoader.getResources拿所有同名资源)。- 非
classpath*::先定位前缀结束位置。普通 URL 取第一个:之后;war:协议特殊——要找到*/分隔符才算模式起点(Tomcat 的 war: URL 结构是war:file:/path/to/app.war*/WEB-INF/...)。前缀之后的部分若是模式 →findPathMatchingResources;否则就是单资源,直接new Resource[]{getResource(locationPattern)}走DefaultResourceLoader.getResource老分派。
11.4.4 classpath*: 的展开:doFindAllClassPathResources 与 jar 根补全
不带通配符的 classpath*:com/example/X.class 走 findAllClassPathResources → doFindAllClassPathResources(第 436-450 行):
protected Set<Resource> doFindAllClassPathResources(String path) throws IOException {
Set<Resource> result = new LinkedHashSet<>(16);
ClassLoader cl = getClassLoader();
Enumeration<URL> resourceUrls = (cl != null ? cl.getResources(path) : ClassLoader.getSystemResources(path));
while (resourceUrls.hasMoreElements()) {
URL url = resourceUrls.nextElement();
result.add(convertClassLoaderURL(url));
}
if (!StringUtils.hasLength(path)) {
// The above result is likely to be incomplete, i.e. only containing file system references.
// We need to have pointers to each of the jar files on the class path as well...
addAllClassLoaderJarRoots(cl, result);
}
return result;
}
核心就是 ClassLoader.getResources(path)——JDK 原生 API 返回类加载器层里所有能加载到该路径的 URL。每个 URL 经 convertClassLoaderURL 转成 Resource:file: 协议 → FileSystemResource(6.0.5 起,与模式扫描的产物类型保持一致);其他协议 → UrlResource。
为什么空路径要额外 addAllClassLoaderJarRoots?这是 JDK 的一个坑:ClassLoader.getResources("") 只返回类路径上目录形式的根,不返回 jar 包根。所以 classpath*:(裸前缀,想拿所有 jar 根)必须自己补:addAllClassLoaderJarRoots(第 510-558 行)遍历 URLClassLoader.getURLs() 拿到每个 jar,包成 jar:url!/ 形式的 UrlResource,还递归遍历父类加载器;如果当前是系统类加载器,再调 addClassPathManifestEntries 去解析 java.class.path 系统属性和每个 jar 的 Class-Path manifest 头——后者把 jar 之间通过 manifest 声明的相对依赖 jar 也补进结果。这段"补全 jar 根"的逻辑就是 classpath*:*.xml 这类根目录通配"在某些环境下不灵"的根源(javadoc 的 WARNING 段落明说它不保证可移植)。
11.4.5 模块路径搜索:findAllModulePathResources
6.0 起 classpath*: 先查模块路径(第 1123-1165 行):
protected Set<Resource> findAllModulePathResources(String locationPattern) throws IOException {
Set<Resource> result = new LinkedHashSet<>(64);
// Native image 下直接跳过
if (NativeDetector.inNativeImage()) {
return result;
}
String resourcePattern = stripLeadingSlash(locationPattern);
Predicate<String> resourcePatternMatches = (getPathMatcher().isPattern(resourcePattern) ?
path -> getPathMatcher().match(resourcePattern, path) :
resourcePattern::equals);
try {
ModuleLayer.boot().configuration().modules().stream()
.filter(isNotSystemModule) // 排除系统模块(java.base 等)
.forEach(resolvedModule -> {
try (ModuleReader moduleReader = resolvedModule.reference().open();
Stream<String> names = moduleReader.list()) {
names.filter(resourcePatternMatches)
.map(name -> findResource(moduleReader, name))
.filter(Objects::nonNull)
.forEach(result::add);
}
// ……IOException 包装为 UncheckedIOException 再拆出
});
}
// ……
return result;
}
逻辑:遍历 boot 层里非系统模块(systemModuleNames 在类加载时通过 ModuleFinder.ofSystem() 一次性收集,静态字段,见第 227-230 行),对每个模块开 ModuleReader、list() 出全部条目名,用 Ant 匹配器过滤后 find(name) 拿 URI,再 convertModuleSystemURI 转 Resource(file: URI → FileSystemResource,避免与类路径扫描结果重复)。try-with-resources 管理 ModuleReader 与 list() 流——注释明确说这两者必须关。
11.4.6 findPathMatchingResources:根目录切分与 jar/目录双路遍历
带通配符的模式(无论前缀是不是 classpath*:)最终都汇入 findPathMatchingResources(String locationPattern)(第 685-789 行)。它是整个类的引擎,分三幕:
第一幕:切根目录。
String rootDirPath = determineRootDir(locationPattern);
String subPattern = locationPattern.substring(rootDirPath.length());
determineRootDir(第 801-811 行)从后往前截:不断把"最后一个含通配符的斜杠段"截掉,直到剩余前缀不含通配符:
protected String determineRootDir(String location) {
int prefixEnd = location.indexOf(':') + 1;
int rootDirEnd = location.length();
while (rootDirEnd > prefixEnd && getPathMatcher().isPattern(location.substring(prefixEnd, rootDirEnd))) {
rootDirEnd = location.lastIndexOf('/', rootDirEnd - 2) + 1;
}
if (rootDirEnd == 0) {
rootDirEnd = prefixEnd;
}
return location.substring(0, rootDirEnd);
}
例:classpath*:com/example/**/applicationContext.xml → rootDir = classpath*:com/example/,subPattern = **/applicationContext.xml。为什么要切?因为只有"不含通配符的根"才能被 getResource(s) 物化成具体的目录/jar URL;通配符部分只能遍历后匹配。
第二幕:拿根目录 Resource[](带缓存与父目录复用优化)。
先查 rootDirCache 直接命中;没命中时,代码会在已有缓存 key 里找"最长公共父目录"(第 697-746 行那段看似复杂的循环),意图是:同一 jar 下先后扫描多个子包时,第一次 getResources(rootDirPath) 拿到的根 Resource[] 可以被后面所有兄弟模式复用——因为它们物理上指向同一批 jar/目录。复用时用 createRelative(子路径) 从缓存的根 Resource 派生子 Resource。这段缓存逻辑是 5.x 后期加的性能优化,避免每来一个模式就重新 ClassLoader.getResources 一次。
最终兜底(第 747-753 行):rootDirResources = getResources(rootDirPath),并写入 rootDirCache(仅当 useCaches != false)。
第三幕:逐个根 Resource 分派遍历策略。
for (Resource rootDirResource : rootDirResources) {
if (actualRootPath != null && actualRootPath.length() < rootDirPath.length()) {
rootDirResource = rootDirResource.createRelative(rootDirPath.substring(actualRootPath.length()));
}
rootDirResource = resolveRootDirResource(rootDirResource);
URL rootDirUrl = rootDirResource.getURL();
if (equinoxResolveMethod != null && rootDirUrl.getProtocol().startsWith("bundle")) {
// OSGi bundle: URL 反射解析
}
if (rootDirUrl.getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) {
result.addAll(VfsResourceMatchingDelegate.findMatchingResources(rootDirUrl, subPattern, getPathMatcher()));
}
else if (ResourceUtils.isJarURL(rootDirUrl) || isJarResource(rootDirResource)) {
result.addAll(doFindPathMatchingJarResources(rootDirResource, rootDirUrl, subPattern));
}
else {
result.addAll(doFindPathMatchingFileResources(rootDirResource, subPattern));
}
}
分派四选一:VFS(JBoss)、jar: 包(含 zip:/wsjar: 等容器变体)、普通文件系统目录。isJarURL 的判定在 ResourceUtils 里:协议是 jar/war/zip/vfszip 之类。isJarResource 是个可覆盖的模板方法(默认 false),留给子类识别特殊 jar 类资源。
11.4.7 jar 内遍历:doFindPathMatchingJarResources
jar 分支(第 865-982 行)先尝试缓存命中(第 879-900 行):从 URL 里切出 jarFileUrl(jar:file:/x/y/app.jar!/com/example/ 按 !/ 切),查 jarEntriesCache——命中就用 entriesCache.tailSet(rootEntryPath, false) 从排序集里二分定位,只遍历前缀匹配的条目段,逐个 getPathMatcher().match(subPattern, relativePath),命中则 rootDirResource.createRelative(relativePath) 包装成 Resource 加进结果。
缓存未命中的首次扫描(第 903-981 行):
URLConnection con = rootDirUrl.openConnection();
JarFile jarFile;
boolean closeJarFile;
if (con instanceof JarURLConnection jarCon) {
if (this.useCaches != null) {
jarCon.setUseCaches(this.useCaches);
}
jarFile = jarCon.getJarFile();
jarFileUrl = jarCon.getJarFileURL().toExternalForm();
JarEntry jarEntry = jarCon.getJarEntry();
rootEntryPath = (jarEntry != null ? jarEntry.getName() : "");
closeJarFile = !jarCon.getUseCaches();
}
else {
// 非 JarURLConnection(老式容器):手动解析 URL 串
if (jarFileUrl != null) {
jarFile = getJarFile(jarFileUrl);
}
else {
jarFile = new JarFile(urlFile);
// ……
}
closeJarFile = true;
}
try {
if (StringUtils.hasLength(rootEntryPath) && !rootEntryPath.endsWith("/")) {
rootEntryPath = rootEntryPath + "/"; // 补斜杠:Sun JRE 不补,BEA JRockit 补
}
Set<Resource> result = new LinkedHashSet<>(64);
NavigableSet<String> entriesCache = new TreeSet<>();
for (Enumeration<JarEntry> entries = jarFile.entries(); entries.hasMoreElements();) {
entriesCache.add(entries.nextElement().getName());
}
for (String entryPath : entriesCache) {
if (entryPath.startsWith(rootEntryPath)) {
String relativePath = entryPath.substring(rootEntryPath.length());
if (getPathMatcher().match(subPattern, relativePath)) {
result.add(rootDirResource.createRelative(relativePath));
}
}
}
if (this.useCaches == null || this.useCaches) {
this.jarEntriesCache.put(jarFileUrl, entriesCache); // 全量条目缓存,供下次 tailSet
}
return result;
}
finally {
if (closeJarFile) {
jarFile.close();
}
}
要点:① JarURLConnection 是主流路径,closeJarFile 只在 useCaches==false 时才关——交给 JVM 缓存 jar 文件句柄;② 兼容老式容器的手动 new JarFile(urlFile),这种情况我们自己持句柄、finally 必关;③ 首扫把整个 jar 的条目名表装进 TreeSet 缓存——内存换时间,一个 jar 无论被多少个模式扫描,条目列表只读一次磁盘。
11.4.8 文件系统遍历:doFindPathMatchingFileResources
文件系统分支(第 1012-1102 行)走的是 NIO.2 Files.walk:
rootDirUri = rootDirResource.getURI();
// 优先 Path.of(uri);FileSystemNotFoundException 时尝试 FileSystems.newFileSystem 安装自定义文件系统
Path rootPath = Path.of(rootDirUri);
// 兜底:Path.of 不行就 rootDirResource.getFile().getAbsolutePath()
if (!Files.exists(rootPath)) { return result; }
String rootDir = StringUtils.cleanPath(rootPath.toString());
if (!rootDir.endsWith("/")) { rootDir += "/"; }
Path rootPathForPattern = rootPath;
String resourcePattern = rootDir + StringUtils.cleanPath(subPattern);
Predicate<Path> isMatchingFile = path -> (!path.equals(rootPathForPattern) &&
getPathMatcher().match(resourcePattern, StringUtils.cleanPath(path.toString())));
try (Stream<Path> files = Files.walk(rootPath, FileVisitOption.FOLLOW_LINKS)) {
files.filter(isMatchingFile).sorted().map(FileSystemResource::new).forEach(result::add);
}
要点:① 统一把 rootDir + subPattern 拼成完整绝对模式串再交给 AntPathMatcher,匹配的是每个文件的全路径;② Files.walk(FOLLOW_LINKS) 递归遍历,sorted() 保证结果顺序稳定;③ 每个命中包成 FileSystemResource——所以文件系统侧扫描出来的 Resource 是 FileSystemResource 类型,与 jar 侧的 UrlResource 不同,但对使用者透明。
至此 classpath*:com/example/**/*.xml 的完整展开链已经清楚:切根目录 → 模块路径全扫 → getResources(rootDir) 拿到一批根 URL(可能是目录也可能是 jar)→ 逐个根按协议分派文件树遍历或 jar 条目遍历 → Ant 匹配过滤 → LinkedHashSet 去重。
11.4.9 AntPathMatcher:doMatch 的四段式贪心算法
通配匹配的语义最终落在 AntPathMatcher(spring-core/src/main/java/org/springframework/util/AntPathMatcher.java)。先说词法:isPattern 扫描字符串里有没有 *、? 或 {...}(第 171-190 行),有任何一个就视为模式。
真正的算法在 doMatch(pattern, path, fullMatch, uriTemplateVariables)(第 210-341 行)。它把 pattern 和 path 都按 / 切成数组,然后用四个指针 pattIdxStart/pattIdxEnd/pathIdxStart/pathIdxEnd 做四段式匹配:
String[] pattDirs = tokenizePattern(pattern);
String[] pathDirs = tokenizePath(path);
int pattIdxStart = 0, pattIdxEnd = pattDirs.length - 1;
int pathIdxStart = 0, pathIdxEnd = pathDirs.length - 1;
// 第一段:从头部同向匹配,直到 pattern 遇到 "**"
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
String pattDir = pattDirs[pattIdxStart];
if ("**".equals(pattDir)) {
break;
}
if (!matchStrings(pattDir, pathDirs[pathIdxStart], uriTemplateVariables)) {
return false;
}
pattIdxStart++;
pathIdxStart++;
}
第一段:pattern 与 path 从头部逐段对齐,段内用 matchStrings(*/?/{var} 的正则匹配);一旦 pattern 段是 ** 就停——因为 ** 可以吞掉任意层目录,头部对齐到此为止。
// 第二段:从尾部反向匹配,直到 pattern 尾部遇到 "**"
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
String pattDir = pattDirs[pattIdxEnd];
if (pattDir.equals("**")) {
break;
}
if (!matchStrings(pattDir, pathDirs[pathIdxEnd], uriTemplateVariables)) {
return false;
}
// ……尾斜杠一致性检查
pattIdxEnd--;
pathIdxEnd--;
}
第二段:从尾部反向对齐。比如 pattern /a/**/b/c、path /a/x/y/b/c,头部吃掉 /a、尾部吃掉 b 和 c,中间剩下 x/y 由 ** 吞掉。
// 第三段:两个 ** 之间,贪心找中间固定段的出现位置
while (pattIdxStart != pattIdxEnd && pathIdxStart <= pathIdxEnd) {
int patIdxTmp = -1;
for (int i = pattIdxStart + 1; i <= pattIdxEnd; i++) {
if (pattDirs[i].equals("**")) {
patIdxTmp = i;
break;
}
}
if (patIdxTmp == pattIdxStart + 1) {
pattIdxStart++; // "**/**" 相邻情况,跳过一个
continue;
}
// 在 path 剩余区间里找 pattDirs[pattIdxStart+1 .. patIdxTmp-1] 这段固定子串
strLoop:
for (int i = 0; i <= strLength - patLength; i++) {
for (int j = 0; j < patLength; j++) {
String subPat = pattDirs[pattIdxStart + j + 1];
String subStr = pathDirs[pathIdxStart + i + j];
if (!matchStrings(subPat, subStr, uriTemplateVariables)) {
continue strLoop;
}
}
foundIdx = pathIdxStart + i;
break;
}
// ……找不到 → false
pattIdxStart = patIdxTmp;
pathIdxStart = foundIdx + patLength;
}
// 第四段:pattern 剩余段必须全是 **
for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
if (!pattDirs[i].equals("**")) {
return false;
}
}
return true;
第三段是核心:多个 ** 把 pattern 切成若干固定片段,在 path 的剩余区间里贪心首次出现这些片段。复杂度是 O(片段数 × path 段数 × 段长),对路径匹配完全够用。
段内匹配 matchStrings 委托内部类 AntPathStringMatcher(第 660-703 行)把 glob 编译成正则:? → .、* → .*、{name} → ((?s).*)、{name:regex} → (regex)。编译结果缓存(stringMatcherCache),且有自适应降级:缓存条目数超过 65536(CACHE_TURNOFF_THRESHOLD)就 deactivatePatternCache() 直接关闭缓存——模式无限增长说明它们不会重复,继续缓存只会浪费内存。tokenizePattern 有同款机制。
三种通配符语义最终一句话总结:? 匹配单字符、* 匹配段内零到多字符(不跨 /)、** 跨任意层目录。这也是为什么 classpath*:*.xml 扫不到 jar 根里的 xml——* 不跨斜杠而 jar 条目名里目录分隔符就是 /,根级匹配只能靠那个"补全 jar 根"的 hack。
11.5 源码剖析(续):MessageSource 抽象与装配
11.5.1 MessageSource 接口:三态 getMessage
国际化部分从 MessageSource 接口(spring-context/src/main/java/org/springframework/context/MessageSource.java)开始。三个重载构成"三态":
// 态一:带默认消息——找不到就返回 defaultMessage(可以是 null)
@Nullable String getMessage(String code, Object @Nullable [] args, @Nullable String defaultMessage, @Nullable Locale locale);
// 态二:不带默认消息——找不到直接抛 NoSuchMessageException
String getMessage(String code, Object @Nullable [] args, @Nullable Locale locale) throws NoSuchMessageException;
// 态三:MessageSourceResolvable 封装——多候选 code + args + 默认消息
String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException;
参数语义:code 是消息键(javadoc 建议用全限定名如 calculator.noRateSet 避免冲突);args 填进消息文本里的 {0}、{1,date}、{2,time} 占位符;locale 决定查哪套语言文件。
MessageSourceResolvable 是值对象,封装 codes[](候选键数组,按优先级排列)、arguments[]、defaultMessage。Spring 校验框架的 FieldError 就是它的子类——一个字段错误会同时带 ["required.user.name", "typeMismatch.user.age", ...] 一串候选 code,按顺序命中即返回。
HierarchicalMessageSource(spring-context/src/main/java/org/springframework/context/HierarchicalMessageSource.java)只加两个方法:setParentMessageSource/getParentMessageSource。语义:自己解析不了的 code,委派给父 MessageSource。
11.5.2 ApplicationContext 的装配:initMessageSource
ApplicationContext 本身实现了 MessageSource 接口,但它自己不存消息——它是个委派者。装配逻辑在 AbstractApplicationContext.initMessageSource()(spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java 第 820-845 行),在 refresh() 流程中于 registerBeanPostProcessors 之后被调用(第 609 行):
protected void initMessageSource() {
ConfigurableListableBeanFactory beanFactory = getBeanFactory();
if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)) {
this.messageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class);
// Make MessageSource aware of parent MessageSource.
if (this.parent != null && this.messageSource instanceof HierarchicalMessageSource hms &&
hms.getParentMessageSource() == null) {
// Only set parent context as parent MessageSource if no parent MessageSource
// registered already.
hms.setParentMessageSource(getInternalParentMessageSource());
}
// ……
}
else {
// Use empty MessageSource to be able to accept getMessage calls.
DelegatingMessageSource dms = new DelegatingMessageSource();
dms.setParentMessageSource(getInternalParentMessageSource());
this.messageSource = dms;
beanFactory.registerSingleton(MESSAGE_SOURCE_BEAN_NAME, this.messageSource);
// ……
}
}
MESSAGE_SOURCE_BEAN_NAME = "messageSource"(第 155 行)。逻辑分两支:
- 容器里有名为
messageSource的 bean:直接取出来作为本上下文的消息源。如果它是HierarchicalMessageSource且还没设父级,就把父上下文的 messageSource 设为它的 parent——父子容器消息委派链就此接通。getInternalParentMessageSource()(第 1562-1565 行)有个细节:父上下文也是AbstractApplicationContext时,取它内部的messageSource字段,而不是把父上下文自己当 MessageSource 传进去。 - 没有自定义 messageSource bean:new 一个
DelegatingMessageSource(空实现),父级指向父上下文的消息源,并registerSingleton("messageSource", dms)注册进容器——保证beanFactory.getBean("messageSource")永远拿得到东西,业务代码注入 MessageSource 不会 NPE。
AbstractApplicationContext 自己的 getMessage* 方法全部委托给这个内部字段(getMessageSource(),第 1550-1556 行,未初始化时抛 IllegalStateException("MessageSource not initialized - call 'refresh' before..."))。
11.5.3 DelegatingMessageSource:默认空实现
DelegatingMessageSource(spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java)约 110 行,逻辑极简。以态一为例(第 56-66 行):
@Override
public @Nullable String getMessage(String code, Object @Nullable [] args, @Nullable String defaultMessage, @Nullable Locale locale) {
if (this.parentMessageSource != null) {
return this.parentMessageSource.getMessage(code, args, defaultMessage, locale);
}
else if (defaultMessage != null) {
return renderDefaultMessage(defaultMessage, args, locale);
}
else {
return null;
}
}
有 parent 就转发;没 parent 就退化为"返回默认消息"。这就是为什么一个没配 messageSource 的容器里 getMessage("x", null, "fallback", locale) 能正常返回 "fallback",而 getMessage("x", null, locale) 会抛 NoSuchMessageException。
11.5.4 AbstractMessageSource:三态 getMessage 的模板骨架
AbstractMessageSource(spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java)继承 MessageSourceSupport、实现 HierarchicalMessageSource,把三个 getMessage 重载的公共流程固化成 final 方法。字段(第 68-72 行):
private @Nullable MessageSource parentMessageSource;
private @Nullable Properties commonMessages;
private boolean useCodeAsDefaultMessage = false;
以最常用的态二为例(第 149-164 行):
@Override
public final String getMessage(String code, Object @Nullable [] args, @Nullable Locale locale) throws NoSuchMessageException {
String msg = getMessageInternal(code, args, locale);
if (msg != null) {
return msg;
}
String fallback = getDefaultMessage(code);
if (fallback != null) {
return fallback;
}
if (locale == null ) {
throw new NoSuchMessageException(code);
}
else {
throw new NoSuchMessageException(code, locale);
}
}
注意 final——子类不许改这个流程,只能扩展 getMessageInternal 里调到的模板方法。流程:内部解析 → 拿到就返回;拿不到看 getDefaultMessage(code)(仅当 useCodeAsDefaultMessage=true 时返回 code 本身,否则 null);还没有就抛 NoSuchMessageException。
态三 getMessage(MessageSourceResolvable, Locale)(第 167-188 行)多一步多 code 轮询:
String[] codes = resolvable.getCodes();
if (codes != null) {
for (String code : codes) {
String message = getMessageInternal(code, resolvable.getArguments(), locale);
if (message != null) {
return message;
}
}
}
String defaultMessage = getDefaultMessage(resolvable, locale);
// ……
候选 code 数组逐个试,第一个命中就返回——这就是字段错误消息"主类型错误 → 字段名错误 → 对象级错误"逐层降级的机制。
11.5.5 getMessageInternal:解析主流程
真正的解析主流程在 getMessageInternal(第 205-250 行),这是本章国际化部分最值得逐行读的方法:
protected @Nullable String getMessageInternal(@Nullable String code, Object @Nullable [] args, @Nullable Locale locale) {
if (code == null) {
return null;
}
if (locale == null) {
locale = Locale.getDefault();
}
Object[] argsToUse = args;
if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
// Optimized resolution: no arguments to apply,
// therefore no MessageFormat needs to be involved.
String message = resolveCodeWithoutArguments(code, locale);
if (message != null) {
return message;
}
}
else {
// Resolve arguments eagerly, for the case where the message
// is defined in a parent MessageSource but resolvable arguments
// are defined in the child MessageSource.
argsToUse = resolveArguments(args, locale);
MessageFormat messageFormat = resolveCode(code, locale);
if (messageFormat != null) {
synchronized (messageFormat) {
return messageFormat.format(argsToUse);
}
}
}
// Check locale-independent common messages for the given message code.
Properties commonMessages = getCommonMessages();
if (commonMessages != null) {
String commonMessage = commonMessages.getProperty(code);
if (commonMessage != null) {
return formatMessage(commonMessage, args, locale);
}
}
// Not found -> check parent, if any.
return getMessageFromParent(code, argsToUse, locale);
}
逐段:
- 空 code / 空 locale 归一:
locale == null时用Locale.getDefault()——这是"忘了传 locale"时的系统语言兜底。 - 无参数快路径(默认行为):只要
alwaysUseMessageFormat==false且args为空,直接走resolveCodeWithoutArguments——不构造 MessageFormat。为什么?java.text.MessageFormat构造很慢,而且会把单引号当转义符解析;绝大多数消息(无占位符)根本不需要它。两个具体实现都覆盖了这个方法走字符串直查。 - 有参数路径:先
resolveArguments(args, locale)把 args 里可能存在的MessageSourceResolvable递归解析成字符串(第 337-351 行:args 元素如果是 resolvable,就调一次getMessage(resolvable, locale))。注释解释了为什么要"提前"解析参数:消息模板定义在 parent 里、参数的多语言表达定义在 child 里,必须先用 child 的解析能力把参数转好。然后resolveCode(code, locale)拿MessageFormat,synchronized (messageFormat)包裹format——MessageFormat不是线程安全的,而实例被缓存复用,这把锁是并发安全的关键。 - commonMessages 兜底:
setCommonMessages配置的 locale 无关公共消息表,查到就formatMessage。 - parent 委托:
getMessageFromParent(第 261-277 行)——父级也是AbstractMessageSource时调getMessageInternal(刻意避开 getMessage 的抛异常/useCodeAsDefaultMessage 行为,注释明说为了不被 parent 的 code-as-default 污染);其他类型的 parent 则调getMessage(code, args, null, locale)态一。
11.5.6 MessageSourceSupport:MessageFormat 缓存与非法格式容错
AbstractMessageSource 的父类 MessageSourceSupport(spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java)只管一件事:如何安全地把消息文本格式化成带参数的最终串。字段(第 48-63 行):
static final Set<Locale> JVM_LOCALES = Arrays.stream(Locale.getAvailableLocales()).
filter(l -> StringUtils.hasLength(l.getLanguage())).collect(Collectors.toSet());
private static final MessageFormat INVALID_MESSAGE_FORMAT = new MessageFormat("");
private boolean alwaysUseMessageFormat = false;
/** Cache to hold already generated MessageFormats per message. */
private final Map<String, Map<Locale, MessageFormat>> messageFormatsPerMessage = new ConcurrentHashMap<>();
JVM_LOCALES 是 JDK 当前可用 Locale 的静态集合——7.0 里凡是"非 JVM 标准 Locale"(比如应用自定义的 Locale("xx", "YY"))都走不缓存的快速路径,避免无限缓存撑爆内存。INVALID_MESSAGE_FORMAT 是个哨兵:消息文本本身格式非法时(比如裸写了没转义的单引号),用它表示"别格式化了,原文返回"。
formatMessage(第 122-143 行):
protected String formatMessage(String msg, Object @Nullable [] args, @Nullable Locale locale) {
if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
return msg;
}
MessageFormat messageFormat;
if (locale != null && JVM_LOCALES.contains(locale)) {
Map<Locale, MessageFormat> messageFormatsPerLocale = this.messageFormatsPerMessage
.computeIfAbsent(msg, key -> new ConcurrentHashMap<>());
messageFormat = messageFormatsPerLocale.computeIfAbsent(locale, key -> resolveMessageFormat(msg, key));
}
else {
messageFormat = resolveMessageFormat(msg, locale);
}
if (messageFormat == INVALID_MESSAGE_FORMAT) {
return msg;
}
synchronized (messageFormat) {
return messageFormat.format(resolveArguments(args, locale));
}
}
要点:① 无 args 快路径原样返回;② JVM 标准 Locale → 双层 ConcurrentHashMap 缓存(消息文本 → Locale → MessageFormat);③ 非标准 Locale → 每次新建不缓存;④ resolveMessageFormat 捕获 IllegalArgumentException(MessageFormat 语法错误)返回哨兵,静默原文返回——除非 alwaysUseMessageFormat=true,那种情况才把错误抛出去。
这解释了一个经典疑问:消息文件里写了个裸单引号 It's,默认行为下会怎样?答:args 为空时根本不走 MessageFormat,原文输出;args 非空时 MessageFormat 解析 It's 恰好合法(单引号不在占位符旁通常被容忍或按哨兵降级原文),不会炸。只有 alwaysUseMessageFormat=true 才要求所有消息严格转义。
11.5.7 AbstractResourceBasedMessageSource:basename 与缓存配置
AbstractResourceBasedMessageSource(spring-context/src/main/java/org/springframework/context/support/AbstractResourceBasedMessageSource.java)是两个 properties 文件实现的公共配置基类。字段(第 43-51 行):
private final Set<String> basenameSet = new LinkedHashSet<>(4);
private @Nullable Charset defaultCharset;
private boolean fallbackToSystemLocale = true;
private @Nullable Locale defaultLocale;
private long cacheMillis = -1;
要点:
- basenameSet 是 LinkedHashSet:有序。javadoc 强调"previous bundle 覆盖 later bundle"——按注册顺序查找,先查到的胜出。
setBasenames先 clear 再 add,addBasenames追加,位置即优先级。 cacheMillis = -1永久缓存(Spring 约定,不是 JDK 的约定);正数 = 缓存毫秒数;0 = 每次访问都查文件时间戳(javadoc 红字警告生产环境别用 0)。setCacheSeconds是秒转毫秒的便捷方法。- Locale 回退链:
getDefaultLocale()(第 217-225 行)——显式defaultLocale优先;否则fallbackToSystemLocale=true(默认)时退回Locale.getDefault();都没有则返回 null(只查无语言后缀的基础文件)。fallbackToSystemLocale的 javadoc 特别提示:应用服务器环境里系统 Locale 与业务无关,建议关掉。 - defaultCharset 类型在 7.0.6 从 String 升级为 Charset:
setDefaultEncoding(String)仍保留,新增setDefaultCharset(Charset)。
11.5.8 ResourceBundleMessageSource:两级缓存与 MessageSourceControl
ResourceBundleMessageSource(spring-context/src/main/java/org/springframework/context/support/ResourceBundleMessageSource.java)是 JDK ResourceBundle 的封装。它的两个缓存字段(第 94-106 行)是性能核心:
/** Cache to hold loaded ResourceBundles. basename -> Locale -> ResourceBundle */
private final Map<String, Map<Locale, ResourceBundle>> cachedResourceBundles = new ConcurrentHashMap<>();
/** Cache to hold already generated MessageFormats. bundle -> code -> Locale -> MessageFormat */
private final Map<ResourceBundle, Map<String, Map<Locale, MessageFormat>>> cachedBundleMessageFormats =
new ConcurrentHashMap<>();
三级 Map 结构:bundle → code → locale → MessageFormat。javadoc 说这比 JDK ResourceBundle 自带缓存快得多——JDK 的缓存是按 (baseName, locale, classLoader) 三元组存 bundle,取消息时每次还要走 bundle.getString + 现场构造 MessageFormat;Spring 直接把 MessageFormat 也缓存了。
默认编码之谜就在构造器(第 111-113 行):
public ResourceBundleMessageSource() {
setDefaultCharset(StandardCharsets.ISO_8859_1);
}
默认 ISO-8859-1——这是 Java .properties 文件的传统编码(JDK 9 之前 PropertyResourceBundle 的硬编码默认)。你的 messages_zh_CN.properties 是 UTF-8 写的,不配 defaultEncoding=UTF-8,读出来就是乱码。本章常见问题节会展开。
bundle 获取的双策略(getResourceBundle,第 190-221 行):
protected @Nullable ResourceBundle getResourceBundle(String basename, Locale locale) {
if (getCacheMillis() >= 0 || !JVM_LOCALES.contains(locale)) {
// 交给 JDK ResourceBundle 自己的原生缓存(带 TTL 的那种)
return doGetBundle(basename, locale);
}
// 永久缓存:用自己的本地缓存
Map<Locale, ResourceBundle> localeMap = this.cachedResourceBundles.get(basename);
if (localeMap != null) {
ResourceBundle bundle = localeMap.get(locale);
if (bundle != null) {
return bundle;
}
}
try {
ResourceBundle bundle = doGetBundle(basename, locale);
if (localeMap == null) {
localeMap = this.cachedResourceBundles.computeIfAbsent(basename, bn -> new ConcurrentHashMap<>());
}
localeMap.put(locale, bundle);
return bundle;
}
catch (MissingResourceException ex) {
logger.warn("ResourceBundle [" + basename + "] not found for MessageSource: " + ex.getMessage());
return null; // 不抛异常,留给 parent 链继续找
}
}
策略选择:只要 cacheMillis >= 0(开了定时刷新)或 Locale 不在 JVM 标准集合里,就放弃自己的缓存、每次走 doGetBundle——让 JDK ResourceBundle 的原生 TTL 缓存接管(MessageSourceControl.getTimeToLive 返回 cacheMillis,见后)。只有永久缓存(cacheMillis=-1,默认)时才用 Spring 自己的 cachedResourceBundles 双层 Map。
doGetBundle(第 232-257 行)处理了模块系统兼容:
protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
ClassLoader classLoader = getBundleClassLoader();
Assert.state(classLoader != null, "No bundle ClassLoader set");
MessageSourceControl control = this.control;
if (control != null) {
try {
return ResourceBundle.getBundle(basename, locale, classLoader, control);
}
catch (UnsupportedOperationException ex) {
// JDK 9+ 模块路径上不支持 ResourceBundle.Control
this.control = null;
// ……打 info 日志提示 defaultCharset 会失效
}
}
return ResourceBundle.getBundle(basename, locale, classLoader);
}
JDK 9+ 的模块路径下 ResourceBundle.Control 不被支持,getBundle 会抛 UnsupportedOperationException——此时 Spring 把 control 置 null,永久退回裸 getBundle,用平台默认编码(JDK 9+ 是 UTF-8 + ISO-8859-1 回退)。这就是 javadoc 里"模块路径上 defaultCharset 配置会被忽略"的真相。
11.5.9 MessageSourceControl:编码注入与 TTL 回退链
ResourceBundleMessageSource 用一个内部类 MessageSourceControl(第 387-460 行)定制 JDK ResourceBundle 的加载行为,三处覆盖:
private class MessageSourceControl extends ResourceBundle.Control {
@Override
public @Nullable ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload)
throws IllegalAccessException, InstantiationException, IOException {
if (format.equals("java.properties")) {
String bundleName = toBundleName(baseName, locale);
final String resourceName = toResourceName(bundleName, "properties");
InputStream inputStream = null;
if (reload) {
URL url = loader.getResource(resourceName);
if (url != null) {
URLConnection connection = url.openConnection();
if (connection != null) {
connection.setUseCaches(false); // 刷新时务必关掉 jar 缓存
inputStream = connection.getInputStream();
}
}
}
else {
inputStream = loader.getResourceAsStream(resourceName);
}
if (inputStream != null) {
Charset charset = getDefaultCharset();
if (charset != null) {
try (InputStreamReader bundleReader = new InputStreamReader(inputStream, charset)) {
return loadBundle(bundleReader);
}
}
else {
try (InputStream bundleStream = inputStream) {
return loadBundle(bundleStream);
}
}
}
else {
return null;
}
}
else {
// "java.class" 格式(ListResourceBundle 类)交还给标准 Control
return super.newBundle(baseName, locale, format, loader, reload);
}
}
@Override
public @Nullable Locale getFallbackLocale(String baseName, Locale locale) {
Locale defaultLocale = getDefaultLocale();
return (defaultLocale != null && !defaultLocale.equals(locale) ? defaultLocale : null);
}
@Override
public long getTimeToLive(String baseName, Locale locale) {
long cacheMillis = getCacheMillis();
return (cacheMillis >= 0 ? cacheMillis : super.getTimeToLive(baseName, locale));
}
@Override
public boolean needsReload(String baseName, Locale locale, String format, ClassLoader loader, ResourceBundle bundle, long loadTime) {
if (super.needsReload(baseName, locale, format, loader, bundle, loadTime)) {
cachedBundleMessageFormats.remove(bundle); // bundle 刷新时清掉它派生的 MessageFormat 缓存
return true;
}
else {
return false;
}
}
}
逐段理解:
newBundle是编码注入点:JDKPropertyResourceBundle默认按 ISO-8859-1(JDK9 前)或平台默认读流,Spring 在这里用new InputStreamReader(inputStream, charset)包一层,把defaultCharset施加到 properties 文件。reload=true(bundle 过期重载)时额外setUseCaches(false)——否则 JVM 的 jar 缓存会返回旧内容。loadBundle(reader)是个 protected 钩子,子类可以换成自定义 ResourceBundle 实现。getFallbackLocale:返回defaultLocale(若配了且不同于当前 locale)。注意它返回的是 Spring 自己的 defaultLocale 而不是 JDK 默认的Locale.getDefault()——配合AbstractResourceBasedMessageSource.getDefaultLocale()的优先级逻辑(显式 defaultLocale > 系统 locale > null)。getTimeToLive:把 Spring 的cacheMillis透传给 JDK 的 TTL 机制。cacheMillis=-1时退回 super 默认(JDK 默认是 -1 即永久)。needsReload:JDK 判定需要重载时,顺手把cachedBundleMessageFormats里这个 bundle 对应的 MessageFormat 缓存整个删掉——bundle 内容换了,旧 MessageFormat 全部作废。这是缓存一致性的关键一行。
回到消息解析:resolveCode(第 167-179 行)按 basenameSet 顺序逐个 getResourceBundle(basename, locale),查到 MessageFormat 即返回;resolveCodeWithoutArguments 走 getStringOrNull 字符串直查。getMessageFormat(第 310-344 行)实现三级缓存查找:cachedBundleMessageFormats.get(bundle) → codeMap.get(code) → localeMap.get(locale),未命中则 getStringOrNull 取消息原文、createMessageFormat(msg, locale) 新建并回填。getStringOrNull(第 360-371 行)先 containsKey 再 getString——避免用抛 MissingResourceException 做控制流(4.2 起的性能优化)。
11.5.10 ReloadableResourceBundleMessageSource:自建缓存体系
ReloadableResourceBundleMessageSource(spring-context/src/main/java/org/springframework/context/support/ReloadableResourceBundleMessageSource.java)不碰 JDK ResourceBundle,而是用 Spring Resource 自己定位 properties 文件、自己维护缓存。它支持 classpath 之外的位置(如 file:./config/messages)、按文件编码、按时间戳热刷新。四个缓存字段(第 115-125 行):
// basename -> Locale -> 候选物理文件名列表
private final ConcurrentMap<String, Map<Locale, List<String>>> cachedFilenames = new ConcurrentHashMap<>();
// 物理文件名 -> PropertiesHolder(已加载的 properties + 时间戳 + 该文件的 MessageFormat 缓存)
private final ConcurrentMap<String, PropertiesHolder> cachedProperties = new ConcurrentHashMap<>();
// Locale -> 合并后的 PropertiesHolder(cacheMillis < 0 永久缓存路径用)
private final ConcurrentMap<Locale, PropertiesHolder> cachedMergedProperties = new ConcurrentHashMap<>();
// 非 JVM 标准 Locale 的 LRU 缓存(容量 64)
private final ConcurrentLruCache<Locale, PropertiesHolder> customLocaleProperties =
new ConcurrentLruCache<>(64, locale -> mergeProperties(collectPropertiesToMerge(locale)));
文件名回退链由 calculateFilenamesForLocale(第 383-408 行)手工实现,因为它不走 JDK ResourceBundle,必须自己复现回退约定:
protected List<String> calculateFilenamesForLocale(String basename, Locale locale) {
List<String> result = new ArrayList<>(3);
String language = locale.getLanguage();
String country = locale.getCountry();
String variant = locale.getVariant();
StringBuilder temp = new StringBuilder(basename);
temp.append('_');
if (!language.isEmpty()) {
temp.append(language);
result.add(0, temp.toString());
}
temp.append('_');
if (!country.isEmpty()) {
temp.append(country);
result.add(0, temp.toString());
}
if (!variant.isEmpty() && (!language.isEmpty() || !country.isEmpty())) {
temp.append('_').append(variant);
result.add(0, temp.toString());
}
return result;
}
Locale de_AT_OO 产出顺序:messages_de_AT_OO → messages_de_AT → messages_de(result.add(0, ...) 保持最细粒度在前)。calculateAllFilenames(第 331-371 行)在此基础上再追加:defaultLocale 的候选文件列表(去重)、以及无语言后缀的基础文件 basename 本身。所以 messages_zh_CN.properties 缺失时,查找顺序是 messages_zh_CN.properties → messages_zh.properties → messages.properties → messages_defaultlocale.properties(若配了 defaultLocale)。
加载与过期的核心在 getProperties(String filename)(第 433-474 行):
protected PropertiesHolder getProperties(String filename) {
PropertiesHolder propHolder = this.cachedProperties.get(filename);
long originalTimestamp = -2;
if (propHolder != null) {
originalTimestamp = propHolder.getRefreshTimestamp();
if (originalTimestamp == -1 || originalTimestamp > System.currentTimeMillis() - getCacheMillis()) {
// Up to date
return propHolder;
}
}
else {
propHolder = new PropertiesHolder();
PropertiesHolder existingHolder = this.cachedProperties.putIfAbsent(filename, propHolder);
if (existingHolder != null) {
propHolder = existingHolder;
}
}
// At this point, we need to refresh...
if (this.concurrentRefresh && propHolder.getRefreshTimestamp() >= 0) {
// A populated but stale holder -> could keep using it.
if (!propHolder.refreshLock.tryLock()) {
// Getting refreshed by another thread already ->
// let's return the existing properties for the time being.
return propHolder;
}
}
else {
propHolder.refreshLock.lock();
}
try {
PropertiesHolder existingHolder = this.cachedProperties.get(filename);
if (existingHolder != null && existingHolder.getRefreshTimestamp() > originalTimestamp) {
return existingHolder;
}
return refreshProperties(filename, propHolder);
}
finally {
propHolder.refreshLock.unlock();
}
}
并发设计的精妙处:
refreshTimestamp == -1表示永久有效(cacheMillis < 0路径);否则判断"距上次刷新是否超过 cacheMillis"。- 并发刷新策略(
concurrentRefresh=true默认):holder 已有内容且已过期时,tryLock()——拿不到锁说明别的线程正在刷新,直接返回旧内容,不阻塞。这是 4.1 引入的"读旧不读新阻塞"策略。lock()阻塞式刷新只发生在 holder 从未加载过时。 - 锁内二次检查
refreshTimestamp > originalTimestamp:拿锁等待期间可能已被别的线程刷新完,直接用新 holder。
refreshProperties(第 484-535 行)做时间戳比对:
long refreshTimestamp = (getCacheMillis() < 0 ? -1 : System.currentTimeMillis());
Resource resource = resolveResource(filename);
if (resource != null) {
long fileTimestamp = -1;
if (getCacheMillis() >= 0) {
try {
fileTimestamp = resource.lastModified();
if (propHolder != null && propHolder.getFileTimestamp() == fileTimestamp) {
// 文件没变:只更新刷新时间戳,不重新读文件
propHolder.setRefreshTimestamp(refreshTimestamp);
return propHolder;
}
}
catch (IOException ex) {
// classpath 资源拿不到时间戳:按"永不变化"处理
fileTimestamp = -1;
}
}
Properties props = loadProperties(resource, filename);
propHolder = new PropertiesHolder(props, fileTimestamp);
}
// ……
propHolder.setRefreshTimestamp(refreshTimestamp);
this.cachedProperties.put(filename, propHolder);
轮询不等于重读:cacheSeconds=10 意味着每 10 秒检查一次 lastModified(),文件 mtime 没变就不重新解析 properties——磁盘 I/O 极小。这也是 javadoc 说"这个间隔可以设得很低"的原因。resource.lastModified() 抛 IOException(classpath jar 内资源拿不到 mtime)时 fileTimestamp=-1,后续永远走"重新加载"分支——所以 classpath 位置的定时刷新不可靠,官方建议热刷新用 file: 位置。
resolveResource(第 568-576 行)按扩展名列表探测:默认依次试 filename + ".properties" 和 filename + ".xml",第一个 exists() 的胜出。loadProperties(第 585-621 行)按文件名后缀分派:.xml 走 propertiesPersister.loadFromXml;普通文件则依次取 fileEncodings(按文件名配的编码,如 messages_zh_CN=UTF-8)→ defaultCharset → 平台默认,用 InputStreamReader 包流后 persister.load。
PropertiesHolder 内部类(第 671-744 行)持有:properties、fileTimestamp、refreshTimestamp(volatile)、refreshLock(ReentrantLock)、以及该文件自己的 cachedMessageFormats(code → locale → MessageFormat)。getMergedProperties(cacheMillis<0 路径)把一个 Locale 下所有 basename×所有回退文件的 Properties 倒序合并(collectPropertiesToMerge 从最后一个 basename 往前收,mergeProperties putAll——先放的被后放的覆盖,最终靠前的 basename 优先级最高,与 resolveCode 的查找顺序语义一致)。
两个清理方法:clearCache() 清 cachedProperties + cachedMergedProperties;clearCacheIncludingAncestors() 沿 parent 链递归清理——这是配合 actuator /refresh 热更新消息文件的入口。
11.5.11 ProtocolResolver:自定义协议的官方扩展点
回看 DefaultResourceLoader.getResource 的第一段分派:
for (ProtocolResolver protocolResolver : getProtocolResolvers()) {
Resource resource = protocolResolver.resolve(location, this);
if (resource != null) {
return resource;
}
}
ProtocolResolver(spring-core/src/main/java/org/springframework/core/io/ProtocolResolver.java)是个函数式接口,单方法 Resource resolve(String location, ResourceLoader resourceLoader)。它的语义定位是"抢在框架默认规则之前"——返回非 null 即终止分派。典型用法:业务里需要从对象存储(S3/OSS)读配置,就写一个 S3ProtocolResolver 识别 s3:// 前缀,返回自定义 Resource 实现(getInputStream() 走 SDK 下载流)。DefaultResourceLoader 同时持有 protocolResolvers(LinkedHashSet,顺序敏感)与 resourceCaches(按 Class 分桶的框架内部缓存),clearResourceCaches() 在容器 refresh 时调用。
ResourceEditor 的构造器有个三参版本(spring-core/src/main/java/org/springframework/core/io/ResourceEditor.java 第 73-85 行)接收 ResourceLoader、PropertyResolver、boolean ignoreUnresolvablePlaceholders:占位符解析失败时,ignoreUnresolvablePlaceholders=true 走 resolvePlaceholders(解析不了的 ${...} 原样保留),否则走 resolveRequiredPlaceholders(解析不了直接抛 IllegalArgumentException)。裸配置类用的 ResourceEditor() 默认构造器里 propertyResolver 是一个临时 StandardEnvironment——不是容器的 Environment,所以脱离容器使用时 ${...} 解析不到业务属性,这是个容易误解的点。
11.5.12 展开链的三个边角:convertClassLoaderURL、Equinox、VFS 委托
doFindAllClassPathResources 拿到 Enumeration<URL> 后每个 URL 走 convertClassLoaderURL(第 456-464 行):file: 协议包成 FileSystemResource(6.0.5 起——此前一律 UrlResource,那次改动让文件系统侧资源统一为可写类型),其余协议一律 UrlResource。
addClassPathManifestEntries(第 566-645 行)是"jar 根补全"里最绕的一段:遍历 java.class.path 系统属性拿到的 jar 文件列表,再读每个 jar 的 manifest Class-Path 头(如 lib/commons-lang.jar lib/logging.jar),把相对引用的 jar 也解析成 jar:file:...!/ URL 加进结果。它靠 ClassPathManifestEntry(inner class,带 URL 与原始路径)做等价判断,避免重复添加。注释明说这段只覆盖"文件系统上的 jar",不可移植——这是官方 WARNING 的由来。
equinoxResolveMethod(第 510-519 行静态块反射拿到 FileLocator.resolve)在 findPathMatchingResources 的根 URL 处理里被调用一次:bundle: 协议的 URL 经反射解析成真实 jar/目录 URL 后才继续走 jar/文件分派。JBoss VFS 则是 VfsResourceMatchingDelegate 静态内部类(第 1369-1420 行):findMatchingResources 用 VFS API 递归遍历 rootUrl 对应的虚拟目录,对每个 children 条目判断是文件还是目录、路径做 Ant 匹配,命中则包成 VfsResource。这两条路径在普通 Tomcat/Java SE 环境都走不到,但代码里留着说明框架对异构容器的兼容纵深。
11.5.13 NoSuchMessageException 与 MessageSourceResolvable 的完整语义
NoSuchMessageException(spring-context/src/main/java/org/springframework/context/NoSuchMessageException.java)是 RuntimeException 子类,持有 code 与 locale 两个字段——异常信息直接拼 code + "' for locale '" + locale。业务里它不应该被 catch,出现即说明消息文件漏配:正确做法是补消息文件或调用时传默认消息。
DefaultMessageSourceResolvable(spring-context/src/main/java/org/springframework/context/support/DefaultMessageSourceResolvable.java)有个容易被忽略的方法 shouldRenderDefaultMessage()(第 137 行附近):当 codes 为空但 defaultMessage 非空时返回 true——含义是"这个 resolvable 没有候选 code,直接渲染默认消息"。AbstractMessageSource.getMessage(MessageSourceResolvable, Locale) 的流程因此分三步:先按 codes 数组逐个 getMessageInternal 试;全 miss 后 getDefaultMessage(resolvable, locale)——这里对 resolvable.shouldRenderDefaultMessage() 为 true 的对象直接渲染其自带默认消息,而不是走 useCodeAsDefaultMessage 逻辑。理解这个分支,才能解释"为什么 FieldError 带了一串 code 和一个默认消息,code 全 miss 时返回的是默认消息原文而不是某个 code 串"。
useCodeAsDefaultMessage(AbstractMessageSource 第 311-320 行)的设计意图是开发期兜底:上线前消息文件没写全时,页面至少显示 user.welcome 这个 key 串而不是报错页。但 javadoc 警告它的副作用:当 resolvable 带多个候选 code 且本级开了这个开关、parent 是另一套配置时,解析会在第一个 code 处短路返回 code 字符串,后续候选 code 永远没机会试。生产环境应关闭它(默认就是关的)。
11.5.14 equals 按 getDescription() 的坑与 createRelative 的传播性
AbstractResource.equals/hashCode 基于 getDescription() 字符串。这意味着:两个内容完全相同、来自同一位置的 Resource,如果 description 串不同就被视为不同资源——这是有意为之:资源相等性比较的是"指向"而不是"内容"。但反过来看,LinkedHashSet<Resource> 去重(PathMatchingResourcePatternResolver 合并多根目录结果时就靠它)去不掉 description 写法不同但物理指向同一文件的两个 Resource。
createRelative 的传播性值得强调:PathMatchingResourcePatternResolver 在 jar/文件树遍历时命中条目后,都是 rootDirResource.createRelative(relativePath) 派生子 Resource——所以子资源的类型与根资源一致:根是 UrlResource(jar 场景)子项也是 UrlResource,根是 FileSystemResource(文件树场景)子项也是 FileSystemResource。UrlResource.createRelative 走 ResourceUtils.toRelativeURL(URL 语义拼路径),FileSystemResource.createRelative 走 StringUtils.applyRelativePath(路径字符串拼合)——两种拼接语义不同,前者按 URL 相对引用规则(.. 可跨段),后者按路径字符串规则。同一段相对路径在两种根资源下可能解出不同结果,这是跨部署形态(jar 包 vs exploded 目录)扫描结果出现差异的隐藏原因之一。
Resource 接口里 getContentAsByteArray()/getContentAsString() 这两个 6.0.5 引入的 default 方法,内部是 FileCopyUtils.copyToByteArray(getInputStream())——每次调用都重新开流读全量。对 ByteArrayResource,AbstractMessageSource 之外的使用场景里它们很顺手;但对 InputStreamResource,调一次就废。选择 Resource 实现时的决策树可以收束成一句话:流的来源在哪里,就选谁——来源是文件选 FileSystemResource,是 URL 选 UrlResource,是内存 byte[] 选 ByteArrayResource,是一次性管线选 InputStreamResource,是 classpath 选 ClassPathResource。
11.5.15 消息文件组织与回退顺序的工作实例
用一个完整目录把前面散落的回退规则串起来。假设 basename 为 i18n/messages,部署为 ReloadableResourceBundleMessageSource:
classpath:/i18n/messages.properties # 基础兜底(无语言后缀)
classpath:/i18n/messages_zh.properties # 中文(不限地区)
classpath:/i18n/messages_zh_CN.properties # 简体中文
classpath:/i18n/messages_en.properties # 英文
查询 getMessage("button.submit", null, Locale("zh", "CN")) 的物理文件命中顺序(calculateAllFilenames 产出的候选表):
i18n/messages_zh_CN.propertiesi18n/messages_zh.propertiesi18n/messages.properties(basename 本身)- 若配了
defaultLocale=en:再追加i18n/messages_en.properties、i18n/messages.properties(已命中去重)
键级合并规则在 getProperties 层:每个文件独立 PropertiesHolder,collectPropertiesToMerge 按"basename 逆序 × 文件回退顺序"倒序收集,mergeProperties 逐个 putAll——先 put 的被后 put 的覆盖,所以最终高优先级(靠前 basename 的最细粒度文件)的键胜出。注意与 ResourceBundle 的差别:JDK ResourceBundle 是"父 bundle 链"(找到最细的那个 bundle 后,未命中的 key 沿 parent chain 向上找,且基础 bundle 一定是链尾),Spring 的 Reloadable 实现是"全量 Properties 合并成一张表"——语义等价但实现路径不同,因此 Spring 能做到"jar A 里的 messages.properties 与 jar B 里的 messages_zh_CN.properties 同时生效",JDK ResourceBundle 做不到(不同 jar 的同名 bundle 各自成链)。
setFileEncodings(Properties) 是 ReloadableResourceBundleMessageSource 特有的逐文件编码能力:key 为 basename(如 messages_zh_CN),value 为编码名(如 UTF-8)。loadProperties 里 this.fileEncodings.getProperty(filename) 优先于 defaultCharset——适合"老文件 GBK、新文件 UTF-8 混存"的迁移期场景。
manifestEntriesCache 的用途再补一句:它服务于 classpath*: 裸前缀(想拿所有 jar 根)的补全逻辑——addClassPathManifestEntries 解析 java.class.path 与各 jar 的 Class-Path manifest 头得到的 ClassPathManifestEntry 集合缓存为 volatile,首次访问时构建一次,后续复用。这就是"应用启动后新增 jar 不被 classpath*: 看到"的原因——缓存只在 clearCache() 时重建。
ResourceBundleMessageSource 的 cacheLimit:6.2 起字段 cacheLimit 与 setCacheLimit(ResourceBundleMessageSource.java 第 125-135 行附近)限制 cachedResourceBundles 里每个 basename 下缓存的 Locale 条目数——Locale 维度无限增长(用户请求任意自定义 Locale)时防止内存膨胀。超过上限后新 Locale 走 doGetBundle 直接查 JDK 缓存,不进 Spring 自己的本地缓存。配合 JVM_LOCALES 判断,这是该类两层防内存失控设计。
11.6 运行机制:把源码串成调用链
11.6.1 getMessage 一次调用的完整旅程
把前面零散的源码拼成一次真实调用。假设 Web 控制器里写:
context.getMessage("user.welcome", new Object[]{"张三"}, Locale.SIMPLIFIED_CHINESE);
时序如下:
控制器
└─ AbstractApplicationContext.getMessage(code, args, locale)
└─ getMessageSource() // 返回内部 this.messageSource 字段
└─ AbstractMessageSource.getMessage(code, args, locale) [final 态二]
└─ getMessageInternal(code, args, locale)
├─ args 非空 → resolveArguments(args, locale) // args 内可能藏 MessageSourceResolvable
├─ resolveCode(code, locale)
│ └─ (ResourceBundleMessageSource) 遍历 basenameSet
│ └─ getResourceBundle(basename, locale)
│ ├─ cacheMillis==-1 且 JVM locale → cachedResourceBundles 双层缓存
│ └─ doGetBundle → ResourceBundle.getBundle(..., control)
│ └─ MessageSourceControl.newBundle:
│ loader.getResourceAsStream → InputStreamReader(charset)
│ → new PropertyResourceBundle(reader)
│ └─ getMessageFormat(bundle, code, locale)
│ └─ 三级缓存 cachedBundleMessageFormats
│ 未命中 → bundle.getString(code) → createMessageFormat(msg, locale)
├─ synchronized(messageFormat) { format.format(argsToUse) } // MessageFormat 非线程安全
├─ 未命中 → commonMessages 表
└─ 未命中 → getMessageFromParent
└─ parent 是 AbstractMessageSource → parent.getMessageInternal(code, args, locale)
(沿父子容器链继续)
几个设计决策在这条链上交汇:① args 非空才走 MessageFormat(性能);② bundle 与 MessageFormat 两级缓存(避免 JDK ResourceBundle 的重复查找);③ synchronized(messageFormat) 这把锁粒度极小——不同 code、不同 locale 的 MessageFormat 是不同对象,锁互不竞争;④ parent 链调用的是 getMessageInternal 而非 getMessage,避免 useCodeAsDefaultMessage 在父级提前把 code 当消息返回。
11.6.2 classpath*: 展开的状态机
getResources("classpath*:com/example/**/*.xml") 的状态流转:
getResources
├─ 剥 "classpath*:" → "com/example/**/*.xml"
├─ findAllModulePathResources:
│ ModuleLayer.boot() 非系统模块逐个 ModuleReader.list() 过滤匹配 → Resource
├─ isPattern("com/example/**/*.xml") == true → findPathMatchingResources(整串)
│ ├─ determineRootDir → rootDir="classpath*:com/example/", sub="**/*.xml"
│ ├─ rootDirCache 查/建 → rootDirResources[]
│ │ = getResources("classpath*:com/example/")
│ │ = findAllModulePathResources("com/example/")
│ │ + findAllClassPathResources("com/example/")
│ │ = ClassLoader.getResources("com/example/") 的全部 URL
│ │ (目录 URL + jar:file:...!/com/example/ URL)
│ └─ 逐个 rootDirResource:
│ ├─ vfszip: → VfsResourceMatchingDelegate
│ ├─ jar:/zip:/war:! → doFindPathMatchingJarResources
│ │ ├─ jarEntriesCache 命中 → tailSet 区间扫描
│ │ └─ 未命中 → JarFile.entries() 全量装入 TreeSet 缓存 → 遍历匹配
│ └─ file: 目录 → doFindPathMatchingFileResources
│ └─ Files.walk(root, FOLLOW_LINKS) 全路径 Ant match
└─ LinkedHashSet 合并去重 → Resource[]
资源定位分派(DefaultResourceLoader.getResource)则是另一条决策树:
location 串
├─ 自定义 ProtocolResolver 命中? → 返回(可覆盖一切)
├─ "/" 开头? → getResourceByPath(子类钩子,Web 容器给 ServletContextResource)
├─ "classpath:" 开头? → 剥前缀 → new ClassPathResource(path, classLoader)
└─ 否则试 ResourceUtils.toURL:
├─ 成功 且 file: 协议 → FileUrlResource
├─ 成功 其他协议 → UrlResource
└─ MalformedURLException → getResourceByPath → ClassPathContextResource
11.7 扩展用法与最佳实践
自定义 MessageSource。当消息存在数据库、配置中心或远程接口时,继承 AbstractMessageSource 只需实现 resolveCode(code, locale)(返回 MessageFormat 或 null),建议同时覆盖 resolveCodeWithoutArguments 走无 MessageFormat 快路径:
public class DatabaseMessageSource extends AbstractMessageSource {
@Override
protected MessageFormat resolveCode(String code, Locale locale) {
String msg = messageMapper.findText(code, locale.toLanguageTag());
return (msg != null ? createMessageFormat(msg, locale) : null);
}
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
return messageMapper.findText(code, locale.toLanguageTag());
}
}
ReloadableResourceBundleMessageSource.resolveResource(filename) 也是个扩展点(6.1 起声明为 protected 钩子):覆盖它可以从数据库 BLOB、JSON 等任意来源返回 Resource,配合 loadProperties 重写解析逻辑。
多语言项目文件规范:① basename 用全限定逻辑名如 i18n/messages,不要裸 messages 避免与第三方 jar 的同名文件撞车;② 每语言一个文件:messages.properties(基础兜底)、messages_zh_CN.properties、messages_en_US.properties;③ 文件必须 UTF-8(配 defaultEncoding=UTF-8);④ key 按"模块.实体.动作"分层,如 order.detail.title;⑤ 带参数消息才写 {0} 占位符,无参数消息保持纯文本。
性能与线程安全:① ResourceBundleMessageSource 默认永久缓存,运行期无额外开销;② 需要热更新用 ReloadableResourceBundleMessageSource + file: 位置 + cacheSeconds=30 左右,别用 0;③ MessageFormat 已被框架缓存,业务侧不要自己反复 new MessageFormat;④ 并发下 getMessage 是线程安全的——锁只发生在共享 MessageFormat 对象上且极短;⑤ PathMatchingResourcePatternResolver 的 rootDirCache/jarEntriesCache 无界累积,长驻进程若模式极多可周期性 clearCache()。
与前端 i18n 配合:后端 MessageSource 服务于服务端渲染与校验错误消息;前端 i18n(vue-i18n 等)服务于浏览器端交互文案。常见做法是后端在启动时把消息文件通过 /api/messages?locale=xx 端点导出为 JSON 给前端,或前后端共用一份 properties 文件经构建脚本编译。注意两边的占位符语法不同(Java MessageFormat {0} vs ICU/vue-i18n 的 {name} 命名占位符),共享文案时要统一选型。
11.8 常见问题与坑
坑一:ResourceBundleMessageSource 中文乱码。 根因在构造器 setDefaultCharset(StandardCharsets.ISO_8859_1)——JDK properties 文件传统编码。UTF-8 写的中文 properties 被按 ISO-8859-1 解码必乱码。解法:messageSource.setDefaultEncoding("UTF-8")(或 7.0.6+ 的 setDefaultCharset(StandardCharsets.UTF_8))。注意 JDK 9+ 模块路径下 ResourceBundle.Control 不支持时 defaultCharset 会失效(见 doGetBundle 的 UnsupportedOperationException 分支),此时应把消息文件迁出模块路径,或干脆用 ReloadableResourceBundleMessageSource(它完全用自己的 InputStreamReader(charset) 读流,不依赖 Control)。
坑二:Reloadable 的文件监听"失效"。 两个源码级原因:① basename 带 classpath: 前缀时,refreshProperties 里 resource.lastModified() 抛 IOException(jar 内资源没有可靠 mtime),代码走 fileTimestamp=-1 分支——文件改了也检测不到。解法:热更新文件放 file: 外部目录。② cacheSeconds 未设置(默认 -1 永久)时根本不会触发刷新检查。另外 jar 包部署的应用,容器/构建工具把 classpath 文件锁在 jar 里,改了也读不到——这不是 Spring 的问题,是部署形态的限制。
坑三:classpath*: 与通配符误用。 ① classpath*:*.xml 扫不到 jar 根的 xml——JDK ClassLoader.getResources("") 只返回目录根,jar 根靠 addAllClassLoaderJarRoots 补全,且官方明说不保证可移植;② classpath:com/example/**/X.xml(注意是单星)在 com/example 存在于多个 jar 时可能漏文件——determineRootDir 只取 getResource("com/example") 返回的第一个 URL,后面 jar 里的同名包不参与遍历;③ 官方建议多 jar 同名资源一律用 classpath*:。
坑四:InputStreamResource 重复读异常。 getInputStream() 第二次调用抛 IllegalStateException("InputStream has already been read...")。两个触发场景:业务代码自己读了两遍;或者调用方先调了 contentLength()(继承自 AbstractResource 的默认实现会把流读完)再读流。需要重复读时换 ByteArrayResource,或用 InputStreamSource lambda 形式并确保 lambda 每次能开新流。
坑五:URL 协议与 file: 的差异。 getResource("file:C:/x.txt") 返回 FileUrlResource(可写),而 getResource("http://host/x.txt") 返回 UrlResource(不可写,getOutputStream 不存在)。jar: 协议的资源 getFile() 会抛 FileNotFoundException(无法暴露为磁盘 File),只能 getInputStream() 读——所以 getResource("classpath:conf.yml").getFile() 在 jar 部署下必炸,别这么写。
坑六:NoSuchMessageException 与默认消息的边界。 不带默认消息的 getMessage(code, args, locale) 在 code 全链路(本 bundle → commonMessages → parent 链)都找不到时抛异常。三种缓解:调态一传默认消息;setUseCodeAsDefaultMessage(true) 让 code 原文返回(仅建议开发环境用,注释警告 parent 上开这个会导致多 code resolvable 在 parent 处提前返回第一个 code);或给基础文件 messages.properties 补齐所有 code 做兜底。
坑七:MessageFormat 单引号转义。 消息文本里写 It's {0},当 args 非空时 MessageFormat 把单引号当转义符——' 开始一个 quoted block,It's {0} 会变成 It 后面字面量 s 直到下一个单引号,{0} 占位符失效。正确写法是 It''s {0}。无 args 时不经过 MessageFormat,所以平时看不出来,一带参数就炸。这是 MessageFormat 家族最经典的坑。
11.9 本章小结
本章从 InputStreamSource 这个单方法根接口出发,逐层剖析了 Spring 资源加载与国际化两大抽象。资源侧:Resource 接口的 13 个方法契约、AbstractResource 的默认行为矩阵、六种主流实现的差异(ClassPath 的双 ClassLoader 模型、FileSystemResource 的 File/Path 双实现、UrlResource 的协议委托、ByteArray/InputStream 的内存两端、PathResource 的 7.0 废弃);加载侧:DefaultResourceLoader 的前缀分派决策树、ResourceEditor/ResourceArrayPropertyEditor 的字符串转换、PathMatchingResourcePatternResolver 的 classpath*: 展开(模块路径优先、jar 条目 TreeSet 缓存、Files.walk 文件遍历、determineRootDir 切根目录)以及 AntPathMatcher 的四段式贪心匹配。国际化侧:三态 getMessage 接口、initMessageSource 的装配与 DelegatingMessageSource 兜底、AbstractMessageSource.getMessageInternal 的无参数快路径/有参数 MessageFormat/parent 委托、MessageSourceSupport 的 MessageFormat 缓存、ResourceBundleMessageSource 的 ISO-8859-1 默认编码与 MessageSourceControl、ReloadableResourceBundleMessageSource 的四缓存体系与时间戳轮询并发刷新。
本章关键类清单:
| 类名 | 模块与相对路径 | 职责一句话 |
|---|---|---|
InputStreamSource | spring-core/src/main/java/org/springframework/core/io/InputStreamSource.java | 单方法根接口:产出 InputStream,约定每次新流 |
Resource | spring-core/src/main/java/org/springframework/core/io/Resource.java | 资源描述符:存在性/可读性/URL/File/长度/修改时间等 13 方法 |
AbstractResource | spring-core/src/main/java/org/springframework/core/io/AbstractResource.java | 模板基类:exists 两段探测、contentLength 流读数、equals 按 description |
ClassPathResource | spring-core/src/main/java/org/springframework/core/io/ClassPathResource.java | classpath 资源:ClassLoader/Class 双路径解析 |
AbstractFileResolvingResource | spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java | URL→文件解析:HEAD 探测、jar entry 检查、VFS 委托 |
FileSystemResource | spring-core/src/main/java/org/springframework/core/io/FileSystemResource.java | 磁盘文件资源:File/Path 双实现,可写 |
UrlResource | spring-core/src/main/java/org/springframework/core/io/UrlResource.java | 任意 URL 资源:协议委托,Basic 认证注入 |
FileUrlResource | spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java | file: URL 的可写子类,缓存 File 句柄 |
ByteArrayResource | spring-core/src/main/java/org/springframework/core/io/ByteArrayResource.java | 内存 byte[] 资源,可重复读 |
InputStreamResource | spring-core/src/main/java/org/springframework/core/io/InputStreamResource.java | 一次性流包装,isOpen=true,二次读抛异常 |
PathResource | spring-core/src/main/java/org/springframework/core/io/PathResource.java | NIO Path 资源,7.0 已 @Deprecated(forRemoval=true) |
ResourceLoader | spring-core/src/main/java/org/springframework/core/io/ResourceLoader.java | 加载器策略接口:location→Resource |
DefaultResourceLoader | spring-core/src/main/java/org/springframework/core/io/DefaultResourceLoader.java | 前缀分派:classpath:/URL/getResourceByPath |
ResourceEditor | spring-core/src/main/java/org/springframework/core/io/ResourceEditor.java | String→Resource 的 PropertyEditor,含占位符解析 |
ResourceArrayPropertyEditor | spring-core/src/main/java/org/springframework/core/io/support/ResourceArrayPropertyEditor.java | String→Resource[],逗号分隔+通配展开 |
ResourcePatternResolver | spring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java | 模式解析接口:getResources + classpath*: 前缀 |
PathMatchingResourcePatternResolver | spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java | 通配展开引擎:模块路径/classpath*、jar 与文件树双路遍历 |
AntPathMatcher | spring-core/src/main/java/org/springframework/util/AntPathMatcher.java | Ant 风格匹配:doMatch 四段式贪心、glob 编译为正则、自适应缓存 |
MessageSource | spring-context/src/main/java/org/springframework/context/MessageSource.java | 国际化策略接口:三态 getMessage |
HierarchicalMessageSource | spring-context/src/main/java/org/springframework/context/HierarchicalMessageSource.java | 父子委派接口 |
AbstractMessageSource | spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java | final 三态模板:getMessageInternal 主流程与 parent 委托 |
MessageSourceSupport | spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java | MessageFormat 缓存、非法格式哨兵、JVM_LOCALES 判断 |
AbstractResourceBasedMessageSource | spring-context/src/main/java/org/springframework/context/support/AbstractResourceBasedMessageSource.java | basename/编码/cacheMillis/defaultLocale 公共配置 |
ResourceBundleMessageSource | spring-context/src/main/java/org/springframework/context/support/ResourceBundleMessageSource.java | JDK ResourceBundle 封装,双缓存+Control 编码注入 |
ReloadableResourceBundleMessageSource | spring-context/src/main/java/org/springframework/context/support/ReloadableResourceBundleMessageSource.java | Spring Resource 驱动,时间戳轮询热刷新,并发读旧策略 |
DelegatingMessageSource | spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java | 容器默认空消息源,纯 parent 转发 |
AbstractApplicationContext | spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java | initMessageSource 装配(第 820-845 行) |