ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写Spring IoC容器:注解+反射实现依赖注入与Bean生命周期

手写Spring IoC容器:注解+反射实现依赖注入与Bean生命周期 我一个朋友第一次看Spring源码翻开AbstractApplicationContext.refresh()没几分钟就合上了跟我说这玩意儿太抽象光类名就够写一篇小说。我当时的建议很简单别看源码先手写一个能跑的IoC容器。当你用注解反射自己把对象创建和依赖注入这摊事理顺之后再看Spring那些类全都能对上号。这篇文章就是记录我手写Spring IoC的完整过程目标不是复刻Spring而是用最少的代码把容器的核心骨架搭出来。这个项目解决的是“知其然不知其所以然”的问题Spring IoC到底在管什么Bean是怎么被扫描到、实例化、注入到别人手里的为什么一个注解就能让对象自动出现适合所有被Spring“魔法”困惑的Java开发者也适合准备面试想聊透Bean生命周期的人。我会从核心原理讲到完整实现再附上我在写的过程中踩过的坑尽量做到看完你也能动手敲一个出来。1. 项目整体思路与能力边界1.1 手写IoC到底在写什么IoC全称Inversion of Control控制反转。很多文章喜欢用“原来你自己new对象现在交给容器”来解释这个说法对但不够深。真正反转的不是“谁来new”这个动作而是“对象获取依赖的方式”。传统写法里UserService要操作数据库自己得new UserDao()这是主动拉取IoC容器里UserService只需要声明“我需要一个UserDao”容器就会把现成的实例塞给你。这个过程中UserService完全不关心UserDao怎么创建、什么时候创建、是不是同一个实例它只关心“接口长什么样”。手写一个IoC容器本质就是实现三件事扫描指定包下的所有类找到被Component等注解标记的类通过反射创建这些类的实例并按配置决定是单例还是每次新建扫描实例内部的字段遇到Autowired等注入标记就按类型或名称把依赖塞进去。这三件事做完一个最简容器的主体就立住了。Spring的BeanFactory、ApplicationContext成千上万个类核心语义也逃不出这三板斧多出来的都是生命周期回调、AOP代理、事件监听、条件装配这些外围能力。1.2 能力清单与设计约束动手之前我先列了一个需求表。毕竟这是教学性质的项目不是要把Spring全量重写一遍能力边界必须划清楚否则写着写着就陷入细节出不来了。我最终确定的能力清单是这样能力项本容器支持情况说明注解定义与解析支持自定义Component、Autowired等注解运行时反射读取包扫描支持扫描指定包及子包下所有class文件Bean实例化支持默认无参构造可配置scope依赖注入支持字段注入按类型优先、按名称辅助单例/原型支持Scope(prototype)区分初始化回调支持简化自定义PostConstruct标记初始化方法循环依赖部分支持单例字段注入可处理构造器注入不支持AOP、事务、条件装配不支持超出最小内核范围这张表其实就是我设计的“验收标准”。写代码最忌讳上来就闷头写先定清楚“做到什么程度算完”后面每一步都有对照。我从一开始就决定不引入任何第三方框架连Lombok都不用全JDK自带能力。理由很简单这个项目的价值就在于让别人看清底层机制如果用了一堆工具类反而把关键逻辑遮住了。JDK的反射、注解API足够完成所有功能这也是标题里“注解反射”的核心依据。2. 核心技术原理注解标记与反射读取2.1 注解为什么适合做配置标记Java注解本质上是一种元数据它不包含业务代码只负责给类、字段、方法打上“标记”。容器启动时读取这些标记再决定怎么处理对应的类。这种做法的好处是配置和代码内聚在一起不会像XML那样出现“配置文件里写错一个类名运行期才报错”。注解有个关键属性必须理解生命周期。Retention取值有三种SOURCE编译期丢弃源码级注释CLASS保留到字节码但运行时读不到RUNTIME保留到运行时可通过反射读取。我们的容器是运行时扫描所以自定义的注解必须设置为RUNTIME。这一点是新手最容易忽略的坑。我见过有人定义注解时没写Retention默认是CLASS结果容器扫描了半天什么都扫不到因为反射根本拿不到注解信息。注解的Target也要认真设置。它规定了注解能贴在哪些元素上TYPE类或接口FIELD成员变量METHOD方法。比如Component只贴类Target(ElementType.TYPE)就够了Autowired要贴字段就得用Target(ElementType.FIELD)。设置得越精确代码的约束性越强能用编译器挡住一部分误用。2.2 反射容器底层的“万能钥匙”反射是Java提供的“自省”能力让程序在运行时可以拿到Class对象进而获取类的构造器、字段、方法并绕过访问控制直接调用。IoC容器之所以能接管对象生命周期靠的就是这一套API。我最常用的反射操作有这么几类Class.forName(className)根据全限定名加载类clazz.getDeclaredFields()获取所有声明的字段包括privatefield.setAccessible(true)打开私有字段的访问权限constructor.newInstance()调用构造器创建对象method.invoke(obj, args)调用对象的方法。为什么要setAccessible(true)因为我们的私有字段正常情况下无法从外部赋值但容器作为“框架层”必须有能力突破访问限制。设置完之后JVM会跳过访问检查直接完成操作。这也是Spring等框架普遍采用的手段。反射有一个绕不开的点性能。相比直接new对象反射调用多了类型检查和访问校验单次耗时确实更高。但写容器时不需要过分担心这一点因为容器只在启动阶段做实例化和注入运行期业务代码直接使用已注入好的对象反射对接口时延的影响是可控的。真要优化可以在创建Bean时做缓存把构造器和字段的Field对象缓存起来复用避免每次都重新获取。我的实现里就是先缓存Field数组再统一注入。2.3 自定义容器注解一览我最终定义了五个注解分别对应组件标记、依赖注入、名称限定、作用域、生命周期回调。代码贴出来供参考。Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface Component { String value() default ; }Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface Autowired { }Target({ElementType.FIELD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface Qualifier { String value(); }Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface Scope { String value() default singleton; }Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface PostConstruct { }这五个注解已经能覆盖一个最小的IoC场景。Qualifier我同时允许它贴在类和字段上贴在类上可以自定义beanName贴在字段上可以指定注入哪个名称的实现。这在实际开发里非常有用因为接口往往有多个实现类单靠类型匹配会报“多个Bean”的错误。3. 容器核心实现从包扫描到完成注入3.1 包扫描器找出所有候选类容器启动后的第一步是找到“哪些类归我管”。这个类扫描器是整个容器的入口实现思路并不复杂把包名转成文件路径交给ClassLoader去定位然后遍历目录下的所有class文件再用反射加载成Class对象。我写的单层扫描版本是这样的public class ClassScanner { public static SetClass? scan(String basePackage) throws Exception { SetClass? classes new HashSet(); String packagePath basePackage.replace(., /); EnumerationURL resources Thread.currentThread() .getContextClassLoader() .getResources(packagePath); while (resources.hasMoreElements()) { URL resource resources.nextElement(); File directory new File(resource.toURI()); for (File file : directory.listFiles()) { if (file.getName().endsWith(.class)) { String className basePackage . file.getName().substring(0, file.getName().length() - 6); classes.add(Class.forName(className)); } } } return classes; } }这个版本只扫一层目录实际项目里类都放在多级子包中。所以我又写了一个递归版本遍历时如果遇到子目录就递归调用同时把子包名拼接进去。核心区别是把listFiles换成递归处理。private static void findClasses(File dir, String packageName, SetClass? classes) throws ClassNotFoundException { File[] files dir.listFiles(); if (files null) { return; } for (File file : files) { if (file.isDirectory()) { findClasses(file, packageName . file.getName(), classes); } else if (file.getName().endsWith(.class)) { String className packageName . file.getName().substring(0, file.getName().length() - 6); classes.add(Class.forName(className)); } } }扫描到这里拿到的还是所有类的Class对象并不都是Bean。下一步就要用注解来筛选只保留标了Component的类。这个筛选逻辑放在容器主类里更合适因为筛选之后马上就要构建Bean定义。3.2 Bean定义注册与实例化Spring里有一个BeanDefinition概念它描述一个Bean的元信息类是谁、作用域是什么、要不要懒加载、初始化方法叫什么。我们简化一下只保留类和scope。public class BeanDefinition { private Class? beanClass; private String scope; public BeanDefinition(Class? beanClass, String scope) { this.beanClass beanClass; this.scope scope; } public Class? getBeanClass() { return beanClass; } public String getScope() { return scope; } }容器主类里需要有两个Map一个存BeanDefinition一个存单例实例。public class SimpleApplicationContext { private MapString, BeanDefinition beanDefinitionMap new ConcurrentHashMap(); private MapString, Object singletonObjects new ConcurrentHashMap(); public SimpleApplicationContext(String basePackage) throws Exception { SetClass? classes ClassScanner.scan(basePackage); registerBeanDefinitions(classes); createSingletons(); populateProperties(); } }构造器里三步走注册定义、创建单例、填充属性。这三步的顺序有讲究我先解释一下第一步只是把Component类收集起来记录beanName、Class、scope不创建对象第二步把所有单例Bean的“空壳”创建出来这个时候字段还都是null第三步统一遍历把依赖注入到字段中。为什么不是“创建一个Bean就立刻注入它的依赖”因为可能会出现A依赖B、B依赖A这种循环依赖。如果创建A后立刻注B而B还没创建那就得递归创建BB又回来找A容易栈溢出。先把所有单例的空引用都放到Map里再统一注入相当于提前把“地址”占上了后面赋值时只要能从Map里找到对方就行。Spring的“三级缓存”处理循环依赖也是类似逻辑只是更精细。注册BeanDefinition的时候beanName的规则很重要如果Component里写了自定义value就用value没写就用类名首字母小写。比如UserService的默认beanName是userService。这个规则和Spring保持一致后面写Demo时不会犯迷糊。private void registerBeanDefinitions(SetClass? classes) { for (Class? clazz : classes) { if (!clazz.isAnnotationPresent(Component.class)) { continue; } Component component clazz.getAnnotation(Component.class); String beanName component.value(); if (beanName.isEmpty()) { beanName lowerFirstChar(clazz.getSimpleName()); } String scope singleton; if (clazz.isAnnotationPresent(Scope.class)) { scope clazz.getAnnotation(Scope.class).value(); } beanDefinitionMap.put(beanName, new BeanDefinition(clazz, scope)); } }3.3 依赖注入的核心逻辑容器里最关键的代码就是populateProperties()。这一步要遍历所有单例对象检查每个字段是否标了Autowired有的话就把对应Bean找出来赋进去。我在实现时把它拆成两个方法一个负责对外遍历一个负责处理单个Bean的所有字段。private void populateProperties() throws IllegalAccessException { for (String beanName : singletonObjects.keySet()) { Object bean singletonObjects.get(beanName); populateProperty(beanName, bean); } } private void populateProperty(String beanName, Object bean) throws IllegalAccessException { Class? clazz bean.getClass(); Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { if (!field.isAnnotationPresent(Autowired.class)) { continue; } field.setAccessible(true); Object dependency resolveDependency(field, beanName); field.set(bean, dependency); } }resolveDependency是类型匹配的核心。我把它设计成一个递归方法先尝试按类型找如果有多个实现再结合Qualifier指定的名称精确查找。private Object resolveDependency(Field field, String currentBeanName) { Class? fieldType field.getType(); ListString matchedNames new ArrayList(); for (Map.EntryString, BeanDefinition entry : beanDefinitionMap.entrySet()) { Class? beanClass entry.getValue().getBeanClass(); boolean isMatch fieldType.isAssignableFrom(beanClass); if (isMatch) { matchedNames.add(entry.getKey()); } } if (matchedNames.size() 1) { return getBean(matchedNames.get(0)); } if (matchedNames.size() 1) { Qualifier qualifier field.getAnnotation(Qualifier.class); if (qualifier ! null beanDefinitionMap.containsKey(qualifier.value())) { return getBean(qualifier.value()); } throw new RuntimeException(存在多个实现类请通过Qualifier指定注入beanName); } throw new RuntimeException( 无法找到类型为 fieldType.getName() 的Bean注入失败); }这里有个细节匹配时用的是fieldType.isAssignableFrom(beanClass)意思是“字段类型能否接收这个BeanClass的实例”。这样字段声明成接口也能匹配到实现类。比如字段类型是UserDaoBean类正好是UserDaoImplUserDao.isAssignableFrom(UserDaoImpl.class)返回true就能正确注进去。getBean方法也要考虑scope。单例直接从Map取原型Bean则走创建流程。public Object getBean(String beanName) { BeanDefinition definition beanDefinitionMap.get(beanName); if (definition null) { throw new RuntimeException(不存在名为 beanName 的Bean); } if (prototype.equals(definition.getScope())) { return createBeanInstance(definition); } return singletonObjects.get(beanName); }3.4 作用域与初始化回调Scope注解我支持singleton和prototype两种取值。单例就是全局共享一个实例不管从哪里获取都返回同一个对象原型则是每次获取都新建适合有状态的对象。调用getBean时如果发现是原型Bean必须走完整的“创建实例属性注入”流程而不是直接从单例缓存里取。这里我单独抽了一个createBeanInstance方法private Object createBeanInstance(BeanDefinition definition) { try { Class? clazz definition.getBeanClass(); Constructor? constructor clazz.getDeclaredConstructor(); constructor.setAccessible(true); return constructor.newInstance(); } catch (Exception e) { throw new RuntimeException(创建Bean实例失败: definition.getBeanClass().getName(), e); } }初始化回调这块我模仿了PostConstruct的语义容器在属性填充完成后自动调用标了这个注解的方法。实现也不难在populateProperty之后加一段代码遍历方法找注解。private void invokeInitMethods(Object bean) throws Exception { Class? clazz bean.getClass(); for (Method method : clazz.getDeclaredMethods()) { if (method.isAnnotationPresent(PostConstruct.class)) { method.setAccessible(true); method.invoke(bean); } } }这个方法要在属性注入完成后调用这样初始化方法里访问字段才是非null的。如果你把顺序搞反了会出现初始化方法里调dao时机不对直接空指针。4. 实操过程与关键步骤记录4.1 项目结构与启动流程串联我用一个最简单的Maven工程来演示目录结构如下src/main/java/com/example/demo ├── annotation │ ├── Component.java │ ├── Autowired.java │ ├── Qualifier.java │ ├── Scope.java │ └── PostConstruct.java ├── context │ ├── BeanDefinition.java │ ├── ClassScanner.java │ └── SimpleApplicationContext.java ├── service │ ├── UserDao.java │ ├── UserDaoImpl.java │ └── UserService.java └── Main.java启动时只需要两行代码public class Main { public static void main(String[] args) throws Exception { SimpleApplicationContext context new SimpleApplicationContext(com.example.demo); UserService userService (UserService) context.getBean(userService); userService.sayHello(); } }SimpleApplicationContext构造器传入根包名容器会自动扫描这个包及子包。看到这里你会发现这不就是Spring Boot启动类上传SpringBootApplication然后跑起来的缩略版嘛。是的原理是相通的只不过Spring Boot背后还封装了自动配置、条件评估、环境处理一大堆逻辑。启动流程我再用文字串一遍扫描class文件筛出标了Component的类注册成BeanDefinition遍历定义先用无参构造把单例的空壳对象new出来放进Map再遍历所有单例找到标了Autowired的字段根据类型或名称从Map里取对象反射赋值最后执行PostConstruct标记的初始化方法。全部走完容器就绪。4.2 一个完整的验证Demo为了验证容器真的能跑我设计了一个带接口、多实现、初始化回调的完整场景。先定义一个接口和两个实现类public interface UserDao { String getName(); } Component(userDaoA) public class UserDaoA implements UserDao { PostConstruct public void init() { System.out.println(UserDaoA 初始化完成); } Override public String getName() { return UserDaoA; } } Component(userDaoB) public class UserDaoB implements UserDao { Override public String getName() { return UserDaoB; } }再写一个依赖接口的ServiceComponent public class UserService { Autowired Qualifier(userDaoB) private UserDao userDao; public void sayHello() { System.out.println(Hello, userDao.getName()); } }注意这里UserService的字段类型是UserDao接口但接口有两个实现类UserDaoA和UserDaoB。如果只标Autowired容器的resolveDependency会跳过唯一性判断直接抛“存在多个实现类”异常。所以我在字段上加了Qualifier(userDaoB)让容器按名称精确注入。运行Main输出结果如下UserDaoA 初始化完成 Hello, UserDaoB第一行是UserDaoA的PostConstruct回调触发的第二行证明UserService里的userDao字段被正确注入成了userDaoB实例。这个Demo虽然简单但把“扫描—注册—实例化—注入—初始化”整个链路全部走通了。4.3 关键决策与踩坑心得写的过程中我记下了几个比较关键的设计决策都是真实踩过坑之后才想明白的。第一个是“先创建所有单例再注入”的顺序。我一开始是“创建一个注入一个”结果遇到A依赖B、B依赖A的循环依赖程序直接StackOverflowError。后来改成两步走先把所有对象的空引用放进Map再做字段赋值循环依赖的问题就没有了。这其实就是Spring三级缓存思想的雏形——先把早期引用暴露出来后续再去填充。第二个是接口多实现时的歧义处理。如果不加Qualifier检查容器遇到多实现会注入失败。我在resolveDependency里做了计数判断找到1个直接注入超过1个抛异常提示加Qualifier。这样错误信息非常友好能直接告诉调用方怎么改。第三个是关于setAccessible的位置。我建议在循环里每次set之前调用一次不要只在外面set一次。因为容器里Bean的Class可能来自不同的ClassLoader或者存在安全策略差异保险起见运行时随时调用才是最稳妥的。第四个是包扫描时file.listFiles()的判空。文件系统权限异常时会返回null我第一次没处理直接NPE。后来加了一个if (files null) return;这个问题就消失了。这种边界问题只有实际跑一遍多环境才能暴露。5. 常见问题与排查技巧实录5.1 高频问题速查表我在把这个容器分享给朋友用时收到了不少反馈整理成了一张问题速查表。现象可能原因排查思路启动后Bean集合为空包扫描路径写错注解没加Retention(RUNTIME)检查构造器传入的包名是否准确检查自定义注解的Retention注入的字段始终为nullAutowired没生效字段是static确认字段标了注解static字段不会被实例注入处理多个实现类导致注入失败接口有多个实现未指定Qualifier在字段上增加Qualifier(beanName)unsupported class file versionJDK版本与编译目标不一致统一本机的JDK版本和编译版本构造器注入报错类没有无参构造手写容器默认走无参构造要么补构造器要么改造成本支持构造器参数解析PostConstruct方法没执行方法不是public注解target没设置对PostConstruct只标在方法上检查方法签名和注解Target循环依赖导致栈溢出构造器注入互相持有把构造器注入改成字段注入或在设计上拆分依赖这张表基本覆盖了我遇到过的绝大多数坑。最典型的还是第一个和第四个几乎新手必踩。5.2 几个容易忽略的细节用ClassScanner扫描时有个比较隐蔽的坑如果类文件是嵌套类文件名会带$符号比如UserService$Inner.class。如果不加过滤Class.forName会加载一个内部类但它没有标Component问题不大但如果哪天内部类也标了注解就会出现预期之外的Bean。稳妥的做法是在扫描时跳过包含$符号的类只扫描顶层类。还有就是beanName大小写问题。类名UserService转换成beanName时很多人直接toLowerCase()变成userservice这样Qualifier(userService)就找不到了。正确写法是只把首字母小写userService。我专门写了个小工具方法处理这个代码虽短但能避免不少神坑。PostConstruct的NullPointerException也值得单独说。我最初在Demo里让UserDaoA的init方法打印日志没问题但如果初始化方法里访问了尚未注入的字段比如调userDao.query()而容器的执行顺序搞错了就会抛NPE。调试方法很简单在invokeInitMethods调用前打印一条日志确认容器执行到哪一步了。还有一个对新手不太友好的点容器里的Bean都是无参构造创建的对于需要传参才能创建的类这个简化版容器无法处理。真实Spring支持构造器参数解析、Value注入配置值、工厂方法创建对象这些都是进阶话题。我在项目里明确标注了“不支持构造器参数解析”这样使用方不会拿着一个需要传参的类来测试然后一头雾水。5.3 写在最后的几点个人体会手写这个容器花了我大概一个晚上但带来的收益远超预期。以前看Spring的DefaultListableBeanFactory、AbstractAutowireCapableBeanFactory感觉每个类都像一个黑盒子。现在再看脑子里会自动对应“哦这是做实例化的”“这是做字段填充的”“这是管生命周期回调的”源码突然就变得可读了。如果要我给后来者建议我会说把这个项目当成一个“理解Spring的解剖课”而不是“又一个造轮子工程”。先把扫描和注册写通再处理依赖注入最后加上scope和初始化回调。每完成一个能力点打开Spring源码翻一翻对应部分两相对照印象会深得多。我后续还打算给它加上Configuration和Bean注解支持用一个独立的配置类来管理一批Bean的创建这样就能实现“编程式装配”和“声明式装配”的融合。再往后可以考虑用动态代理包一层在一个方法上加自定义注解就触发增强逻辑往AOP的方向靠一靠。当然这是后话先把注解、反射这套容器基本功吃透后面的路自然就顺畅了。
返回列表