1. 前言
在 Spring 框架中,BeanFactory 只负责 Bean 的管理工作,包括实例化、依赖解析、初始化、销毁等。同时我们知道,BeanFactory 主要通过 BeanDefinition 来创建实例,现在的问题是谁来注册 BeanDefinition。事实上,这项工作是由 ApplicationContext 完成的,BeanDefinitionRegistry 接口充当了中间桥梁的作用。
对于字节码文件来说,想要变成 BeanDefinition 并注册到 Spring 容器中,整个流程分为三步。首先,将字节码文件以输入流或字节数组的形式加载到内存中。其次,读取输入流或字节数组中的内容,解析相关的 Class 信息。第三,创建 BeanDefinition 实例,并注册到 BeanFactory 中。在整个过程中,BeanDefinitionRegistry 接口只解决了最后一步,本节将讨论前两步是如何完成的。
2. 资源
2.1 Resource
Resource 接口是 Spring 对资源的统一抽象,包括本地磁盘数据、网络数据、内存数据等等。Resource 的继承结构比较简单,主要的实现类如下:
InputStreamResource:表示输入流资源ByteArrayResource: 表示字节数组资源FileSystemResource: 表示本地磁盘资源(使用File描述)PathResource:表示本地磁盘资源(使用Path描述)UrlResource: 表示网络资源(使用URL描述)ClassPathResource: 表示类路径下的资源,可以看做特殊的本地磁盘资源,这是 Spring 加载组件的方式
2.2 资源分类
InputStreamSource 作为顶级接口,只定义了一个方法 getInputStream,作用是获取输入流。一般情况下,Resource 接口的实现类都是可读的,但只有一部分资源是可写的。造成这种差异的原因是什么,我们可以将主要实现类分为以下四种情况。
-
InputStreamResource和ByteArrayResource的特点是不代表特定的资源,无法根据类名来推断资源的类型。既然不知道资源的来源,自然是不可写的。 -
PathResource和FileSystemResource表示本地磁盘资源,资源所对应的文件路径是已知的,因此是可写的。 -
UrlResource表示网络资源,一般来说网络资源只能读取,除非提供特定接口才能修改。 -
ClassPathResource表示类路径下的资源,本质上是磁盘资源,但不可写。这是因为类路径下的资源是程序的敏感数据,必须进行保护,一旦被修改程序就不能正常运行。这类资源包括配置文件、静态资源(比如 js、html、css 文件)、程序代码(class 文件编译后的字节码)等。
注:讨论资源的可读性和可写性不是重点,而是为了引入
ClassPathResource这一特殊的资源。这是资源加载的核心,我们所关心的是 Spring 对配置文件(静态资源)以及程序代码(字节码)的处理。
3. 资源加载器
3.1 概述
ResourceLoader 接口的继承结构由三部分组成,一是主体部分(蓝色区域),包括 ResourceLoader 接口及其子接口/实现类;二是红色区域,PathMatcher 接口及其实现类提供了路径解析的功能;三是紫色区域,Resource 接口负责对资源进行统一抽象。此外,AbstractApplicationContext 继承了 DefaultResourceLoader,使得 Spring 容器拥有了加载资源的能力。
从类图中可以看到,加载资源的功能是由 ResourceLoader 实现的,而路径解析的功能是委托给 PathMatcher 完成的。也就是说,加载资源是核心功能,路径解析是外围操作,加载资源不一定会用到路径解析,路径解决更像是一种锦上添花的作用。由此想到了什么?没错,还是装饰模式,我们来分析一下具体的组成:
ResourceLoader接口是抽象组件角色,定义了getResource方法DefaultResourceLoader类是具体组件角色,实现了getResource方法ResourcePatternResolver接口是抽象装饰角色,不仅继承ResourceLoader接口,还定义了getResources方法PathMatchingResourcePatternResolver类是具体装饰角色,持有一个具体组件角色的实例,实现了加载资源的基本功能。还持有一个PathMatcher实例,实现了额外的路径解析功能
3.2 ResourceLoader
ResourceLoader 是资源加载的核心接口,定义了加载资源的行为。在 getResource 方法中,参数 location 表示资源的路径。其中,资源路径有多种表现形式:
-
绝对路径:比如
file:C:/abc.txt,绝对路径是标准的 URL,由协议名和文件路径组成。 -
类路径:比如
classpath:abc.txt,类路径是伪 URL,classpath:不是标准的协议名,需要由 Spring 来进行解释。 -
相对路径:比如
WEB-INF/abc.txt,这是程序中的某个位置,前缀路径由程序的实际情况决定。
public interface ResourceLoader {
//根据指定路径加载单一资源
Resource getResource(String location);
}
ResourcePatternResolver 接口继承了 ResourceLoader,作用是解析资源的路径模式。getResources 方法有两个特点,一是 locationPattern 参数可以是一个模式字符串,二是返回类型是一个数组。这是因为模式匹配可能有多个符合条件的结果,比如扫描指定路径下的所有文件。
public interface ResourcePatternResolver extends ResourceLoader {
//对路径模式字符串进行解析,并加载多个资源
Resource[] getResources(String locationPattern) throws IOException;
}
ResourcePatternResolver 本身并没有实现路径模式的解析,而是交给 PathMatcher 来处理。PathMatcher 接口用于解析资源所在的路径,AntPathMatcher 是唯一的实现类,可以解析 Ant 风格的路径。Ant 风格的通配符有以下几种:
-
?:匹配任何单字符
-
*:匹配 0 个或任意多个字符,不包括“/”
-
**:匹配 0 个或任意多个字符,包括“/”(可以是多级目录)
Ant 风格的路径模式匹配是一种非常灵活的定位资源的方式,比如 classpath:/**/*.class 表示类路径下的任意目录下的任意名称的 class 文件。
3.3 Spring 的实现
Spring 对 ResourceLoader 接口的使用体现在两个方面。首先,ApplicationContext 接口继承了 ResourcePatternResolver 接口,AbstractApplicationContext 持有一个 PathMatchingResourcePatternResolver 实例,通过 getResources 方法完成路径解析和资源加载的工作。
public interface ApplicationContext extends BeanFactory, ResourcePatternResolver {}
public abstract class AbstractApplicationContext extends DefaultResourceLoader {
private ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver(this);
@Override
public Resource[] getResources(String locationPattern) throws IOException {
return this.resourcePatternResolver.getResources(locationPattern);
}
}
其次,AbstractApplicationContext 还继承了 DefaultResourceLoader,可以调用 getResource 方法完成简单的资源加载工作。
4. 资源解析
4.1 概述
Spring 对资源进行抽象,区分的依据是介质和读写问题。但当进行解析的时候,必须考虑资源的具体类型。我们以三种常见的资源为例来说明资源是如何解析,如下所示:
-
静态文件:比如
.html、.css、.js文件,一般由服务器返回给客户端处理,比如浏览器会将这些静态文件渲染成网页。 -
配置文件:主要类型有
.properties、.xml、.yml等,其中.xml和.yml文件有专门的框架负责解析,.properties文件可以使用 JDK 的集合类Properties处理。 -
字节码文件:源代码的后缀是
.java,编译成字节码的后缀为.class,加载之后变成ClassPathResource对象。Spring 提供了两种解析方式,一是通过反射来解析类的信息,二是使用 ASM 框架处理。
4.2 元数据
Spring 将类的相关信息称为元数据(metadata),在核心包中定义了相关 API 来描述元数据,以及如何获取这些元数据。Spring 使用两个维度来描述元数据,第一个维度是根据目标的不同分为类和方法,主要是接口层面的划分,如下所示:
-
AnnotatedTypeMetadata接口表示声明的注解的类或方法 -
MethodMetadata接口表示方法的元数据 -
AnnotationMetadata接口继承了ClassMeta接口,表示类的元数据
第二个维度是根据解析方式分为反射和 AMS 两种方式。这两个维度相互作用,对应四个实现类,如下所示:
StandardMethodMetadata:表示通过反射的方式解析方法MethodMetadataReadingVisitor:表示通过 ASM 方式解析方法StandardAnnotationMetadata:表示通过反射的方式解析类AnnotationMetadataReadingVisitor:表示通过 ASM 方式解析类
注:这些类名比较长,难以记忆,有个简单的区分方法。ASM 解析是通过访问者模式实现的,因此以 Visitor 结尾的类是 ASM 解析,反之是通过反射解析的。
4.3 元数据的解析
StandardMethodMetadata 和 StandardAnnotationMetadata 是通过反射方式获取元数据,可以直接调用构造函数创建实例。此外,方法是类的一部分,MethodMetadata 接口的实例是可以通过 AnnotationMetadata 接口的实例间接获得,因此我们重点关注 AnnotationMetadataReadingVisitor 是如何创建的。
AnnotationMetadataReadingVisitor 的创建过程较为复杂,Spring 通过工厂模式,隐藏了繁琐的解析过程,只要调用工厂方法,便可以得到解析后的类的元数据。相关 API 的结构比较简单,主要包括两部分,如下所示:
-
MetadataReader接口封装了解析元数据的具体逻辑,唯一的实现类是SimpleMetadataReader -
MetadataReaderFactory接口负责创建MetadataReader的工厂,一般使用默认的实现类SimpleMetadataReaderFactory
5. 测试
5.1 资源加载
本测试实现了两个目标,一是加载配置文件,二是对配置文件进行解析。首先在 test/resources 目录下创建文件 jdbc.properties,这是数据库连接的配置文件,内容如下:
url=jdbc:mysql:///spring-wheel
namename=root
password=root
在测试方法中,先加载配置文件,然后转换成 Properties 类型的数据,最后遍历集合打印所有的键值对。需要注意的是,由于 AbstractApplicationContext 继承了 DefaultResourceLoader,因此直接调用 getResource 方法加载配置文件。我们也可以不使用 Spring 容器,直接创建 DefaultResourceLoader 实例也能达到同样效果。
//测试方法
@Test
public void testResourceLoader() throws IOException {
GenericApplicationContext context = new GenericApplicationContext();
Resource resource = context.getResource("classpath:jdbc.properties");
Properties properties = new Properties();
properties.load(resource.getInputStream());
System.out.println("ResourceLoader 测试");
for (Map.Entry<Object, Object> entry : properties.entrySet()) {
System.out.println(entry.getKey() + " --> " + entry.getValue());
}
}
从测试结果来看,打印的信息和配置文件中的内容一致,只是键值对的顺序发生了改变,这是因为键是按照英文字母的顺序进行排列的。
ResourceLoader 测试
password --> root
url --> jdbc:mysql:///spring-wheel
username --> root
5.2 元数据解析
本测试实现了两个目标,一是加载字节码文件,二是对元数据进行解析。测试方法比较复杂,可以分为三步,如下所示:
-
创建 Spring 容器,加载指定类的资源。需要注意的是,测试代码在执行时,源码被编译成字节码,因此文件的后缀名必须是
.class。 -
创建
MetadataReaderFactory实例,通过 ASM 方式对资源进行解析,得到的AnnotationMetadata包含了类的相关信息。 -
创建
BeanDefinition对象,指定className属性。需要注意的是,通过 ASM 方式解析的元数据不能直接得到类的Class信息,因此只能指定全类名。
//测试方法
@Test
public void testResolveMetadata() throws Exception {
//1. 加载资源
GenericApplicationContext context = new GenericApplicationContext();
Resource resource = context.getResource("classpath:context/basic/User.class");
//2. 使用 ASM 方式解析元数据
SimpleMetadataReaderFactory factory = new SimpleMetadataReaderFactory();
MetadataReader metadataReader = factory.getMetadataReader(resource);
AnnotationMetadata metadata = metadataReader.getAnnotationMetadata();
//3. 创建BeanDefinition
RootBeanDefinition definition = new RootBeanDefinition();
definition.setBeanClassName(metadataReader.getAnnotationMetadata().getClassName());
context.registerBeanDefinition("user", definition);
context.refresh();
User user = context.getBean("user", User.class);
System.out.println("元数据解析测试:" + user);
}
从测试结果可以看到,User 对象注册到了 Spring 容器中。
元数据解析测试:context.test.basic.User@531be3c5
6. 总结
在得到初步的 ApplicationContext 实现之后,我们首要解决的是加载 Bean 的问题,也就是从字节码文件到注册 BeanDefinition 的过程。这一过程分为三步,首先将文件加载到程序中;其次对文件进行解析,获取类的相关信息;最后创建 BeanDefinition 实例,并注册到容器中。
Spring 将所有文件和数据都视为资源(resource),根据存储介质以及可读写性这两个维度来定义实现类。其中 ClassPathResource 是我们关心的,该类有两个特点,一是路径是固定为项目的类路径,可以方便地访问项目文件;二是资源是只读的,这就保证了代码和其他文件不会被修改。此外,Spring 提供 ResourceLoader 这个组件完成了读取文件并转换成资源的操作。
得到资源之后,接下来需要对资源进行解析。由于源文件的类型不同,解析资源的方式也是不同的。对于字节码文件来说,我们需要获取相关的类信息。Spring 提供了两种解析方式,一是基于 JVM 的类加载机制,二是通过 ASM 框架实现。Spring 对这两种解析方式进行统一地抽象,使用注解元数据来描述。
总的来说,资源加载和资源解析是 Spring 框架提供的通用功能,不仅可以将字节码解析为注解元数据(包含类的信息),也可以处理其他文件。比如配置文件和静态文件等和项目有关的资源,它们也有对应的解析方式,但这不是本节的重点,我们将在后续内容中专门讨论配置文件和静态文件是如何解析的。
7. 项目结构
新增修改一览,新增(1),修改(4)。
context
└─ src
├─ main
│ └─ java
│ └─ cn.stimd.spring.context
│ ├─ support
│ │ ├─ AbstractApplicationContext.java (*)
│ │ └─ ApplicationContextAwareProcessor.java (*)
│ └─ ApplicationContext.java (*)
└─ test
├─ java
│ └─ context
│ └─ basic
│ └─ BasicTest.java (*)
└─ resources
└─ jdbc.properties (+)
注:+号表示新增、*表示修改
注:项目的 master 分支会跟随教程的进度不断更新,如果想查看某一节的代码,请选择对应小节的分支代码。
欢迎关注公众号【Java编程探微】,加群一起讨论。
原创不易,觉得内容不错请关注、点赞、收藏。