Java-27 深入浅出 Spring - 实现简易Ioc-03 在上节的业务下手动实现IoC 从 XML 配置到 BeanFactory 反射注入

手写简易版 IoC 容器:从 XML 配置到 BeanFactory 反射注入

TL;DR

  • 场景:在转账案例基础上,业务代码仍需手动 new 对象并维护 Service → Dao → ConnectionUtils 的依赖链,对象稍多就开始混乱。
  • 结论 :用一份 beans.xml + 一个 70 行的 BeanFactory(dom4j 读 XML + 反射 + setter 注入),就能把对象的创建和依赖组装从业务代码里抽出去,为后续事务管理和 AOP 铺路。
  • 产出 :可运行的简易版 IoC 容器原型,包含 beans.xml、反射式 BeanFactoryWzkServlet 接入示例与转账验证结果。

版本矩阵

功能 状态 说明
XML 解析(dom4j SAXReader) ✅ 已验证 dom4j 2.1.3,广泛用于 Java XML 处理,2024 年仍是主流选择
反射创建 Bean(Class.forName + newInstance) ✅ 已验证 JDK 原生反射,无第三方依赖
Setter 依赖注入(<property name ref>) ✅ 已验证 按 setter 名匹配,容器反射调用
MySQL 驱动类 com.mysql.cj.jdbc.Driver ✅ 已验证 mysql-connector-java 8.0+ 推荐使用,旧类 com.mysql.jdbc.Driver 已 deprecated
Druid 数据库连接池 ✅ 已验证 阿里巴巴开源,常用版本 1.2.x,输出 {dataSource-1} inited 即视为初始化成功
Spring BeanFactory 设计思想 ✅ 已验证 BeanFactory 是 Spring 基础 IoC 容器,ApplicationContext 在其之上扩展
Bean 不存在 / class 写错 / setter 缺失时的友好提示 ⚠️ 待验证 当前仅 try/catch 打印堆栈,未做用户级错误处理
单例 / 多例(scope)区分 ⚠️ 待验证 当前为静态 Map 单例模式,未实现 prototype scope
循环依赖处理 ⚠️ 待验证 当前两步式初始化可避免构造注入循环,setter 循环依赖未处理

上节进度

上节我们完成了基础转账案例的代码编写,但对象之间的创建和依赖关系仍然需要手动维护。

本节继续在这个案例基础上实现一个简易版 IoC 容器,把对象的创建和依赖组装交给容器处理,为后面继续实现事务管理和 AOP 做铺垫。

IoC 实现

当前代码中,如果需要使用某个对象,通常要手动 new 出来,并且还要手动给它设置依赖对象。

例如:

  • Service 依赖 Dao
  • Dao 依赖 ConnectionUtils
  • ConnectionUtils 负责维护数据库连接

对象少的时候还能接受,但项目稍微复杂一点,手动创建和组装对象就会变得非常混乱。

所以我们需要一个简单的 Bean 管理容器。当程序启动时,容器根据配置文件创建对象,并维护对象之间的依赖关系。后续业务代码只需要从容器中获取对象即可。

本节使用 XML 文件描述需要创建哪些 Bean,以及 Bean 之间的依赖关系。

IoC(Inversion of Control,控制反转)简介

IoC,全称 Inversion of Control,中文通常叫控制反转。

它的核心思想是:对象的创建权和依赖管理权不再由业务代码自己控制,而是交给容器统一管理。

没有 IoC 时,代码通常是这样:

java 复制代码
WzkConnectionUtils connectionUtils = new WzkConnectionUtils();

JdbcWzkAccountDaoImpl accountDao = new JdbcWzkAccountDaoImpl();
accountDao.setWzkConnectionUtils(connectionUtils);

WzkTransferServiceImpl transferService = new WzkTransferServiceImpl();
transferService.setWzkAccountDao(accountDao);

引入 IoC 后,对象由容器提前创建并组装好,业务代码只需要获取:

java 复制代码
WzkTransferService transferService = (WzkTransferService) BeanFactory.getBean("wzkTransferService");

这样就把对象创建和依赖组装的逻辑从业务代码中抽离出来了。

IoC 的特点

解耦

IoC 将对象创建和依赖管理交给容器处理,业务类不需要关心依赖对象如何创建,从而降低模块之间的耦合。

动态依赖管理

容器可以根据配置文件或注解完成依赖注入,避免在业务代码中写死具体实现类。

灵活性

如果后续需要替换某个实现类,只需要调整配置,不需要大范围修改业务代码。

IoC 的应用场景

IoC 在很多框架中都有应用:

  • Spring Framework:Spring 使用 IoC 容器管理 Bean 的生命周期和依赖关系。
  • Guice、Dagger:常见的轻量级依赖注入框架。
  • Angular:前端框架 Angular 中也有依赖注入机制,用于管理组件和服务之间的依赖。

在 Java 后端开发中,最典型的 IoC 实现就是 Spring 容器。

IoC 的优势

  • 增强模块化:减少类与类之间的直接依赖。
  • 提高可测试性:测试时可以替换依赖对象。
  • 增强灵活性:通过配置切换实现类。
  • 便于维护:对象创建和依赖管理集中到容器中处理。

IoC 的限制

  • 初学者需要理解 IoC、DI、Bean 容器等概念。
  • 容器初始化和反射调用会带来一定运行时开销。
  • 如果项目中依赖关系过多,配置也可能变得复杂。

不过在实际项目中,IoC 带来的解耦收益通常远大于这点复杂度。

Resources

Resources 目录下新增 beans.xml 文件,用来描述需要交给容器管理的 Bean。

beans.xml

我们先编写一个 XML 文件保存 Bean 信息:

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!-- BeanFactory 类会处理这里的内容 -->
<beans>
    <!-- WzkConnectionUtils 交给容器管理 -->
    <bean id="wzkConnectionUtils" class="wzk.utils.WzkConnectionUtils"></bean>

    <!-- JdbcWzkAccountDaoImpl 依赖 WzkConnectionUtils -->
    <bean id="wzkAccountDao" class="wzk.dao.impl.JdbcWzkAccountDaoImpl">
        <!-- name 表示需要调用的 setter 后缀,ref 表示引用容器中的哪个 Bean -->
        <property name="WzkConnectionUtils" ref="wzkConnectionUtils"/>
    </bean>

    <!-- WzkTransferServiceImpl 依赖 WzkAccountDao -->
    <bean id="wzkTransferService" class="wzk.service.impl.WzkTransferServiceImpl">
        <!-- name 表示需要调用的 setter 后缀,ref 表示引用容器中的哪个 Bean -->
        <property name="WzkAccountDao" ref="wzkAccountDao"></property>
    </bean>
</beans>

这份配置主要做了三件事:

  1. 创建 wzkConnectionUtils
  2. 创建 wzkAccountDao,并注入 wzkConnectionUtils
  3. 创建 wzkTransferService,并注入 wzkAccountDao

对应的内容截图如下所示:

图片说明:IntelliJ IDEA 中 beans.xml 的编辑界面,展示 wzkConnectionUtilswzkAccountDaowzkTransferService 三个 Bean 定义及其 Setter 注入关系,带中文注释,右下角 CSDN @武子康 水印。

注意:需要将 beans.xml 放到 resources 目录下,否则类加载器无法读取到该配置文件。

图片说明:IntelliJ IDEA 左侧项目结构,展示 src/main/resources/beans.xmlwzk/utilswebapp/WEB-INF/web.xml 等同级目录的关系,说明配置文件必须放在 resources 目录才能被 ClassLoader 加载。

Proxy

BeanFactory

下面开始实现一个简易版 BeanFactory

核心流程分为两步:

  1. 读取 XML 中的 bean 标签,通过反射创建对象,并放入容器。
  2. 读取 XML 中的 property 标签,通过 setter 方法完成依赖注入。
java 复制代码
public class BeanFactory {

    private BeanFactory() {}

    /**
     * 全局容器,所有创建好的对象都存储在这里
     */
    private static Map<String, Object> map = new HashMap<>();

    static {
        // 读取 resources 目录下的 beans.xml
        // 这里的 XML 解析方式,可以参考之前手写 MyBatis 的部分
        InputStream resourceAsSteam = BeanFactory.class.getClassLoader().getResourceAsStream("beans.xml");
        SAXReader saxReader = new SAXReader();
        try {
            Document document = saxReader.read(resourceAsSteam);
            Element rootElement = document.getRootElement();

            // 第一步:创建所有 Bean 对象,并放入容器
            List<Element> beanList = rootElement.selectNodes("//bean");
            for (Element element : beanList) {
                String id = element.attributeValue("id");
                String clazz = element.attributeValue("class");

                // 通过反射创建对象
                Class<?> aClass = Class.forName(clazz);
                Object object = aClass.newInstance();

                // 保存到容器中
                map.put(id, object);
            }

            // 第二步:处理 Bean 之间的依赖关系
            List<Element> propertyList = rootElement.selectNodes("//property");
            for (Element element : propertyList) {
                String name = element.attributeValue("name");
                String ref = element.attributeValue("ref");

                // 获取 property 所属的 bean 节点
                Element parentElement = element.getParent();
                String parentId = parentElement.attributeValue("id");

                // 根据父级 bean 的 id,从容器中取出当前对象
                Object parentObject = map.get(parentId);

                // 找到对应的 setter 方法,完成依赖注入
                Method[] methods = parentObject.getClass().getMethods();
                for (Method method : methods) {
                    if (method.getName().equalsIgnoreCase("set" + name)) {
                        method.invoke(parentObject, map.get(ref));
                    }
                }

                // 更新容器中的对象
                map.put(parentId, parentObject);
            }

            System.out.println("BeanFactory 初始化完毕 map:" + map);
        } catch (Exception e) {
            System.out.println("BeanFactory 初始化失败");
            e.printStackTrace();
        }
    }

    public static Object getBean(String id) {
        return map.get(id);
    }

}

这段代码只是一个简化版 IoC 容器,还没有处理很多边界情况,例如:

  • Bean 不存在时如何提示
  • class 路径写错时如何处理
  • setter 方法不存在时如何处理
  • 单例和多例如何区分
  • 循环依赖如何解决

本节先关注主流程:读取配置、创建对象、注入依赖、从容器获取 Bean。

对应的截图如下所示:

图片说明:IntelliJ IDEA 中 BeanFactory 类的完整源码,展示 dom4j 解析 XML、反射创建对象、Set 注入依赖的两步式初始化流程。

Controller

WzkServlet

接下来修改 WzkServlet

原来我们需要在 Servlet 中手动创建 Dao、Service,并手动维护它们之间的依赖关系。现在有了 BeanFactory,就可以直接从容器中获取 wzkTransferService

java 复制代码
@WebServlet(name="wzkServlet", urlPatterns = "/wzkServlet")
public class WzkServlet extends HttpServlet {

    // ========================== 2 ==========================
    // 由 BeanFactory 创建并管理
    private WzkTransferService wzkTransferService = (WzkTransferService) BeanFactory.getBean("wzkTransferService");
    // =======================================================

    @Override
    protected void doGet(javax.servlet.http.HttpServletRequest req, javax.servlet.http.HttpServletResponse resp) throws javax.servlet.ServletException, IOException {
        System.out.println("=== WzkServlet doGet ===");

        // ======================= 1 =============================
        // 没有 IoC 容器之前,需要手动创建对象并维护依赖关系
        //
        // JdbcWzkAccountDaoImpl jdbcWzkAccountDaoImpl = new JdbcWzkAccountDaoImpl();
        // jdbcWzkAccountDaoImpl.setWzkConnectionUtils(new WzkConnectionUtils());
        //
        // WzkTransferServiceImpl wzkTransferService = new WzkTransferServiceImpl();
        // wzkTransferService.setWzkAccountDao(jdbcWzkAccountDaoImpl);
        // ======================================================

        // 执行业务逻辑
        try {
            wzkTransferService.transfer("1", "2", 100);
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("=== transfer error ====");
        }

        resp.setContentType("application/json;charset=utf-8");
        resp.getWriter().print("=== WzkServlet doGet ===");
    }

}

此时 Servlet 不再负责对象创建和依赖组装,只负责调用业务方法。

这就是 IoC 带来的直接变化:业务代码更干净,对象关系交给容器处理。

WzkServlet(2)

还有一种写法:在 Servlet 初始化时再从容器中获取 Bean。

java 复制代码
// ========================== 3 ==========================
    // 另一种写法:在 Servlet 初始化阶段获取 Bean
    private WzkTransferService wzkTransferService;

    @Override
    public void init() throws ServletException {
        super.init();
        this.wzkTransferService = (WzkTransferService) BeanFactory.getBean("wzkTransferService");
    }
    // ======================================================

这种写法和前面的思路一致,都是从 BeanFactory 中获取已经创建好的对象。区别只是获取时机不同。

测试运行

运行代码后,可以看到控制台输出如下:

shell 复制代码
BeanFactory 初始化完毕 map:{wzkTransferService=wzk.service.impl.WzkTransferServiceImpl@5dfdd67c, wzkAccountDao=wzk.dao.impl.JdbcWzkAccountDaoImpl@380d9fc0, wzkConnectionUtils=wzk.utils.WzkConnectionUtils@2d150354}
=== WzkServlet doGet ===
Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.
11月 18, 2024 6:24:24 下午 com.alibaba.druid.pool.DruidDataSource info
信息: {dataSource-1} inited
transfer fromResult: 1 toResult: 1

从日志可以看到:

  1. BeanFactory 已经完成初始化。
  2. wzkTransferServicewzkAccountDaowzkConnectionUtils 都已经放入容器。
  3. 转账方法正常执行。
  4. 数据库更新结果为 fromResult: 1 toResult: 1

其中 MySQL 驱动的提示是因为旧驱动类 com.mysql.jdbc.Driver 已经过时,新版本推荐使用 com.mysql.cj.jdbc.Driver。这不影响本节 IoC 的主流程,但后续可以顺手调整。

对应的控制台结果如下所示:

图片说明:IntelliJ IDEA Run 控制台输出,展示 BeanFactory 初始化完毕 日志、=== WzkServlet doGet === 进入标记、MySQL 驱动弃用警告、Druid {dataSource-1} inited 连接池初始化日志,以及核心业务结果 transfer fromResult: 1 toResult: 1,底部面包屑定位到 wzk-spring-ioc-aop 项目的 JdbcWzkAccountDaoImpl#updateW...

查看结果

数据库中的账户金额已经发生变化,说明转账逻辑成功执行。

图片说明:数据库管理工具(Navicat/DBeaver)中的 account 表,包含 card / name / money 三列;第一行 1 / wzk / 300,第二行 2 / icu / 700,展示 wzk 向 icu 转账 200 后的账户金额结果(总金额 1000 守恒)。

当然,也可以通过断点调试进一步确认对象之间的依赖是否已经注入成功。

重点可以观察:

  • BeanFactory 是否读取到了 beans.xml
  • map 中是否保存了三个 Bean
  • wzkAccountDao 中是否注入了 wzkConnectionUtils
  • wzkTransferService 中是否注入了 wzkAccountDao

目前问题

到这里,我们已经完成了一个简易版 IoC 容器。

它解决了对象创建和依赖管理的问题,让 Servlet 不再需要手动组装 Service、Dao 和工具类。

但是当前转账业务还有一个明显问题:事务还没有统一管理。

转账操作至少包含两次数据库更新:

  1. 转出账户扣钱
  2. 转入账户加钱

如果第一步成功,第二步失败,就会出现数据不一致。

所以接下来需要继续实现一个事务管理器。事务管理器会使用 ThreadLocal 保存当前线程的数据库连接,保证同一次请求中的多个数据库操作使用同一个连接,从而实现统一提交和统一回滚。


错误速查卡

症状 根因 定位 修复
BeanFactory 初始化失败 + ClassNotFoundException beans.xml<bean class="..."> 写错了全限定类名,或类没编译到 classpath 看堆栈中的类名,核对 IDE 编译产物(target/classes) 修正 class 属性,或执行 mvn clean compile 重新编译
NullPointerException 出现在 method.invoke(parentObject, map.get(ref)) ref 指向的 Bean id 与 //bean 中 id 不一致;或 XML 中 <property> 拼写错 打印 map.keySet() 确认容器里的 id,核对 <property ref> 保持 id 引用一致,或调整命名
setter 没被调用,依赖对象为 null name 与 setter 后缀不匹配(忽略大小写时大小写错、或属性名拼错) methods 循环里打印候选方法名 修正 name 值为 setter 后缀(如 WzkAccountDao 对应 setWzkAccountDao)
FileNotFoundException: beans.xml(或 getResourceAsStream 返回 null) beans.xml 没放到 src/main/resources 下,Maven 不会把其他目录打到 classpath target/classes 里是否存在 beans.xml 把文件移到 src/main/resources 后重新构建
控制台报警告 Illegal reflective access to method ... SAXContentHandler JDK 9+ 对 dom4j 这类库的非法反射访问告警 看启动日志首行 WARNING: An illegal reflective access operation has occurred 不影响运行,可忽略;长期方案:升级到 dom4j 2.1.3+ 或换 JDK 8
启动告警 Loading class 'com.mysql.jdbc.Driver'. This is deprecated mysql-connector-java 8.0+ 已弃用旧驱动类 看 Druid 之前的 driver 加载日志 把驱动类改为 com.mysql.cj.jdbc.Driver,并检查连接 URL 的时区参数
转账只成功一半(fromResult: 1, toResult: 0)或中途异常 转账没有事务,两次 UPDATE 互相独立 关闭自动提交后看是否两边一起回滚 下节引入 TransactionManager + ThreadLocal 统一管理连接和事务边界
循环依赖导致启动栈溢出或 NPE 两个 Bean 互相 setter 注入(本实现两步式初始化可避开构造循环,但 setter 循环仍会出问题) 画 Bean 依赖图,找闭环 通过 @Lazy、三级缓存或重构依赖方向解决(Spring 的标准方案)

作者:武子康的个人博客

相关推荐
王木风1 小时前
Spring Boot + LLM 工程化:把短视频流水线拆成 16 个独立角色的踩坑记录
人工智能·spring boot·后端·开源·新媒体运营·音视频·agent
二哈赛车手1 小时前
新人笔记---idea索引失效问题解决方案
java·笔记·spring·elasticsearch·intellij-idea
月光刺眼1 小时前
Bun + TypeScript 后端入门:从类型约束到 LLM API 调用
后端·typescript
万岳科技1 小时前
教育培训系统开发流程详解:平台建设关键环节解析
数据库·后端·学习
Java编程爱好者1 小时前
服务里的 Redis 锁惊群问题:一次本地合流优化实践
后端
Nturmoils1 小时前
线上修一批脏数据,先别急着全量重来
数据库·后端
飞天狗1111 小时前
零基础JavaWeb入门——第五课第一小节:九大内置对象 · 第1个:request(请求对象)
java·开发语言·前端·后端·servlet
a15108416931 小时前
记一次大模型探索
java·服务器·前端
c++之路2 小时前
Bazel C++ 构建系列文档(五):多目标与多包项目
java·开发语言·c++