搞Java后端的人,谁没被SpringBoot的“黑魔法”震撼过?一个带main方法的类跑起来,内嵌Tomcat就启动了,一堆starter往pom里一扔,自动配置就生效了。刚入行那会儿我觉得这玩意儿高深莫测,直到被面试官连环问“SpringBoot自动装配原理到底是什么”,我才意识到光会用不够,必须动手搞明白。于是花了一个周末,我把SpringBoot最核心的启动链路、自动装配、内嵌Web服务器这三个机制,用300行代码重新撸了一遍。这篇文章就是完整复盘,看懂之后你再去面试问“SpringBoot自动配置原理”、“自动装配原理”,不会只背概念。
1. 为什么要手写SpringBoot核心原理?先拆穿它的三大魔法
1.1 SpringBoot到底替我们做了什么
很多人理解的SpringBoot就是“启动类加一堆starter”,其实它的核心就三件事:内嵌Web服务器、自动配置、起步依赖管理。起步依赖本质是Maven的依赖聚合,不涉及运行时逻辑,没什么好讲的。真正让SpringBoot“魔法般”工作的,是另外两件事的组合。
第一,内嵌Web服务器。传统SSM项目要部署到外置Tomcat,打war包、丢webapps目录、重启容器,麻烦得要死。SpringBoot把Tomcat、Jetty、Undertow这些Web服务器作为普通依赖打进来,在main方法里直接启动。省掉的不光是部署步骤,更重要的是让Web应用退化成一个普通Java进程,可以像调普通程序一样去调试、监控、分发。
第二,自动配置。SpringBoot引入一个starter后,比如spring-boot-starter-data-redis,它会在类路径中检测到Redis相关类存在后,自动创建RedisConnectionFactory、RedisTemplate等Bean,你几乎不需要自己写配置类。这套机制的灵魂是@EnableAutoConfiguration注解配合AutoConfigurationImportSelector,把所有“自动配置类”从META-INF/spring.factories(新版本是AutoConfiguration.imports文件)里加载出来,再用条件注解做筛选。
所以“自动装配原理”说的就是这整条链路:@SpringBootApplication组合了@EnableAutoConfiguration,后者通过ImportSelector机制读取配置文件里的候选配置类,再逐个用@ConditionalOnClass、@ConditionalOnMissingBean等条件判断是否加载。
1.2 300行代码能复刻哪些能力,哪些必须舍弃
目标要现实。用300行代码手写的MiniSpringBoot,不可能完整覆盖SpringBoot所有能力,但可以把最核心的骨架复刻出来,让整个机制跑通。
我的目标定位是:能通过一个带main方法的类启动内嵌Tomcat,能通过自定义注解扫描Controller,能通过手写的自动配置模块按类路径条件注册Bean。具体来说包含:
MiniSpringApplication.run()启动链路,走一遍容器创建、配置解析、Tomcat启动的完整流程。- 手写
@EnableMiniAutoConfiguration+AutoConfigurationImportSelector,从mini-spring.factories加载配置类。 - 手写
@ConditionalOnClass条件注解,模拟“类路径存在才生效”的机制。 - 手写
DispatcherServlet,处理HTTP请求到@MiniRestController方法的映射。 - 内嵌Tomcat集成,真正部署DispatcherServlet并监听8080端口。
不能覆盖的,我明确舍弃:Starter依赖的版本管理(那是Maven层面的事)、@ConfigurationProperties属性绑定、自动配置类排序机制、@Conditional条件注解的完整家族、异步Servlet、拦截器等等。这些不是核心链路,不影响理解原理。
2. 从零搭建MiniSpringBoot:依赖选型与项目骨架
2.1 不引入spring-boot-starter-web,项目靠什么启动
既然要复刻SpringBoot,就不能引入spring-boot-starter-web,那是作弊。但Spring基础能力还是可以用的,毕竟SpringBoot自己也没有重写Spring IoC。我引入了这几个依赖:
spring-context:提供AnnotationConfigApplicationContext、@Configuration、@Bean、@ComponentScan等核心能力。tomcat-embed-core:提供内嵌Tomcat运行时的核心类,让Web服务器能嵌入普通Java进程。javax.servlet-api:HttpServlet、HttpServletRequest、HttpServletResponse这些Servlet API,编译期需要,运行期由Tomcat提供。jackson-databind:把Controller返回的对象序列化成JSON。
完整pom里的核心部分长这样:
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.31</version> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-core</artifactId> <version>9.0.80</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> </dependencies>这里有两处坑要提醒。第一,JDK版本别选太高,我用的JDK8+Tomcat9,组合最稳。如果你用JDK11以上,注意Tomcat版本要换到9.0.x以上;如果拉高到SpringBoot 3.x那种要求JDK17的环境,Servlet API也要从javax换成jakarta,代码里所有import javax.servlet都要变成import jakarta.servlet,别踩这个版本坑。第二,javax.servlet-api一定要用provided,因为Tomcat容器里自带Servlet实现,引入到打包产物里反而会冲突。
2.2 代码结构与自定义注解怎么设计
我先规划了包结构,让代码清晰对应SpringBoot的各个角色:
src/main/java/com/minispring/ ├── MiniSpringApplication.java // 启动器,对应SpringApplication ├── annotation/ │ ├── MiniSpringBootApplication.java // 组合注解,对应@SpringBootApplication │ ├── EnableMiniAutoConfiguration.java // 自动配置开关 │ ├── ConditionalOnClass.java // 条件注解 │ ├── MiniRestController.java // 控制器注解 │ └── MiniRequestMapping.java // 路由映射注解 ├── autoconfigure/ │ ├── AutoConfigurationImportSelector.java // 加载配置类 │ └── ConditionEvaluator.java // 条件评估器 └── web/ ├── TomcatServer.java // 内嵌Tomcat启动器 └── DispatcherServlet.java // 请求分发器设计组合注解时,我尽量让写法贴近真实SpringBoot。@MiniSpringBootApplication就是@Configuration、@ComponentScan、@EnableMiniAutoConfiguration三个注解的合成:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Configuration @ComponentScan @EnableMiniAutoConfiguration public @interface MiniSpringBootApplication { }程序员最熟的一句话“@SpringBootApplication是一个组合注解”,到这儿会变得特别直观。注意@ComponentScan不指定包名时,默认扫描的是“标注了@MiniSpringBootApplication的启动类所在包及其子包”,所以你的Controller、Service必须放在启动类的子包路径下,否则扫描不到,这跟真实SpringBoot的坑一模一样。
3. 手写启动器与内嵌Tomcat:hello world跑通第一步
3.1 MiniSpringApplication.run()全流程拆解
启动类是整套机制的总装配线。最开始我写了一个非常暴力的版本,把自动配置处理、容器刷新、Tomcat启动全部塞在一起,结果发现耦合太重,而且顺序不对就会启动失败。后来我按照SpringBoot源码的轮子重新捋了一遍,思路变成三步:
public class MiniSpringApplication { public static void run(Class<?> primarySource, String[] args) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); context.register(primarySource); context.refresh(); TomcatServer server = new TomcatServer(8080); server.start(context); } }这段代码只有8行,但背后有几个关键决策。第一,我没有用new AnnotationConfigApplicationContext(primarySource)这种一行式写法,而是分成了new + register + refresh,原因是要掌握容器初始化的中间步骤,你才能理解SpringBoot在极致封装背后到底插入了多少逻辑。第二,context.refresh()是最关键的转折点,Spring容器在这一步才会真正执行配置解析、Bean创建、依赖注入。第三,Tomcat的启动必须在refresh()之后,因为DispatcherServlet要拿到已经就绪的容器里的Bean,顺序反了就会出现“控制器还没注册,请求已经打过来”的尴尬。
3.2 手写内嵌Tomcat:两个核心要点别搞错
内嵌Tomcat最关键的是理解“Tomcat一旦作为库引入,就不再是一个独立进程,而是你的代码的一部分”。我用TomcatServer封装了这层逻辑:
public class TomcatServer { private final Tomcat tomcat = new Tomcat(); private final int port; public TomcatServer(int port) { this.port = port; } public void start(ApplicationContext applicationContext) { try { tomcat.setPort(port); tomcat.getConnector(); Context ctx = tomcat.addContext("", new File(".").getAbsolutePath()); DispatcherServlet dispatcherServlet = new DispatcherServlet(applicationContext); Tomcat.addServlet(ctx, "dispatcher", dispatcherServlet); ctx.addServletMappingDecoded("/", "dispatcher"); tomcat.start(); System.out.println("MiniSpringBoot started on port " + port); } catch (Exception e) { throw new RuntimeException(e); } } }这里有两个新手必踩的坑。第一,tomcat.getConnector()必须调用,这一行会初始化HTTP连接器,不写的话Tomcat虽然能启动,但端口根本没监听,页面死活连不上。第二,tomcat.addContext("", new File(".").getAbsolutePath())中,第一个参数""是Context路径,映射到根路径;第二个参数是docBase,指向静态资源目录。因为我们没有标准的Web应用目录结构,用当前目录作为docBase就行,主要作用是让Tomcat内部能建起Context。
很多第一次手写的人以为Servlet要像以前web.xml那样配置映射,其实Tomcat.addServlet和ctx.addServletMappingDecoded两行就完成了注册和URL映射。addServletMappingDecoded("/", "dispatcher")表示根路径下所有请求都打进DispatcherServlet。
4. 手写自动装配:理解SpringBoot的“开关”机制
4.1 自动配置类是怎么被扫描出来的
自动装配是整个SpringBoot原理里最常被面试官追问的地方,也是我这次手写最想要搞透的部分。它的起点在@EnableMiniAutoConfiguration:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Import(AutoConfigurationImportSelector.class) public @interface EnableMiniAutoConfiguration { }注意核心在@Import(AutoConfigurationImportSelector.class)。@Import是Spring框架提供的一个非常实用的功能,可以把一个普通的配置类注册进容器。而ImportSelector是@Import的一种特殊用法:它不是直接注册自己在容器里,而是返回一组类名给Spring,让Spring把返回的类作为配置类加载。
public class AutoConfigurationImportSelector implements ImportSelector { private static final String FACTORIES_RESOURCE = "META-INF/mini-spring.factories"; @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { List<String> configClasses = new ArrayList<>(); ClassLoader classLoader = getClass().getClassLoader(); try { Enumeration<URL> resources = classLoader.getResources(FACTORIES_RESOURCE); while (resources.hasMoreElements()) { URL url = resources.nextElement(); Properties properties = new Properties(); try (InputStream in = url.openStream()) { properties.load(in); } String value = properties.getProperty("com.minispring.autoconfigure.Configurations"); if (value == null) { continue; } for (String className : value.split(",")) { Class<?> clazz = Class.forName(className.trim(), false, classLoader); if (ConditionEvaluator.matches(clazz)) { configClasses.add(clazz.getName()); } } } } catch (IOException | ClassNotFoundException e) { throw new RuntimeException("加载自动配置类失败", e); } return configClasses.toArray(new String[0]); } }这段逻辑对应SpringBoot源码的SpringFactoriesLoader.loadFactoryNames,只是我简化了很多。它会去classpath下所有META-INF/mini-spring.factories文件,读取指定key对应的配置类列表,逐一判断条件后返回。
我建了一个最简单但足够说明问题的配置文件:
com.minispring.autoconfigure.Configurations=\ com.minispring.autoconfigure.HelloAutoConfiguration这个HelloAutoConfiguration是一个普通的Spring配置类:
@Configuration @ConditionalOnClass("com.fasterxml.jackson.databind.ObjectMapper") public class HelloAutoConfiguration { @Bean public String greetingMessage() { return "hello from auto configuration"; } }注意,这里我用了一个自定义条件注解@ConditionalOnClass。只要类路径下有Jackson,这个配置类就会被加载,greetingMessage这个Bean就会注册进容器。这就是“为什么引入starter,配置就自动生效”的本质:starter带来依赖,依赖决定类路径,类路径触发条件判断,条件判断决定配置类是否加载。
4.2 条件评估器:@ConditionalOnClass是怎么做到的
我自己写的条件注解非常克制,只有一个value属性,用来指定需要检测的类全限定名:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ConditionalOnClass { String value(); }条件评估器也简洁:
public class ConditionEvaluator { public static boolean matches(Class<?> configClass) { ConditionalOnClass condition = configClass.getAnnotation(ConditionalOnClass.class); if (condition == null) { return true; } try { Class.forName(condition.value(), false, configClass.getClassLoader()); return true; } catch (ClassNotFoundException e) { System.out.println("[MiniSpringBoot] 跳过配置类 " + configClass.getSimpleName() + ",因为缺少依赖 " + condition.value()); return false; } } }关键在Class.forName(condition.value(), false, classLoader)这个API。第三个参数false表示只做类加载的“连接”但不初始化静态代码块,我把是否触发静态初始化写成了false,避免加载一个类还引发用户类的静态逻辑执行,这是很隐蔽的细节。真实SpringBoot的条件注解远比我这个复杂,它会通过ASM直接读取类的元数据,避免真正把类加载进JVM,但原理本质是一样的:判断某个Class是否存在于类路径中。
这个“判断类路径”机制非常有价值。你也可以用它配合@ConditionalOnMissingBean实现“用户定义了Bean,我的自动配置就后退”的优先级逻辑,这在真实SpringBoot里是自动配置的黄金法则:默认配置兜底,用户配置优先。
5. 手写DispatcherServlet:让请求真正跑起来
5.1 从Servlet转发到Controller方法
Tomcat能启动并不是终点,请求进来之后还要能找到对应的业务方法。我的DispatcherServlet继承自javax.servlet.http.HttpServlet,在构造时把Spring容器里的@MiniRestController类和方法扫描出来,建立“URL -> Method”的映射:
public class DispatcherServlet extends HttpServlet { private final Map<String, Method> handlerMapping = new HashMap<>(); private final ApplicationContext context; private final ObjectMapper objectMapper = new ObjectMapper(); public DispatcherServlet(ApplicationContext context) { this.context = context; initHandlerMapping(); } private void initHandlerMapping() { for (String beanName : context.getBeanDefinitionNames()) { Object bean = context.getBean(beanName); Class<?> clazz = bean.getClass(); boolean present = clazz.isAnnotationPresent(MiniRestController.class) || clazz.getSuperclass() != null && clazz.getSuperclass().isAnnotationPresent(MiniRestController.class); if (!present) { continue; } for (Method method : clazz.getDeclaredMethods()) { MiniRequestMapping mapping = method.getAnnotation(MiniRequestMapping.class); if (mapping != null) { handlerMapping.put(mapping.value(), method); } } } } }这里我加了一个小细节,处理CGLIB代理的情况。Spring容器里很多Bean是CGLIB代理类,bean.getClass()拿到的是代理子类,注解可能标在父类的方法上,所以不光要检查当前类的注解,还要检查父类的。真实SpringBoot里也面临一样的问题,比如@Configuration类默认会被CGLIB代理,如果Controller定义中有AOP切面,同样要注意。
再来看自定义的路由注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface MiniRequestMapping { String value(); }写个测试Controller试试:
@MiniRestController public class HelloController { @MiniRequestMapping("/hello") public Map<String, String> hello() { Map<String, String> map = new HashMap<>(); map.put("message", "hello mini springboot"); map.put("from", "auto-config"); return map; } }5.2 请求响应、JSON序列化与404处理
请求进来后,service()方法负责分发:
@Override protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException { response.setContentType("application/json;charset=UTF-8"); String path = request.getRequestURI(); Method method = handlerMapping.get(path); if (method == null) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); response.getWriter().write("{\"error\":\"404 Not Found\"}"); return; } try { Object bean = context.getBean(method.getDeclaringClass()); Object result = method.invoke(bean); String json = objectMapper.writeValueAsString(result); response.getWriter().write(json); } catch (Exception e) { response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.getWriter().write("{\"error\":\"" + e.getCause().getMessage() + "\"}"); } }这段逻辑很直白,但解释了真实SpringMVC的核心骨架:请求进来后,根据URL找到HandlerMethod,再通过反射调用目标方法,做参数解析、返回值处理。我这里做的是最简版本,只处理了无参方法,也没有做视图解析,统一返回JSON。但把路径映射、反射调用、错误处理这三个关键点打通之后,SpringMVC对于你来说就不再是“配置个Controller就行”的黑盒子了。
测试时访问http://localhost:8080/hello,就能看到JSON内容返回。明明没有Tomcat安装,也没有任何web.xml配置,一个普通main程序就能对外提供HTTP服务,这种“亲手搭起来”的感觉,比看十遍源码截图都来得直接。
6. 调试实录:常见问题、踩坑与排查清单
6.1 高频问题速查,照着排查能少走弯路
手写过程中我踩了不少坑,也参考了一些同行的反馈,整理成一份速查清单,都出现过真实案例。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| Tomcat启动了但8080端口访问不了 | 没调用tomcat.getConnector(),Connector未初始化 | 在start()里显式调用tomcat.getConnector() |
| 自定义Bean扫描不到 | 包扫描路径和启动类所在包不匹配 | @ComponentScan默认扫启动类所在包,Controller放同一包或子包下 |
| 请求全部404 | DispatcherServlet的URL映射没配成/ | ctx.addServletMappingDecoded("/", "dispatcher"),别写成具体路径 |
| 自动配置类没生效 | mini-spring.factories里key写错,或类名没写全限定名 | 检查properties.getProperty("com.minispring.autoconfigure.Configurations")是否和你文件里的key一致 |
| 条件注解误跳过配置类 | 类名拼错,或类加载器不对 | 用Class.forName时指定configClass.getClassLoader(),不要用默认加载器 |
| 修改代码后还是旧行为 | 没重新编译打包,target里还是旧class和旧配置 | 执行mvn clean compile,确认mini-spring.factories被复制到了target/classes |
| JDK8跑Tomcat9报错 | 版本组合不对,Tomcat9最低要求JDK8 | 建议JDK8搭配Tomcat9.x;用JDK11以上时升级Tomcat |
| 返回中文乱码 | 没设置响应编码 | response.setContentType("application/json;charset=UTF-8") |
反射调用方法报IllegalArgumentException | bean的类型和方法声明类不匹配 | 用method.getDeclaringClass()去容器里取Bean,不要用Object类型直接取 |
6.2 推荐调试技巧:从“能跑”到“跑明白”
第一个技巧是打标记日志。我在ConditionEvaluator里写了一段输出“跳过配置类...”的日志,启动时观察日志,很快就能定位“是不是条件不满足导致配置类没加载”。这个方法你也可以用在理解真实SpringBoot上,打开debug=true配置,SpringBoot会打印自动配置判断结论,比如Positive matches、Negative matches,就是官方版的条件评估日志。
第二个技巧是用最小复现反推原理。如果哪一步不生效,就先把项目改成“最土的方式”验证:比如自动配置类没生效,先写一个@Configuration类在启动器里context.register()手动注册,确认配置类本身没问题,再逐步改成从文件加载,问题范围一下就缩小了。我在手写的时候发现AutoConfigurationImportSelector返回类名后,Spring容器处理这些类还需要时间,手动注册和通过@Import注册的效果是有差异的,用这种“对比实验法”就能找到问题所在。
7. 手写一遍之后,SpringBoot面试和源码阅读全顺了
7.1 自动配置面试题,现在可以这样答
以前背面试题,遇到“SpringBoot自动装配原理”总是找不到合适的表达方式。手写一遍之后,我的回答变成了这样:
@SpringBootApplication是一个组合注解,核心是@EnableAutoConfiguration。这个注解通过@Import引入了AutoConfigurationImportSelector,它是一个ImportSelector实现类。Spring容器刷新时,会调用selectImports方法,通过SpringFactoriesLoader加载classpath下META-INF/spring.factories文件里的自动配置类列表,然后结合@ConditionalOnClass等条件注解逐个判断是否生效,最后把匹配的配置类注册进容器,创建出对应的Bean。所以自动装配的本质是“IoC容器的BeanDefinition注册机制 + SPI配置加载 + 条件判断”的组合。
这套回答里没有一句是背的,因为每个环节我都亲手实现过。尤其“从spring.factories读取配置”这一步,很多人以为配置是“扫描到的”,其实走的是SPI机制,通过配置文件显式声明候选类。我把AutoConfigurationImportSelector里的classLoader.getResources("META-INF/mini-spring.factories")跑通之后,这个概念就刻在脑子里了。
7.2 这个MiniSpringBoot后续还能怎么扩展
代码虽然只有300行,但骨架是完整的,我觉得至少可以从四个方向继续练手。
第一个方向是完善条件注解家族。目前只有@ConditionalOnClass一个,你还可以实现@ConditionalOnMissingBean、@ConditionalOnProperty、@ConditionalOnWebApplication,把SpringBoot的判定体系补全。第二个方向是加@ConfigurationProperties属性绑定,模拟从application.properties里读取配置并注入到Bean的属性中。第三个方向是支持Controller方法参数解析,至少做一个@RequestParam注解,把HttpServletRequest的参数绑定到方法入参上。第四个方向是研究一下DeferredImportSelector和自动配置类的执行顺序,真实SpringBoot里很多复杂bug都出在“配置类加载顺序不对”上。
每扩展一个方向,你对SpringBoot底层机制的理解就会更透一点。这也是我一直推荐“框架源码一定要手写过一次”的原因:源码太多看不完,但抓住主干写一遍,主干承载的思想就会变成你的东西。下次手写代码时,记得保持怀疑精神,亲自验证每一个机制,远比背一百个答案有意义。