资讯动态

从零手写 Spring 核心:IOC 容器与动态代理 AOP 实战

发布时间:2026/10/22 11:59:04 来源:尧图企业网站定制
摘要本文记录了从零实现一个轻量级 IOC 与 AOP 框架的完整过程。该框架支持注解驱动的包扫描、基于反射的依赖注入、双重检查锁单例管理以及基于 JDK 动态代理和责任链模式实现的 AOP 拦截器链。文章不仅详细拆解了MyApplicationContext和ReflectiveMethodInvocation的核心架构还重点分享了开发过程中遇到的路径编码解析、代理对象与原始对象映射等实际问题的解决方案。UML类图开发过程包扫描IOC 的起点在 Spring 中我们只需要在启动类上加一个ComponentScan它就能自动找到我们写的Service、Controller比如Spring Boot中启动类上的聚合注解SpringBootApplication内就有ComponentScan注解。它是怎么知道要去哪里找类的呢自己手写时才发现核心思路其实很朴素把包名转换成文件目录路径然后去这个目录下递归遍历所有的.class文件最后用反射把它们加载到 JVM 中。1.1 核心步骤拆解包名转路径将com.fengrui.miniioc.service转换成com/fengrui/miniioc/service。获取资源 URL通过类加载器getResource()拿到这个路径的物理位置。递归扫描如果是文件夹就继续往下找如果是.class文件就生成全限定类名比如com.fengrui.miniioc.service.UserService然后用Class.forName()加载。判断注解看看这个类头上有没有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 就不是同一个了。本文暂时不讨论多例 BeanPrototype的情况后续有时间会继续拓展这一块。2.1 线程安全的单例容器为了让容器全局唯一我采用了经典的双重检查锁DCLvolatile来实现单例模式public class MyApplicationContext { // volatile 防止指令重排序保证 instance 的可见性 private static volatile MyApplicationContext instance; // 容器本体使用 ConcurrentHashMap 保证线程安全 private static final MapString, 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; } }为什么一定要加volatilenew对象其实并不是一个原子操作它底层分为三步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, ListMyMethodInterceptor 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. 过滤出匹配当前方法的切面 ListMyMethodInterceptor 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 MapObject, 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 ListMyMethodInterceptor 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对象路径里保留了%20dir.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 的认知又深入了反射是框架的灵魂所有的自动化包扫描、属性注入都离不开Class.forName和field.set。设计模式让代码有了骨架如果没有单例模式容器就会一团糟如果没有责任链模式多个切面的执行顺序和原方法调用次数就会彻底失控。真实环境永远比想象复杂一个路径空格的%20编码问题就足以让整个框架静默失效。这让我以后在使用 Spring 时多了一份对底层细节的敬畏。6.2 源码获取项目代码已全部开源包含详细的中文注释和测试用例可以直接拉取运行Gitee 仓库地址https://gitee.com/liang010913/mini-spring.githttps://gitee.com/liang010913/mini-spring.git如果你觉得这个项目对你有帮助或者从这篇文章里学到了一点东西欢迎给个Star⭐ 支持一下如果在阅读源码或运行过程中遇到任何问题也欢迎在评论区留言交流。全文完

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑