003-ioc容器
实现 ResourceResolver
要实现 ResourceResolver,可以按照以下步骤进行操作:
-
定义不变类
Resource,用于表示资源。这个类可以包含资源的相关信息和操作方法。 -
创建目录解析类
ResourceResolver,用于扫描目录中的资源。这个类应该具有以下功能:- 接受待搜索的包名作为输入参数。
- 允许客户端定制只保留特定类型的资源,例如只保留 class 文件。
- 扫描指定包下的所有文件,并将它们表示为
Resource对象。
-
获取包名的类加载路径,可以使用
ClassLoader来实现。这样可以得到 URL 地址,URL 地址和 URI 地址相同。 -
处理 URL 地址:
- 如果扫描的是 JAR 包中的内容,URL 地址开头会有
jar:。 - 如果扫描的不是 JAR 包中的内容,URL 地址开头为
file:/。 - 注意有时候地址末尾会多出
/,需要避免这种情况。
- 如果扫描的是 JAR 包中的内容,URL 地址开头会有
-
将路径转换为标准路径,以便可以被
File读取:- 根据 URI 的类型,去除 URI 前导的协议前缀,例如
jar:file:/。 - 根据操作系统的不同,替换路径分隔符,例如在 Linux 上保持不变,在 Windows 上将
/替换为\。可以使用File.separator动态获取分隔符。
- 根据 URI 的类型,去除 URI 前导的协议前缀,例如
-
遍历目录下的所有文件,可以使用
Files.walk()方法结合流操作来实现:- 使用方法引用
Files::isRegularFile判断是否是一个合法的文件路径,即判断是否为文件。 - 对于每个文件路径,判断是否为 JAR 文件,根据文件类型生成相应名称的
Resource对象。 - 将
Resource对象添加到列表中。
- 使用方法引用
-
完成
ResourceResolver的实现。
需要注意的是,这个实现涉及到文件地址的类型、URI 和 URL 的区别以及转换等细节。因此,在实现过程中需要特别注意处理这些细节。
可能的 uri 路径如下:
jar:file:/E:/Program%20Files%20(x86)/maven/repository/org/yaml/snakeyaml/2.0/snakeyaml-2.0.jar!/META-INF/versions/9/org/yaml/
file:/D:/myCode/formal-projects/yelanyanyu-mvc/yelanyanyu-mvc/mvc-context/target/classes/com/yelanyanyu/io
注意
Jar 的读取
关于 Jar 的读取。jar 文件是一个压缩文件,所以我们不能简单粗暴用类加载地址的形式(即形如 D:/myCode/formal-projects/yelanyanyu-mvc/yelanyanyu-mvc/mvc-context/target/classes/com/yelanyanyu)传入 Files 进行遍历;
要对 jar 进行遍历加载,可以直接调用特殊的 api,这里给一个通用的:
private Path jarUriToPath(String basePackagePath, URI jarUri) {
try {
return FileSystems.newFileSystem(jarUri, Map.of()).getPath(basePackagePath);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
解读:
basePackagePath:若要搜索cn.hutool下的所有文件,就传入cn/hutool;jarUri:cn/hutool的 uri 资源可以通过类加载器的getResource("...")获取;- return:返回的是一个 path 文件,该 Path 文件可以直接传给 Files;
以下是一个简单的使用:
@Test
public void t2() throws Exception {
URL resource = Thread.currentThread().getContextClassLoader().getResource("cn/hutool");
Files.walk(jarUriToPath("cn/hutool", resource.toURIprintln;
}
uri 和 url 的格式区别
在该实现中,url 和 uri 的格式并没有什么区别。因为,都是在同一台主机上编译运行的。
文件的开头和结尾分隔符
当我们得到文件的路径或者 uri 时,开头或者结尾可能会带有分隔符 / 或者 \(仅 windows),我们需要将其去掉。我们举个例子:
即,扫描目录时,返回的路径可能是 abc/xyz,也可能是 abc/xyz/,需要注意处理末尾的 /。
同一个文件,可能在 Java 中或显示为:
D:\a\b
D:\a\b\
\D:\a\b
我们通过两个 api 解决:
private String removeLeadingSlash(String s) {
if (s.startsWith("/") || s.startsWith("\\")) {
s = s.substring(1);
}
return s;
}
private String removeTrailingSlash(String string) {
if (string.endsWith("/") || string.endsWith("\\")) {
string = string.substring(0, string.length() - 1);
}
return string;
}
中文路径问题
为了解决中文路径问题,在 uri 转换为 string 的时候,需要用 utf 8 转换一下:
URLDecoder.decode(uri.toString(), StandardCharsets.UTF_8);
实现 PropertyResolver
扫描配置,形如
${...},或者.properties文件中的a.b=c形式的配置;
- 基本配置扫描,保存。用于
@Value注入; - 通过函数式接口实现类型转换;
- 通过 snakeyaml 读取 yaml 文件;
创建 BeanDefinition
- 创建基本的容器框架:
Map<String, BeanDefinition>; - 通过 BeanDefinition 保存 Bean 的基本信息;
- 扫描指定包下的所有 Class,然后返回 Class 名字;
寻找符合条件的 bean
如果我们将其他 jar 的对象加入 ioc,那么就不能用 @Component 的注解了,我们需要用 @Bean 来注入,而 Bean 注解的注入依赖的是工厂设计模式。
spring 中的 ioc 容器提供了两种获取 bean 的方式:
-
通过 bean 指定的名字获取;
-
通过 class 对象获取,如果对象是单例的话;
-
我们先扫描所有满足条件的 bean 对象,将其存入到 list 列表中。详见方法
List<BeanDefinition> findBeanDefinitions(Class<?> type); -
然后,我们通过 stream 流的方式得到所有被 Primary 注释的
BeanDefintion。通过方法public BeanDefinition findBeanDefinition(Class<?> type)。
获取所有BeanDefinition信息
- 扫描指定包下的所有 Class,然后返回 Class 名字;
- 继续在
scanFactoryMethods()中查找@Bean标注的方法;
先通过我们之前写的 ResourceResolver 得到 ComponentScan 注解下的所有资源。注意,我们还有获取通过 import 注解注入的对象。通过方法 scanForClassNames 。
真正的扫描类,并加入 beans map。我们再想一个问题,spring 有那么多注解,难道我们就一个个写 if 判断吗?其实不是的,controller 和 service 等注解都是基于 Component 注解的。也就是说 spring 的注解是有父子关系的。所以,我们只需要找到一个组件是否有 Component 注解即可。
通过方法 createBeanDefinitions 来注入 bean,基本的流程如下:
- 对于传入的 set,我们对其进行遍历;
- 通过反射得到 bean 的空对象(没有赋属性值);
- 判断其是否有
@Component注解,如果没有就判断下一个;有的话,继续进行下一个步骤; - 若有注解,那么我就要对 bean 进行预注入(bean 并没有实例化,只是填写一些 BeanDefinition 的基本信息);
- 对于有 Configuration 注解的 bean,由于内部可能有
@Bean注解的方法需要我们用工厂模式返回,故而需要特殊处理。即若有该注解注释,那么需要调用方法scanFactoryMethods来进行注入; - 对于预注入的操作,我们需要解决真正注入 bean 的时候该调用哪个构造器的问题,所以,我们还需要对 bean 的构造器进行检验,看看构造器是否符合要求:
- 必须只有一个可供获取的构造器。有两种情况,若构造器公开,即可以通过
clazz.getConstructs()获取到的构造器,则公开的构造器只能有一个;若构造器不公开,即通过clazz.getDeclaredConstructs()获取的构造器只能有一个。 - 故而,只有这种情况是允许的:一个公开的构造器和不公开的构造器。
- 必须只有一个可供获取的构造器。有两种情况,若构造器公开,即可以通过
- 对于
scanFactoryMethods方法,我们需要对类中的方法进行遍历,若有@Bean 注释,则进行注入;
Bean 实例的创建
完成 bean 的创建,解决依赖注入的问题。这个步骤的创建是强依赖的注入。
- 创建 Bean 的实例,此时必须注入强依赖;
- 对 Bean 实例进行 Setter 方法注入和字段注入。
第一步如果遇到循环依赖则直接报错,第二步则不需要关心有没有循环依赖。
基本概念
bean 注入有 4 种方式:构造方法、工厂方法注入、Setter 方法注入、字段注入。
在这个阶段,我们完成前两种注入的方式。
- 强依赖:通过工厂方法和唯一构造方法注入的依赖,称为强依赖。这种方式注入的依赖是不能分为两步的(先创建实例,再进行注入);
- 对于强依赖,我们必须在创建实例的时候就注入 bean。对于弱依赖,我们可以先创建实例再注入。
- 假如我们有强依赖型的循环依赖问题,那么就是无解的,故而可以直接报错。弱依赖的循环依赖问题却可以通过先创建实例再注入的方式解决;
基本步骤
创建 bean 的步骤如下:
- 创建Bean的实例,此时必须注入强依赖;
- 在构造器中先过滤出所有的工厂 bean(被
@Configuration修饰),先创建这些工厂 bean。这就意味着在工厂 bean 中无法注入其他 bean 对象,即被@Autowired 注释的属性(但是可以注入 @Value 修饰的值); - 工厂 bean 本身是被由其默认无参构造器创建的;
- 创建非工厂类,详见方法
createBeanAsEarlySingleton:- 可能会存在强依赖的情况,所以首先需要判断该 bean 是否为强依赖。可以添加一个参数
Set<String> creatingBeanNames;,该参数记录了之前已经创建过的 bean。如果在某个情况,尝试二次注入某个 bean,那么就极有可能是强依赖循环的错误,直接抛出异常即可; - 不论是工厂方法还是构造器注入,都是依赖的是方法注入,而方法就有参数,我们先要解析参数,先将参数注入完成;
- 参数注入完成后,再调用方法。具体来说,如果是构造器注入,那么就调用
def.getConstructor().newInstance(args);若是工厂方式注入,那么就调用def.getFactoryMethod().invoke(bean, args);
- 可能会存在强依赖的情况,所以首先需要判断该 bean 是否为强依赖。可以添加一个参数
Bean 的初始化
根据 Setter 方法和字段完成弱依赖注入,接着调用用
@PostConstruct标注的 init 方法,就完成了所有 Bean 的初始化。
在这个阶段,我们将会完成 bean 的最后两种注入方式。
注入 bean 的核心方法为:injectBean() and injectProperties。
调用 init 方法的核心方法为:initBean and callMethod。
实现 BeanPostProcessor
该类的作用其实就是类的替换,实际环境中,我们会将原来的类替换成一个代理类。
引入这个功能,会使得依赖注入,变得复杂。
但有两个规律:
- 若 A 依赖 B,那么 A 在注入 B 对象时,应该注入 B 的 proxy 对象。只要创建 Bean 完成后,立刻调用
BeanPostProcessor就实现了替换,后续其他 Bean 引用的肯定就是 Proxy 了; - 若 A 依赖 B,那么当要再 B 注入对象时,应该注入到 B 的原始对象。否则在 proxy 调用 target 方法的时候,会由于原始对象 B 没有注入相关属性而报空指针异常;
根据以上规律,需要做如下的动作:
步骤
- 在创建
@Configuration后,接着创建BeanPostProcessor,再创建其他普通 Bean; - 修改
createBeanAsEarlySingleton(),创建 Bean 实例后,调用BeanPostProcessor处理; - 给
BeanPostProcessor加一个postProcessOnSetProperty()方法,让它返回原始 Bean。每当我们要实现一个后置处理器,就要实现这个方法,使其能够返回原始的 bean。具体做法是,在 processor 中加入一个 map 型的变量用来存储原始对象,然后在调用方法postProcessOnSetProperty()取出替换; - 把
injectBean()改一下,不要直接拿BeanDefinition.getInstance(),而是拿到原始 Bean。新建方法getOriginBean(BeanDefinition def)得到原始的 bean:- 利用倒序调用 processor 的
postProcessOnSetProperty()方法来达成恢复 Bean 的作用; - 为什么要倒序调用?这是为了解决多重代理的情况。假如 A 由 B 代理,B 又由 C 代理,那么首先我们就要将 C 恢复成 B,最后再将 B 恢复成 A。显然是要倒序调用的;
- 利用倒序调用 processor 的
收尾工作
提供对外,和对 framework 开发级别的接口,并实现相关方法。
有几点需要注意的:
- 对于 close 方法,需要执行所有的 destroy 方法。而执行 destroy 方法的时候,想要得到 bean 实例,切忌直接使用
def.getInstance(),因为这样的话,得到的实例是代理 bean,而我们真正想要执行的方法在原来的 bean 中; - 对于 pre-destroy 方法不能有参数;
- 最后加一个
ApplicationUtils类,目的是能通过getRequiredApplicationContext()方法随时获取到ApplicationContext实例;