ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写300行代码还原SpringBoot自动装配原理

手写300行代码还原SpringBoot自动装配原理 有段时间我特别怕面试官问“聊聊SpringBoot自动装配原理”。因为八股文背得再熟一旦被追问“AutoConfigurationImportSelector到底怎么生效的”“那个ConditionalOnClass是怎么判断的”“启动类上那个注解一路解析过去做了什么”我基本就接不上话了。后来我换了个思路与其反复背不如用300行代码亲手把SpringBoot的核心骨架搭出来就当是给自己交一份“原理作业”。这篇文章就是我这次手写的完整复盘从注解设计到类扫描、条件装配、配置解析、内嵌Web容器启动把SpringBoot最核心的几条链路走一遍。适合那些已经会写SpringBoot增删改查、但想真正搞懂框架底层的同学也适合准备SpringBoot面试题时不想只靠背诵的人。1. 为什么我要用300行代码“重写”一遍SpringBoot1.1 背了八百遍自动装配一面试就露馅SpringBoot的面试题在网上随便一搜就是几十条从“SpringBootApplication组合注解有哪些”到“自动配置原理”再到“SpringBoot怎么解决循环依赖”之类的延伸题。我一开始也是老老实实背背完之后感觉自己懂了可真被面试官按着细节一直往下问的时候就原形毕露了。举一个很典型的例子自动装配。大家都知道SpringBoot是利用AutoConfigurationImportSelector读META-INF/spring/...AutoConfiguration.imports文件里的配置类然后批量注入到容器里。但面试官接着问你“那些配置类上标的ConditionalOnClass、ConditionalOnMissingBean是在什么阶段被处理的它们的判断依据是啥”我就不太说得清楚了因为我从来没从代码层面去看过这件事。那次面试结束之后我反思了一下主要原因不是我不努力而是我的知识全是零散的“结论层”知识没有串成一条完整的因果链。于是我就决定自己动手写一个微型的SpringBoot用最少的代码把“扫描、注册、条件判断、自动配置加载、内嵌服务器启动”这一整条链路打通。事实证明当我自己从零开始写的时候很多以前记不住的东西根本不用背了因为代码就是逻辑本身。1.2 300行代码到底能还原哪些“核心原理”先说明一下我这里的300行是一个约数指的是核心骨架代码量不包括测试用的Controller、Service之类的Demo代码。真正算下来核心类的代码加在一起大概在300行附近这个体量非常适合用来做原理拆解。那这300行能覆盖SpringBoot的哪些核心原理呢我划了一个范围约定优于配置的体现只要主类在某个包下启动时自动扫描该包及子包下的组件注解条件装配机制我用一个自定义注解模拟ConditionalOnClass类路径里存在指定类就注册Bean不存在就跳过这正是SpringBoot按需加载的思想自动配置类加载机制用META-INF/yauto.imports文件模拟SpringBoot的AutoConfiguration.imports启动时自动读取并注册配置类内嵌Web服务器启动用JDK自带的轻量级HttpServer模拟内嵌Tomcat启动应用后就能直接访问HTTP接口。至于SpringBoot里非常复杂的东西比如AOP代理、事务管理、循环依赖三级缓存、Bean生命周期完整回调、SpringApplication的监听器事件体系这些不在这次手写的范围内。300行代码的价值不是替代SpringBoot而是帮我们看清它的骨架。1.3 MiniSpringBoot的整体架构与模块划分我在动手之前先画了个模块划分这样写代码的时候不会被绕晕。整个MiniSpringBoot由这么几个部分组成注解定义模块定义YComponent、YConfiguration、YBean、YAutowired、YConditionalOnClass等注解用来模拟Spring和SpringBoot的注解体系类扫描模块负责扫描指定包路径下所有的class文件拿到全限定类名条件判断模块读取类上的条件注解检查对应的类是否存在决定这个Bean要不要被注册配置注册与容器模块维护一个简单的Bean容器负责创建对象、注入依赖自动配置加载模块从配置文件读取自动配置类列表按条件判断后再注册到容器中启动模块模拟SpringApplication.run把上面几件事串起来最后启动HTTP服务器。这个结构和SpringBoot本身是一一对应的只是每一部分都做了极度简化。比如SpringBoot里负责扫描的是ClassPathBeanDefinitionScanner我写的就是一个几十行的ClassScannerSpringBoot里负责条件判断的有ConditionEvaluator和一堆Condition实现类我就只实现了一个按类名存在性判断的逻辑。2. 核心设计SpringBoot到底在“自动”什么2.1 起步依赖与自动配置是两层事很多初学者会把SpringBoot的“自动配置”和“starter起步依赖”混在一起。其实这是两件事。starter解决的是依赖引入的问题。比如你引入spring-boot-starter-webMaven或Gradle会把SpringMVC、Jackson、内嵌Tomcat等一堆依赖全部拉进来省得你自己一个个配版本号。而自动配置解决的是Bean创建的问题。项目里已经引入了某个类库的jar包SpringBoot扫描到它之后自动帮你把它需要的核心Bean创建好比如引入Redis客户端之后SpringBoot自动创建RedisTemplate和ConnectionFactory。所以SpringBoot的“自动”体现在运行时而不是构建时。我在手写MiniSpringBoot时刻意把这两件事分开我没有去写构建工具只模拟了运行时的那一层。也就是说假设外部类库已经被打进classpath了我的启动类再去classpath里探测“有没有这个类”有就创建对应的Bean。这就是条件装配和自动配置的核心意义。2.2 条件装配那个“如果存在就生效”的开关SpringBoot自动配置类上有大量条件注解最常见的是ConditionalOnClass和ConditionalOnMissingBean。前者的意思是“如果classpath里有这个类我才生效”后者的意思是“如果容器里已经没有这个Bean了我才会注册一个默认的”。这就解释了为什么SpringBoot能同时兼容“导入包就自动生效”和“用户自定义Bean可以覆盖默认配置”这两种行为。我拿家里的电路来打比方自动配置类就像一个智能插座它上面有个开关。插座的说明书写着“如果旁边放了一台冰箱且这台冰箱不是你自己装进去的那就自动给它通电如果没有冰箱或者冰箱是你自己装的我就不管”。这个“如果”就是条件判断。SpringBoot正是因为把这种开关逻辑大量放在自动配置类上才能做到按需生效而不是一股脑把几百个配置类全部加载进去。我在手写的时候把这个机制简化成了一句核心逻辑扫描到一个配置类先看类上有没有YConditionalOnClass注解有的话就去classpath里找指定的类。找到了就继续注册找不到就跳过并打印一行日志。2.3 从BeanFactory到自动配置加载的完整链路SpringBoot的真正启动链路如果粗略拆下来大概是这样的启动类上的SpringBootApplication被解析它是个组合注解包含了EnableAutoConfiguration和ComponentScanComponentScan启动包扫描把用户包下所有标注了Component、Service、Controller的类注册成BeanDefinitionEnableAutoConfiguration通过导入AutoConfigurationImportSelector读取classpath下所有AutoConfiguration.imports文件中的自动配置类对这些自动配置类逐个做条件判断符合条件的前置条件判断之后再把具体Bean注册进容器容器刷新完成实例化、依赖注入、初始化等流程创建内嵌Web服务器并启动。MiniSpringBoot的顺序几乎是一样的唯一不同的是我没有把BeanDefinition这个概念做成一个独立的类而是直接用Map记录了Bean的名称和类类型。简化归简化链路是完整的。3. 手写实现注解、扫描器与条件判断3.1 先把元注解定义清楚既然要模拟SpringBoot那第一件事就是定义自己的注解。我定义了一套带Y前缀的注解用来对应Spring家族里的常见注解。注意这里为了省代码我把ComponentScan的能力直接放到了启动注解上没有单独做ComponentScan。Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface YSpringBootApplication { String basePackage() default ; } Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface YComponent { } Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface YConfiguration { } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface YBean { } Retention(RetentionPolicy.RUNTIME) Target(ElementType.FIELD) public interface YAutowired { } Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface YConditionalOnClass { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface YAutoConfiguration { }这里有个细节我踩过坑注解的Target一定要写对。YAutowired的Target是FIELDYBean的Target是METHODYConditionalOnClass的Target是TYPE。如果Target写宽了比如让Autowired能标在方法上后续判断逻辑就要多写分支如果写窄了标错位置会直接编译报错。Spring源码里对注解Target限制特别严格我们手写的版本应该同样严格。3.2 实现ClassScanner类扫描扫描器做的是SpringBootComponentScan的活给定一个包名把这个包以及所有子包下编译好的class文件找出来。因为我们是本地跑Demo使用文件目录和Jar包两种情况都考虑到比较好。我这里做了一个尽量简洁的版本支持目录遍历也做了Jar包遍历的兼容。public class ClassScanner { public static ListClass? scan(String packageName) throws Exception { ListClass? classes new ArrayList(); String path packageName.replace(., /); ClassLoader cl Thread.currentThread().getContextClassLoader(); EnumerationURL resources cl.getResources(path); while (resources.hasMoreElements()) { URL url resources.nextElement(); String protocol url.getProtocol(); if (file.equals(protocol)) { File dir new File(url.toURI()); scanDir(dir, packageName, classes, cl); } else if (jar.equals(protocol)) { scanJar(url, path, classes, cl); } } return classes; } private static void scanDir(File dir, String packageName, ListClass? classes, ClassLoader cl) throws Exception { if (!dir.exists()) return; File[] files dir.listFiles(); if (files null) return; for (File file : files) { if (file.isDirectory()) { scanDir(file, packageName . file.getName(), classes, cl); } else if (file.getName().endsWith(.class)) { String className packageName . file.getName().substring(0, file.getName().length() - 6); classes.add(Class.forName(className, false, cl)); } } } private static void scanJar(URL url, String path, ListClass? classes, ClassLoader cl) throws Exception { JarURLConnection connection (JarURLConnection) url.openConnection(); JarFile jarFile connection.getJarFile(); EnumerationJarEntry entries jarFile.entries(); while (entries.hasMoreElements()) { String entryName entries.nextElement().getName(); if (entryName.startsWith(path) entryName.endsWith(.class)) { String className entryName.replace(/, .).substring(0, entryName.length() - 6); classes.add(Class.forName(className, false, cl)); } } } }这里有个值得说明的点Class.forName(className, false, cl)里的第二个参数是false意思是只加载类不做静态初始化。这样可以避免扫描阶段就触发类的静态代码块因为SpringBoot在扫描阶段也只是加载类元数据不会急着初始化静态资源。如果这里用了true遇到一些有复杂静态逻辑的类会拖慢启动速度甚至可能因为缺依赖而报错。3.3 实现条件判断器条件判断器的职责是回答一个问题这个配置类到底要不要被加载在SpringBoot里这个判断逻辑分散在很多Condition实现类中我这边只实现一种最常见的场景检查YConditionalOnClass上写的类名能不能在当前classpath中被加载到。public class ConditionEvaluator { public static boolean matches(Class? clazz, ClassLoader classLoader) { YConditionalOnClass condition clazz.getAnnotation(YConditionalOnClass.class); if (condition null) { return true; } try { Class.forName(condition.value(), false, classLoader); return true; } catch (ClassNotFoundException e) { System.out.println([条件不满足] 缺少类: condition.value() , 跳过: clazz.getName()); return false; } } }在实际手写过程中我把这个判断器放在两个地方调用一处是处理自动配置类的时候另一处是处理普通配置类的时候。这样就能保证“如果某个可选依赖没引入那么依赖它的一组Bean都不会被创建”。这一点和SpringBoot的机制非常像比如你没引入Spring Security那些SecurityAutoConfiguration相关的配置就会因为条件不满足而全部不生效。3.4 配置解析YBean方法与YComponent类的注册接下来是整个容器最核心的部分。我设计了一个简单的MiniContainer它内部维护两个Map一个存Bean名称到类型定义的映射一个存Bean名称到实例对象的映射。因为是自己写的极简版我没有再单独抽出BeanDefinition体系而是直接用Class对象代替代码会省很多。public class MiniContainer { private final MapString, Class? definitions new ConcurrentHashMap(); private final MapString, Object singletons new ConcurrentHashMap(); public void register(String beanName, Class? clazz) { definitions.put(beanName, clazz); } public void registerInstance(String beanName, Object instance) { singletons.put(beanName, instance); } public Object getBean(String beanName) { return singletons.get(beanName); } public void refresh() throws Exception { for (Map.EntryString, Class? entry : definitions.entrySet()) { createSingleton(entry.getKey(), entry.getValue()); } injectDependencies(); } private void createSingleton(String beanName, Class? clazz) throws Exception { if (singletons.containsKey(beanName)) { return; } Object instance clazz.getDeclaredConstructor().newInstance(); singletons.put(beanName, instance); } private void injectDependencies() throws Exception { for (Object bean : singletons.values()) { for (Field field : bean.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(YAutowired.class)) { String fieldName field.getName(); Object dependency singletons.get(fieldName); if (dependency null) { throw new IllegalStateException(找不到依赖: fieldName); } field.setAccessible(true); field.set(bean, dependency); } } } } }注意我这里依赖注入的查找方式是用字段名去容器里找Bean实例这是一种约定。因为我在手写项目里都按字段首字母小写来命名Bean所以这块是最省事的做法。真正的SpringBoot会先根据类型找再根据名字匹配找不到再走构造器注入复杂度远高于这个。我简化成按名字匹配对原理理解没有太大影响。组件注册的时候主体是一个ConfigurationProcessor负责扫描到的普通类和配置类。扫描到的类如果标了YComponent直接注册如果标了YConfiguration除了注册它本身还要解析里面标了YBean的方法把方法的返回值注册为Bean。public class ConfigurationProcessor { public static void process(ListClass? classes, MiniContainer container) throws Exception { for (Class? clazz : classes) { if (!ConditionEvaluator.matches(clazz, clazz.getClassLoader())) { continue; } if (clazz.isAnnotationPresent(YComponent.class)) { container.register(lowerFirst(clazz.getSimpleName()), clazz); } if (clazz.isAnnotationPresent(YConfiguration.class)) { container.register(lowerFirst(clazz.getSimpleName()), clazz); for (Method method : clazz.getDeclaredMethods()) { if (method.isAnnotationPresent(YBean.class)) { container.register(method.getName(), method.getReturnType()); } } } } } public static String lowerFirst(String name) { if (Character.isUpperCase(name.charAt(0))) { return Character.toLowerCase(name.charAt(0)) name.substring(1); } return name; } }这里有一个坑YConfiguration类本身也会被注册成一个Bean这样标了YBean的方法执行的时候才有地方可以调用。真实SpringBoot里配置类也会被CGLIB增强并注册成一个代理对象我这边只是简单注册原始类没有做代理增强。这就够了因为我们的目标是理解原理不是复制Spring的全部特性。4. 启动流程与Web容器演示4.1 启动类与run方法到了这一步所有零件都齐了就差一个把它们组装起来的run方法。这个方法对应的就是SpringApplication.run()。它的执行顺序我严格按SpringBoot的链路来设计扫描用户包、注册普通Bean、加载自动配置类、刷新容器、注入依赖、启动HTTP服务器。public class MiniSpringBoot { public static MiniContainer run(Class? mainClass, String[] args) throws Exception { String basePackage mainClass.getPackageName(); MiniContainer container new MiniContainer(); ListClass? classes ClassScanner.scan(basePackage); ConfigurationProcessor.process(classes, container); AutoConfigLoader.loadAutoConfigs(container, basePackage); container.refresh(); WebServer.start(container); return container; } }看到这个启动流程再回头看SpringBoot源码里SpringApplication.run那一大坨方法其实本质也是这几步。只不过它在中间插入了非常多的扩展点比如ApplicationContextInitializer、ApplicationListener、EnvironmentPostProcessor这些让框架的使用者可以通过扩展点干预启动过程。我们手写的版本省略了所有扩展点主流程一目了然。4.2 用JDK内置HttpServer模拟内嵌Web容器SpringBoot默认内嵌Tomcat所以只要引入了spring-boot-starter-web启动之后就能直接访问接口。我不会在300行代码里真的引入Tomcat源码那样代码量就爆了。所以我用了JDK自带的com.sun.net.httpserver.HttpServer来模拟内嵌容器效果是类似的启动一个监听在8080端口的HTTP服务把用户写的Controller方法映射成可访问的URL。public class WebServer { public static void start(MiniContainer container) throws Exception { HttpServer server HttpServer.create(new InetSocketAddress(8080), 0); server.createContext(/, exchange - { String path exchange.getRequestURI().getPath(); Object result null; boolean found false; for (Object bean : container.allBeans()) { for (Method method : bean.getClass().getDeclaredMethods()) { YGetMapping mapping method.getAnnotation(YGetMapping.class); if (mapping ! null mapping.value().equals(path)) { result method.invoke(bean); found true; break; } } if (found) break; } byte[] resp String.valueOf(result).getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, resp.length); exchange.getResponseBody().write(resp); exchange.close(); }); server.start(); System.out.println(MiniSpringBoot started on port 8080); } }对应的YGetMapping注解也要定义一下Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface YGetMapping { String value(); }虽然这段HTTP代码用到了JDK内部模块在生产项目里我绝对不建议依赖它写业务但拿来做原理Demo非常合适因为它不用引入任何第三方依赖运行一个Java类就能起来一个服务。4.3 写个Demo验证从启动到HTTP请求光说不练没有意义我写了一个非常简单的Demo来验证整条链路。先建一个Controller和Service负责模拟真实的业务结构YComponent public class UserService { public String getUser() { return hello, mini spring boot; } } YComponent public class UserController { YAutowired private UserService userService; YGetMapping(/user) public String user() { return userService.getUser(); } }再写一个自动配置类专门模拟SpringBoot里类似“只要classpath里有某个类就自动注册一个Bean”的场景YAutoConfiguration YConditionalOnClass(com.demo.UserService) public class UserAutoConfiguration { YBean public String appName() { return mini-spring-boot-app; } }最后是启动类YSpringBootApplication public class DemoApplication { public static void main(String[] args) throws Exception { MiniContainer container MiniSpringBoot.run(DemoApplication.class, args); System.out.println(container.getBean(userService)); System.out.println(container.getBean(appName)); } }运行之后控制台会打印出Bean创建成功的信息然后浏览器或curl访问http://localhost:8080/user会拿到hello, mini spring boot。这个效果意味着整条链路是通的包扫描把UserController和UserService找到并注册依赖注入把UserService塞进UserController自动配置类按条件注册了appName内嵌Web容器接收请求并通过反射调用到Controller方法。我实际跑过之后最重要的一点感受是当你看到自己手写的300行代码能像真正的SpringBoot一样把项目跑起来你对“框架不过是在固定的流程上做扩展”这件事会特别有体感。5. 常见问题与避坑指南5.1 扫描不到类Bean为null最常见的问题是扫描器返回空集合结果容器里什么都没有。这种情况大多数是包路径写错了或者用了ClassLoader.getSystemResource去拿资源路径导致在IDE里能跑、打包后跑不了。我排查这类问题时有一个套路先直接打印扫描到的URL和类名列表确认扫描阶段有没有找到class文件再确认启动注解里的basePackage是不是指向了正确的包最后确认编译输出目录里有没有target/classes下确实生成了class文件。还有一个容易被忽略的问题如果扫描的包名写得太细比如写成了com.demo.service而Controller在com.demo.controller下那Controller就永远扫不到。SpringBoot让我们把启动类放在包的根路径上就是为了让默认扫描范围能覆盖所有子包。我的MiniSpringBoot默认取主类所在包也是同样的约定。5.2 Bean重复注册与覆盖手写的时候如果我不小心在一个配置类里用YBean定义了一个叫appName的Bean同时又在扫描路径下的普通组件里也定义了一个叫appName的组件容器就会在注册阶段出现同名覆盖。我的第一版实现使用put后注册的会把先注册的顶掉这种隐式覆盖很容易造成诡异的问题。真实SpringBoot对这种情况默认是会报错而不是覆盖的除非显式配置allowBeanDefinitionOverridingtrue。我在后来修正版本里加了一个简单的重复判断发现同名Bean时直接抛异常并提示哪个类导致的重复注册。这个习惯建议大家也保留下来排查问题的时候会省很多事。5.3 条件注解失效的三种情况我调试过程中发现YConditionalOnClass在某些情况下“失效”了明明是希望它跳过、结果没跳过。后来总结了三种典型情况把条件注解加在了普通组件上但判断逻辑没有执行。我的设计里组件扫描时也会执行条件判断如果你改动的时候没把判断逻辑加上条件注解自然不生效类名写错或者没有写全限定名。YConditionalOnClass(UserService)这种写法会挂必须是带包路径的全限定名判断的类恰好被扫描器加载了。因为扫描阶段用Class.forName加载过一次后面再判断时类路径里确实存在条件就通过了。这在真实SpringBoot里也有类似情况判断时机很关键。5.4 手写过程中容易踩的反射坑反射是这套代码里用得最多的技术也是最容易出问题的地方。我有几个经验分享调用无参构造器创建实例时如果类没有显式声明无参构造器并且定义了带参构造器getDeclaredConstructor()会直接抛异常。解决办法是先调用setAccessible(true)再加构造器可用或者让Demo里所有类都保留无参构造器字段注入的时候一定要先field.setAccessible(true)否则私有字段无法赋值。Module系统开了强封装之后某些场景下还需要额外加导出参数不过普通项目不用管方法反射调用时需要留意method.invoke(bean)和method.invoke(null)的区别普通实例方法第一个参数传bean对象静态方法传null。我一开始在Controller方法调用时传了null结果一直报空指针。6. 写在最后这段手写经历给我带来了什么写完之后我自己最大的收获不是“我手搓了一个框架”这种成就感而是我以后再去看SpringBoot源码的时候脑子里会有一个完整的“地图”。比如我打开AutoConfigurationImportSelector的源码能看到它做事件通知、做去重排序、做排除过滤这些都是核心链路之外的扩展细节我不再会觉得那些代码是散乱的天书因为我知道它在整个流程的哪一步、为什么出现在那里。如果你正在准备SpringBoot面试题我特别建议像我一样找个完整的时间段从头把这个迷你版写一遍。刚开始可能会被反射、注解、类加载这些细节折磨得想放弃但写通之后你对“自动装配原理”“SpringBoot启动流程”“条件装配”这类问题的理解会有一个质的飞跃。等真正面试官问到你的时候你不再是背答案而是有逻辑地把一条链路当场讲出来这个效果是背多少篇博客都换不来的。最后再分享一个小技巧写完这个项目之后我习惯在源码里遇到一个类就顺手查一下它是在哪一步被加载的仔细想想这个类开发人员在设计它的时候到底是解决什么问题的比对代码里的注释和类名理解会越来越顺。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进