【重写SpringFramework】资源加载与解析(chapter 3-3)

99 阅读11分钟

1. 前言

在 Spring 框架中,BeanFactory 只负责 Bean 的管理工作,包括实例化、依赖解析、初始化、销毁等。同时我们知道,BeanFactory 主要通过 BeanDefinition 来创建实例,现在的问题是谁来注册 BeanDefinition。事实上,这项工作是由 ApplicationContext 完成的,BeanDefinitionRegistry 接口充当了中间桥梁的作用。

对于字节码文件来说,想要变成 BeanDefinition 并注册到 Spring 容器中,整个流程分为三步。首先,将字节码文件以输入流或字节数组的形式加载到内存中。其次,读取输入流或字节数组中的内容,解析相关的 Class 信息。第三,创建 BeanDefinition 实例,并注册到 BeanFactory 中。在整个过程中,BeanDefinitionRegistry 接口只解决了最后一步,本节将讨论前两步是如何完成的。

3.1 资源加流程.png

2. 资源

2.1 Resource

Resource 接口是 Spring 对资源的统一抽象,包括本地磁盘数据、网络数据、内存数据等等。Resource 的继承结构比较简单,主要的实现类如下:

  • InputStreamResource:表示输入流资源
  • ByteArrayResource: 表示字节数组资源
  • FileSystemResource: 表示本地磁盘资源(使用 File 描述)
  • PathResource :表示本地磁盘资源(使用 Path 描述)
  • UrlResource: 表示网络资源(使用 URL 描述)
  • ClassPathResource: 表示类路径下的资源,可以看做特殊的本地磁盘资源,这是 Spring 加载组件的方式

3.2 Resource类图.png

2.2 资源分类

InputStreamSource 作为顶级接口,只定义了一个方法 getInputStream,作用是获取输入流。一般情况下,Resource 接口的实现类都是可读的,但只有一部分资源是可写的。造成这种差异的原因是什么,我们可以将主要实现类分为以下四种情况。

  • InputStreamResourceByteArrayResource 的特点是不代表特定的资源,无法根据类名来推断资源的类型。既然不知道资源的来源,自然是不可写的。

  • PathResourceFileSystemResource 表示本地磁盘资源,资源所对应的文件路径是已知的,因此是可写的。

  • UrlResource 表示网络资源,一般来说网络资源只能读取,除非提供特定接口才能修改。

  • ClassPathResource 表示类路径下的资源,本质上是磁盘资源,但不可写。这是因为类路径下的资源是程序的敏感数据,必须进行保护,一旦被修改程序就不能正常运行。这类资源包括配置文件、静态资源(比如 js、html、css 文件)、程序代码(class 文件编译后的字节码)等。

注:讨论资源的可读性和可写性不是重点,而是为了引入 ClassPathResource 这一特殊的资源。这是资源加载的核心,我们所关心的是 Spring 对配置文件(静态资源)以及程序代码(字节码)的处理。

3. 资源加载器

3.1 概述

ResourceLoader 接口的继承结构由三部分组成,一是主体部分(蓝色区域),包括 ResourceLoader 接口及其子接口/实现类;二是红色区域,PathMatcher 接口及其实现类提供了路径解析的功能;三是紫色区域,Resource 接口负责对资源进行统一抽象。此外,AbstractApplicationContext 继承了 DefaultResourceLoader,使得 Spring 容器拥有了加载资源的能力

3.3 ResourceLoader类图.png

从类图中可以看到,加载资源的功能是由 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 接口,表示类的元数据

3.4 元数据解析类图.png

第二个维度是根据解析方式分为反射和 AMS 两种方式。这两个维度相互作用,对应四个实现类,如下所示:

  • StandardMethodMetadata:表示通过反射的方式解析方法
  • MethodMetadataReadingVisitor:表示通过 ASM 方式解析方法
  • StandardAnnotationMetadata:表示通过反射的方式解析类
  • AnnotationMetadataReadingVisitor:表示通过 ASM 方式解析类

注:这些类名比较长,难以记忆,有个简单的区分方法。ASM 解析是通过访问者模式实现的,因此以 Visitor 结尾的类是 ASM 解析,反之是通过反射解析的。

4.3 元数据的解析

StandardMethodMetadataStandardAnnotationMetadata 是通过反射方式获取元数据,可以直接调用构造函数创建实例。此外,方法是类的一部分,MethodMetadata 接口的实例是可以通过 AnnotationMetadata 接口的实例间接获得,因此我们重点关注 AnnotationMetadataReadingVisitor 是如何创建的。

AnnotationMetadataReadingVisitor 的创建过程较为复杂,Spring 通过工厂模式,隐藏了繁琐的解析过程,只要调用工厂方法,便可以得到解析后的类的元数据。相关 API 的结构比较简单,主要包括两部分,如下所示:

  • MetadataReader 接口封装了解析元数据的具体逻辑,唯一的实现类是 SimpleMetadataReader

  • MetadataReaderFactory 接口负责创建 MetadataReader 的工厂,一般使用默认的实现类 SimpleMetadataReaderFactory

3.5 ASM加载类图.png

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 元数据解析

本测试实现了两个目标,一是加载字节码文件,二是对元数据进行解析。测试方法比较复杂,可以分为三步,如下所示:

  1. 创建 Spring 容器,加载指定类的资源。需要注意的是,测试代码在执行时,源码被编译成字节码,因此文件的后缀名必须是 .class

  2. 创建 MetadataReaderFactory 实例,通过 ASM 方式对资源进行解析,得到的 AnnotationMetadata 包含了类的相关信息。

  3. 创建 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 对这两种解析方式进行统一地抽象,使用注解元数据来描述。

3.6 资源加载与解析脑图.png

总的来说,资源加载和资源解析是 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编程探微】,加群一起讨论。

原创不易,觉得内容不错请关注、点赞、收藏。