☰
从零手写 Spring 核心:IOC 容器与动态代理 AOP 实战
2026/10/21 9:31:54 网站建设 项目流程

摘要

本文记录了从零实现一个轻量级 IOC 与 AOP 框架的完整过程。该框架支持注解驱动的包扫描、基于反射的依赖注入、双重检查锁单例管理,以及基于 JDK 动态代理和责任链模式实现的 AOP 拦截器链。文章不仅详细拆解了MyApplicationContext和ReflectiveMethodInvocation的核心架构,还重点分享了开发过程中遇到的路径编码解析、代理对象与原始对象映射等实际问题的解决方案。

UML类图

开发过程

包扫描:IOC 的起点

在 Spring 中,我们只需要在启动类上加一个@ComponentScan,它就能自动找到我们写的@Service、@Controller,比如Spring Boot中,启动类上的聚合注解@SpringBootApplication内就有@ComponentScan注解。它是怎么知道要去哪里找类的呢?

自己手写时才发现,核心思路其实很朴素:把包名转换成文件目录路径,然后去这个目录下递归遍历所有的.class文件,最后用反射把它们加载到 JVM 中。

1.1 核心步骤拆解
  1. 包名转路径:将com.fengrui.miniioc.service转换成com/fengrui/miniioc/service。

  2. 获取资源 URL:通过类加载器getResource()拿到这个路径的物理位置。

  3. 递归扫描:如果是文件夹就继续往下找,如果是.class文件,就生成全限定类名(比如com.fengrui.miniioc.service.UserService),然后用Class.forName()加载。

  4. 判断注解:看看这个类头上有没有@MyComponent注解,有的话就进入下一步(实例化和注入)。

1.2 核心代码实现
private void scanPackages(String basePackage) { // 1. 包名转路径 (com.xxx -> com/xxx) String path = basePackage.replace(".", "/"); // 2. 通过类加载器获取资源 URL resource = Thread.currentThread().getContextClassLoader().getResource(path); if (resource == null) return; // 3. 转换为 File 对象 (这里踩了一个大坑,后面会讲!) File dir = Paths.get(resource.toURI()).toFile(); if (!dir.exists() || !dir.isDirectory()) return; // 4. 遍历目录下的文件 File[] files = dir.listFiles(); if (files == null) return; for (File file : files) { if (file.isDirectory()) { // 如果是子目录,递归扫描 scanPackages(basePackage + "." + file.getName()); } else if (file.getName().endsWith(".class")) { // 如果是 class 文件,去掉后缀,拼接全限定类名 String className = basePackage + "." + file.getName().replace(".class", ""); try { Class<?> clazz = Class.forName(className); // 判断是否包含 @MyComponent 注解 if (clazz.isAnnotationPresent(MyComponent.class)) { // ... 实例化并放入容器 (下一节讲解) } } catch (ClassNotFoundException e) { e.printStackTrace(); } } } }
1.3 为什么不用new File(resource.getFile())?

在我最早的第一版代码中,我直接用new File(resource.getFile())创建了实例。后来在本地测试时报错,我发现如果项目的绝对路径中包含空格(比如D:\Program Data\...),getFile()拿到的路径会把空格变成%20,导致dir.exists()直接返回false,扫描彻底失效。

最终我改成了Paths.get(resource.toURI()).toFile(),通过 URI 转换,完美解决了路径编码问题。


依赖注入与单例管理:IOC 的核心

包扫描把带有@MyComponent的类都找出来了,接下来要做的,就是把它们实例化,并把它们塞进容器里,顺便解决对象之间的依赖关系。

Spring 的 IOC 容器默认是单例的(Singleton),我们手写的容器也必须保证全局只有一个容器实例,否则不同地方拿到的 Bean 就不是同一个了。本文暂时不讨论多例 Bean(Prototype)的情况,后续有时间会继续拓展这一块。

2.1 线程安全的单例容器

为了让容器全局唯一,我采用了经典的双重检查锁(DCL)+volatile来实现单例模式:

public class MyApplicationContext { // volatile 防止指令重排序,保证 instance 的可见性 private static volatile MyApplicationContext instance; // 容器本体,使用 ConcurrentHashMap 保证线程安全 private static final Map<String, Object> BEAN_MAP = new ConcurrentHashMap<>(); private MyApplicationContext(String basePackage) { scanPackages(basePackage); injectDependencies(); // 扫描完成后,统一进行依赖注入 } public static MyApplicationContext getInstance(String basePackage) { if (instance == null) { synchronized (MyApplicationContext.class) { if (instance == null) { instance = new MyApplicationContext(basePackage); } } } return instance; } }
为什么一定要加volatile?
new对象其实并不是一个原子操作,它底层分为三步:1. 分配内存空间;2. 初始化对象;3. 将对象指向分配的内存地址。
如果发生指令重排(比如执行了 1 -> 3 -> 2),其他线程可能在instance != null时,拿到一个还没初始化完成的“半成品”对象。volatile关键字恰好可以禁止指令重排序,保证对象完美创建后才被其他线程看到。
2.2 反射注入:把对象塞进字段里

当所有的 Bean 都被实例化并放入BEAN_MAP之后,我们需要遍历每一个 Bean 的字段,看看有没有标了@MyAutowired的。如果有,就从BEAN_MAP里找到对应类型的对象,通过反射给它赋值。

private void injectDependencies() { for (Object bean : BEAN_MAP.values()) { // 遍历类中所有的字段(包括 private) for (Field field : bean.getClass().getDeclaredFields()) { // 只处理带有 @MyAutowired 注解的字段 if (field.isAnnotationPresent(MyAutowired.class)) { // 根据字段类型,从容器中获取对应的 Bean Object beanToInject = getBean(field.getType()); if (beanToInject != null) { try { // 突破 private 限制,强行赋值 field.setAccessible(true); field.set(bean, beanToInject); } catch (IllegalAccessException e) { e.printStackTrace(); } } } } } }
2.3 为什么注入要单独抽一个方法?

这里有一个设计上的小细节:千万不能在实例化的同时进行注入。
因为如果OrderService依赖了UserService,但UserService还没有被扫描到并放入容器,此时去注入就会拿到null。所以必须分两步走:先把所有 Bean 实例化放入容器,再统一遍历进行属性注入。


AOP 代理工厂:JDK 动态代理

IOC 解决了对象的创建和依赖问题。但如果我们想在方法执行前后加点逻辑(比如打日志、算耗时、做权限校验),总不能去改每一个业务类的源码吧?这就是 AOP 要干的事。

在 Spring 中,如果目标类实现了接口,默认会使用JDK 动态代理。在我的容器中,我也使用了Proxy.newProxyInstance来为 Bean 生成代理对象。

3.1 如何判断是否需要生成代理?

并不是所有的 Bean 都需要被代理。只有当容器中存在切面(@MyAspect),并且当前类实现了接口(JDK 代理的硬性要求)时,才进行代理。

// 在 scanPackages 创建完实例后,放入 BEAN_MAP 之前的逻辑 Object newInstance = aClass.getDeclaredConstructor().newInstance(); if (!ASPECTS.isEmpty() && aClass.getInterfaces().length > 0) { // 生成代理对象 newInstance = ProxyFactory.createProxy(newInstance, ASPECTS); } BEAN_MAP.put(beanName, newInstance);
3.2 代理工厂的核心实现

ProxyFactory的核心就是调用Proxy.newProxyInstance。它会拦截目标对象的所有接口方法,交给InvocationHandler处理。

public static Object createProxy(Object target, List<MyMethodInterceptor> interceptors) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) -> { // 1. 如果是 Object 的基础方法(toString/hashCode/equals),直接放行 if (method.getDeclaringClass() == Object.class) { return method.invoke(target, args); } // 2. 过滤出匹配当前方法的切面 List<MyMethodInterceptor> matched = new ArrayList<>(); for (MyMethodInterceptor interceptor : interceptors) { if (interceptor.matches(method)) { matched.add(interceptor); } } // 3. 如果没有切面匹配,直接执行原方法 if (matched.isEmpty()) { return method.invoke(target, args); } // 4. 如果有切面,进入拦截器链(下一节详细讲) MethodInvocation invocation = new ReflectiveMethodInvocation(target, method, args, matched); return invocation.proceed(); } ); }

细节排雷:如果不加method.getDeclaringClass() == Object.class这个判断,你会发现在把代理对象放进ConcurrentHashMap时,会疯狂触发hashCode()的切面拦截。加上这行代码,就能让基础方法直接走原对象,避免无意义的拦截。

3.3 超级大坑:代理对象的依赖注入失效

这里是我开发过程中卡了最久的一个 Bug。

当OrderService被代理后,放入BEAN_MAP里的其实是代理对象($Proxy类),而不是原始的OrderServiceImpl。
到了统一依赖注入的环节,injectDependencies()遍历的是BEAN_MAP,拿到的是代理对象。代理对象是基于接口生成的,它里面根本没有原实现类的userService字段!这就导致field.set(proxy, ...)根本找不到字段,注入彻底失效,运行时疯狂报NullPointerException。

我是怎么解决的?
维护一个“代理对象 -> 原始对象”的映射表。
在生成代理时,把映射关系存起来;在注入时,通过代理对象拿到背后的原始对象,再对原始对象进行反射注入。

// 1. 增加一个映射表 private static final Map<Object, Object> PROXY_TO_TARGET = new ConcurrentHashMap<>(); // 2. 生成代理时记录映射 Object proxy = ProxyFactory.createProxy(newInstance, ASPECTS); PROXY_TO_TARGET.put(proxy, newInstance); BEAN_MAP.put(beanName, proxy); // 3. 注入时,剥开代理的“外衣” for (Object bean : BEAN_MAP.values()) { // 如果是代理,拿到原始对象;如果不是,就用自己 Object target = PROXY_TO_TARGET.getOrDefault(bean, bean); for (Field field : target.getClass().getDeclaredFields()) { // ... 对 target 进行 field.set 注入 } }

拦截器链:责任链模式

代理对象生成好了,但当有多个切面同时拦截同一个方法时,怎么保证它们按顺序执行?怎么保证原方法只执行一次?

遍历问题很多人第一反应一定是使用for循环解决,但这会导致一个致命问题:每个切面内部都会调用method.invoke(),导致原方法被执行多次。为了解决这个问题,我引入了责任链模式(Chain of Responsibility)。

public interface MethodInvocation { Object proceed() throws Throwable; // ... 其他获取 target、method、args 的方法 }
4.1 核心思想:执行权的“接力”

切面不应该直接调用原方法,而是调用一个统一的proceed()方法。proceed()负责决定下一步是执行下一个切面,还是执行最终的原方法。

4.2 链的实现:ReflectiveMethodInvocation

这个类是整条链的核心。它通过维护一个currentIndex游标,来控制当前执行到第几个切面了。

public class ReflectiveMethodInvocation implements MethodInvocation { private final Object target; private final Method method; private final Object[] args; private final List<MyMethodInterceptor> interceptors; private int currentIndex = -1; // 当前执行到第几个切面 // ... 构造方法 @Override public Object proceed() throws Throwable { // 如果所有切面都执行完了,执行原方法 if (currentIndex == interceptors.size() - 1) { method.setAccessible(true); return method.invoke(target, args); } // 否则,游标前进一步,执行下一个切面 currentIndex++; MyMethodInterceptor interceptor = interceptors.get(currentIndex); return interceptor.invoke(this); } }

关键点:currentIndex是共享状态。每次调用proceed(),它只会+1,保证了链条一直往前走,且原方法只在终点执行一次。

4.3 洋葱模型是怎么形成的?

在切面里,我们不再直接调原方法,而是调invocation.proceed():

@Override public Object invoke(MethodInvocation invocation) throws Throwable { System.out.println("【Before】调用方法:" + invocation.getMethod().getName()); // 接力:执行下一个切面(或者原方法) Object result = invocation.proceed(); System.out.println("【After】调用方法:" + invocation.getMethod().getName()); return result; }
4.4 运行效果展示

假设我们有两个切面:LogAspect(匹配create*)和TimeAspect(匹配所有),调用createOrder()时的控制台输出如下:

【Before】调用方法:createOrder <-- LogAspect 前置 【Time-Start】createOrder <-- TimeAspect 前置 UserService sayHello <-- 原方法执行 OrderService createOrder <-- 原方法执行 【Time-End】耗时:3ms <-- TimeAspect 后置 【After】调用方法:createOrder <-- LogAspect 后置

这就是 AOP 的“洋葱模型”:前置逻辑按顺序入栈,原方法在栈底执行一次,后置逻辑按逆序出栈。拦截器链本质上就是一个共享了游标状态、通过递归/嵌套调用形成的执行链。


踩坑实录

写轮子的过程中,如果只是按部就班地敲代码,往往一切顺利;但一旦放到真实的 Windows 环境下,各种奇奇怪怪的问题就全出来了。这里记录了两个让我印象最深的坑。

5.1 坑一:Windows 路径空格导致的“幽灵扫描”

【现象】:代码写好后,启动容器,结果什么 Bean 都没有注册,控制台连个报错都没有,getBean直接返回null。

【排查过程】:
我在scanPackages方法里加了日志,发现getResource(path)返回的 URL 是这样的:
file:/D:/Program%20Data/Code%20Repository/...
注意那个%20。我的项目路径里包含了空格(Program Data),直接resource.getFile()去创建File对象会导致生成的File对象路径里保留了%20,dir.exists()直接判定为false,整个扫描逻辑静默失败。

【解决方案】:
不能直接用resource.getFile(),必须通过 URI 来转换,让系统自动处理 URL 编码。

// ❌ 如果路径存在空格会导致扫描失败 // File dir = new File(resource.getFile()); // ✅ 健壮性写法 File dir = Paths.get(resource.toURI()).toFile();

【现象】:引入 AOP 代理后,之前正常的依赖注入突然失效了,运行时报NullPointerException。

【排查过程】:
因为OrderService被代理后,放入BEAN_MAP里的是 JDK 动态生成的$Proxy对象。在进行属性注入时,遍历的是代理类的getDeclaredFields()。代理类只实现了接口方法,根本没有原实现类的成员变量,自然无法赋值。

【解决方案】:
用一个Map记录“代理对象 -> 原始对象”的映射。在注入时,通过getOrDefault拿到背后的原始对象进行反射赋值。这样既保留了 AOP 代理,又保证了属性注入成功。

效果展示与总结

到这里,一个具备核心功能的轻量级 IOC + AOP 容器就大功告成了。

6.1 写在最后:我收获了什么?

回过头来看,手写这个框架大概花了 1000 行左右的代码。虽然它和 Spring 庞大的生态(如循环依赖处理、BeanPostProcessor 扩展点、多种作用域等)相比还非常稚嫩,但这个过程让我对 Spring 的认知又深入了:

  1. 反射是框架的灵魂:所有的自动化(包扫描、属性注入)都离不开Class.forName和field.set。

  2. 设计模式让代码有了骨架:如果没有单例模式,容器就会一团糟;如果没有责任链模式,多个切面的执行顺序和原方法调用次数就会彻底失控。

  3. 真实环境永远比想象复杂:一个路径空格的%20编码问题,就足以让整个框架静默失效。这让我以后在使用 Spring 时,多了一份对底层细节的敬畏。

6.2 源码获取

项目代码已全部开源,包含详细的中文注释和测试用例,可以直接拉取运行:

👉Gitee 仓库地址:https://gitee.com/liang010913/mini-spring.githttps://gitee.com/liang010913/mini-spring.git

如果你觉得这个项目对你有帮助,或者从这篇文章里学到了一点东西,欢迎给个Star⭐ 支持一下!如果在阅读源码或运行过程中遇到任何问题,也欢迎在评论区留言交流。

(全文完)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询