MiniSpring框架学习笔记-解决循环依赖的简化IoC容器

MiniSpring框架学习笔记-解决循环依赖的简化IoC容器

  • [03. 解决循环依赖的简化 IoC 容器](#03. 解决循环依赖的简化 IoC 容器)

教程: https://github.com/YaleGuo/minis
极客时间: 手把手带你写一个 MiniSpring

03. 解决循环依赖的简化 IoC 容器

学MIniSpring不是为了把 Spring 源码完整复刻一遍,也不是为了写一个能上线的容器。

我们真正要学的是:平时写 @Service@Autowired 的时候,对象到底是从哪里来的?依赖关系到底是谁装进去的?为什么有些循环依赖 Spring 能处理,有些又不能?

如果这些东西一直停留在"框架帮我做了"的层面,业务代码能跑当然没问题。一旦项目启动失败、Bean 注入失败、循环依赖报错,就很容易只看到异常,看不到异常背后的流程。

所以这一节的重点是把 IoC 容器拆成一个很小的 Demo。它足够小,方便看清主线;它也足够像 Spring,能帮助我们理解 Spring 里那些更复杂的设计。

学完这一节要明白什么

先把目标说清楚。学完之后,你至少要能说清楚这几件事:

  1. IoC 到底反转了什么控制权。
  2. BeanDefinitionBeanFactoryApplicationContext 分别负责什么。
  3. getBean("aservice") 背后大概走了哪些步骤。
  4. setter 循环依赖为什么可以靠"提前暴露半成品对象"解决。
  5. 这个 Demo 能解决什么,不能解决什么。

本节的主线可以压缩成一句话:

text 复制代码
XML 配置 -> BeanDefinition -> BeanFactory -> 创建对象 -> 提前放入 earlySingletonObjects -> setter 注入 -> 放入 singletons -> getBean 返回

抓住这条线,后面的代码就不会显得零散。

先看没有 IoC 的问题

传统写法里,一个对象经常会自己创建它依赖的对象:

java 复制代码
public class OrderService {
    private UserService userService = new UserServiceImpl();
}

这段代码能跑,但它有一个明显问题:OrderService 直接写死依赖了 UserServiceImpl

如果哪天想换成 UserServiceImpl2,就必须改 OrderService 的代码。对象创建、对象选择、业务逻辑,全都混在一起了。

这就是"控制权在业务代码手里"。

IoC 做的事情,就是把这部分控制权拿出来,交给容器:

java 复制代码
public class OrderService {
    private UserService userService;

    public void setUserService(UserService userService) {
        this.userService = userService;
    }
}

业务类只声明"我需要一个 UserService",至于具体用哪个实现类、什么时候创建、怎么注入,由容器负责。

所以 IoC 的核心不是"用了 XML"或者"用了注解",而是:

text 复制代码
对象的创建权、依赖的装配权,从业务代码转移到了容器。

依赖注入,也就是 DI,是实现 IoC 的一种方式。

这节只讨论 IoC,不讨论所有 Spring 问题

开头很容易把 Spring 的所有能力都混在一起,比如:

  1. @Service 为什么能创建对象?
  2. @Autowired 为什么能注入对象?
  3. HTTP 请求为什么能进入 Controller?
  4. MyBatis 的 SQL 为什么能和 Spring 集成?

这些问题都和 Spring 生态有关,但不都属于 IoC 容器本身。

本节只聚焦一个更小的问题:

text 复制代码
容器如何根据配置创建 Bean,并把 Bean 之间的依赖关系装配起来?

只要这个问题搞懂了,再看注解扫描、MVC、AOP、事务、MyBatis 集成,就有了地基。

IoC 容器是什么

IoC 容器可以先理解成一个对象工厂,不过它比普通工厂多做了几件事:

  1. 保存 Bean 的定义信息。
  2. 按定义创建对象。
  3. 处理对象之间的依赖关系。
  4. 缓存单例对象。
  5. 在合适的时机返回已经准备好的对象。

在这个 Demo 里,几个概念对应关系是这样的:

概念 可以怎么理解 在 Demo 里的角色
BeanDefinition Bean 的说明书 记录 id、class、构造参数、属性注入信息
BeanFactory 真正干活的工厂 创建 Bean、查找 Bean、缓存 Bean
ApplicationContext 更上层的容器入口 读取配置,启动 BeanFactory,对外提供 getBean
singletons 成品仓库 保存已经创建完成的单例 Bean
earlySingletonObjects 半成品仓库 保存刚实例化、还没完成 setter 注入的 Bean

注意:真实 Spring 的 ApplicationContext 比这里强很多,它还负责事件、资源、环境变量、国际化、AOP 等等。Demo 里只保留最关键的 IoC 主线。

Demo 要解决的循环依赖

本节讨论的是 setter 循环依赖。举个例子:

text 复制代码
aservice -> baseservice -> basebaseservice -> aservice

也就是说:

  1. 创建 aservice 时,需要注入 baseservice
  2. 创建 baseservice 时,需要注入 basebaseservice
  3. 创建 basebaseservice 时,又需要注入 aservice

如果容器只会"缺什么就立刻完整创建什么",流程就会变成:

text 复制代码
创建 aservice
  需要 baseservice
    创建 baseservice
      需要 basebaseservice
        创建 basebaseservice
          又需要 aservice
            再创建 aservice
              ...

这样会一直递归下去。

解决思路是:创建对象分两步走。

text 复制代码
第一步:先调用构造方法,拿到一个对象引用。
第二步:再做 setter 属性注入。

只要第一步拿到了对象引用,就可以先把它放到 earlySingletonObjects 里。后面如果别的 Bean 又依赖它,就先把这个"半成品对象"拿出来用,等依赖都串起来之后,再逐步完成属性注入。

这也是本节最重要的概念。

入口代码

测试代码很简单:

java 复制代码
public class Test1 {
    public static void main(String[] args) throws BeansException {
        ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("beans.xml");

        AService aService = (AService) ctx.getBean("aservice");
        aService.sayHello();
    }
}

这里有两个动作:

  1. new ClassPathXmlApplicationContext("beans.xml"):读取配置,启动容器。
  2. ctx.getBean("aservice"):从容器里拿一个已经装配好的对象。

ApplicationContext 这个名字可以先简单理解成"应用上下文"。它是应用启动后的一整个运行环境,里面有 Bean 定义、Bean 实例、配置关系等信息。

在这个 Demo 里,ClassPathXmlApplicationContext 主要就是一个入口类:它负责读取 XML,然后把具体创建 Bean 的工作交给 SimpleBeanFactory

创建 ApplicationContext 时做了什么

核心流程是:

  1. 根据文件名创建 Resource
  2. 创建 SimpleBeanFactory
  3. XmlBeanDefinitionReader 读取 XML。
  4. 把 XML 里的 <bean> 解析成 BeanDefinition
  5. 调用 refresh() 预创建单例 Bean。

代码可以这样看:

java 复制代码
public class ClassPathXmlApplicationContext implements BeanFactory {
    private final SimpleBeanFactory beanFactory;

    public ClassPathXmlApplicationContext(String fileName) throws BeansException {
        this(fileName, true);
    }

    public ClassPathXmlApplicationContext(String fileName, boolean isRefresh) throws BeansException {
        Resource resource = new ClassPathXmlResource(fileName);
        SimpleBeanFactory simpleBeanFactory = new SimpleBeanFactory();
        XmlBeanDefinitionReader reader = new XmlBeanDefinitionReader(simpleBeanFactory);

        // 读取 XML,把 bean 配置解析成 BeanDefinition。
        reader.loadBeanDefinitions(resource);
        this.beanFactory = simpleBeanFactory;

        // 预创建单例 Bean。传 false 时,可以等第一次 getBean 再创建。
        if (isRefresh) {
            this.beanFactory.refresh();
        }
    }

    public Object getBean(String beanName) throws BeansException {
        return this.beanFactory.getBean(beanName);
    }

    // containsBean、isSingleton、isPrototype、getType 等方法,
    // Demo 中也是继续转发给 this.beanFactory,这里不展开。
}

这里有一个容易说错的点:不是 refresh() 本身解决了循环依赖。

真正解决循环依赖的是 createBean() 里的"先实例化、提前暴露、再注入属性"这套流程。refresh() 只是触发所有单例 Bean 提前走一遍 getBean()

BeanDefinition 是什么

XML 里的 Bean 配置长这样:

xml 复制代码
<bean id="aservice" class="com.chenhai.test.AServiceImpl">
    <constructor-arg type="String" name="name" value="abc"/>
    <constructor-arg type="int" name="level" value="3"/>
    <property type="String" name="property1" value="Someone says"/>
    <property type="String" name="property2" value="Hello World!"/>
    <property type="com.chenhai.test.BaseService" name="ref1" ref="baseservice"/>
</bean>

容器不能直接拿 XML 字符串创建对象,它需要先把 XML 转成程序里的结构。

这个结构就是 BeanDefinition

可以把它理解成 Bean 的说明书:

text 复制代码
beanName: aservice
className: com.chenhai.test.AServiceImpl
constructorArgs: name=abc, level=3
properties: property1, property2, ref1

后面 BeanFactory 创建对象时,不再关心 XML 原文,只关心 BeanDefinition

这一步很关键。真实 Spring 也是先把各种配置来源变成 BeanDefinition,比如 XML、注解扫描、Java Config,最后都会进入比较统一的 Bean 创建流程。

getBean 的主流程

getBean(beanName) 是整个容器最重要的方法。

它的逻辑不要背代码,记住顺序就行:

  1. 先从 singletons 里找完整 Bean。
  2. 找不到,再从 earlySingletonObjects 里找半成品 Bean。
  3. 还找不到,就根据 BeanDefinition 创建 Bean。
  4. 创建完成后放进 singletons
  5. earlySingletonObjects 移除半成品引用。

关键代码如下:

java 复制代码
@Override
public Object getBean(String beanName) throws BeansException {
    Object singleton = this.getSingleton(beanName);
    if (singleton != null) {
        return singleton;
    }

    singleton = this.earlySingletonObjects.get(beanName);
    if (singleton != null) {
        return singleton;
    }

    BeanDefinition beanDefinition = this.beanDefinitionMap.get(beanName);
    if (beanDefinition == null) {
        throw new BeansException("No bean named " + beanName);
    }

    Object bean = createBean(beanDefinition);
    this.registerSingleton(beanName, bean);
    this.earlySingletonObjects.remove(beanName);
    return bean;
}

这里的 earlySingletonObjects 不是"复制了一份对象",它保存的是同一个对象引用。

所以后面 setter 注入继续给这个对象补属性时,已经拿到早期引用的地方,也会看到同一个对象被补完整。

createBean 的关键点

创建 Bean 的关键不是一行 newInstance(),而是分成两个阶段:

java 复制代码
private Object createBean(BeanDefinition beanDefinition) throws BeansException {
    try {
        Class<?> clz = Class.forName(beanDefinition.getClassName());

        // 第一阶段:先调用构造方法,得到一个对象引用。
        Object obj = doCreateBean(clz, beanDefinition.getConstructorArgumentValues());

        // 先把半成品放出来,后面处理循环依赖时可能会用到。
        this.earlySingletonObjects.put(beanDefinition.getId(), obj);

        // 第二阶段:再做 setter 注入。
        handleProperties(beanDefinition, clz, obj);
        return obj;
    } catch (Exception e) {
        throw new BeansException("Create bean failed: " + beanDefinition.getId());
    }
}

这段代码就是本节的核心。

如果你把 earlySingletonObjects.put(...) 放到 handleProperties(...) 后面,循环依赖就解决不了了。因为属性注入的时候已经开始递归找依赖了,必须在递归发生前,把当前对象的引用先暴露出去。

构造方法注入

构造方法注入主要是演示反射创建对象。

java 复制代码
private Object doCreateBean(Class<?> clz, ArgumentValues argumentValues) throws Exception {
    if (argumentValues == null || argumentValues.isEmpty()) {
        return clz.getDeclaredConstructor().newInstance();
    }

    Class<?>[] paramTypes = new Class<?>[argumentValues.getArgumentCount()];
    Object[] paramValues = new Object[argumentValues.getArgumentCount()];

    for (int i = 0; i < argumentValues.getArgumentCount(); i++) {
        ArgumentValue argumentValue = argumentValues.getIndexedArgumentValue(i);
        paramTypes[i] = getClassType(argumentValue.getType());
        paramValues[i] = getValue(argumentValue.getType(), argumentValue.getValue());
    }

    Constructor<?> constructor = clz.getConstructor(paramTypes);
    return constructor.newInstance(paramValues);
}

这里需要注意两个小点:

  1. paramTypesparamValues 必须一一对应,否则反射找不到正确构造方法。
  2. XML 里写的 "int""String" 是字符串,需要转换成真正的 int.classString.class

这个 Demo 里构造方法参数只处理了少数类型,够演示就行。真实框架会做更完整的类型转换。

setter 属性注入

setter 注入分两种情况:

  1. 普通值,比如 Stringint
  2. Bean 引用,比如 ref="baseservice"

代码重点在这里:

java 复制代码
private void handleProperties(BeanDefinition beanDefinition, Class<?> clz, Object obj) throws Exception {
    PropertyValues propertyValues = beanDefinition.getPropertyValues();
    if (propertyValues == null || propertyValues.isEmpty()) {
        return;
    }

    for (PropertyValue propertyValue : propertyValues.getPropertyValueList()) {
        String propertyName = propertyValue.getName();
        Class<?> paramType = getClassType(propertyValue.getType());
        Object paramValue = getValue(propertyValue.getType(), propertyValue.getValue());

        if (propertyValue.isRef()) {
            paramValue = getBean((String) propertyValue.getValue());
            paramType = Class.forName(propertyValue.getType());
        }

        String methodName = "set"
                + propertyName.substring(0, 1).toUpperCase()
                + propertyName.substring(1);

        Method method = clz.getMethod(methodName, paramType);
        method.invoke(obj, paramValue);
    }
}

普通值比较简单,直接做类型转换后调用 setter。

ref 更关键。遇到 ref="baseservice" 时,容器不会把字符串 "baseservice" 注入进去,而是会递归调用:

java 复制代码
getBean("baseservice")

这就是依赖关系被串起来的地方,也是循环依赖可能发生的地方。

循环依赖是怎么被拆开的

假设依赖关系是:

text 复制代码
aservice -> baseservice -> basebaseservice -> aservice

容器启动后,执行 getBean("aservice"),过程大概是这样:

text 复制代码
1. 创建 aservice
2. 调用 aservice 构造方法,得到 aservice 对象
3. 把 aservice 放入 earlySingletonObjects
4. 给 aservice 注入 ref1,需要 baseservice

5. 创建 baseservice
6. 调用 baseservice 构造方法,得到 baseservice 对象
7. 把 baseservice 放入 earlySingletonObjects
8. 给 baseservice 注入依赖,需要 basebaseservice

9. 创建 basebaseservice
10. 调用 basebaseservice 构造方法,得到 basebaseservice 对象
11. 把 basebaseservice 放入 earlySingletonObjects
12. 给 basebaseservice 注入依赖,需要 aservice

13. 再次 getBean("aservice")
14. singletons 里还没有完整 aservice
15. earlySingletonObjects 里有半成品 aservice
16. 直接返回这个 aservice 引用

到这里,递归就断开了。

后面 basebaseservice 完成注入,放入 singletonsbaseservice 继续完成注入,放入 singletons;最后 aservice 也完成注入,放入 singletons

这就是"提前暴露半成品对象"的价值。

为什么构造器循环依赖解决不了

这点一定要分清楚。

这个 Demo 解决的是 setter 循环依赖,不是构造器循环依赖。

setter 注入可以这样拆:

text 复制代码
先 new A()
把 A 暴露出去
再 setB(...)

但构造器注入是这样:

java 复制代码
public A(B b) {
    this.b = b;
}

如果 A 的构造方法需要 B,B 的构造方法又需要 A,那 A 连构造方法都走不完,对象引用还没有产生,自然也就没法提前放进 earlySingletonObjects

所以这类依赖在这个 Demo 里解决不了。真实 Spring 对构造器循环依赖也通常会报错。

两个缓存分别负责什么

这个 Demo 里有两个缓存:

java 复制代码
protected Map<String, Object> singletons = new ConcurrentHashMap<>(256);

singletons 保存完整 Bean。一个 Bean 已经完成构造、属性注入,后面再来拿,直接从这里返回。

java 复制代码
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(256);

earlySingletonObjects 保存半成品 Bean。它只在 Bean 创建过程中临时使用,用来打断 setter 循环依赖。

完整流程是:

text 复制代码
创建对象后:放入 earlySingletonObjects
属性注入后:放入 singletons
创建完成后:从 earlySingletonObjects 移除

真实 Spring 里还有更复杂的三级缓存,主要是为了处理 AOP 代理等场景。本节 Demo 没有 AOP,所以用二级缓存就能讲清楚主线。

XML 示例

下面这个 XML 演示了三种配置:

  1. constructor-arg:构造器参数。
  2. property value:普通属性值。
  3. property ref:引用另一个 Bean。
xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<beans>
    <bean id="aservice" class="com.chenhai.test.AServiceImpl">
        <constructor-arg type="String" name="name" value="abc"/>
        <constructor-arg type="int" name="level" value="3"/>
        <property type="String" name="property1" value="Someone says"/>
        <property type="String" name="property2" value="Hello World!"/>
        <property type="com.chenhai.test.BaseService" name="ref1" ref="baseservice"/>
    </bean>
</beans>

这里要注意:propertytype 要和 setter 方法的参数类型对得上。

比如 XML 写的是:

xml 复制代码
<property type="com.chenhai.test.BaseService" name="ref1" ref="baseservice"/>

那目标类里应该有类似这样的 setter:

java 复制代码
public void setRef1(BaseService ref1) {
    this.ref1 = ref1;
}

如果 setter 参数类型写成了别的类型,clz.getMethod(methodName, paramType) 就可能找不到方法。

这份 Demo 里容易误解的地方

第一,refresh() 不是解决循环依赖的核心。

它只是触发 Bean 创建。真正关键的是 createBean() 里先把对象实例放进 earlySingletonObjects,再进行 setter 注入。

第二,earlySingletonObjects 里不是完整对象。

它里面的对象已经调用过构造方法,但还没完成所有属性注入。所以它只能在创建过程中临时救场,不能当作最终成品长期使用。

第三,这个 Demo 只讨论单例 Bean。

如果是 prototype Bean,每次 getBean() 都要创建新对象,缓存策略就不同了。setter 循环依赖也不能简单套用这个方案。

第四,这个 Demo 没有处理 AOP 代理。

真实 Spring 有时候返回的不是原始对象,而是代理对象。为了保证循环依赖场景里拿到的也是正确代理对象,Spring 需要更复杂的缓存和生命周期处理。

第五,异常处理可以更友好。

示例代码里:

java 复制代码
throw new BeansException("Create bean failed: " + beanDefinition.getId());

为了 Demo 简洁可以这样写,但真实项目最好把原始异常带上,否则排查反射失败、类型不匹配、setter 不存在时会比较难。

最后总结一下

这一节最重要的不是记住每一行代码,而是记住 IoC 容器创建 Bean 的节奏:

text 复制代码
先读配置,得到 BeanDefinition。
再按 BeanDefinition 创建对象。
创建对象时先实例化,提前暴露引用。
然后做 setter 注入。
最后把完整 Bean 放入单例缓存。

循环依赖能被解决,是因为 setter 注入把"创建对象"和"注入依赖"拆成了两个阶段。容器可以在对象刚创建出来的时候,先把引用放出来,等依赖链走完之后再把对象补完整。

理解了这条主线,再回头看 Spring 的 @Service@AutowiredApplicationContext,就不会只是"框架自动帮我做了",而是能大概知道它是在什么阶段、用什么思路做的。

相关推荐
晓梦林1 小时前
cp520靶场学习笔记
android·笔记·学习
心中有国也有家3 小时前
cann-recipes-infer:昇腾 NPU 推理的“菜谱集合”
经验分享·笔记·学习·算法
玄米乌龙茶1233 小时前
LLM成长笔记(三):API 开发基础
笔记
Upsy-Daisy3 小时前
AI Agent 项目学习笔记(八):Tool Calling 工具调用机制总览
人工智能·笔记·学习
LuminousCPP4 小时前
数据结构 - 线性表第四篇:C 语言通讯录优化升级全记录(踩坑 + 思考)
c语言·开发语言·数据结构·经验分享·笔记·学习
魔法阵维护师4 小时前
从零开发游戏需要学习的c#模块,第十四章(保存和加载)
学习·游戏·c#
_李小白5 小时前
【android opencv学习笔记】Day 17: 目标追踪(MeanShift)
android·opencv·学习
一只机电自动化菜鸟5 小时前
一建机电备考笔记(40) 建筑机电施工—排水管道施工(含考频+题型)
经验分享·笔记·学习·职场和发展·课程设计
2301_818730566 小时前
numpy的学习(笔记)
学习·numpy