Spring Framework 7.0.9 深度剖析-11-资源加载与国际化

5 阅读36分钟

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(基于 JDK ResourceBundle)与 ReloadableResourceBundleMessageSource(基于 Spring Resource 抽象,支持热刷新),以及默认空实现 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 相关与描述辅助类)。先用表格建立全局图景,后面逐类深入:

实现类定位方式可重复读isOpengetFile()典型用途
ClassPathResourceClassLoader/Class 资源查找是(每次新开流)false仅当落在文件系统时成功读 classpath 下的配置
FileSystemResourcejava.io.File 或 NIO Path是false恒成功读写磁盘任意文件
UrlResource任意 URL(http/jar/file/…)是false仅 file: 协议成功通用 URL 资源
FileUrlResourcefile: URL 的 UrlResource 子类是false成功且缓存 Filefile: 位置的可写资源
ByteArrayResource内存 byte[]是(每次 new ByteArrayInputStream)false不支持邮件附件、内存内容
InputStreamResource已打开的 InputStream/InputStreamSource否true不支持一次性流式输入
PathResourceNIO Path(7.0 已 @Deprecated(forRemoval=true))是falsepath.toFile()旧代码迁移至 FileSystemResource
VfsResourceJBoss VFS 虚拟文件是false委派 VFSJBoss 容器内部
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 对"异构协议存在性探测"的完整答案,分四条路径:

  1. file: 协议:直接转 File 探测,最快。
  2. http/https: 协议:发 HEAD 请求(setRequestMethod("HEAD"))。200 → 存在;404 → 不存在;405(BAD_METHOD)→ 服务器不允许 HEAD,退回普通连接再判一次。这段 405 回退逻辑是为了兼容不规范的静态服务器。
  3. jar: 协议:不看 Content-Length(jar 条目没有可靠长度头),而是 jarCon.getJarEntry() != null——查 jar 里这个 entry 在不在。注意 finally 里只有 getUseCaches()==false 时才 jarFile.close(),否则 JVM 的 jar 缓存自己管理句柄。
  4. 其他协议 / 兜底: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();
	}
}

三个设计决策点:

  1. isOpen() 恒 true:这是它对 InputStreamSource 契约的正式宣告——我持有的是已打开的资源,调用方读完必须关。
  2. read 标记 + IllegalStateException:第二次取流直接报错,错误信息还贴心地提示"可能是被 contentLength 提前消费了"。
  3. 6.1.7 起支持 InputStreamSource lambda 构造: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);
		}
	}
}

分派顺序逐段解读:

  1. 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:" 这类自定义协议的扩展点。
  2. / 开头:交给 protected Resource getResourceByPath(String path),默认实现是 new ClassPathContextResource(path, getClassLoader())(第 185-187 行)。注意这个设计:ClassPathXmlApplicationContext 等子类可以覆盖它,让 /WEB-INF/foo.xml 解析成 ServletContextResource。Web 环境的 XmlWebApplicationContext 就是这么做的。
  3. classpath: 前缀:剥掉前缀,包上 ClassPathResource。getClassLoader() 的兜底逻辑(第 96-98 行):字段非 null 用字段,否则 ClassUtils.getDefaultClassLoader()——即线程上下文类加载器优先。
  4. 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-Path manifest 头得到的 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 行)。逻辑分两支:

  1. 容器里有名为 messageSource 的 bean:直接取出来作为本上下文的消息源。如果它是 HierarchicalMessageSource 且还没设父级,就把父上下文的 messageSource 设为它的 parent——父子容器消息委派链就此接通。getInternalParentMessageSource()(第 1562-1565 行)有个细节:父上下文也是 AbstractApplicationContext 时,取它内部的 messageSource 字段,而不是把父上下文自己当 MessageSource 传进去。
  2. 没有自定义 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);
}

逐段:

  1. 空 code / 空 locale 归一:locale == null 时用 Locale.getDefault()——这是"忘了传 locale"时的系统语言兜底。
  2. 无参数快路径(默认行为):只要 alwaysUseMessageFormat==false 且 args 为空,直接走 resolveCodeWithoutArguments——不构造 MessageFormat。为什么?java.text.MessageFormat 构造很慢,而且会把单引号当转义符解析;绝大多数消息(无占位符)根本不需要它。两个具体实现都覆盖了这个方法走字符串直查。
  3. 有参数路径:先 resolveArguments(args, locale) 把 args 里可能存在的 MessageSourceResolvable 递归解析成字符串(第 337-351 行:args 元素如果是 resolvable,就调一次 getMessage(resolvable, locale))。注释解释了为什么要"提前"解析参数:消息模板定义在 parent 里、参数的多语言表达定义在 child 里,必须先用 child 的解析能力把参数转好。然后 resolveCode(code, locale) 拿 MessageFormat,synchronized (messageFormat) 包裹 format——MessageFormat 不是线程安全的,而实例被缓存复用,这把锁是并发安全的关键。
  4. commonMessages 兜底:setCommonMessages 配置的 locale 无关公共消息表,查到就 formatMessage。
  5. 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;
		}
	}
}

逐段理解:

  1. newBundle 是编码注入点:JDK PropertyResourceBundle 默认按 ISO-8859-1(JDK9 前)或平台默认读流,Spring 在这里用 new InputStreamReader(inputStream, charset) 包一层,把 defaultCharset 施加到 properties 文件。reload=true(bundle 过期重载)时额外 setUseCaches(false)——否则 JVM 的 jar 缓存会返回旧内容。loadBundle(reader) 是个 protected 钩子,子类可以换成自定义 ResourceBundle 实现。
  2. getFallbackLocale:返回 defaultLocale(若配了且不同于当前 locale)。注意它返回的是 Spring 自己的 defaultLocale 而不是 JDK 默认的 Locale.getDefault()——配合 AbstractResourceBasedMessageSource.getDefaultLocale() 的优先级逻辑(显式 defaultLocale > 系统 locale > null)。
  3. getTimeToLive:把 Spring 的 cacheMillis 透传给 JDK 的 TTL 机制。cacheMillis=-1 时退回 super 默认(JDK 默认是 -1 即永久)。
  4. 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();
	}
}

并发设计的精妙处:

  1. refreshTimestamp == -1 表示永久有效(cacheMillis < 0 路径);否则判断"距上次刷新是否超过 cacheMillis"。
  2. 并发刷新策略(concurrentRefresh=true 默认):holder 已有内容且已过期时,tryLock()——拿不到锁说明别的线程正在刷新,直接返回旧内容,不阻塞。这是 4.1 引入的"读旧不读新阻塞"策略。lock() 阻塞式刷新只发生在 holder 从未加载过时。
  3. 锁内二次检查 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 产出的候选表):

  1. i18n/messages_zh_CN.properties
  2. i18n/messages_zh.properties
  3. i18n/messages.properties(basename 本身)
  4. 若配了 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 的四缓存体系与时间戳轮询并发刷新。

本章关键类清单:

类名模块与相对路径职责一句话
InputStreamSourcespring-core/src/main/java/org/springframework/core/io/InputStreamSource.java单方法根接口:产出 InputStream,约定每次新流
Resourcespring-core/src/main/java/org/springframework/core/io/Resource.java资源描述符:存在性/可读性/URL/File/长度/修改时间等 13 方法
AbstractResourcespring-core/src/main/java/org/springframework/core/io/AbstractResource.java模板基类:exists 两段探测、contentLength 流读数、equals 按 description
ClassPathResourcespring-core/src/main/java/org/springframework/core/io/ClassPathResource.javaclasspath 资源:ClassLoader/Class 双路径解析
AbstractFileResolvingResourcespring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.javaURL→文件解析:HEAD 探测、jar entry 检查、VFS 委托
FileSystemResourcespring-core/src/main/java/org/springframework/core/io/FileSystemResource.java磁盘文件资源:File/Path 双实现,可写
UrlResourcespring-core/src/main/java/org/springframework/core/io/UrlResource.java任意 URL 资源:协议委托,Basic 认证注入
FileUrlResourcespring-core/src/main/java/org/springframework/core/io/FileUrlResource.javafile: URL 的可写子类,缓存 File 句柄
ByteArrayResourcespring-core/src/main/java/org/springframework/core/io/ByteArrayResource.java内存 byte[] 资源,可重复读
InputStreamResourcespring-core/src/main/java/org/springframework/core/io/InputStreamResource.java一次性流包装,isOpen=true,二次读抛异常
PathResourcespring-core/src/main/java/org/springframework/core/io/PathResource.javaNIO Path 资源,7.0 已 @Deprecated(forRemoval=true)
ResourceLoaderspring-core/src/main/java/org/springframework/core/io/ResourceLoader.java加载器策略接口:location→Resource
DefaultResourceLoaderspring-core/src/main/java/org/springframework/core/io/DefaultResourceLoader.java前缀分派:classpath:/URL/getResourceByPath
ResourceEditorspring-core/src/main/java/org/springframework/core/io/ResourceEditor.javaString→Resource 的 PropertyEditor,含占位符解析
ResourceArrayPropertyEditorspring-core/src/main/java/org/springframework/core/io/support/ResourceArrayPropertyEditor.javaString→Resource[],逗号分隔+通配展开
ResourcePatternResolverspring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java模式解析接口:getResources + classpath*: 前缀
PathMatchingResourcePatternResolverspring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java通配展开引擎:模块路径/classpath*、jar 与文件树双路遍历
AntPathMatcherspring-core/src/main/java/org/springframework/util/AntPathMatcher.javaAnt 风格匹配:doMatch 四段式贪心、glob 编译为正则、自适应缓存
MessageSourcespring-context/src/main/java/org/springframework/context/MessageSource.java国际化策略接口:三态 getMessage
HierarchicalMessageSourcespring-context/src/main/java/org/springframework/context/HierarchicalMessageSource.java父子委派接口
AbstractMessageSourcespring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.javafinal 三态模板:getMessageInternal 主流程与 parent 委托
MessageSourceSupportspring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.javaMessageFormat 缓存、非法格式哨兵、JVM_LOCALES 判断
AbstractResourceBasedMessageSourcespring-context/src/main/java/org/springframework/context/support/AbstractResourceBasedMessageSource.javabasename/编码/cacheMillis/defaultLocale 公共配置
ResourceBundleMessageSourcespring-context/src/main/java/org/springframework/context/support/ResourceBundleMessageSource.javaJDK ResourceBundle 封装,双缓存+Control 编码注入
ReloadableResourceBundleMessageSourcespring-context/src/main/java/org/springframework/context/support/ReloadableResourceBundleMessageSource.javaSpring Resource 驱动,时间戳轮询热刷新,并发读旧策略
DelegatingMessageSourcespring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java容器默认空消息源,纯 parent 转发
AbstractApplicationContextspring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.javainitMessageSource 装配(第 820-845 行)