尧图精选

300行代码手写迷你SpringBoot:自动装配与内嵌Tomcat原理实战

🕒 发布时间:2026/9/16 3:56:04 📁 来源:尧图网络
天天用SpringBootIDE里点一下启动按钮日志刷刷刷就出来了跟吃饭喝水一样自然。可真要你解释一下“自动装配到底怎么做到的”大多数人的答案就只剩三句话读配置文件、加载Bean、启动Tomcat。这三句话没错但你只背结论、讲不出链路面试官一句“那你手写个最小版SpringBoot出来看看”就能把你打回原形。我一直信奉一个笨办法理解一个框架与其一头扎进那几十万行源码里跟它死磕不如先手写一个能跑的迷你版。这次我就用300行代码手写一个简化版的SpringBoot核心原理不追求功能完整只求把“启动流程、包扫描、依赖注入、内嵌Tomcat、自动配置”这五件事的底层链路完整跑通。这篇东西适合两类人一种是准备SpringBoot面试、想真正把原理讲清楚的人另一种是用SpringBoot写了不少业务但总觉得“框架黑盒”心里没底的人。跟着我写完你自己都能把启动过程给别人讲明白。1. 先想清楚SpringBoot到底替你做了什么很多人对SpringBoot的印象是“约定优于配置”这个说法对但太虚。在动手之前先把SpringBoot替我们干的几件大事拆出来你才知道那300行代码该往哪使劲。1.1 三个最核心的能力内嵌容器、自动装配、配置约定第一件大事是内嵌Servlet容器。在SpringBoot没有普及的年代一个Web项目要经历打war包、丢进Tomcat的webapps目录、启动外部Tomcat、再等部署完成这一整套流程。SpringBoot直接把Tomcat变成Maven依赖用代码创建和启动你一条java -jar命令就能把整个应用拉起来。这个转变的内核就是把原来外部容器的启动动作搬进了你自己的main方法里。第二件大事是自动装配。往pom.xml里加一个spring-boot-starter-data-redisRedis的连接工厂、模板工具类就被自动准备好了加一个spring-boot-starter-web内嵌Tomcat和DispatcherServlet就自动就位了。这个过程靠的不是魔法而是SpringFactoriesLoader机制配合条件注解。这也就是面试题里最常问的EnableAutoConfiguration原理。第三件大事是配置约束与约定。比如SpringBootApplication默认扫描它所在包及子包比如自动配置类通过ConditionalOnClass、ConditionalOnMissingBean来判断“当前环境到底该不该装配某个Bean”再比如通过ConfigurationProperties把配置文件绑定成强类型对象。这三件事共同构成了SpringBoot的开发体验。另外还有个容易被忽略的小细节——启动Banner。你每次启动时看到的那个ASCII艺术字其实是SpringApplication在run最开始阶段渲染出来的它本身不是核心功能但恰恰说明SpringBoot把“启动过程”做成了一个可扩展的流水线。1.2 为什么要手写以及手写能换来什么有人可能会说“SpringBoot源码我读了呀类图也画了咋还是感觉不稳”原因很简单读源码是被动接收信息手写是主动构造链路。你看10遍BeanPostProcessor的处理时序不如自己写一遍依赖注入失败再修好的过程来得深刻。300行这个规模很巧妙它刚好能覆盖SpringBoot最主干的那条链路又不会多到让你失去耐心。你可以把它理解成拆发动机不用拆到每一个活塞环但核心的“进气-压缩-做功-排气”四冲程你必须亲眼看到。写完之后你再看SpringApplication.run那一大段源码会发现很多类名和方法名都很眼熟因为它们就是这套迷你逻辑的完整工业版。2. 整体设计300行代码怎么分配手写之前先规划别上来就写。SpringBoot再复杂它的起步动作也就是“创建容器、扫描Bean、启动Web服务器”这三板斧。所以我的迷你版也按照这个顺序来设计代码结构。2.1 技术底座只用Tomcat embed不碰Spring这个项目我没有引入任何Spring依赖唯一的第三方依赖是tomcat-embed-core。这个包把真实的Tomcat核心打包成了一个普通Jar我们直接new Tomcat就行。选择Tomcat 9.x是因为它还是javax.servlet命名空间Tomcat 10以后切到了jakarta.servlet如果你的项目是JDK17或SpringBoot 3.x要留意这个包名迁移问题很多老项目从JDK8升到JDK21时踩的坑都卡在这。POM依赖长这样dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-core/artifactId version9.0.83/version /dependency其余全部基于JDK自带的反射、注解和类加载器。之所以这么穷酸是为了把框架的职责暴露出来扫描用File和ClassLoader注入用反射条件判断用Class.forName够用就行。2.2 类清单与行数规划先列个清单心里有数再动工。整体分为注解、容器、Web、自动配置、示例代码五类模块类名职责预估行数启动入口MiniApplicationmain方法编排启动流程20启动配置AppConfig配置扫描包路径10注解MiniApplication启动注解携带包扫描配置10注解MiniComponent、MiniAutowired组件注册与依赖注入10注解MiniGetMappingURL映射10注解MiniAutoConfiguration、MiniConditionalOnClass自动配置与条件判断10容器ApplicationContext扫描、注册、注入、保存Bean80容器ClassScanner按包路径扫描class文件30WebTomcatServer创建并启动内嵌Tomcat30WebDispatcherServlet请求分发到Controller方法60自动配置AutoConfigurationLoader加载自动配置类35示例HelloController、HelloService、JsonAutoConfiguration验证整体链路45加起来正好300行上下。注意这里说的是“可以不用Spring全家桶实现主干逻辑”示例Controller之类属于验证代码你完全可以再减少。逻辑上这个迷你框架就是三个核心类在干活ApplicationContext负责BeansTomcatServer负责Web容器DispatcherServlet负责请求分发。3. 先把原理打通自动装配这件事写代码之前我建议先把自动装配的底层逻辑捋一遍。因为这是SpringBoot面试出题率最高的点也是手写时最容易出“顺序”问题的环节。3.1 从war包到内嵌服务器的思路转变传统SSM项目里Tomcat是先于你的应用存在的。你的工程打出一个war包丢到Tomcat的webapps目录下Tomcat启动时通过Servlet规范去解析war包的WEB-INF/web.xml然后加载Spring的ContextLoaderListener最后才初始化DispatcherServlet。整套流程里Spring是“住在”Tomcat里的房客。SpringBoot把这个顺序倒过来了你的main方法先创建一个应用上下文按需加载Bean然后这段逻辑里主动new一个Tomcat实例把自己的DispatcherServlet塞进去最后tomcat.start()。这个思路上的转变如果没有建立起来你后面看WebServerFactoryCustomizer和ServletContextInitializer时只会一脸懵。在手写时这个顺序是被强制体现的先有Context再有ServerServer里再挂DispatcherServlet。3.2 spring.factories与SPI机制发现自动配置类的钥匙Java世界有一套经典的插件化机制叫SPI。简单说核心框架定义好一个接口扩展方把实现类的全限定名写进META-INF/services/接口全限定名这个文件里运行的时候框架通过ClassLoader扫描到这些名字再用反射加载实现类。这就像你家墙上预留好了插座孔不同品牌的电器只要按照同一个标准做插头插上就能供电。SpringFactoriesLoader就是Spring框架对SPI机制的自定义实现。它会在META-INF/spring.factories里找key为EnableAutoConfiguration的值这些值就是一个个自动配置类的全限定名。SpringBoot启动时AutoConfigurationImportSelector通过它拿到候选配置类列表再逐个交给条件注解去筛选。手写的时候没必要复刻整个文件系统扫描用一个String数组硬编码模拟候选列表反而更容易看清本质。3.3 条件装配是自动配置的灵魂自动配置类不是无脑全部装配它必须根据当前classpath和环境来决定“干不干活”。最典型的是Redis的自动配置类它上面有ConditionalOnClass(RedisOperations.class)意思是你必须引入了spring-data-redis依赖RedisOperations这个类才会出现在classpath里自动配置才会生效。这就像很多家庭的智能插座检测到“有大功率电器接入”才通电SpringBoot的Conditional系列注解就是那个检测器。面试时讲自动装配你一定要讲出这一层自动配置类是“候选”条件注解是“开关”两者配合才叫完整的自动装配。光说“读取spring.factories”只能拿一半分。4. 实操实现一个简化版SpringBoot诞生记理论铺垫完开始敲代码。我会按启动入口、容器、Web服务器、请求分发、自动配置这五个环节逐个拆解每一步都会标注“为什么这么做”而不是只给代码。4.1 入口MiniApplication与注解定义真实SpringBoot的入口是一个带SpringBootApplication的类里面写上SpringApplication.run(App.class, args)。迷你版照葫芦画瓢public class MiniApplication { public static void main(String[] args) throws Exception { ApplicationContext context new ApplicationContext(AppConfig.class); TomcatServer server new TomcatServer(context); server.start(8080); } }看到没有整个启动流程只有三行创建容器、创建Web服务器、启动服务器。真实SpringBoot的SpringApplication.run内部做的远不止这些但它最核心的骨架就是这三步。AppConfig负责告诉框架扫哪个包MiniApplication(scanBasePackages com.example) public class AppConfig { }再来看注解定义。我用了四个自定义注解分别对应真实SpringBoot里的SpringBootApplication、Component、Autowired、GetMapping和ConditionalOnClass的简化版Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface MiniApplication { String scanBasePackages() default ; } Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface MiniComponent { } Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface MiniAutowired { } Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface MiniGetMapping { String value(); } Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface MiniAutoConfiguration { } Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface MiniConditionalOnClass { String value(); }写注解的时候有两个细节Retention(RetentionPolicy.RUNTIME)必须加否则运行期反射拿不到字段注解的Target要写FIELD方法注解的Target要写METHOD别混。真实SpringBoot里SpringBootApplication是一个组合注解由SpringBootConfiguration、EnableAutoConfiguration、ComponentScan三个注解组成它的包扫描默认值也是从该注解所在类的包开始的。4.2 IOC容器扫描、注册、注入一个都不能少这是迷你框架里最重的一块。ApplicationContext的构造函数做了三件事扫描指定包下的class过滤出带MiniComponent的类并创建实例最后统一执行字段注入。public class ApplicationContext { private final MapString, Object beans new ConcurrentHashMap(); public ApplicationContext(Class? configClass) throws Exception { // 1. 获取扫描包路径 MiniApplication app configClass.getAnnotation(MiniApplication.class); String basePackage app.scanBasePackages(); if (basePackage.isEmpty()) { basePackage configClass.getPackage().getName(); } // 2. 先注册普通组件 SetClass? classes ClassScanner.scan(basePackage); for (Class? clazz : classes) { if (clazz.isAnnotationPresent(MiniComponent.class)) { String beanName firstLower(clazz.getSimpleName()); beans.put(beanName, clazz.getDeclaredConstructor().newInstance()); } } // 3. 加载自动配置类必须先于依赖注入 AutoConfigurationLoader.load(this); // 4. 统一执行字段注入 for (Object bean : beans.values()) { inject(bean, new HashSet()); } } private void inject(Object bean, SetString visiting) throws IllegalAccessException { Field[] fields bean.getClass().getDeclaredFields(); for (Field field : fields) { if (field.isAnnotationPresent(MiniAutowired.class)) { String name firstLower(field.getType().getSimpleName()); Object dependency beans.get(name); if (dependency null) { throw new IllegalStateException(找不到依赖: name); } field.setAccessible(true); field.set(bean, dependency); } } } public Object getBean(String name) { return beans.get(name); } public CollectionObject getBeans() { return beans.values(); } public void register(Object bean) { beans.put(firstLower(bean.getClass().getSimpleName()), bean); } private String firstLower(String name) { return name.substring(0, 1).toLowerCase() name.substring(1); } }这里的beanName策略取自“类名首字母小写”真实Spring的BeanName生成器会考虑一个类多个实例、特殊命名等情况但教学场景这样够用。我特意在构造函数里把自动配置加载放在依赖注入之前因为后续的Service可能要注入自动配置类产生的Bean如果先注入再加载那些字段就全是null——这个问题后面还会展开讲。ClassScanner的实现非常简单它用ClassLoader找到包对应的目录再遍历目录下的所有class文件加载成Class对象public class ClassScanner { public static SetClass? scan(String packageName) throws Exception { SetClass? classes new HashSet(); String path packageName.replace(., /); ClassLoader loader Thread.currentThread().getContextClassLoader(); EnumerationURL resources loader.getResources(path); while (resources.hasMoreElements()) { URL resource resources.nextElement(); File dir new File(resource.toURI()); for (File file : dir.listFiles()) { if (file.getName().endsWith(.class)) { String className packageName . file.getName().replace(.class, ); classes.add(Class.forName(className)); } } } return classes; } }注意这个扫描器只能扫目录形式的class文件打成Jar之后new File(resource.toURI())会炸。这是我刻意保留的“缺陷”后面讲局限时会用。4.3 内嵌Tomcat三步挂起一个Web应用Web服务器这一部分只要理解了Tomcat提供了一套可编程API就很简单。创建Tomcat实例、设置端口、添加Context、往Context里注册Servlet和URL映射一共三步public class TomcatServer { private final ApplicationContext context; public TomcatServer(ApplicationContext context) { this.context context; } public void start(int port) throws Exception { Tomcat tomcat new Tomcat(); tomcat.setPort(port); tomcat.getConnector(); Context ctx tomcat.addContext(, new File(.).getAbsolutePath()); Tomcat.addServlet(ctx, dispatcher, new DispatcherServlet(context)); ctx.addServletMappingDecoded(/*, dispatcher); tomcat.start(); tomcat.getServer().await(); } }tomcat.getConnector()这行很关键它是把内置连接器创建出来并绑定到端口上的动作。addContext(, ...)表示根路径上下文第二个参数是docBase指向当前项目目录。然后通过Tomcat.addServlet注册一个名为dispatcher的Servlet再调用addServletMappingDecoded(/*, dispatcher)把根路径下所有请求都交给这个Servlet处理。最后tomcat.getServer().await()会让主线程阻塞让Tomcat持续运行。为什么DispatcherServlet要接管/*因为SpringMVC的核心思想就是前端控制器模式所有请求先进来由DispatcherServlet根据路径分发给具体Controller。我们的迷你版也是这样设计的。4.4 DispatcherServlet手写前端控制器DispatcherServlet继承HttpServlet覆盖init()和service()两个方法。init阶段扫描容器里每个Bean的方法把带MiniGetMapping的方法和URL记录下来service阶段就拿来一个请求URI从映射表里找到对应方法用反射执行并返回结果。public class DispatcherServlet extends HttpServlet { private final ApplicationContext context; private final MapString, Method methodMapping new HashMap(); private final MapString, Object beanMapping new HashMap(); public DispatcherServlet(ApplicationContext context) { this.context context; } Override public void init() { for (Object bean : context.getBeans()) { Method[] methods bean.getClass().getDeclaredMethods(); for (Method method : methods) { if (method.isAnnotationPresent(MiniGetMapping.class)) { MiniGetMapping mapping method.getAnnotation(MiniGetMapping.class); methodMapping.put(mapping.value(), method); beanMapping.put(mapping.value(), bean); } } } } Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { String uri req.getRequestURI(); Method method methodMapping.get(uri); Object bean beanMapping.get(uri); resp.setContentType(text/html; charsetutf-8); if (method null || bean null) { resp.setStatus(404); resp.getWriter().write(404 Not Found); return; } try { Object result method.invoke(bean); resp.getWriter().write(String.valueOf(result)); } catch (Exception e) { resp.setStatus(500); resp.getWriter().write(500 Internal Error: e.getCause().getMessage()); } } }这个类就是SpringMVC DispatcherServlet的极简影子。真实版HandlerMapping会处理Ant通配符路径、方法参数解析、返回值适配、ResponseBody序列化等一大堆逻辑但核心分发思路一模一样。写到这里你会发现原来Controller方法的映射本质上就是一个“URL - Method”的Map查询过程没那么多玄机。配套示例Controller和ServiceMiniComponent public class HelloController { MiniAutowired private HelloService helloService; MiniGetMapping(/hello) public String hello() { return helloService.sayHello(); } } MiniComponent public class HelloService { MiniAutowired private JsonAutoConfiguration jsonAutoConfiguration; public String sayHello() { return mini springboot say: jsonAutoConfiguration.getOutputType(); } }4.5 自动配置加载器模拟Starter的感觉接下来是重头戏AutoConfigurationLoader。它做的事情非常接近SpringFactoriesLoader读取候选自动配置类列表判断条件注解是否满足满足就把类的实例也注册进容器。public class AutoConfigurationLoader { // 模拟 META-INF/spring.factories 中的 EnableAutoConfiguration 配置项 private static final String[] AUTO_CONFIGURATIONS { com.example.autoconfig.JsonAutoConfiguration }; public static void load(ApplicationContext context) throws Exception { for (String className : AUTO_CONFIGURATIONS) { Class? clazz Class.forName(className); if (!clazz.isAnnotationPresent(MiniAutoConfiguration.class)) { continue; } MiniConditionalOnClass condition clazz.getAnnotation(MiniConditionalOnClass.class); if (condition ! null !isPresent(condition.value())) { System.out.println([AutoConfig] 跳过 className 缺少类 condition.value()); continue; } context.register(clazz.getDeclaredConstructor().newInstance()); System.out.println([AutoConfig] 加载 className); } } private static boolean isPresent(String className) { try { Class.forName(className); return true; } catch (ClassNotFoundException e) { return false; } } }再配一个自动配置类MiniAutoConfiguration MiniConditionalOnClass(com.fasterxml.jackson.databind.ObjectMapper) public class JsonAutoConfiguration { public String getOutputType() { return json; } }这里MiniConditionalOnClass(com.fasterxml.jackson.databind.ObjectMapper)模拟的是“只有classpath里存在Jackson才装配”。如果项目没有引入Jackson依赖这个自动配置类就会被跳过。跑起来以后的启动日志长这样[AutoConfig] 加载 com.example.autoconfig.JsonAutoConfiguration打开浏览器访问http://localhost:8080/hello你会看到mini springboot say: json到这里一个能跑的迷你SpringBoot闭环就完成了。5. 调试与踩坑实录手写这套东西的过程踩坑是不可避免的。有些坑恰恰是理解SpringBoot生命周期的钥匙我挑几个最有价值的记录下来。5.1 自动配置Bean是null依赖注入的顺序不能乱第一个巨坑是加载顺序。最开始我的写法是ApplicationContext构造结束后再由main方法里调用AutoConfigurationLoader.load(context)。结构看起来很美但一运行就报“找不到依赖: jsonAutoConfiguration”。原因很简单ApplicationContext的构造函数在扫描完普通组件后立刻执行了字段注入而自动配置Bean还没被注册进容器HelloService里的MiniAutowired字段自然找不到目标。后来我把自动配置加载挪到了容器构造函数的第三步让“扫描普通Bean - 加载自动配置Bean - 统一注入”的顺序固定下来问题就解决了。这个坑特别值得讲因为它对应了SpringBoot真实设计里Bean定义注册和Bean实例化两个阶段。Spring容器里不是扫描完立刻实例化所有Bean而是先收集BeanDefinition再在refresh()的finishBeanFactoryInitialization阶段才实例化单例。自动配置类之所以能在真实容器里正常工作正是因为它的Bean定义在实例化之前就被导入并注册了。5.2 扫描不到类文件扫描方式的天然局限另一类常见问题是扫描路径写错或者打包成Jar后ClassScanner失效。我们的ClassScanner通过loader.getResources(path)拿到URL后转成File再遍历这在IDEA里运行没问题因为target/classes就是目录结构。但打成Jar包后class文件是压缩在Jar内部的File方式根本访问不到。真实SpringBoot用ASM配合ClassPathScanningCandidateComponentProvider既可以扫描文件目录也可以扫描Jar内部资源。这也是为什么很多框架要引入字节码操作库的原因——不是闲得慌是为了能在各种部署形态下正确地发现组件。我这里故意保留了这个小缺陷就是让你体会一下一个看起来简单的“扫描包”在真实场景里需要考虑多少东西。5.3 端口占用、start后立刻退出Tomcat开发时的小麻烦调试过程中最容易遇见的两个Tomcat小麻烦一个是8080端口被占用一个是Tomcat启动后主线程退出直接结束程序。端口问题直接用tomcat.setPort(8081)换端口就能规避或者查占用进程kill掉。第二个问题是因为忘了加tomcat.getServer().await()。Tomcat启动后如果主线程马上执行完main方法JVM会退出容器也就停了。await()让主线程在这里阻塞等待Tomcat才会持续对外服务。真实的SpringBoot当然也调用了await机制只是封装在SpringApplication内部而已。5.4 与真实SpringBoot的差异对比每个坑背后其实都是一条知识点。我把这套迷你版和真实SpringBoot的差异整理成一个对照表能力维度真实SpringBoot300行迷你版类扫描ASM扫描支持目录与Jar包反射File扫描只支持目录结构依赖注入完整生命周期支持循环依赖、代理反射字段注入不支持循环依赖自动配置spring.factories 几十种Conditional条件硬编码数组 单个ConditionalOnClassWeb容器启动WebServerFactory 自动配置Tomcat embed API手动硬编码配置绑定Environment ConfigurationProperties没有配置解析AOP/事务内置或通过starter提供没有需要自行扩展这张表同时也是一张线索图照着它能知道真实SpringBoot在哪些方向上做得更深。你读源码的时候会发现它的每个模块都对应着这些差异的某个解法。6. 这个迷你项目的延伸价值写到这里你已经拥有一个能跑通的迷你框架了。但它的真正价值不只是验证一条链路而是能帮你办成两件具体的实事。6.1 面试被问自动装配时你可以这样答有这套东西打底面对“SpringBoot自动装配原理”这个问题你的回答可以自成体系第一句先指出入口SpringBootApplication是组合注解其中EnableAutoConfiguration是自动装配的总开关第二句讲机制它内部通过AutoConfigurationImportSelector调用SpringFactoriesLoader读取META-INF目录下spring.factories文件里所有EnableAutoConfiguration的配置项得到候选自动配置类列表第三句讲过滤每个候选类上都有一组Conditional条件注解比如ConditionalOnClass要求classpath存在指定类才生效第四句讲结果满足条件的配置类被导入容器它内部用Bean定义各种组件最终完成自动装配。你要是还能补一句“条件不满足的配置类会在启动日志里被标记为ConditionEvaluationReport”面试官基本就知道你是真看过源码的人。这套逻辑你现在已经亲手实现过一遍讲出来比背面试题自然太多。6.2 下一步还能怎么扩展如果你想把这套迷你框架继续玩下去我建议按下面的顺序加功能先加一个BeanPostProcessor接口模拟真实Spring里允许每个Bean在初始化前后被修改的特性再加一个MiniAspect注解配合JDK动态代理做最简单的切面最后可以试着解析一个application.properties文件把server.port这种配置真正读出来绑到TomcatServer上。每加一个功能都会遇到一个新的“为什么不这样做就崩了”的问题那才是框架设计最值钱的部分。顺便一提现在SpringBoot还在发展比如MCP这样的新能力就是通过标准协议让应用能接入AI模型能力但它底层的容器、装配、配置骨架并没有变。你把基础链路打通了新特性对你而言只是在这套地基上多搭了一层楼。300行代码写完的那个晚上我又回去翻了翻SpringApplication.run的源码发现我们的主流程和它一模一样创建上下文、加载候选配置、准备Web服务器、启动。那一刻确实挺有成就感的。我个人最大的体会是框架这东西越是天天用越要用“过一遍手写版”来打破黑盒恐惧。你不用学我写300行哪怕只写一个100行的最小IOC容器自己对“注解到底是怎么起作用的”就会有完全不一样的感知。下次启动SpringBoot看到那个Banner刷出来的时候你可能心里会默默想哦这里不过就是几行我写过的代码加上一个漂亮的皮肤罢了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →